From fb4cabee3b3ef6619f07159b704c105b4668cc09 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 3 Jun 2023 22:43:04 +0200 Subject: [PATCH 01/71] =?UTF-8?q?-=20Am=C3=A9lioration=20enregistrement=20?= =?UTF-8?q?note.=20-=20Nouveau=20point=20API:=20/evaluation//notes/set=20-=20Corrige=20API=20/evaluation//notes=20-=20Modernisation=20de=20code.=20-=20Am?= =?UTF-8?q?=C3=A9liore=20tests=20unitaires=20APi=20evaluation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/evaluations.py | 70 +++- app/scodoc/sco_evaluation_db.py | 2 +- app/scodoc/sco_saisie_notes.py | 301 +++++++++--------- app/static/js/saisie_notes.js | 213 +++++++------ app/views/notes.py | 6 - sco_version.py | 2 +- tests/api/test_api_evaluations.py | 32 +- tests/api/test_api_permissions.py | 1 + tests/api/tools_test_api.py | 3 +- tests/unit/test_sco_basic.py | 8 +- .../fakedatabase/create_test_api_database.py | 2 +- 11 files changed, 347 insertions(+), 293 deletions(-) diff --git a/app/api/evaluations.py b/app/api/evaluations.py index d5a7a3cfc..8cd277679 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -8,7 +8,7 @@ ScoDoc 9 API : accès aux évaluations """ -from flask import g +from flask import g, request from flask_json import as_json from flask_login import login_required @@ -17,7 +17,7 @@ import app from app.api import api_bp as bp, api_web_bp from app.decorators import scodoc, permission_required from app.models import Evaluation, ModuleImpl, FormSemestre -from app.scodoc import sco_evaluation_db +from app.scodoc import sco_evaluation_db, sco_saisie_notes from app.scodoc.sco_permissions import Permission import app.scodoc.sco_utils as scu @@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu @scodoc @permission_required(Permission.ScoView) @as_json -def the_eval(evaluation_id: int): +def evaluation(evaluation_id: int): """Description d'une évaluation. { @@ -93,24 +93,22 @@ def evaluations(moduleimpl_id: int): @as_json def evaluation_notes(evaluation_id: int): """ - Retourne la liste des notes à partir de l'id d'une évaluation donnée + Retourne la liste des notes de l'évaluation - evaluation_id : l'id d'une évaluation + evaluation_id : l'id de l'évaluation Exemple de résultat : { - "1": { - "id": 1, - "etudid": 10, + "11": { + "etudid": 11, "evaluation_id": 1, "value": 15.0, "comment": "", "date": "Wed, 20 Apr 2022 06:49:05 GMT", "uid": 2 }, - "2": { - "id": 2, - "etudid": 1, + "12": { + "etudid": 12, "evaluation_id": 1, "value": 12.0, "comment": "", @@ -128,8 +126,8 @@ def evaluation_notes(evaluation_id: int): .filter_by(dept_id=g.scodoc_dept_id) ) - the_eval = query.first_or_404() - dept = the_eval.moduleimpl.formsemestre.departement + evaluation = query.first_or_404() + dept = evaluation.moduleimpl.formsemestre.departement app.set_sco_dept(dept.acronym) notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) @@ -137,7 +135,49 @@ def evaluation_notes(evaluation_id: int): # "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval. note = notes[etudid] note["value"] = scu.fmt_note(note["value"], keep_numeric=True) - note["note_max"] = the_eval.note_max + note["note_max"] = evaluation.note_max del note["id"] - return notes + # in JS, keys must be string, not integers + return {str(etudid): note for etudid, note in notes.items()} + + +@bp.route("/evaluation//notes/set", methods=["POST"]) +@api_web_bp.route("/evaluation//notes/set", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEnsView) +@as_json +def evaluation_set_notes(evaluation_id: int): + """Écriture de notes dans une évaluation. + The request content type should be "application/json", + and contains: + { + 'notes' : [ (etudid, value), ... ], + 'comment' : opetional string + } + Result: + - nb_changed: nombre de notes changées + - nb_suppress: nombre de notes effacées + - etudids_with_decision: liste des etudiants dont la note a changé + alors qu'ils ont une décision de jury enregistrée. + """ + query = Evaluation.query.filter_by(id=evaluation_id) + if g.scodoc_dept: + query = ( + query.join(ModuleImpl) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + ) + evaluation = query.first_or_404() + dept = evaluation.moduleimpl.formsemestre.departement + app.set_sco_dept(dept.acronym) + data = request.get_json(force=True) # may raise 400 Bad Request + notes = data.get("notes") + if notes is None: + return scu.json_error(404, "no notes") + if not isinstance(notes, list): + return scu.json_error(404, "invalid notes argument (must be a list)") + return sco_saisie_notes.save_notes( + evaluation, notes, comment=data.get("comment", "") + ) diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py index 44ec6a768..7cbc32331 100644 --- a/app/scodoc/sco_evaluation_db.py +++ b/app/scodoc/sco_evaluation_db.py @@ -252,7 +252,7 @@ def do_evaluation_delete(evaluation_id): def do_evaluation_get_all_notes( evaluation_id, table="notes_notes", filter_suppressed=True, by_uid=None ): - """Toutes les notes pour une evaluation: { etudid : { 'value' : value, 'date' : date ... }} + """Toutes les notes pour une évaluation: { etudid : { 'value' : value, 'date' : date ... }} Attention: inclut aussi les notes des étudiants qui ne sont plus inscrits au module. """ # pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index e5d7f3da5..6e9084d0c 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -36,16 +36,20 @@ import flask from flask import g, url_for, request from flask_login import current_user +from app import log from app.auth.models import User from app.comp import res_sem from app.comp.res_compat import NotesTableCompat -from app.models import Evaluation, FormSemestre -from app.models import ModuleImpl, NotesNotes, ScolarNews +from app.models import ( + Evaluation, + FormSemestre, + Module, + ModuleImpl, + NotesNotes, + ScolarNews, +) from app.models.etudiants import Identite -import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ModuleType -import app.scodoc.notesdb as ndb -from app import log + from app.scodoc.sco_exceptions import ( AccessDenied, InvalidNoteValue, @@ -55,14 +59,14 @@ from app.scodoc.sco_exceptions import ( ScoInvalidParamError, ScoValueError, ) -from app.scodoc.TrivialFormulator import TrivialFormulator, TF from app.scodoc import html_sco_header, sco_users from app.scodoc import htmlutils from app.scodoc import sco_abs from app.scodoc import sco_cache from app.scodoc import sco_edit_module -from app.scodoc import sco_evaluations +from app.scodoc import sco_etud from app.scodoc import sco_evaluation_db +from app.scodoc import sco_evaluations from app.scodoc import sco_excel from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups @@ -70,7 +74,11 @@ from app.scodoc import sco_groups_view from app.scodoc import sco_moduleimpl from app.scodoc import sco_permissions_check from app.scodoc import sco_undo_notes -from app.scodoc import sco_etud +import app.scodoc.notesdb as ndb +from app.scodoc.TrivialFormulator import TrivialFormulator, TF +import app.scodoc.sco_utils as scu +from app.scodoc.sco_utils import json_error +from app.scodoc.sco_utils import ModuleType def convert_note_from_string( @@ -128,29 +136,30 @@ def _displayNote(val): return val -def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict): +def _check_notes(notes: list[(int, float)], evaluation: Evaluation): # XXX typehint : float or str """notes is a list of tuples (etudid, value) mod is the module (used to ckeck type, for malus) returns list of valid notes (etudid, float value) - and 4 lists of etudid: invalids, withoutnotes, absents, tosuppress, existingjury + and 4 lists of etudid: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress """ - note_max = evaluation["note_max"] - if mod["module_type"] in ( + note_max = evaluation.note_max or 0.0 + module: Module = evaluation.moduleimpl.module + if module.module_type in ( scu.ModuleType.STANDARD, scu.ModuleType.RESSOURCE, scu.ModuleType.SAE, ): note_min = scu.NOTES_MIN - elif mod["module_type"] == ModuleType.MALUS: + elif module.module_type == ModuleType.MALUS: note_min = -20.0 else: raise ValueError("Invalid module type") # bug - L = [] # liste (etudid, note) des notes ok (ou absent) - invalids = [] # etudid avec notes invalides - withoutnotes = [] # etudid sans notes (champs vides) - absents = [] # etudid absents - tosuppress = [] # etudids avec ancienne note à supprimer + valid_notes = [] # liste (etudid, note) des notes ok (ou absent) + etudids_invalids = [] # etudid avec notes invalides + etudids_without_notes = [] # etudid sans notes (champs vides) + etudids_absents = [] # etudid absents + etudid_to_suppress = [] # etudids avec ancienne note à supprimer for etudid, note in notes: note = str(note).strip().upper() @@ -166,31 +175,34 @@ def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict): note_max, note_min=note_min, etudid=etudid, - absents=absents, - tosuppress=tosuppress, - invalids=invalids, + absents=etudids_absents, + tosuppress=etudid_to_suppress, + invalids=etudids_invalids, ) if not invalid: - L.append((etudid, value)) + valid_notes.append((etudid, value)) else: - withoutnotes.append(etudid) - return L, invalids, withoutnotes, absents, tosuppress + etudids_without_notes.append(etudid) + return ( + valid_notes, + etudids_invalids, + etudids_without_notes, + etudids_absents, + etudid_to_suppress, + ) def do_evaluation_upload_xls(): """ Soumission d'un fichier XLS (evaluation_id, notefile) """ - authuser = current_user vals = scu.get_request_args() evaluation_id = int(vals["evaluation_id"]) comment = vals["comment"] - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] - # Check access - # (admin, respformation, and responsable_id) - if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]): - raise AccessDenied("Modification des notes impossible pour %s" % authuser) + evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id) + # Check access (admin, respformation, and responsable_id) + if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id): + raise AccessDenied(f"Modification des notes impossible pour {current_user}") # diag, lines = sco_excel.excel_file_to_list(vals["notefile"]) try: @@ -239,14 +251,16 @@ def do_evaluation_upload_xls(): if etudid: notes.append((etudid, val)) ni += 1 - except: + except Exception as exc: diag.append( f"""Erreur: Ligne invalide ! (erreur ligne {ni})
{lines[ni]}""" ) - raise InvalidNoteValue() + raise InvalidNoteValue() from exc # -- check values - L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"]) - if len(invalids): + valid_notes, invalids, withoutnotes, absents, _ = _check_notes( + notes, evaluation + ) + if invalids: diag.append( f"Erreur: la feuille contient {len(invalids)} notes invalides

" ) @@ -258,37 +272,33 @@ def do_evaluation_upload_xls(): diag.append("Notes invalides pour: " + ", ".join(etudsnames)) raise InvalidNoteValue() else: - nb_changed, nb_suppress, existing_decisions = notes_add( - authuser, evaluation_id, L, comment + etudids_changed, nb_suppress, etudids_with_decisions = notes_add( + current_user, evaluation_id, valid_notes, comment ) # news - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[ - 0 - ] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - mod["moduleimpl_id"] = M["moduleimpl_id"] - mod["url"] = url_for( + module: Module = evaluation.moduleimpl.module + status_url = url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, - moduleimpl_id=mod["moduleimpl_id"], + moduleimpl_id=evaluation.moduleimpl_id, _external=True, ) ScolarNews.add( typ=ScolarNews.NEWS_NOTE, - obj=M["moduleimpl_id"], - text='Chargement notes dans %(titre)s' % mod, - url=mod["url"], + obj=evaluation.moduleimpl_id, + text=f"""Chargement notes dans { + module.titre or module.code}""", + url=status_url, max_frequency=30 * 60, # 30 minutes ) - msg = ( - "

%d notes changées (%d sans notes, %d absents, %d note supprimées)

" - % (nb_changed, len(withoutnotes), len(absents), nb_suppress) - ) - if existing_decisions: - msg += """

Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !

