ScoDoc/app/api/etudiants.py

692 lines
24 KiB
Python
Executable File

##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
API : accès aux étudiants
"""
from datetime import datetime
from operator import attrgetter
from flask import g, request, Response
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import tools
from app.api import api_permission_required as permission_required
from app.but import bulletin_but_court
from app.decorators import scodoc
from app.models import (
Admission,
Departement,
EtudAnnotation,
FormSemestreInscription,
FormSemestre,
Identite,
ScolarNews,
)
from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc import sco_etud
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents
import app.scodoc.sco_photos as sco_photos
import app.scodoc.sco_utils as scu
# Un exemple:
# @bp.route("/api_function/<int:arg>")
# @api_web_bp.route("/api_function/<int:arg>")
# @login_required
# @scodoc
# @permission_required(Permission.ScoView)
# @as_json
# def api_function(arg: int):
# """Une fonction quelconque de l'API"""
# return {"current_user": current_user.to_dict(), "arg": arg, "dept": g.scodoc_dept}
#
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})
@api_web_bp.route("/etudiants/courants/long", defaults={"long": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_courants(long: bool = False):
"""
La liste des étudiants des semestres "courants".
Considère tous les départements dans lesquels l'utilisateur a la
permission `ScoView` (donc tous si le dépt. du rôle est `None`),
et les formsemestres contenant la date courante,
ou à défaut celle indiquée en argument (au format ISO).
QUERY
-----
date_courante:<string:date_courante>
Exemple de résultat :
[
{
"id": 1234,
"code_nip": "12345678",
"code_ine": null,
"nom": "JOHN",
"nom_usuel": None,
"prenom": "DEUF",
"civilite": "M",
}
...
]
En format "long": voir documentation.
"""
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
etuds = Identite.query.filter(
Identite.id == FormSemestreInscription.etudid,
FormSemestreInscription.formsemestre_id == FormSemestre.id,
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
if not None in allowed_depts:
# restreint aux départements autorisés:
etuds = etuds.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if long:
restrict = not current_user.has_permission(Permission.ViewEtudData)
data = [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
]
else:
data = [etud.to_dict_short() for etud in etuds]
return data
@bp.route("/etudiant/etudid/<int:etudid>")
@bp.route("/etudiant/nip/<string:nip>")
@bp.route("/etudiant/ine/<string:ine>")
@api_web_bp.route("/etudiant/etudid/<int:etudid>")
@api_web_bp.route("/etudiant/nip/<string:nip>")
@api_web_bp.route("/etudiant/ine/<string:ine>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne les informations de l'étudiant correspondant, ou 404 si non trouvé.
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
Les codes INE et NIP sont uniques au sein d'un département.
Si plusieurs objets ont le même code, on ramène le plus récemment inscrit.
"""
etud = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return etud.to_dict_api(restrict=restrict, with_annotations=True)
@bp.route("/etudiant/etudid/<int:etudid>/photo")
@bp.route("/etudiant/nip/<string:nip>/photo")
@bp.route("/etudiant/ine/<string:ine>/photo")
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo")
@api_web_bp.route("/etudiant/nip/<string:nip>/photo")
@api_web_bp.route("/etudiant/ine/<string:ine>/photo")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne la photo de l'étudiant ou un placeholder si non existant.
Le paramètre `size` peut prendre la valeur `small` (taille réduite, hauteur
environ 90 pixels) ou `orig` (défaut, image de la taille originale).
QUERY
-----
size:<string:size>
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
"""
etud = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
if not etudid:
filename = sco_photos.UNKNOWN_IMAGE_PATH
size = request.args.get("size", "orig")
filename = sco_photos.photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = sco_photos.UNKNOWN_IMAGE_PATH
res = sco_photos.build_image_response(filename)
return res
@bp.route("/etudiant/etudid/<int:etudid>/photo", methods=["POST"])
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.EtudChangeAdr)
@as_json
def set_photo_image(etudid: int = None):
"""Enregistre la photo de l'étudiant."""
allowed_depts = current_user.get_depts_with_permission(Permission.EtudChangeAdr)
query = Identite.query.filter_by(id=etudid)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if g.scodoc_dept is not None:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
# Récupère l'image
if len(request.files) == 0:
return json_error(404, "Il n'y a pas de fichier joint")
file = list(request.files.values())[0]
if not file.filename:
return json_error(404, "Il n'y a pas de fichier joint")
data = file.stream.read()
status, err_msg = sco_photos.store_photo(etud, data, file.filename)
if status:
return {"etudid": etud.id, "message": "recorded photo"}
return json_error(
404,
message=f"Erreur: {err_msg}",
)
@bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
@bp.route("/etudiants/nip/<string:nip>", methods=["GET"])
@bp.route("/etudiants/ine/<string:ine>", methods=["GET"])
@api_web_bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
@api_web_bp.route("/etudiants/nip/<string:nip>", methods=["GET"])
@api_web_bp.route("/etudiants/ine/<string:ine>", methods=["GET"])
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants(etudid: int = None, nip: str = None, ine: str = None):
"""
Info sur le ou les étudiants correspondant. Comme /etudiant mais renvoie
toujours une liste.
Si non trouvé, liste vide, pas d'erreur.
Dans 99% des cas, la liste contient un seul étudiant, mais si l'étudiant a
été inscrit dans plusieurs départements, on a plusieurs objets (1 par dept.).
"""
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
if etudid is not None:
query = Identite.query.filter_by(id=etudid)
elif nip is not None:
query = Identite.query.filter_by(code_nip=nip)
elif ine is not None:
query = Identite.query.filter_by(code_ine=ine)
else:
return json_error(
404,
message="parametre manquant",
)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
]
@bp.route("/etudiants/name/<string:start>")
@api_web_bp.route("/etudiants/name/<string:start>")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_by_name(start: str = "", min_len=3, limit=32):
"""Liste des étudiants dont le nom débute par start.
Si start fait moins de min_len=3 caractères, liste vide.
La casse et les accents sont ignorés.
"""
if len(start) < min_len:
return []
start = suppress_accents(start).lower()
query = Identite.query.filter(
func.lower(func.unaccent(Identite.nom, type_=VARCHAR)).ilike(start + "%")
)
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict)
for etud in sorted(etuds, key=attrgetter("sort_key"))
]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@bp.route("/etudiant/nip/<string:nip>/formsemestres")
@bp.route("/etudiant/ine/<string:ine>/formsemestres")
@api_web_bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@api_web_bp.route("/etudiant/nip/<string:nip>/formsemestres")
@api_web_bp.route("/etudiant/ine/<string:ine>/formsemestres")
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None):
"""
Liste des semestres qu'un étudiant a suivi, triés par ordre chronologique.
Accès par etudid, nip ou ine.
Attention, si accès via NIP ou INE, les semestres peuvent être de départements
différents (si l'étudiant a changé de département). L'id du département est `dept_id`.
Si accès par département, ne retourne que les formsemestre suivis dans le département.
"""
if etudid is not None:
q_etud = Identite.query.filter_by(id=etudid)
elif nip is not None:
q_etud = Identite.query.filter_by(code_nip=nip)
elif ine is not None:
q_etud = Identite.query.filter_by(code_ine=ine)
else:
return json_error(404, message="parametre manquant")
if g.scodoc_dept is not None:
q_etud = q_etud.filter_by(dept_id=g.scodoc_dept_id)
etud = q_etud.join(Admission).order_by(desc(Admission.annee)).first()
if etud is None:
return json_error(404, message="etudiant inexistant")
query = FormSemestre.query.filter(
FormSemestreInscription.etudid == etud.id,
FormSemestreInscription.formsemestre_id == FormSemestre.id,
)
if g.scodoc_dept is not None:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestres = query.order_by(FormSemestre.date_debut)
return [formsemestre.to_dict_api() for formsemestre in formsemestres]
@bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
)
@bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
)
@bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
defaults={"pdf": True},
)
@bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
defaults={"pdf": True, "with_img_signatures_pdf": False},
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
defaults={"pdf": True},
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
defaults={"pdf": True, "with_img_signatures_pdf": False},
)
@scodoc
@permission_required(Permission.ScoView)
def bulletin(
code_type: str = "etudid",
code: str = None,
formsemestre_id: int = None,
version: str = "selectedevals",
pdf: bool = False,
with_img_signatures_pdf: bool = True,
):
"""
Retourne le bulletin d'un étudiant dans un formsemestre.
formsemestre_id : l'id d'un formsemestre
code_type : "etudid", "nip" ou "ine"
code : valeur du code INE, NIP ou etudid, selon code_type.
version : type de bulletin (par défaut, "selectedevals"): short, long, selectedevals, butcourt
pdf : si spécifié, bulletin au format PDF (et non JSON).
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
"""
if version == "pdf":
version = "long"
pdf = True
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if version not in (
scu.BULLETINS_VERSIONS_BUT
if formsemestre.formation.is_apc()
else scu.BULLETINS_VERSIONS
):
return json_error(404, "version invalide")
if formsemestre.bul_hide_xml and pdf:
return json_error(403, "bulletin non disponible")
# note: la version json est réduite si bul_hide_xml
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)
ok, etud = _get_etud_by_code(code_type, code, dept)
if not ok:
return etud # json error
if version == "butcourt":
if pdf:
return bulletin_but_court.bulletin_but(formsemestre_id, etud.id, fmt="pdf")
else:
return json_error(404, message="butcourt available only in pdf")
if pdf:
pdf_response, _ = do_formsemestre_bulletinetud(
formsemestre,
etud,
version=version,
fmt="pdf",
with_img_signatures_pdf=with_img_signatures_pdf,
)
return pdf_response
return sco_bulletins.get_formsemestre_bulletin_etud_json(
formsemestre, etud, version=version
)
@bp.route("/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups")
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/groups"
)
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiant_groups(formsemestre_id: int, etudid: int = None):
"""
Retourne la liste des groupes auxquels appartient l'étudiant dans le formsemestre indiqué
formsemestre_id : l'id d'un formsemestre
etudid : l'etudid d'un étudiant
Exemple de résultat :
[
{
"partition_id": 1,
"id": 1,
"formsemestre_id": 1,
"partition_name": null,
"numero": 0,
"bul_show_rank": false,
"show_in_lists": true,
"group_id": 1,
"group_name": null
},
{
"partition_id": 2,
"id": 2,
"formsemestre_id": 1,
"partition_name": "TD",
"numero": 1,
"bul_show_rank": false,
"show_in_lists": true,
"group_id": 2,
"group_name": "A"
}
]
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre = query.first()
if formsemestre is None:
return json_error(
404,
message="formsemestre inconnu",
)
dept = formsemestre.departement
etud = Identite.query.filter_by(id=etudid, dept_id=dept.id).first_or_404(etudid)
app.set_sco_dept(dept.acronym)
data = sco_groups.get_etud_groups(etud.id, formsemestre.id)
return data
@bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@api_web_bp.route("/etudiant/create", methods=["POST"], defaults={"force": False})
@api_web_bp.route("/etudiant/create/force", methods=["POST"], defaults={"force": True})
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_create(force=False):
"""Création d'un nouvel étudiant
Si force, crée même si homonymie détectée.
L'étudiant créé n'est pas inscrit à un semestre.
Champs requis: nom, prenom (sauf si config sans prénom), dept (string:acronyme)
"""
args = request.get_json(force=True) # may raise 400 Bad Request
dept = args.get("dept", None)
if not dept:
return scu.json_error(400, "dept requis")
dept_o = Departement.query.filter_by(acronym=dept).first()
if not dept_o:
return scu.json_error(400, "dept invalide")
if g.scodoc_dept and g.scodoc_dept_id != dept_o.id:
return scu.json_error(400, "dept invalide (route departementale)")
else:
app.set_sco_dept(dept)
args["dept_id"] = dept_o.id
# vérifie que le département de création est bien autorisé
if not current_user.has_permission(Permission.EtudInscrit, dept):
return json_error(403, "departement non autorisé")
nom = args.get("nom", None)
prenom = args.get("prenom", None)
ok, homonyms = sco_etud.check_nom_prenom_homonyms(nom=nom, prenom=prenom)
if not ok:
return scu.json_error(400, "nom ou prénom invalide")
if len(homonyms) > 0 and not force:
return scu.json_error(
400, f"{len(homonyms)} homonymes détectés. Vous pouvez utiliser /force."
)
etud = Identite.create_etud(**args)
db.session.flush()
# --- Données admission
admission_args = args.get("admission", None)
if admission_args:
etud.admission.from_dict(admission_args)
# --- Adresse
adresses = args.get("adresses", [])
if adresses:
# ne prend en compte que la première adresse
# car si la base est concue pour avoir plusieurs adresses par étudiant,
# l'application n'en gère plus qu'une seule.
adresse = etud.adresses.first()
adresse.from_dict(adresses[0])
# Poste une nouvelle dans le département concerné:
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
text=f"Nouvel étudiant {etud.html_link_fiche()}",
url=etud.url_fiche(),
max_frequency=0,
dept_id=dept_o.id,
)
db.session.commit()
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@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)"""
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)
admission_args = args.get("admission", None)
if admission_args:
etud.admission.from_dict(admission_args)
# --- Adresse
adresses = args.get("adresses", [])
if adresses:
# ne prend en compte que la première adresse
# car si la base est concue pour avoir plusieurs adresses par étudiant,
# l'application n'en gère plus qu'une seule.
adresse = etud.adresses.first()
adresse.from_dict(adresses[0])
db.session.commit()
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
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"