############################################################################## # 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 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 from app.api import api_bp as bp, api_web_bp from app.api import tools from app.but import bulletin_but_court from app.decorators import scodoc, permission_required from app.models import ( Admission, Departement, 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} # @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=False): """ La liste des étudiants des semestres "courants" (tous départements) (date du jour comprise dans la période couverte par le sem.) dans lesquels l'utilisateur a la permission ScoView (donc tous si le dept du rôle est None). 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: data = [etud.to_dict_api() 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", ) return etud.to_dict_api() @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 correspondant ou un placeholder si non existant. 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) ) return [etud.to_dict_api() 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: return [etud.to_dict_api() 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 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") 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() 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) 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() # 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) r = etud.to_dict_api() return r