""" - # msg += '

' + str(notes) # debug + msg = f"""

{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, { + len(absents)} absents, {nb_suppress} note supprimées) +

""" + if etudids_with_decisions: + msg += """

Important: il y avait déjà des décisions de jury + enregistrées, qui sont peut-être à revoir suite à cette modification !

+ """ return 1, msg except InvalidNoteValue: @@ -310,14 +320,12 @@ def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) - if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl.id): raise AccessDenied(f"Modification des notes impossible pour {current_user}") # Convert and check value - L, invalids, _, _, _ = _check_notes( - [(etud.id, value)], evaluation.to_dict(), evaluation.moduleimpl.module.to_dict() - ) + L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation) if len(invalids) == 0: - nb_changed, _, _ = notes_add( + etudids_changed, _, _ = notes_add( current_user, evaluation.id, L, "Initialisation notes" ) - if nb_changed == 1: + if len(etudids_changed) == 1: return True return False # error @@ -352,9 +360,7 @@ def do_evaluation_set_missing( if etudid not in notes_db: # pas de note notes.append((etudid, value)) # Convert and check values - L, invalids, _, _, _ = _check_notes( - notes, evaluation.to_dict(), modimpl.module.to_dict() - ) + valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation) dest_url = url_for( "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id ) @@ -372,13 +378,13 @@ def do_evaluation_set_missing( """ # Confirm action if not dialog_confirmed: - plural = len(L) > 1 + plural = len(valid_notes) > 1 return scu.confirm_dialog( f"""

