############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """ API : accès aux étudiants CATEGORY -------- É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 import sco_photos from app.scodoc.sco_utils import json_error, suppress_accents import app.scodoc.sco_utils as scu # Un exemple: # @bp.route("/api_function/") # @api_web_bp.route("/api_function/") # @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). En format "long": voir l'exemple. QUERY ----- date_courante: SAMPLES ------- /etudiants/courants?date_courante=2022-05-01; /etudiants/courants/long?date_courante=2022-05-01; """ 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/") @bp.route("/etudiant/nip/") @bp.route("/etudiant/ine/") @api_web_bp.route("/etudiant/etudid/") @api_web_bp.route("/etudiant/nip/") @api_web_bp.route("/etudiant/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é. PARAMS ------ etudid : l'etudid de l'étudiant nip : le code nip de l'étudiant ine : le code ine de l'étudiant `etudid` est unique dans la base (tous départements). 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//photo") @bp.route("/etudiant/nip//photo") @bp.route("/etudiant/ine//photo") @api_web_bp.route("/etudiant/etudid//photo") @api_web_bp.route("/etudiant/nip//photo") @api_web_bp.route("/etudiant/ine//photo") @login_required @scodoc @permission_required(Permission.ScoView) def etudiant_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: PARAMS ------ 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//photo", methods=["POST"]) @api_web_bp.route("/etudiant/etudid//photo", methods=["POST"]) @login_required @scodoc @permission_required(Permission.EtudChangeAdr) @as_json def etudiant_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/", methods=["GET"]) @bp.route("/etudiants/nip/", methods=["GET"]) @bp.route("/etudiants/ine/", methods=["GET"]) @api_web_bp.route("/etudiants/etudid/", methods=["GET"]) @api_web_bp.route("/etudiants/nip/", methods=["GET"]) @api_web_bp.route("/etudiants/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 correspondants. 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/") @api_web_bp.route("/etudiants/name/") @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//formsemestres") @bp.route("/etudiant/nip//formsemestres") @bp.route("/etudiant/ine//formsemestres") @api_web_bp.route("/etudiant/etudid//formsemestres") @api_web_bp.route("/etudiant/nip//formsemestres") @api_web_bp.route("/etudiant/ine//formsemestres") @scodoc @permission_required(Permission.ScoView) @as_json def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None): """ Liste des formsemestres 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 formsemestres 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 formsemestres 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///formsemestre//bulletin", ) @bp.route( "/etudiant///formsemestre//bulletin/", ) @bp.route( "/etudiant///formsemestre//bulletin//pdf", defaults={"pdf": True}, ) @bp.route( "/etudiant///formsemestre//bulletin//pdf/nosig", defaults={"pdf": True, "with_img_signatures_pdf": False}, ) @api_web_bp.route( "/etudiant///formsemestre//bulletin", ) @api_web_bp.route( "/etudiant///formsemestre//bulletin/", ) @api_web_bp.route( "/etudiant///formsemestre//bulletin//pdf", defaults={"pdf": True}, ) @api_web_bp.route( "/etudiant///formsemestre//bulletin//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. PARAMS ------ 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). SAMPLES ------- /etudiant/etudid/1/formsemestre/1/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//formsemestre//groups") @api_web_bp.route( "/etudiant/etudid//formsemestre//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é PARAMS ------ formsemestre_id : l'id d'un formsemestre etudid : l'etudid d'un étudiant SAMPLES ------- /etudiant/etudid/1/formsemestre/1/groups """ 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///edit", methods=["POST"]) @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, ): """Édition des données étudiant (identité, admission, adresses). PARAMS ------ `code_type`: le type du code, `etudid`, `ine` ou `nip`. `code`: la valeur du code SAMPLES ------- /etudiant/ine/INE1/edit;{""prenom"":""Nouveau Prénom"", ""adresses"":[{""email"":""nouvelle@adresse.fr""}]} """ 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///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. Renvoie l'annotation créée. PARAMS ------ `code_type`: le type du code, `etudid`, `ine` ou `nip`. `code`: la valeur du code DATA ---- ```json { "comment" : string } ``` SAMPLES ------- /etudiant/etudid/1/annotation;{""comment"":""une annotation sur l'é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. On spécifie l'étudiant et l'id de l'annotation. PARAMS ------ `code_type`: le type du code, `etudid`, `ine` ou `nip`. `code`: la valeur du code `annotation_id` : id de l'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"