############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """ ScoDoc 9 API : accès aux formsemestres CATEGORY -------- FormSemestre """ import mimetypes from operator import attrgetter, itemgetter from flask import g, make_response, request from flask_json import as_json from flask_login import current_user, login_required import sqlalchemy as sa import app from app import db, log from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR from app.api import api_permission_required as permission_required from app.decorators import scodoc from app.scodoc.sco_utils import json_error from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults from app.comp.res_compat import NotesTableCompat from app.models import ( Departement, Evaluation, FormSemestre, FormSemestreEtape, FormSemestreInscription, Identite, ModuleImpl, NotesNotes, ) from app.models.formsemestre import GROUPS_AUTO_ASSIGNMENT_DATA_MAX from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json from app.scodoc import sco_edt_cal from app.scodoc.sco_formsemestre_inscriptions import ( do_formsemestre_inscription_with_modules, do_formsemestre_desinscription, ) from app.scodoc import sco_groups from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import ModuleType import app.scodoc.sco_utils as scu from app.tables.recap import TableRecap, RowRecap @bp.route("/formsemestre/") @api_web_bp.route("/formsemestre/") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_get(formsemestre_id: int): """ Information sur le formsemestre indiqué. formsemestre_id : l'id du formsemestre SAMPLES ------- /formsemestre/1 """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) return formsemestre.to_dict_api() @bp.route("/formsemestres/query") @api_web_bp.route("/formsemestres/query") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestres_query(): """ Retourne les formsemestres filtrés par étape Apogée ou année scolaire ou département (acronyme ou id) ou état ou code étudiant. PARAMS ------ etape_apo : un code étape apogée annee_scolaire : année de début de l'année scolaire dept_acronym : acronyme du département (eg "RT") dept_id : id du département ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit. etat: 0 si verrouillé, 1 sinon QUERY ----- etape_apo: annee_scolaire: dept_acronym: dept_id: etat: nip: ine: """ etape_apo = request.args.get("etape_apo") annee_scolaire = request.args.get("annee_scolaire") dept_acronym = request.args.get("dept_acronym") dept_id = request.args.get("dept_id") etat = request.args.get("etat") nip = request.args.get("nip") ine = request.args.get("ine") formsemestres = FormSemestre.query if g.scodoc_dept: formsemestres = formsemestres.filter_by(dept_id=g.scodoc_dept_id) if annee_scolaire is not None: try: annee_scolaire_int = int(annee_scolaire) except ValueError: return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int") debut_annee = scu.date_debut_annee_scolaire(annee_scolaire_int) fin_annee = scu.date_fin_annee_scolaire(annee_scolaire_int) formsemestres = formsemestres.filter( FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee ) if etat is not None: try: etat = bool(int(etat)) except ValueError: return json_error(404, "invalid etat: integer expected") formsemestres = formsemestres.filter_by(etat=etat) if dept_acronym is not None: formsemestres = formsemestres.join(Departement).filter_by(acronym=dept_acronym) if dept_id is not None: try: dept_id = int(dept_id) except ValueError: return json_error(404, "invalid dept_id: integer expected") formsemestres = formsemestres.filter_by(dept_id=dept_id) if etape_apo is not None: formsemestres = formsemestres.join(FormSemestreEtape).filter( FormSemestreEtape.etape_apo == etape_apo ) inscr_joined = False if nip is not None: formsemestres = ( formsemestres.join(FormSemestreInscription) .join(Identite) .filter_by(code_nip=nip) ) inscr_joined = True if ine is not None: if not inscr_joined: formsemestres = formsemestres.join(FormSemestreInscription).join(Identite) formsemestres = formsemestres.filter_by(code_ine=ine) return [ formsemestre.to_dict_api() for formsemestre in formsemestres.order_by( FormSemestre.date_debut.desc(), FormSemestre.modalite, FormSemestre.semestre_id, FormSemestre.titre, ) ] @bp.route("/formsemestre//edit", methods=["POST"]) @api_web_bp.route("/formsemestre//edit", methods=["POST"]) @scodoc @permission_required(Permission.EditFormSemestre) @as_json def formsemestre_edit(formsemestre_id: int): """Modifie les champs d'un formsemestre. On peut spécifier un ou plusieurs champs. DATA --- ```json { "semestre_id" : string, "titre" : string, "date_debut" : date iso, "date_fin" : date iso, "edt_id" : string, "etat" : string, "modalite" : string, "gestion_compensation" : bool, "bul_hide_xml" : bool, "block_moyennes" : bool, "block_moyenne_generale" : bool, "mode_calcul_moyennes" : string, "gestion_semestrielle" : string, "bul_bgcolor" : string, "resp_can_edit" : bool, "resp_can_change_ens" : bool, "ens_can_edit_eval" : bool, "elt_sem_apo" : string, "elt_annee_apo : string, } ``` """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) args = request.get_json(force=True) # may raise 400 Bad Request editable_keys = { "semestre_id", "titre", "date_debut", "date_fin", "edt_id", "etat", "modalite", "gestion_compensation", "bul_hide_xml", "block_moyennes", "block_moyenne_generale", "mode_calcul_moyennes", "gestion_semestrielle", "bul_bgcolor", "resp_can_edit", "resp_can_change_ens", "ens_can_edit_eval", "elt_sem_apo", "elt_annee_apo", } formsemestre.from_dict({k: v for (k, v) in args.items() if k in editable_keys}) try: db.session.commit() except sa.exc.StatementError as exc: return json_error(404, f"invalid argument(s): {exc.args[0]}") return formsemestre.to_dict_api() @bp.route("/formsemestre/apo/set_etapes", methods=["POST"]) @api_web_bp.route("/formsemestre/apo/set_etapes", methods=["POST"]) @scodoc @permission_required(Permission.EditApogee) def formsemestre_set_apo_etapes(): """Change les codes étapes du semestre indiqué. Le code est une chaîne, avec éventuellement plusieurs valeurs séparées par des virgules. Ce changement peut être fait sur un semestre verrouillé. DATA ---- ```json { oid : int, le formsemestre_id value : string, eg "V1RT, V1RT2", codes séparés par des virgules } """ formsemestre_id = int(request.form.get("oid")) etapes_apo_str = request.form.get("value") formsemestre = FormSemestre.get_formsemestre(formsemestre_id) current_etapes = {e.etape_apo for e in formsemestre.etapes} new_etapes = {s.strip() for s in etapes_apo_str.split(",")} if new_etapes != current_etapes: formsemestre.etapes = [] for etape_apo in new_etapes: etape = FormSemestreEtape( formsemestre_id=formsemestre_id, etape_apo=etape_apo ) formsemestre.etapes.append(etape) db.session.add(formsemestre) db.session.commit() log( f"""API formsemestre_set_apo_etapes: formsemestre_id={ formsemestre.id} code_apogee={etapes_apo_str}""" ) return ("", 204) @bp.route("/formsemestre/apo/set_elt_sem", methods=["POST"]) @api_web_bp.route("/formsemestre/apo/set_elt_sem", methods=["POST"]) @scodoc @permission_required(Permission.EditApogee) def formsemestre_set_elt_sem_apo(): """Change les codes étapes du semestre indiqué. Le code est une chaîne, avec éventuellement plusieurs valeurs séparées par des virgules. Ce changement peut être fait sur un semestre verrouillé. DATA ---- ```json { oid : int, le formsemestre_id value : string, eg "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules } ``` """ oid = int(request.form.get("oid")) value = (request.form.get("value") or "").strip() formsemestre = FormSemestre.get_formsemestre(oid) if value != formsemestre.elt_sem_apo: formsemestre.elt_sem_apo = value db.session.add(formsemestre) db.session.commit() log( f"""API formsemestre_set_elt_sem_apo: formsemestre_id={ formsemestre.id} code_apogee={value}""" ) return ("", 204) @bp.route("/formsemestre/apo/set_elt_annee", methods=["POST"]) @api_web_bp.route("/formsemestre/apo/set_elt_annee", methods=["POST"]) @scodoc @permission_required(Permission.EditApogee) def formsemestre_set_elt_annee_apo(): """Change les codes étapes du semestre indiqué (par le champ oid). Le code est une chaîne, avec éventuellement plusieurs valeurs séparées par des virgules. Ce changement peut être fait sur un semestre verrouillé. DATA ---- ```json { oid : int, le formsemestre_id value : string, eg "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules } ``` """ oid = int(request.form.get("oid")) value = (request.form.get("value") or "").strip() formsemestre = FormSemestre.get_formsemestre(oid) if value != formsemestre.elt_annee_apo: formsemestre.elt_annee_apo = value db.session.add(formsemestre) db.session.commit() log( f"""API formsemestre_set_elt_annee_apo: formsemestre_id={ formsemestre.id} code_apogee={value}""" ) return ("", 204) @bp.route("/formsemestre/apo/set_elt_passage", methods=["POST"]) @api_web_bp.route("/formsemestre/apo/set_elt_passage", methods=["POST"]) @scodoc @permission_required(Permission.EditApogee) def formsemestre_set_elt_passage_apo(): """Change les codes Apogée de passage du semestre indiqué (par le champ oid). Le code est une chaîne, avec éventuellement plusieurs valeurs séparées par des virgules. Ce changement peut être fait sur un semestre verrouillé. DATA ---- ```json { oid : int, le formsemestre_id value : string, eg "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules } ``` """ oid = int(request.form.get("oid")) value = (request.form.get("value") or "").strip() formsemestre = FormSemestre.get_formsemestre(oid) if value != formsemestre.elt_annee_apo: formsemestre.elt_passage_apo = value db.session.add(formsemestre) db.session.commit() log( f"""API formsemestre_set_elt_passage_apo: formsemestre_id={ formsemestre.id} code_apogee={value}""" ) return ("", 204) @bp.route("/formsemestre//bulletins") @bp.route("/formsemestre//bulletins/") @api_web_bp.route("/formsemestre//bulletins") @api_web_bp.route("/formsemestre//bulletins/") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def bulletins(formsemestre_id: int, version: str = "long"): """ Retourne les bulletins d'un formsemestre. PARAMS ------ formsemestre_id : int version : string ("long", "short", "selectedevals") SAMPLES ------- /formsemestre/1/bulletins """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) data = [] for etu in formsemestre.etuds: bul_etu = get_formsemestre_bulletin_etud_json( formsemestre, etu, version=version ) data.append(bul_etu.json) return data @bp.route("/formsemestre//programme") @api_web_bp.route("/formsemestre//programme") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_programme(formsemestre_id: int): """ Retourne la liste des UEs, ressources et SAEs d'un semestre SAMPLES ------- /formsemestre/1/programme """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) ues = formsemestre.get_ues() m_list = { ModuleType.RESSOURCE: [], ModuleType.SAE: [], ModuleType.STANDARD: [], ModuleType.MALUS: [], } for modimpl in formsemestre.modimpls_sorted: d = modimpl.to_dict(convert_objects=True) m_list[modimpl.module.module_type].append(d) return { "ues": [ue.to_dict(convert_objects=True) for ue in ues], "ressources": m_list[ModuleType.RESSOURCE], "saes": m_list[ModuleType.SAE], "modules": m_list[ModuleType.STANDARD], "malus": m_list[ModuleType.MALUS], } @bp.route( "/formsemestre//etudiants", defaults={"with_query": False, "long": False}, ) @bp.route( "/formsemestre//etudiants/long", defaults={"with_query": False, "long": True}, ) @bp.route( "/formsemestre//etudiants/query", defaults={"with_query": True, "long": False}, ) @bp.route( "/formsemestre//etudiants/long/query", defaults={"with_query": True, "long": True}, ) @api_web_bp.route( "/formsemestre//etudiants", defaults={"with_query": False, "long": False}, ) @api_web_bp.route( "/formsemestre//etudiants/long", defaults={"with_query": False, "long": True}, ) @api_web_bp.route( "/formsemestre//etudiants/query", defaults={"with_query": True, "long": False}, ) @api_web_bp.route( "/formsemestre//etudiants/long/query", defaults={"with_query": True, "long": True}, ) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_etudiants( formsemestre_id: int, with_query: bool = False, long: bool = False ): """Étudiants d'un formsemestre. Si l'état est spécifié, ne renvoie que les inscrits (`I`), les démissionnaires (`D`) ou les défaillants (`DEF`) QUERY ----- etat: SAMPLES ------- /formsemestre/1/etudiants/query; """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) if with_query: etat = request.args.get("etat") if etat is not None: etat = { "actifs": scu.INSCRIT, "demissionnaires": scu.DEMISSION, "defaillants": scu.DEF, }.get(etat, etat) inscriptions = [ ins for ins in formsemestre.inscriptions if ins.etat == etat ] else: inscriptions = formsemestre.inscriptions else: inscriptions = formsemestre.inscriptions if long: restrict = not current_user.has_permission(Permission.ViewEtudData) etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions] else: etuds = [ins.etud.to_dict_short() for ins in inscriptions] # Ajout des groupes de chaque étudiants # XXX A REVOIR: trop inefficace ! for etud in etuds: etud["groups"] = sco_groups.get_etud_groups( etud["id"], formsemestre_id, exclude_default=True ) return sorted(etuds, key=itemgetter("sort_key")) @bp.post("/formsemestre//etudid//inscrit") @api_web_bp.post("/formsemestre//etudid//inscrit") @login_required @scodoc @permission_required(Permission.EtudInscrit) @as_json def formsemestre_etud_inscrit(formsemestre_id: int, etudid: int): """Inscrit l'étudiant à ce formsemestre et TOUS ses modules STANDARDS (donc sauf les modules bonus sport). DATA ---- ```json { "dept_id" : int, # le département "etape" : string, # optionnel: l'étape Apogée d'inscription "group_ids" : [int], # optionnel: liste des groupes où inscrire l'étudiant (doivent exister) } ``` """ data = request.get_json(force=True) if request.data else {} dept_id = data.get("dept_id", g.scodoc_dept_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id) app.set_sco_dept(formsemestre.departement.acronym) etud = Identite.get_etud(etudid) group_ids = data.get("group_ids", []) etape = data.get("etape", None) do_formsemestre_inscription_with_modules( formsemestre.id, etud.id, dept_id=dept_id, etape=etape, group_ids=group_ids ) app.log(f"formsemestre_etud_inscrit: {etud} inscrit à {formsemestre}") return ( FormSemestreInscription.query.filter_by( formsemestre_id=formsemestre.id, etudid=etud.id ) .first() .to_dict() ) @bp.post("/formsemestre//etudid//desinscrit") @api_web_bp.post("/formsemestre//etudid//desinscrit") @login_required @scodoc @permission_required(Permission.EtudInscrit) @as_json def formsemestre_etud_desinscrit(formsemestre_id: int, etudid: int): """Désinscrit l'étudiant de ce formsemestre et TOUS ses modules""" formsemestre = FormSemestre.get_formsemestre(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) etud = Identite.get_etud(etudid) do_formsemestre_desinscription(etud.id, formsemestre.id) app.log(f"formsemestre_etud_desinscrit: {etud} désinscrit de {formsemestre}") return {"status": "ok"} @bp.route("/formsemestre//etat_evals") @api_web_bp.route("/formsemestre//etat_evals") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_etat_evaluations(formsemestre_id: int): """ Informations sur l'état des évaluations d'un formsemestre. SAMPLES ------- /formsemestre/1/etat_evals """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) result = [] for modimpl_id in nt.modimpls_results: modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id] modimpl: ModuleImpl = ModuleImpl.query.get_or_404(modimpl_id) modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False) list_eval = [] for evaluation_id in modimpl_results.evaluations_etat: eval_etat = modimpl_results.evaluations_etat[evaluation_id] evaluation = Evaluation.query.get_or_404(evaluation_id) eval_dict = evaluation.to_dict_api() eval_dict["etat"] = eval_etat.to_dict() eval_dict["nb_inscrits"] = modimpl_results.nb_inscrits_module eval_dict["nb_notes_manquantes"] = len( modimpl_results.evals_etudids_sans_note[evaluation.id] ) eval_dict["nb_notes_abs"] = sum( modimpl_results.evals_notes[evaluation.id] == scu.NOTES_ABSENCE ) eval_dict["nb_notes_att"] = eval_etat.nb_attente eval_dict["nb_notes_exc"] = sum( modimpl_results.evals_notes[evaluation.id] == scu.NOTES_NEUTRALISE ) # Récupération de toutes les notes de l'évaluation # eval["notes"] = modimpl_results.get_eval_notes_dict(evaluation_id) notes = NotesNotes.query.filter_by(evaluation_id=evaluation.id).all() date_debut = None date_fin = None date_mediane = None # Si il y a plus d'une note saisie pour l'évaluation if len(notes) >= 1: # Tri des notes en fonction de leurs dates notes_sorted = sorted(notes, key=attrgetter("date")) date_debut = notes_sorted[0].date date_fin = notes_sorted[-1].date # Note médiane date_mediane = notes_sorted[len(notes_sorted) // 2].date eval_dict["saisie_notes"] = { "datetime_debut": ( date_debut.isoformat() if date_debut is not None else None ), "datetime_fin": date_fin.isoformat() if date_fin is not None else None, "datetime_mediane": ( date_mediane.isoformat() if date_mediane is not None else None ), } list_eval.append(eval_dict) modimpl_dict["evaluations"] = list_eval result.append(modimpl_dict) return result @bp.route("/formsemestre//resultats") @api_web_bp.route("/formsemestre//resultats") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_resultat(formsemestre_id: int): """Tableau récapitulatif des résultats. Pour chaque étudiant, son état, ses groupes, ses moyennes d'UE et de modules. Si `format=raw`, ne converti pas les valeurs. QUERY ----- format: SAMPLES ------- /formsemestre/1/resultats; """ format_spec = request.args.get("format", None) if format_spec is not None and format_spec != "raw": return json_error(API_CLIENT_ERROR, "invalid format specification") convert_values = format_spec != "raw" formsemestre = FormSemestre.get_formsemestre(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) # Ajoute le groupe de chaque partition, etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id) class RowRecapAPI(RowRecap): """Pour table avec partitions et sort_key""" def add_etud_cols(self): """Ajoute colonnes étudiant: codes, noms""" super().add_etud_cols() self.add_cell("partitions", "partitions", etud_groups.get(self.etud.id, {})) self.add_cell("sort_key", "sort_key", self.etud.sort_key) table = TableRecap( res, convert_values=convert_values, include_evaluations=False, mode_jury=False, row_class=RowRecapAPI, ) rows = table.to_list() # for row in rows: # row["partitions"] = etud_groups.get(row["etudid"], {}) return rows @bp.route("/formsemestre//groups_get_auto_assignment") @api_web_bp.route("/formsemestre//groups_get_auto_assignment") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def groups_get_auto_assignment(formsemestre_id: int): """Rend les données stockées par `groups_save_auto_assignment`.""" formsemestre = FormSemestre.get_formsemestre(formsemestre_id) response = make_response(formsemestre.groups_auto_assignment_data or b"") response.headers["Content-Type"] = scu.JSON_MIMETYPE return response @bp.route( "/formsemestre//groups_save_auto_assignment", methods=["POST"] ) @api_web_bp.route( "/formsemestre//groups_save_auto_assignment", methods=["POST"] ) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def groups_save_auto_assignment(formsemestre_id: int): """Enregistre les données, associées à ce formsemestre. Usage réservé aux fonctions de gestion des groupes, ne pas utiliser ailleurs. """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) if not formsemestre.can_change_groups(): return json_error(403, "non autorisé (can_change_groups)") if len(request.data) > GROUPS_AUTO_ASSIGNMENT_DATA_MAX: return json_error(413, "data too large") formsemestre.groups_auto_assignment_data = request.data db.session.add(formsemestre) db.session.commit() return {"status": "ok"} @bp.route("/formsemestre//edt") @api_web_bp.route("/formsemestre//edt") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_edt(formsemestre_id: int): """L'emploi du temps du semestre. Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur. Expérimental, ne pas utiliser hors ScoDoc. QUERY ----- group_ids : string (optionnel) filtre sur les groupes ScoDoc. show_modules_titles: show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) group_ids = request.args.getlist("group_ids", int) show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False)) return sco_edt_cal.formsemestre_edt_dict( formsemestre, group_ids=group_ids, show_modules_titles=show_modules_titles ) @bp.route("/formsemestre//description") @api_web_bp.route("/formsemestre//description") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def formsemestre_get_description(formsemestre_id: int): """Description externe du formsemestre. Peut être vide. formsemestre_id : l'id du formsemestre SAMPLES ------- /formsemestre/1/description """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) return formsemestre.description.to_dict() if formsemestre.description else {} @bp.route("/formsemestre//description/image") @api_web_bp.route("/formsemestre//description/image") @login_required @scodoc @permission_required(Permission.ScoView) def formsemestre_get_description_image(formsemestre_id: int): """Image de la description externe du formsemestre. Peut être vide. formsemestre_id : l'id du formsemestre """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) if not formsemestre.description or not formsemestre.description.image: return make_response("", 204) # 204 No Content status # Guess the mimetype based on the image data image_data = formsemestre.description.image mimetype = mimetypes.guess_type("image")[0] if not mimetype: # Default to binary stream if mimetype cannot be determined mimetype = "application/octet-stream" response = make_response(image_data) response.headers["Content-Type"] = mimetype return response