############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """ ScoDoc 9 API : accès aux formsemestres """ 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 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_infos(formsemestre_id: int): """ Information sur le formsemestre indiqué. formsemestre_id : l'id du formsemestre Exemple de résultat : { "block_moyennes": false, "bul_bgcolor": "white", "bul_hide_xml": false, "date_debut_iso": "2021-09-01", "date_debut": "01/09/2021", "date_fin_iso": "2022-08-31", "date_fin": "31/08/2022", "dept_id": 1, "elt_annee_apo": null, "elt_passage_apo" : null, "elt_sem_apo": null, "ens_can_edit_eval": false, "etat": true, "formation_id": 1, "formsemestre_id": 1, "gestion_compensation": false, "gestion_semestrielle": false, "id": 1, "modalite": "FI", "resp_can_change_ens": true, "resp_can_edit": false, "responsables": [1, 99], // uids "scodoc7_id": null, "semestre_id": 1, "titre_formation" : "BUT GEA", "titre_num": "BUT GEA semestre 1", "titre": "BUT GEA", } """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(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.""" 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é) Args: oid=int, le formsemestre_id value=chaine "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é) Args: oid=int, le formsemestre_id value=chaine "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é) Args: oid=int, le formsemestre_id value=chaine "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é) Args: oid=int, le formsemestre_id value=chaine "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 donné formsemestre_id : l'id d'un formesemestre Exemple de résultat : liste, voir https://scodoc.org/ScoDoc9API/#bulletin """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first() if formsemestre is None: return json_error(404, "formsemestre non trouve") 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 formsemestre_id : l'id d'un formsemestre Exemple de résultat : { "ues": [ { "type": 0, "formation_id": 1, "ue_code": "UCOD11", "id": 1, "ects": 12.0, "acronyme": "RT1.1", "is_external": false, "numero": 1, "code_apogee": "", "titre": "Administrer les r\u00e9seaux et l\u2019Internet", "coefficient": 0.0, "semestre_idx": 1, "color": "#B80004", "ue_id": 1 }, ... ], "ressources": [ { "ens": [ 10, 18 ], "formsemestre_id": 1, "id": 15, "module": { "abbrev": "Programmer", "code": "SAE15", "code_apogee": "V7GOP", "coefficient": 1.0, "formation_id": 1, "heures_cours": 0.0, "heures_td": 0.0, "heures_tp": 0.0, "id": 15, "matiere_id": 3, "module_id": 15, "module_type": 3, "numero": 50, "semestre_id": 1, "titre": "Programmer en Python", "ue_id": 3 }, "module_id": 15, "moduleimpl_id": 15, "responsable_id": 2 }, ... ], "saes": [ { ... }, ... ], "modules" : [ ... les modules qui ne sont ni des SAEs ni des ressources ... ] } """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(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: """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(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.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. formsemestre_id : l'id d'un semestre Exemple de résultat : [ { "id": 1, // moduleimpl_id "titre": "Initiation aux réseaux informatiques", "evaluations": [ { "id": 1, "description": null, "datetime_epreuve": null, "heure_fin": "09:00:00", "coefficient": "02.00" "is_complete": true, "nb_inscrits": 16, "nb_manquantes": 0, "ABS": 0, "ATT": 0, "EXC": 0, "saisie_notes": { "datetime_debut": "2021-09-11T00:00:00+02:00", "datetime_fin": "2022-08-25T00:00:00+02:00", "datetime_mediane": "2022-03-19T00:00:00+01:00" } }, ... ] }, ] """ 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: """ 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" query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(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""" query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(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""" query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id) 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() @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. group_ids permet de filtrer sur les groupes ScoDoc. show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. QUERY ----- group_ids: show_modules_titles: """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: query = query.filter_by(dept_id=g.scodoc_dept_id) formsemestre: FormSemestre = query.first_or_404(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 )