############################################################################## # 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 from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR from app.decorators import scodoc, permission_required 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/<int:formsemestre_id>") @api_web_bp.route("/formsemestre/<int:formsemestre_id>") @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_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 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 """ 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/<int:formsemestre_id>/edit", methods=["POST"]) @api_web_bp.route("/formsemestre/<int:formsemestre_id>/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/<int:formsemestre_id>/bulletins") @bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>") @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/<int:formsemestre_id>/programme") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/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/<int:formsemestre_id>/etudiants", defaults={"with_query": False, "long": False}, ) @bp.route( "/formsemestre/<int:formsemestre_id>/etudiants/long", defaults={"with_query": False, "long": True}, ) @bp.route( "/formsemestre/<int:formsemestre_id>/etudiants/query", defaults={"with_query": True, "long": False}, ) @bp.route( "/formsemestre/<int:formsemestre_id>/etudiants/long/query", defaults={"with_query": True, "long": True}, ) @api_web_bp.route( "/formsemestre/<int:formsemestre_id>/etudiants", defaults={"with_query": False, "long": False}, ) @api_web_bp.route( "/formsemestre/<int:formsemestre_id>/etudiants/long", defaults={"with_query": False, "long": True}, ) @api_web_bp.route( "/formsemestre/<int:formsemestre_id>/etudiants/query", defaults={"with_query": True, "long": False}, ) @api_web_bp.route( "/formsemestre/<int:formsemestre_id>/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.""" 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/<int:formsemestre_id>/etat_evals") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/etat_evals") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def etat_evals(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" } }, ... ] }, ] """ 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) 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/<int:formsemestre_id>/resultats") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/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. """ 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/<int:formsemestre_id>/get_groups_auto_assignment") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/get_groups_auto_assignment") @login_required @scodoc @permission_required(Permission.ScoView) @as_json def get_groups_auto_assignment(formsemestre_id: int): """rend 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) response = make_response(formsemestre.groups_auto_assignment_data or b"") response.headers["Content-Type"] = scu.JSON_MIMETYPE return response @bp.route( "/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"] ) @api_web_bp.route( "/formsemestre/<int:formsemestre_id>/save_groups_auto_assignment", methods=["POST"] ) @login_required @scodoc @permission_required(Permission.ScoView) @as_json def save_groups_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/<int:formsemestre_id>/edt") @api_web_bp.route("/formsemestre/<int:formsemestre_id>/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 = 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 )