############################################################################## # 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/") # @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). QUERY ----- 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/") @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é. 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//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: 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 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/") @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 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///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. 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//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é 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///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, ): """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///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"