Mettre toutes les notes manquantes de l'évaluation à la valeur {value} ?

Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC) n'a été rentrée seront affectés.

-

{len(L)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""} +

{len(valid_notes)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""} par ce changement de note.

""", @@ -392,7 +398,7 @@ def do_evaluation_set_missing( ) # ok comment = "Initialisation notes manquantes" - nb_changed, _, _ = notes_add(current_user, evaluation_id, L, comment) + etudids_changed, _, _ = notes_add(current_user, evaluation_id, valid_notes, comment) # news url = url_for( "notes.moduleimpl_status", @@ -408,7 +414,7 @@ def do_evaluation_set_missing( ) return f""" { html_sco_header.sco_header() } -

{nb_changed} notes changées

+

{len(etudids_changed)} notes changées

@@ -902,7 +900,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): pour cette année: décisions d'UE, de RCUE, d'année, et autorisations d'inscription émises. Efface même si étudiant DEM ou DEF. - Si à cheval, n'efface que pour le semestre d'origine du deca. + Si à cheval ou only_one_sem, n'efface que les décisions UE et les + autorisations de passage du semestre d'origine du deca. (commite la session.) """ if only_one_sem or self.a_cheval: diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 0a20e9334..a61f1f14b 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -246,7 +246,7 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: scoplement = ( f"""
{ - dec_rcue.validation.to_html() + dec_rcue.validation.html() }
""" if dec_rcue.validation else "" diff --git a/app/comp/jury.py b/app/comp/jury.py index cee32ffdd..1c43158da 100644 --- a/app/comp/jury.py +++ b/app/comp/jury.py @@ -10,8 +10,17 @@ import pandas as pd import sqlalchemy as sa from app import db -from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns from app.comp.res_cache import ResultatsCache +from app.models import ( + ApcValidationAnnee, + ApcValidationRCUE, + Formation, + FormSemestre, + Identite, + ScolarAutorisationInscription, + ScolarFormSemestreValidation, + UniteEns, +) from app.scodoc import sco_cache from app.scodoc import codes_cursus @@ -81,7 +90,7 @@ class ValidationsSemestre(ResultatsCache): # UEs: { etudid : { ue_id : {"code":, "ects":, "event_date":} }} decisions_jury_ues = {} - # Parcours les décisions d'UE: + # Parcoure les décisions d'UE: for decision in ( decisions_jury_q.filter(db.text("ue_id is not NULL")) .join(UniteEns) @@ -172,3 +181,80 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame with db.engine.begin() as connection: df = pd.read_sql_query(query, connection, params=params, index_col="etudid") return df + + +def erase_decisions_annee_formation( + etud: Identite, formation: Formation, annee: int, delete=False +) -> list: + """Efface toutes les décisions de jury de l'étudiant dans les formations de même code + que celle donnée pour cette année de la formation: + UEs, RCUEs de l'année BUT, année BUT, passage vers l'année suivante. + Ne considère pas l'origine de la décision. + annee: entier, 1, 2, 3, ... + Si delete est faux, renvoie la liste des validations qu'il faudrait effacer, sans y toucher. + """ + sem1, sem2 = annee * 2 - 1, annee * 2 + # UEs + validations = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) + .join(UniteEns) + .filter(db.or_(UniteEns.semestre_idx == sem1, UniteEns.semestre_idx == sem2)) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .order_by( + UniteEns.acronyme, UniteEns.numero + ) # acronyme d'abord car 2 semestres + .all() + ) + # RCUEs (a priori inutile de matcher sur l'ue2_id) + validations += ( + ApcValidationRCUE.query.filter_by(etudid=etud.id) + .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) + .filter_by(semestre_idx=sem1) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .order_by(UniteEns.acronyme, UniteEns.numero) + .all() + ) + # Validation de semestres classiques + validations += ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id, ue_id=None) + .join( + FormSemestre, + FormSemestre.id == ScolarFormSemestreValidation.formsemestre_id, + ) + .filter( + db.or_(FormSemestre.semestre_id == sem1, FormSemestre.semestre_id == sem2) + ) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .all() + ) + # Année BUT + validations += ( + ApcValidationAnnee.query.filter_by(etudid=etud.id, ordre=annee) + .join(Formation) + .filter_by(formation_code=formation.formation_code) + .all() + ) + # Autorisations vers les semestres suivants ceux de l'année: + validations += ( + ScolarAutorisationInscription.query.filter_by( + etudid=etud.id, formation_code=formation.formation_code + ) + .filter( + db.or_( + ScolarAutorisationInscription.semestre_id == sem1 + 1, + ScolarAutorisationInscription.semestre_id == sem2 + 1, + ) + ) + .all() + ) + + if delete: + for validation in validations: + db.session.delete(validation) + db.session.commit() + sco_cache.invalidate_formsemestre_etud(etud) + return [] + return validations diff --git a/app/models/but_validations.py b/app/models/but_validations.py index c2be058b3..778164765 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -66,7 +66,7 @@ class ApcValidationRCUE(db.Model): return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: { self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}""" - def to_html(self) -> str: + def html(self) -> str: "description en HTML" return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {self.code} @@ -348,6 +348,13 @@ class ApcValidationAnnee(db.Model): "ordre": self.ordre, } + def html(self) -> str: + "Affichage html" + return f"""Validation année BUT{self.ordre} émise par + {self.formsemestre.html_link_status() if self.formsemestre else "-"} + le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")} + """ + def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict: """ diff --git a/app/models/formations.py b/app/models/formations.py index e98d66f7b..fb7529e32 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -60,7 +60,7 @@ class Formation(db.Model): return f"""<{self.__class__.__name__}(id={self.id}, dept_id={ self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>""" - def to_html(self) -> str: + def html(self) -> str: "titre complet pour affichage" return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 516d8fc51..efbceb74b 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -16,7 +16,7 @@ from operator import attrgetter from flask_login import current_user -from flask import flash, g +from flask import flash, g, url_for from sqlalchemy.sql import text import app.scodoc.sco_utils as scu @@ -163,6 +163,14 @@ class FormSemestre(db.Model): def __repr__(self): return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>" + def html_link_status(self) -> str: + "html link to status page" + return f"""{self.titre_mois()} + """ + @classmethod def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre": """ "FormSemestre ou 404, cherche uniquement dans le département courant""" diff --git a/app/models/validations.py b/app/models/validations.py index 229d15ad5..9a938b6c5 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -59,13 +59,16 @@ class ScolarFormSemestreValidation(db.Model): ) def __repr__(self): - return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})" + return f"""{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={ + self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})""" def __str__(self): if self.ue_id: # Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue ! - return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id}: {self.code}""" - return f"""décision sur semestre {self.formsemestre.titre_mois()} du {self.event_date.strftime("%d/%m/%Y")}""" + return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id + }: {self.code}""" + return f"""décision sur semestre {self.formsemestre.titre_mois()} du { + self.event_date.strftime("%d/%m/%Y")}""" def to_dict(self) -> dict: "as a dict" @@ -73,6 +76,20 @@ class ScolarFormSemestreValidation(db.Model): d.pop("_sa_instance_state", None) return d + def html(self) -> str: + "Affichage html" + if self.ue_id is not None: + return f"""Validation de l'UE {self.ue.acronyme} + ({self.code} + le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}) + """ + else: + return f"""Validation du semestre S{ + self.formsemestre.semestre_id if self.formsemestre else "?"} + ({self.code} + le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}) + """ + class ScolarAutorisationInscription(db.Model): """Autorisation d'inscription dans un semestre""" @@ -93,6 +110,7 @@ class ScolarAutorisationInscription(db.Model): db.Integer, db.ForeignKey("notes_formsemestre.id"), ) + origin_formsemestre = db.relationship("FormSemestre", lazy="select", uselist=False) def __repr__(self) -> str: return f"""{self.__class__.__name__}(id={self.id}, etudid={ @@ -104,6 +122,15 @@ class ScolarAutorisationInscription(db.Model): d.pop("_sa_instance_state", None) return d + def html(self) -> str: + "Affichage html" + return f"""Autorisation de passage vers S{self.semestre_id} émise par + {self.origin_formsemestre.html_link_status() + if self.origin_formsemestre + else "-"} + le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")} + """ + @classmethod def autorise_etud( cls, diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index 0b9d27a83..36b8db30b 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -315,6 +315,19 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa SemBulletinsPDFCache.invalidate_sems(formsemestre_ids) +def invalidate_formsemestre_etud(etud: "Identite"): + """Invalide tous les formsemestres auxquels l'étudiant est inscrit""" + from app.models import FormSemestre, FormSemestreInscription + + inscriptions = ( + FormSemestreInscription.query.filter_by(etudid=etud.id) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + ) + for inscription in inscriptions: + invalidate_formsemestre(inscription.formsemestre_id) + + class DeferredSemCacheManager: """Contexte pour effectuer des opérations indépendantes dans la même requete qui invalident le cache. Par exemple, quand on inscrit diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index f41804f13..ba6cb918e 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -757,7 +757,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list ], page_title=f"Programme {formation.acronyme} v{formation.version}", ), - f"""

{formation.to_html()} {lockicon} + f"""

{formation.html()} {lockicon}

""", ] @@ -1010,12 +1010,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);