From e6d61fcd8ac2796c0b7788d5f11dede3ca29f287 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 19 Feb 2024 14:10:55 +0100 Subject: [PATCH 01/32] export nationalite. Closes #860 --- app/comp/res_common.py | 29 +++++-- app/models/etudiants.py | 2 +- app/scodoc/sco_evaluations.py | 11 ++- app/scodoc/sco_groups_view.py | 92 ++++++++++++----------- app/scodoc/sco_moduleimpl_inscriptions.py | 2 +- 5 files changed, 80 insertions(+), 56 deletions(-) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index c4bc908f..954f3523 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -209,7 +209,7 @@ class ResultatsSemestre(ResultatsCache): "last_modif" : datetime.datetime | None, # saisie de note la plus récente "nb_notes" : int, # nb notes d'étudiants inscrits }, - "evaluatiuon_id" : int, + "evaluation_id" : int, "jour" : datetime.datetime, # e.date_debut or datetime.datetime(1900, 1, 1) "publish_incomplete" : bool, } @@ -435,6 +435,21 @@ class ResultatsSemestre(ResultatsCache): def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: """L'état de l'UE pour cet étudiant. Result: dict, ou None si l'UE n'est pas dans ce semestre. + { + "is_capitalized": # vrai si la version capitalisée est celle prise en compte (meilleure) + "was_capitalized":# si elle a été capitalisée (meilleure ou pas) + "is_external": # si UE externe + "coef_ue": 0.0, + "cur_moy_ue": 0.0, # moyenne de l'UE courante + "moy": 0.0, # moyenne prise en compte + "event_date": # date de la capiltalisation éventuelle (ou None) + "ue": ue_dict, # l'UE, comme un dict + "formsemestre_id": None, + "capitalized_ue_id": None, # l'id de l'UE capitalisée, ou None + "ects_pot": 0.0, # deprecated (les ECTS liés à cette UE) + "ects": 0.0, # les ECTS acquis grace à cette UE + "ects_ue": # les ECTS liés à cette UE + } """ ue: UniteEns = db.session.get(UniteEns, ue_id) ue_dict = ue.to_dict() @@ -512,11 +527,13 @@ class ResultatsSemestre(ResultatsCache): "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external, "coef_ue": coef_ue, "ects_pot": ue.ects or 0.0, - "ects": self.validations.decisions_jury_ues.get(etudid, {}) - .get(ue.id, {}) - .get("ects", 0.0) - if self.validations.decisions_jury_ues - else 0.0, + "ects": ( + self.validations.decisions_jury_ues.get(etudid, {}) + .get(ue.id, {}) + .get("ects", 0.0) + if self.validations.decisions_jury_ues + else 0.0 + ), "ects_ue": ue.ects, "cur_moy_ue": cur_moy_ue, "moy": moy_ue, diff --git a/app/models/etudiants.py b/app/models/etudiants.py index bd70e03c..ef470f3e 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -125,7 +125,7 @@ class Identite(models.ScoDocModel): ) # Champs "protégés" par ViewEtudData (RGPD) - protected_attrs = {"boursier"} + protected_attrs = {"boursier", "nationalite"} def __repr__(self): return ( diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 20ae2356..610cb255 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -44,7 +44,6 @@ from app.models import Evaluation, FormSemestre, ModuleImpl import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType -import app.scodoc.notesdb as ndb from app.scodoc.gen_tables import GenTable from app.scodoc import html_sco_header from app.scodoc import sco_cal @@ -114,9 +113,9 @@ def do_evaluation_etat( nb_neutre, nb_att, moy, median, mini, maxi : # notes, en chaine, sur 20 - last_modif: datetime, + last_modif: datetime, * gr_complets, gr_incomplets, - evalcomplete + evalcomplete * } evalcomplete est vrai si l'eval est complete (tous les inscrits à ce module ont des notes) @@ -519,9 +518,9 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"): { "date_first_complete": date_first_complete, "delai_correction": delai_correction, - "jour": e.date_debut.strftime("%d/%m/%Y") - if e.date_debut - else "sans date", + "jour": ( + e.date_debut.strftime("%d/%m/%Y") if e.date_debut else "sans date" + ), "_jour_target": url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index a404c7fa..0d1d1dfb 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -514,10 +514,11 @@ def groups_table( "paiementinscription_str": "Paiement", "etudarchive": "Fichiers", "annotations_str": "Annotations", - "bourse_str": "Boursier", + "bourse_str": "Boursier", # requière ViewEtudData "etape": "Etape", "semestre_groupe": "Semestre-Groupe", # pour Moodle "annee": "annee_admission", + "nationalite": "nationalite", # requière ViewEtudData } # ajoute colonnes pour groupes @@ -559,53 +560,61 @@ def groups_table( moodle_sem_name = groups_infos.formsemestre["session_id"] moodle_groupenames = set() # ajoute liens - for etud in groups_infos.members: - if etud["email"]: - etud["_email_target"] = "mailto:" + etud["email"] + for etud_info in groups_infos.members: + if etud_info["email"]: + etud_info["_email_target"] = "mailto:" + etud_info["email"] else: - etud["_email_target"] = "" - if etud["emailperso"]: - etud["_emailperso_target"] = "mailto:" + etud["emailperso"] + etud_info["_email_target"] = "" + if etud_info["emailperso"]: + etud_info["_emailperso_target"] = "mailto:" + etud_info["emailperso"] else: - etud["_emailperso_target"] = "" + etud_info["_emailperso_target"] = "" fiche_url = url_for( - "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud_info["etudid"] ) - etud["_nom_disp_target"] = fiche_url - etud["_nom_disp_order"] = etud_sort_key(etud) - etud["_prenom_target"] = fiche_url + etud_info["_nom_disp_target"] = fiche_url + etud_info["_nom_disp_order"] = etud_sort_key(etud_info) + etud_info["_prenom_target"] = fiche_url - etud["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) - etud["bourse_str"] = "oui" if etud["boursier"] else "non" - if etud["etat"] == "D": - etud["_css_row_class"] = "etuddem" + etud_info["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % ( + etud_info["etudid"] + ) + etud_info["bourse_str"] = "oui" if etud_info["boursier"] else "non" + if etud_info["etat"] == "D": + etud_info["_css_row_class"] = "etuddem" # et groupes: - for partition_id in etud["partitions"]: - etud[partition_id] = etud["partitions"][partition_id]["group_name"] + for partition_id in etud_info["partitions"]: + etud_info[partition_id] = etud_info["partitions"][partition_id][ + "group_name" + ] # Ajoute colonne pour moodle: semestre_groupe, de la forme # RT-DUT-FI-S3-2021-PARTITION-GROUPE moodle_groupename = [] if groups_infos.selected_partitions: # il y a des groupes selectionnes, utilise leurs partitions for partition_id in groups_infos.selected_partitions: - if partition_id in etud["partitions"]: + if partition_id in etud_info["partitions"]: moodle_groupename.append( partitions_name[partition_id] + "-" - + etud["partitions"][partition_id]["group_name"] + + etud_info["partitions"][partition_id]["group_name"] ) else: # pas de groupes sélectionnés: prend le premier s'il y en a un moodle_groupename = ["tous"] - if etud["partitions"]: - for p in etud["partitions"].items(): # partitions is an OrderedDict + if etud_info["partitions"]: + for p in etud_info[ + "partitions" + ].items(): # partitions is an OrderedDict moodle_groupename = [ partitions_name[p[0]] + "-" + p[1]["group_name"] ] break moodle_groupenames |= set(moodle_groupename) - etud["semestre_groupe"] = moodle_sem_name + "-" + "+".join(moodle_groupename) + etud_info["semestre_groupe"] = ( + moodle_sem_name + "-" + "+".join(moodle_groupename) + ) if groups_infos.nbdem > 1: s = "s" @@ -714,9 +723,11 @@ def groups_table( }); """, - """accès aux données personnelles interdit""" - if not can_view_etud_data - else "", + ( + """accès aux données personnelles interdit""" + if not can_view_etud_data + else "" + ), ] ) H.append("") @@ -768,13 +779,7 @@ def groups_table( return "".join(H) - elif ( - fmt == "pdf" - or fmt == "xml" - or fmt == "json" - or fmt == "xls" - or fmt == "moodlecsv" - ): + elif fmt in {"pdf", "xml", "json", "xls", "moodlecsv"}: if fmt == "moodlecsv": fmt = "csv" return tab.make_page(fmt=fmt) @@ -789,7 +794,7 @@ def groups_table( with_paiement=with_paiement, server_name=request.url_root, ) - filename = "liste_%s" % groups_infos.groups_filename + filename = f"liste_{groups_infos.groups_filename}" return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) elif fmt == "allxls": if not can_view_etud_data: @@ -823,6 +828,7 @@ def groups_table( "fax", "date_naissance", "lieu_naissance", + "nationalite", "bac", "specialite", "annee_bac", @@ -845,16 +851,16 @@ def groups_table( # remplis infos lycee si on a que le code lycée # et ajoute infos inscription for m in groups_infos.members: - etud = sco_etud.get_etud_info(m["etudid"], filled=True)[0] - m.update(etud) - sco_etud.etud_add_lycee_infos(etud) + etud_info = sco_etud.get_etud_info(m["etudid"], filled=True)[0] + m.update(etud_info) + sco_etud.etud_add_lycee_infos(etud_info) # et ajoute le parcours Se = sco_cursus.get_situation_etud_cursus( - etud, groups_infos.formsemestre_id + etud_info, groups_infos.formsemestre_id ) m["parcours"] = Se.get_cursus_descr() m["code_cursus"], _ = sco_report.get_code_cursus_etud( - etud["etudid"], sems=etud["sems"] + etud_info["etudid"], sems=etud_info["sems"] ) rows = [[m.get(k, "") for k in keys] for m in groups_infos.members] title = "etudiants_%s" % groups_infos.groups_filename @@ -905,9 +911,11 @@ def tab_absences_html(groups_infos, etat=None): % groups_infos.groups_query_args, """
  • Liste d'appel avec photos
  • """ % groups_infos.groups_query_args, - f"""
  • Liste des annotations
  • """ - if authuser.has_permission(Permission.ViewEtudData) - else """
  • Liste des annotations
  • """, + ( + f"""
  • Liste des annotations
  • """ + if authuser.has_permission(Permission.ViewEtudData) + else """
  • Liste des annotations
  • """ + ), "", ] ) diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index c4b683eb..20467db7 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -617,7 +617,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->

    L'inscription ou désinscription aux UEs du BUT n'affecte pas les inscriptions aux modules mais permet de "dispenser" un étudiant de suivre certaines UEs de son parcours.

    -

    Il peut s'agit d'étudiants redoublants ayant déjà acquis l'UE, ou d'une UE +

    Il peut s'agir d'étudiants redoublants ayant déjà acquis l'UE, ou d'une UE présente dans le semestre mais pas dans le parcours de l'étudiant, ou bien d'autres cas particuliers.

    From 56aa5fbba3ea8b87334ce11ad2e6fbed361d7127 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 19 Feb 2024 19:10:12 +0100 Subject: [PATCH 02/32] Modernise code inscription/passage semestre. Closes #859 --- app/scodoc/codes_cursus.py | 13 - app/scodoc/sco_formsemestre_inscriptions.py | 27 ++- app/scodoc/sco_inscr_passage.py | 256 +++++++++++--------- app/scodoc/sco_synchro_etuds.py | 44 ++-- app/static/css/scodoc.css | 9 +- 5 files changed, 194 insertions(+), 155 deletions(-) diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py index 8e595ead..5120aa05 100644 --- a/app/scodoc/codes_cursus.py +++ b/app/scodoc/codes_cursus.py @@ -85,17 +85,6 @@ UE_ELECTIVE = 4 # UE "élective" dans certains cursus (UCAC?, ISCID) UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...) UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...) - -def ue_is_fondamentale(ue_type): - return ue_type in (UE_STANDARD, UE_STAGE_LP, UE_PROFESSIONNELLE) - - -def ue_is_professionnelle(ue_type): - return ( - ue_type == UE_PROFESSIONNELLE - ) # NB: les UE_PROFESSIONNELLE sont à la fois fondamentales et pro - - UE_TYPE_NAME = { UE_STANDARD: "Standard", UE_SPORT: "Sport/Culture (points bonus)", @@ -104,8 +93,6 @@ UE_TYPE_NAME = { UE_ELECTIVE: "Elective (ISCID)", UE_PROFESSIONNELLE: "Professionnelle (ISCID)", UE_OPTIONNELLE: "Optionnelle", - # UE_FONDAMENTALE : '"Fondamentale" (eg UCAC)', - # UE_OPTIONNELLE : '"Optionnelle" (UCAC)' } # Couleurs RGB (dans [0.,1.]) des UE pour les bulletins: diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index e3474ebf..7ee6439a 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -191,7 +191,23 @@ def do_formsemestre_inscription_edit(args=None, formsemestre_id=None): ) # > modif inscription semestre -def do_formsemestre_desinscription(etudid, formsemestre_id): +def check_if_has_decision_jury( + formsemestre: FormSemestre, etudids: list[int] | set[int] +): + "raise exception if one of the etuds has a decision in formsemestre" + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + for etudid in etudids: + if nt.etud_has_decision(etudid): + etud = Identite.query.get(etudid) + raise ScoValueError( + f"""désinscription impossible: l'étudiant {etud.nomprenom} a + une décision de jury (la supprimer avant si nécessaire)""" + ) + + +def do_formsemestre_desinscription( + etudid, formsemestre_id: int, check_has_dec_jury=True +): """Désinscription d'un étudiant. Si semestre extérieur et dernier inscrit, suppression de ce semestre. """ @@ -204,13 +220,8 @@ def do_formsemestre_desinscription(etudid, formsemestre_id): raise ScoValueError("désinscription impossible: semestre verrouille") # -- Si decisions de jury, désinscription interdite - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - if nt.etud_has_decision(etudid): - raise ScoValueError( - f"""désinscription impossible: l'étudiant {etud.nomprenom} a - une décision de jury (la supprimer avant si nécessaire)""" - ) + if check_has_dec_jury: + check_if_has_decision_jury(formsemestre, [etudid]) insem = do_formsemestre_inscription_list( args={"formsemestre_id": formsemestre_id, "etudid": etudid} diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index f97e0d44..4f881022 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -36,13 +36,14 @@ from flask import url_for, g, request import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app import db, log -from app.models import Formation, FormSemestre, GroupDescr +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import Formation, FormSemestre, GroupDescr, Identite from app.scodoc.gen_tables import GenTable from app.scodoc import html_sco_header from app.scodoc import sco_cache from app.scodoc import codes_cursus from app.scodoc import sco_etud -from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_preferences @@ -50,62 +51,69 @@ from app.scodoc import sco_pv_dict from app.scodoc.sco_exceptions import ScoValueError -def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False): +def _list_authorized_etuds_by_sem( + formsemestre: FormSemestre, ignore_jury=False +) -> tuple[dict[int, dict], list[dict], dict[int, Identite]]: """Liste des etudiants autorisés à s'inscrire dans sem. delai = nb de jours max entre la date de l'autorisation et celle de debut du semestre cible. ignore_jury: si vrai, considère tous les étudiants comme autorisés, même s'ils n'ont pas de décision de jury. """ - src_sems = list_source_sems(sem, delai=delai) - inscrits = list_inscrits(sem["formsemestre_id"]) + src_sems = _list_source_sems(formsemestre) + inscrits = list_inscrits(formsemestre.id) r = {} candidats = {} # etudid : etud (tous les etudiants candidats) nb = 0 # debug - for src in src_sems: + src_formsemestre: FormSemestre + for src_formsemestre in src_sems: if ignore_jury: # liste de tous les inscrits au semestre (sans dems) - liste = list_inscrits(src["formsemestre_id"]).values() + etud_list = list_inscrits(formsemestre.id).values() else: # liste des étudiants autorisés par le jury à s'inscrire ici - liste = list_etuds_from_sem(src, sem) + etud_list = _list_etuds_from_sem(src_formsemestre, formsemestre) liste_filtree = [] - for e in liste: + for e in etud_list: # Filtre ceux qui se sont déjà inscrit dans un semestre APRES le semestre src auth_used = False # autorisation deja utilisée ? - etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0] - for isem in etud["sems"]: - if ndb.DateDMYtoISO(isem["date_debut"]) >= ndb.DateDMYtoISO( - src["date_fin"] - ): + etud = Identite.get_etud(e["etudid"]) + for inscription in etud.inscriptions(): + if inscription.formsemestre.date_debut >= src_formsemestre.date_fin: auth_used = True if not auth_used: candidats[e["etudid"]] = etud liste_filtree.append(e) nb += 1 - r[src["formsemestre_id"]] = { + r[src_formsemestre.id] = { "etuds": liste_filtree, "infos": { - "id": src["formsemestre_id"], - "title": src["titreannee"], - "title_target": "formsemestre_status?formsemestre_id=%s" - % src["formsemestre_id"], + "id": src_formsemestre.id, + "title": src_formsemestre.titre_annee(), + "title_target": url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=src_formsemestre.id, + ), "filename": "etud_autorises", }, } # ajoute attribut inscrit qui indique si l'étudiant est déjà inscrit dans le semestre dest. - for e in r[src["formsemestre_id"]]["etuds"]: + for e in r[src_formsemestre.id]["etuds"]: e["inscrit"] = e["etudid"] in inscrits # Ajoute liste des etudiants actuellement inscrits for e in inscrits.values(): e["inscrit"] = True - r[sem["formsemestre_id"]] = { + r[formsemestre.id] = { "etuds": list(inscrits.values()), "infos": { - "id": sem["formsemestre_id"], - "title": "Semestre cible: " + sem["titreannee"], - "title_target": "formsemestre_status?formsemestre_id=%s" - % sem["formsemestre_id"], + "id": formsemestre.id, + "title": "Semestre cible: " + formsemestre.titre_annee(), + "title_target": url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + ), "comment": " actuellement inscrits dans ce semestre", "help": "Ces étudiants sont actuellement inscrits dans ce semestre. Si vous les décochez, il seront désinscrits.", "filename": "etud_inscrits", @@ -115,7 +123,7 @@ def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False): return r, inscrits, candidats -def list_inscrits(formsemestre_id, with_dems=False): +def list_inscrits(formsemestre_id: int, with_dems=False) -> list[dict]: """Étudiants déjà inscrits à ce semestre { etudid : etud } """ @@ -133,28 +141,27 @@ def list_inscrits(formsemestre_id, with_dems=False): return inscr -def list_etuds_from_sem(src, dst) -> list[dict]: - """Liste des etudiants du semestre src qui sont autorisés à passer dans le semestre dst.""" - target = dst["semestre_id"] - dpv = sco_pv_dict.dict_pvjury(src["formsemestre_id"]) +def _list_etuds_from_sem(src: FormSemestre, dst: FormSemestre) -> list[dict]: + """Liste des étudiants du semestre src qui sont autorisés à passer dans le semestre dst.""" + target_semestre_id = dst.semestre_id + dpv = sco_pv_dict.dict_pvjury(src.id) if not dpv: return [] etuds = [ x["identite"] for x in dpv["decisions"] - if target in [a["semestre_id"] for a in x["autorisations"]] + if target_semestre_id in [a["semestre_id"] for a in x["autorisations"]] ] return etuds -def list_inscrits_date(sem): - """Liste les etudiants inscrits dans n'importe quel semestre - du même département - SAUF sem à la date de début de sem. +def list_inscrits_date(formsemestre: FormSemestre): + """Liste les etudiants inscrits à la date de début de formsemestre + dans n'importe quel semestre du même département + SAUF formsemestre """ cnx = ndb.GetDBConnexion() cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - sem["date_debut_iso"] = ndb.DateDMYtoISO(sem["date_debut"]) cursor.execute( """SELECT ins.etudid FROM @@ -166,12 +173,18 @@ def list_inscrits_date(sem): AND S.date_fin >= %(date_debut_iso)s AND S.dept_id = %(dept_id)s """, - sem, + { + "formsemestre_id": formsemestre.id, + "date_debut_iso": formsemestre.date_debut.isoformat(), + "dept_id": formsemestre.dept_id, + }, ) return [x[0] for x in cursor.fetchall()] -def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False): +def do_inscrit( + formsemestre: FormSemestre, etudids, inscrit_groupes=False, inscrit_parcours=False +): """Inscrit ces etudiants dans ce semestre (la liste doit avoir été vérifiée au préalable) En option: @@ -181,12 +194,11 @@ def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False): (si les deux sont vrais, inscrit_parcours n'a pas d'effet) """ # TODO à ré-écrire pour utiliser les modèles, notamment GroupDescr - formsemestre: FormSemestre = db.session.get(FormSemestre, sem["formsemestre_id"]) formsemestre.setup_parcours_groups() log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}") for etudid in etudids: sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( - sem["formsemestre_id"], + formsemestre.id, etudid, etat=scu.INSCRIT, method="formsemestre_inscr_passage", @@ -210,7 +222,7 @@ def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False): cursem_groups_by_name = { g["group_name"]: g - for g in sco_groups.get_sem_groups(sem["formsemestre_id"]) + for g in sco_groups.get_sem_groups(formsemestre.id) if g["group_name"] } @@ -234,53 +246,46 @@ def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False): sco_groups.change_etud_group_in_partition(etudid, group) -def do_desinscrit(sem: dict, etudids: list[int]): +def do_desinscrit( + formsemestre: FormSemestre, etudids: list[int], check_has_dec_jury=True +): "désinscrit les étudiants indiqués du formsemestre" log(f"do_desinscrit: {etudids}") for etudid in etudids: sco_formsemestre_inscriptions.do_formsemestre_desinscription( - etudid, sem["formsemestre_id"] + etudid, formsemestre.id, check_has_dec_jury=check_has_dec_jury ) -def list_source_sems(sem, delai=None) -> list[dict]: +def _list_source_sems(formsemestre: FormSemestre) -> list[FormSemestre]: """Liste des semestres sources - sem est le semestre destination + formsemestre est le semestre destination """ - # liste des semestres débutant a moins - # de delai (en jours) de la date de fin du semestre d'origine. - sems = sco_formsemestre.do_formsemestre_list() - othersems = [] - d, m, y = [int(x) for x in sem["date_debut"].split("/")] - date_debut_dst = datetime.date(y, m, d) - - delais = datetime.timedelta(delai) - for s in sems: - if s["formsemestre_id"] == sem["formsemestre_id"]: - continue # saute le semestre destination - if s["date_fin"]: - d, m, y = [int(x) for x in s["date_fin"].split("/")] - date_fin = datetime.date(y, m, d) - if date_debut_dst - date_fin > delais: - continue # semestre trop ancien - if date_fin > date_debut_dst: - continue # semestre trop récent - # Elimine les semestres de formations speciales (sans parcours) - if s["semestre_id"] == codes_cursus.NO_SEMESTRE_ID: - continue - # - formation: Formation = Formation.query.get_or_404(s["formation_id"]) - parcours = codes_cursus.get_cursus_from_code(formation.type_parcours) - if not parcours.ALLOW_SEM_SKIP: - if s["semestre_id"] < (sem["semestre_id"] - 1): - continue - othersems.append(s) - return othersems + # liste des semestres du même type de cursus terminant + # pas trop loin de la date de début du semestre destination + date_fin_min = formsemestre.date_debut - datetime.timedelta(days=275) + date_fin_max = formsemestre.date_debut + datetime.timedelta(days=45) + return ( + FormSemestre.query.filter( + FormSemestre.dept_id == formsemestre.dept_id, + # saute le semestre destination: + FormSemestre.id != formsemestre.id, + # et les semestres de formations speciales (monosemestres): + FormSemestre.semestre_id != codes_cursus.NO_SEMESTRE_ID, + # semestre pas trop dans le futur + FormSemestre.date_fin <= date_fin_max, + # ni trop loin dans le passé + FormSemestre.date_fin >= date_fin_min, + ) + .join(Formation) + .filter_by(type_parcours=formsemestre.formation.type_parcours) + ).all() +# view, GET, POST def formsemestre_inscr_passage( formsemestre_id, - etuds=[], + etuds: str | list[int] | list[str] | int | None = None, inscrit_groupes=False, inscrit_parcours=False, submitted=False, @@ -300,36 +305,41 @@ def formsemestre_inscr_passage( - Confirmation: indiquer les étudiants inscrits et ceux désinscrits, le total courant. """ + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) inscrit_groupes = int(inscrit_groupes) inscrit_parcours = int(inscrit_parcours) ignore_jury = int(ignore_jury) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) # -- check lock - if not sem["etat"]: + if not formsemestre.etat: raise ScoValueError("opération impossible: semestre verrouille") - header = html_sco_header.sco_header(page_title="Passage des étudiants") + header = html_sco_header.sco_header( + page_title="Passage des étudiants", + init_qtip=True, + javascripts=["js/etud_info.js"], + ) footer = html_sco_header.sco_footer() H = [header] + etuds = [] if etuds is None else etuds if isinstance(etuds, str): - # list de strings, vient du form de confirmation + # string, vient du form de confirmation etuds = [int(x) for x in etuds.split(",") if x] elif isinstance(etuds, int): etuds = [etuds] elif etuds and isinstance(etuds[0], str): etuds = [int(x) for x in etuds] - auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem( - sem, ignore_jury=ignore_jury + auth_etuds_by_sem, inscrits, candidats = _list_authorized_etuds_by_sem( + formsemestre, ignore_jury=ignore_jury ) etuds_set = set(etuds) candidats_set = set(candidats) inscrits_set = set(inscrits) candidats_non_inscrits = candidats_set - inscrits_set - inscrits_ailleurs = set(list_inscrits_date(sem)) + inscrits_ailleurs = set(list_inscrits_date(formsemestre)) - def set_to_sorted_etud_list(etudset): + def set_to_sorted_etud_list(etudset) -> list[Identite]: etuds = [candidats[etudid] for etudid in etudset] - etuds.sort(key=itemgetter("nom")) + etuds.sort(key=lambda e: e.sort_key) return etuds if submitted: @@ -340,7 +350,7 @@ def formsemestre_inscr_passage( if not submitted: H += _build_page( - sem, + formsemestre, auth_etuds_by_sem, inscrits, candidats_non_inscrits, @@ -355,30 +365,31 @@ def formsemestre_inscr_passage( if a_inscrire: H.append("

    Étudiants à inscrire

      ") for etud in set_to_sorted_etud_list(a_inscrire): - H.append("
    1. %(nomprenom)s
    2. " % etud) + H.append(f"
    3. {etud.nomprenom}
    4. ") H.append("
    ") a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire) if a_inscrire_en_double: H.append("

    dont étudiants déjà inscrits:

    ") if a_desinscrire: H.append("

    Étudiants à désinscrire

      ") - for etudid in a_desinscrire: - H.append( - '
    1. %(nomprenom)s
    2. ' - % inscrits[etudid] - ) + a_desinscrire_ident = sorted( + (Identite.query.get(eid) for eid in a_desinscrire), + key=lambda x: x.sort_key, + ) + for etud in a_desinscrire_ident: + H.append(f'
    3. {etud.nomprenom}
    4. ') H.append("
    ") todo = a_inscrire or a_desinscrire if not todo: H.append("""

    Il n'y a rien à modifier !

    """) H.append( scu.confirm_dialog( - dest_url="formsemestre_inscr_passage" - if todo - else "formsemestre_status", + dest_url=( + "formsemestre_inscr_passage" if todo else "formsemestre_status" + ), message="

    Confirmer ?

    " if todo else "", add_headers=False, cancel_url="formsemestre_inscr_passage?formsemestre_id=" @@ -395,16 +406,26 @@ def formsemestre_inscr_passage( ) ) else: + # check decisions jury ici pour éviter de recontruire le cache + # après chaque desinscription + sco_formsemestre_inscriptions.check_if_has_decision_jury( + formsemestre, a_desinscrire + ) + # check decisions jury ici pour éviter de recontruire le cache + # après chaque desinscription + sco_formsemestre_inscriptions.check_if_has_decision_jury( + formsemestre, a_desinscrire + ) with sco_cache.DeferredSemCacheManager(): # Inscription des étudiants au nouveau semestre: do_inscrit( - sem, + formsemestre, a_inscrire, inscrit_groupes=inscrit_groupes, inscrit_parcours=inscrit_parcours, ) # Désinscriptions: - do_desinscrit(sem, a_desinscrire) + do_desinscrit(formsemestre, a_desinscrire, check_has_dec_jury=False) H.append( f"""

    Opération effectuée

    @@ -441,7 +462,7 @@ def formsemestre_inscr_passage( def _build_page( - sem, + formsemestre: FormSemestre, auth_etuds_by_sem, inscrits, candidats_non_inscrits, @@ -450,7 +471,6 @@ def _build_page( inscrit_parcours=False, ignore_jury=False, ): - formsemestre: FormSemestre = db.session.get(FormSemestre, sem["formsemestre_id"]) inscrit_groupes = int(inscrit_groupes) inscrit_parcours = int(inscrit_parcours) ignore_jury = int(ignore_jury) @@ -472,7 +492,7 @@ def _build_page( ), f"""
    - +  aide @@ -491,7 +511,7 @@ def _build_page(
    {scu.EMO_WARNING} - Seuls les semestres dont la date de fin est antérieure à la date de début + Seuls les semestres dont la date de fin est proche de la date de début de ce semestre ({formsemestre.date_debut.strftime("%d/%m/%Y")}) sont pris en compte.
    @@ -499,7 +519,7 @@ def _build_page( - {formsemestre_inscr_passage_help(sem)} + {formsemestre_inscr_passage_help(formsemestre)} """, @@ -524,19 +544,20 @@ def _build_page( return H -def formsemestre_inscr_passage_help(sem: dict): +def formsemestre_inscr_passage_help(formsemestre: FormSemestre): "texte d'aide en bas de la page passage des étudiants" return f"""

    Explications

    Cette page permet d'inscrire des étudiants dans le semestre destination {sem['titreannee']}, + url_for("notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id ) + }">{formsemestre.titre_annee()}, et d'en désincrire si besoin.

    Les étudiants sont groupés par semestre d'origine. Ceux qui sont en caractères - gras sont déjà inscrits dans le semestre destination. - Ceux qui sont en gras et en rouge sont inscrits + gras sont déjà inscrits dans le semestre destination. + Ceux qui sont en gras et en rouge sont inscrits dans un autre semestre.

    Au départ, les étudiants déjà inscrits sont sélectionnés; vous pouvez ajouter @@ -555,7 +576,7 @@ def formsemestre_inscr_passage_help(sem: dict): conserve les groupes, on conserve les parcours (là aussi, pensez à les cocher dans modifier le semestre avant de faire passer les étudiants). @@ -656,25 +677,24 @@ def etuds_select_boxes( H.append("

    ") for etud in etuds: if etud.get("inscrit", False): - c = " inscrit" + c = " deja-inscrit" checked = 'checked="checked"' else: checked = "" if etud["etudid"] in inscrits_ailleurs: - c = " inscrailleurs" + c = " inscrit-ailleurs" else: c = "" sco_etud.format_etud_ident(etud) if etud["etudid"]: - elink = """%s""" % ( - c, - url_for( - "scolar.fiche_etud", + elink = f"""{etud['nomprenom']} + """ else: # ce n'est pas un etudiant ScoDoc elink = etud["nomprenom"] diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index 5cab9c87..a4a9fd2c 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -35,7 +35,7 @@ from flask import g, url_for from flask_login import current_user from app import db, log -from app.models import Admission, Adresse, Identite, ScolarNews +from app.models import Admission, Adresse, FormSemestre, Identite, ScolarNews import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb @@ -94,6 +94,7 @@ def formsemestre_synchro_etuds( que l'on va importer/inscrire """ etuds = etuds or [] + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) inscrits_without_key = inscrits_without_key or [] log(f"formsemestre_synchro_etuds: formsemestre_id={formsemestre_id}") sem = sco_formsemestre.get_formsemestre(formsemestre_id) @@ -184,7 +185,7 @@ def formsemestre_synchro_etuds( inscrits_without_key ) log("a_desinscrire_without_key=%s" % a_desinscrire_without_key) - inscrits_ailleurs = set(sco_inscr_passage.list_inscrits_date(sem)) + inscrits_ailleurs = set(sco_inscr_passage.list_inscrits_date(formsemestre)) a_inscrire = a_inscrire.intersection(etuds_set) if not dialog_confirmed: @@ -205,10 +206,12 @@ def formsemestre_synchro_etuds( a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire) if a_inscrire_en_double: - H.append("

    dont étudiants déjà inscrits:

      ") + H.append( + "

      dont étudiants déjà inscrits dans un autre semestre:

        " + ) for key in a_inscrire_en_double: nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}""" - H.append(f'
      1. {nom}
      2. ') + H.append(f'
      3. {nom}
      4. ') H.append("
      ") if a_desinscrire: @@ -260,16 +263,26 @@ def formsemestre_synchro_etuds( etudids_a_desinscrire = [nip2etudid(x) for x in a_desinscrire] etudids_a_desinscrire += a_desinscrire_without_key # + # check decisions jury ici pour éviter de recontruire le cache + # après chaque desinscription + sco_formsemestre_inscriptions.check_if_has_decision_jury( + formsemestre, a_desinscrire + ) with sco_cache.DeferredSemCacheManager(): - do_import_etuds_from_portal(sem, a_importer, etudsapo_ident) - sco_inscr_passage.do_inscrit(sem, etudids_a_inscrire) - sco_inscr_passage.do_desinscrit(sem, etudids_a_desinscrire) + do_import_etuds_from_portal(formsemestre, a_importer, etudsapo_ident) + sco_inscr_passage.do_inscrit(formsemestre, etudids_a_inscrire) + sco_inscr_passage.do_desinscrit( + formsemestre, etudids_a_desinscrire, check_has_dec_jury=False + ) H.append( - """

      Opération effectuée

      + f"""

      Opération effectuée

        -
      • Continuer la synchronisation
      • """ - % formsemestre_id +
      • Continuer la synchronisation +
      • """ ) # partitions = sco_groups.get_partitions_list( @@ -279,8 +292,9 @@ def formsemestre_synchro_etuds( H.append( f"""
      • Répartir les groupes de {partitions[0]["partition_name"]}
      • + scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id + )}">Répartir les groupes de {partitions[0]["partition_name"]} + """ ) @@ -618,7 +632,7 @@ def get_annee_naissance(ddmmyyyyy: str) -> int: return None -def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident): +def do_import_etuds_from_portal(formsemestre: FormSemestre, a_importer, etudsapo_ident): """Inscrit les etudiants Apogee dans ce semestre.""" log(f"do_import_etuds_from_portal: a_importer={a_importer}") if not a_importer: @@ -672,7 +686,7 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident): # Inscription au semestre sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( - sem["formsemestre_id"], + formsemestre.id, etud.id, etat=scu.INSCRIT, etape=args["etape"], @@ -716,7 +730,7 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident): ScolarNews.add( typ=ScolarNews.NEWS_INSCR, text=f"Import Apogée de {len(created_etudids)} étudiants en ", - obj=sem["formsemestre_id"], + obj=formsemestre.id, ) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 948554f2..5d141fd5 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -3714,10 +3714,17 @@ span.sp_etape { color: black; } -.inscrailleurs { +.deja-inscrit { + font-weight: bold; + color: rgb(1, 76, 1) !important; +} +.inscrit-ailleurs { font-weight: bold; color: red !important; } +div.etuds_select_boxes { + margin-bottom: 16px; +} span.paspaye, span.paspaye a { From fae9fbdd09777ba0ab6af096a7c284e805d85687 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Feb 2024 21:30:08 +0100 Subject: [PATCH 03/32] =?UTF-8?q?Diverses=20am=C3=A9liorations=20pour=20fa?= =?UTF-8?q?ciliter=20la=20config=20BUT.=20Voir=20#862?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/bulletin_but_court.py | 6 + app/but/cursus_but.py | 102 ++++++++++++++--- app/scodoc/sco_bulletins_generator.py | 43 ++++---- app/scodoc/sco_edit_apc.py | 26 +++-- app/scodoc/sco_edit_ue.py | 4 +- app/scodoc/sco_formsemestre_inscriptions.py | 115 +++++++++++--------- app/scodoc/sco_synchro_etuds.py | 17 ++- app/static/css/scodoc.css | 32 +++++- app/templates/pn/form_ues.j2 | 3 + 9 files changed, 239 insertions(+), 109 deletions(-) diff --git a/app/but/bulletin_but_court.py b/app/but/bulletin_but_court.py index 7fb389a5..a4fc6ec1 100644 --- a/app/but/bulletin_but_court.py +++ b/app/but/bulletin_but_court.py @@ -119,6 +119,12 @@ def _build_bulletin_but_infos( refcomp = formsemestre.formation.referentiel_competence if refcomp is None: raise ScoNoReferentielCompetences(formation=formsemestre.formation) + + warn_html = cursus_but.formsemestre_warning_apc_setup( + formsemestre, bulletins_sem.res + ) + if warn_html: + raise ScoValueError("Formation mal configurée pour le BUT" + warn_html) ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau( refcomp, etud ) diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 1f453882..4a16bf05 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -23,29 +23,21 @@ from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_compat import NotesTableCompat from app.models.but_refcomp import ( - ApcAnneeParcours, ApcCompetence, ApcNiveau, ApcParcours, - ApcParcoursNiveauCompetence, ApcReferentielCompetences, ) -from app.models import Scolog, ScolarAutorisationInscription -from app.models.but_validations import ( - ApcValidationAnnee, - ApcValidationRCUE, -) +from app.models.ues import UEParcours +from app.models.but_validations import ApcValidationRCUE from app.models.etudiants import Identite from app.models.formations import Formation -from app.models.formsemestre import FormSemestre, FormSemestreInscription +from app.models.formsemestre import FormSemestre from app.models.ues import UniteEns from app.models.validations import ScolarFormSemestreValidation from app.scodoc import codes_cursus as sco_codes from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD - -from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError - from app.scodoc import sco_cursus_dut @@ -440,11 +432,16 @@ def formsemestre_warning_apc_setup( """ if not formsemestre.formation.is_apc(): return "" + url_formation = url_for( + "notes.ue_table", + scodoc_dept=g.scodoc_dept, + formation_id=formsemestre.formation.id, + semestre_idx=formsemestre.semestre_id, + ) if formsemestre.formation.referentiel_competence is None: return f""" """ H = [] @@ -462,7 +459,9 @@ def formsemestre_warning_apc_setup( ) if nb_ues_sans_parcours != nb_ues_tot: H.append( - f"""Le semestre n'est associé à aucun parcours, mais les UEs de la formation ont des parcours""" + """Le semestre n'est associé à aucun parcours, + mais les UEs de la formation ont des parcours + """ ) # Vérifie les niveaux de chaque parcours for parcour in formsemestre.parcours or [None]: @@ -489,7 +488,8 @@ def formsemestre_warning_apc_setup( if not H: return "" return f"""
        - Problème dans la configuration de la formation: + Problème dans la + configuration de la formation:
        • { '
        • '.join(H) }
        @@ -502,6 +502,76 @@ def formsemestre_warning_apc_setup( """ +def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str: + """Vérifie que tous les niveaux de compétences de cette année de formation + ont bien des UEs. + Afin de ne pas générer trop de messages, on ne considère que les parcours + du référentiel de compétences pour lesquels au moins une UE a été associée. + + Renvoie fragment de html + """ + annee = (semestre_idx - 1) // 2 + 1 # année BUT + ref_comp: ApcReferentielCompetences = formation.referentiel_competence + niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] } + parcours_ids = { + uep.parcours_id + for uep in UEParcours.query.join(UniteEns).filter_by( + formation_id=formation.id, type=UE_STANDARD + ) + } + for parcour in ref_comp.parcours: + if parcour.id not in parcours_ids: + continue # saute parcours associés à aucune UE (tous semestres) + niveaux_sans_ue = [] + niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp) + # print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux") + for niveau in niveaux: + ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id] + if not ues: + niveaux_sans_ue.append(niveau) + # print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) ) + if niveaux_sans_ue: + niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue + # + H = [] + for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items(): + H.append( + f"""
      • Parcours {parcour_code} : { + len(niveaux)} niveaux sans UEs + + { ', '.join( f'{niveau.competence.titre} {niveau.ordre}' + for niveau in niveaux + ) + } + +
      • + """ + ) + # Combien de compétences de tronc commun ? + _, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee) + nb_niveaux_tc = len(niveaux_by_parcours["TC"]) + nb_ues_tc = len( + formation.query_ues_parcour(None) + .filter(UniteEns.semestre_idx == semestre_idx) + .all() + ) + if nb_niveaux_tc != nb_ues_tc: + H.append( + f"""
      • {nb_niveaux_tc} niveaux de compétences de tronc commun, + mais {nb_ues_tc} UEs de tronc commun !
      • """ + ) + + if H: + return f"""
        +
        Problèmes détectés à corriger :
        +
          + {"".join(H)} +
        +
        + """ + return "" # no problem detected + + def ue_associee_au_niveau_du_parcours( ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S" ) -> UniteEns: diff --git a/app/scodoc/sco_bulletins_generator.py b/app/scodoc/sco_bulletins_generator.py index bda2567e..b8a00f9b 100644 --- a/app/scodoc/sco_bulletins_generator.py +++ b/app/scodoc/sco_bulletins_generator.py @@ -50,14 +50,11 @@ import traceback import reportlab from reportlab.platypus import ( - SimpleDocTemplate, DocIf, Paragraph, - Spacer, - Frame, PageBreak, ) -from reportlab.platypus import Table, TableStyle, Image, KeepInFrame +from reportlab.platypus import Table, KeepInFrame from flask import request from flask_login import current_user @@ -213,26 +210,26 @@ class BulletinGenerator: story.append(PageBreak()) # insert page break at end return story - else: - # Generation du document PDF - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - report = io.BytesIO() # in-memory document, no disk file - document = sco_pdf.BaseDocTemplate(report) - document.addPageTemplates( - sco_pdf.ScoDocPageTemplate( - document, - author="%s %s (E. Viennet) [%s]" - % (sco_version.SCONAME, sco_version.SCOVERSION, self.description), - title=f"""Bulletin {sem["titremois"]} de {etat_civil}""", - subject="Bulletin de note", - margins=self.margins, - server_name=self.server_name, - filigranne=self.filigranne, - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) + + # Generation du document PDF + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + report = io.BytesIO() # in-memory document, no disk file + document = sco_pdf.BaseDocTemplate(report) + document.addPageTemplates( + sco_pdf.ScoDocPageTemplate( + document, + author=f"""{sco_version.SCONAME} { + sco_version.SCOVERSION} (E. Viennet) [{self.description}]""", + title=f"""Bulletin {sem["titremois"]} de {etat_civil}""", + subject="Bulletin de note", + margins=self.margins, + server_name=self.server_name, + filigranne=self.filigranne, + preferences=sco_preferences.SemPreferences(formsemestre_id), ) - document.build(story) - data = report.getvalue() + ) + document.build(story) + data = report.getvalue() return data def buildTableObject(self, P, pdfTableStyle, colWidths): diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index a90d37e5..b56a9393 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -28,7 +28,7 @@ from flask.templating import render_template from app import db -from app.but import apc_edit_ue +from app.but import cursus_but from app.models import UniteEns, Matiere, Module, FormSemestre, ModuleImpl from app.models.validations import ScolarFormSemestreValidation from app.scodoc import codes_cursus @@ -101,18 +101,26 @@ def html_edit_formation_apc( ), } + html_ue_warning = { + semestre_idx: cursus_but.formation_semestre_niveaux_warning( + formation, semestre_idx + ) + for semestre_idx in semestre_ids + } + H = [ render_template( "pn/form_ues.j2", - formation=formation, - semestre_ids=semestre_ids, - editable=editable, - tag_editable=tag_editable, - icons=icons, - ues_by_sem=ues_by_sem, - ects_by_sem=ects_by_sem, - scu=scu, codes_cursus=codes_cursus, + ects_by_sem=ects_by_sem, + editable=editable, + formation=formation, + html_ue_warning=html_ue_warning, + icons=icons, + scu=scu, + semestre_ids=semestre_ids, + tag_editable=tag_editable, + ues_by_sem=ues_by_sem, ), ] for semestre_idx in semestre_ids: diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index fa40ad55..7e9543ce 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -892,7 +892,9 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
        Montrer les tags des modules voire en ajouter (ceux correspondant aux titres des compétences étant ajoutés par défaut) + > Montrer les tags des modules voire en ajouter + (ceux correspondant aux titres des compétences étant ajoutés par défaut) +
        """ ) diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 7ee6439a..f8ddc30d 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -211,8 +211,6 @@ def do_formsemestre_desinscription( """Désinscription d'un étudiant. Si semestre extérieur et dernier inscrit, suppression de ce semestre. """ - from app.scodoc import sco_formsemestre_edit - formsemestre = FormSemestre.get_formsemestre(formsemestre_id) etud = Identite.get_etud(etudid) # -- check lock @@ -258,17 +256,14 @@ def do_formsemestre_desinscription( sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) # --- Semestre extérieur if formsemestre.modalite == "EXT": - inscrits = do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id} - ) - nbinscrits = len(inscrits) - if nbinscrits == 0: + if 0 == len(formsemestre.inscriptions): log( f"""do_formsemestre_desinscription: suppression du semestre extérieur {formsemestre}""" ) - flash("Semestre exterieur supprimé") - sco_formsemestre_edit.do_formsemestre_delete(formsemestre_id) + db.session.delete(formsemestre) + db.session.commit() + flash(f"Semestre extérieur supprimé: {formsemestre.titre_annee()}") logdb( cnx, @@ -587,26 +582,29 @@ def formsemestre_inscription_option(etudid, formsemestre_id): ue_id = ue.id ue_descr = ue.acronyme if ue.type != UE_STANDARD: - ue_descr += " %s" % UE_TYPE_NAME[ue.type] + ue_descr += f" {UE_TYPE_NAME[ue.type]}" ue_status = nt.get_etud_ue_status(etudid, ue_id) if ue_status and ue_status["is_capitalized"]: sem_origin = sco_formsemestre.get_formsemestre(ue_status["formsemestre_id"]) - ue_descr += ( - ' (capitalisée le %s)' - % ( - sem_origin["formsemestre_id"], - etudid, - sem_origin["titreannee"], - ndb.DateISOtoDMY(ue_status["event_date"]), - ) - ) + ue_descr += f""" + (capitalisée le { + ndb.DateISOtoDMY(ue_status["event_date"]) + }) + """ descr.append( ( - "sec_%s" % ue_id, + f"sec_{ue_id}", { "input_type": "separator", - "title": """%s : inscrire | désinscrire à tous les modules""" - % (ue_descr, ue_id, ue_id), + "title": f"""{ue_descr} : + inscrire | désinscrire + à tous les modules + """, }, ) ) @@ -776,9 +774,7 @@ def do_moduleimpl_incription_options( # verifie que ce module existe bien mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) if len(mods) != 1: - raise ScoValueError( - "inscription: invalid moduleimpl_id: %s" % moduleimpl_id - ) + raise ScoValueError(f"inscription: invalid moduleimpl_id: {moduleimpl_id}") mod = mods[0] sco_moduleimpl.do_moduleimpl_inscription_create( {"moduleimpl_id": moduleimpl_id, "etudid": etudid}, @@ -790,7 +786,7 @@ def do_moduleimpl_incription_options( mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) if len(mods) != 1: raise ScoValueError( - "desinscription: invalid moduleimpl_id: %s" % moduleimpl_id + f"desinscription: invalid moduleimpl_id: {moduleimpl_id}" ) mod = mods[0] inscr = sco_moduleimpl.do_moduleimpl_inscription_list( @@ -798,8 +794,7 @@ def do_moduleimpl_incription_options( ) if not inscr: raise ScoValueError( - "pas inscrit a ce module ! (etudid=%s, moduleimpl_id=%s)" - % (etudid, moduleimpl_id) + f"pas inscrit a ce module ! (etudid={etudid}, moduleimpl_id={moduleimpl_id})" ) oid = inscr[0]["moduleimpl_inscription_id"] sco_moduleimpl.do_moduleimpl_inscription_delete( @@ -808,11 +803,13 @@ def do_moduleimpl_incription_options( H = [ html_sco_header.sco_header(), - """

        Modifications effectuées

        -

        - Retour à la fiche étudiant

        - """ - % url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), + f"""

        Modifications effectuées

        +

        + Retour à la fiche étudiant +

        + """, html_sco_header.sco_footer(), ] return "\n".join(H) @@ -856,49 +853,59 @@ def formsemestre_inscrits_ailleurs(formsemestre_id): """Page listant les étudiants inscrits dans un autre semestre dont les dates recouvrent le semestre indiqué. """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) H = [ html_sco_header.html_sem_header( "Inscriptions multiples parmi les étudiants du semestre ", + init_qtip=True, + javascripts=["js/etud_info.js"], ) ] insd = list_inscrits_ailleurs(formsemestre_id) # liste ordonnée par nom - etudlist = [ - sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - for etudid in insd.keys() - if insd[etudid] - ] - etudlist.sort(key=lambda x: x["nom"]) + etudlist = [Identite.get_etud(etudid) for etudid, sems in insd.items() if sems] + etudlist.sort(key=lambda x: x.sort_key) if etudlist: H.append("
          ") for etud in etudlist: H.append( - '
        • %s : ' - % ( + f"""
        • {etud.nomprenom} : + """ ) l = [] - for s in insd[etud["etudid"]]: + for s in insd[etud.id]: l.append( - '%(titremois)s' - % s + f"""{s['titremois']}""" ) H.append(", ".join(l)) H.append("
        • ") - H.append("
        ") - H.append("

        Total: %d étudiants concernés.

        " % len(etudlist)) H.append( - """

        Ces étudiants sont inscrits dans le semestre sélectionné et aussi dans d'autres semestres qui se déroulent en même temps !
        Sauf exception, cette situation est anormale:

        + f""" +
      +

      Total: {len(etudlist)} étudiants concernés.

      + +

      Ces étudiants sont inscrits dans le semestre sélectionné et aussi + dans d'autres semestres qui se déroulent en même temps ! +

      +

      + Sauf exception, cette situation est anormale: +

        -
      • vérifier que les dates des semestres se suivent sans se chevaucher
      • -
      • ou si besoin désinscrire le(s) étudiant(s) de l'un des semestres (via leurs fiches individuelles).
      • +
      • vérifier que les dates des semestres se suivent sans se chevaucher +
      • +
      • ou bien si besoin désinscrire le(s) étudiant(s) de l'un des semestres + (via leurs fiches individuelles). +
      """ ) diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index a4a9fd2c..3f5008e3 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -110,12 +110,13 @@ def formsemestre_synchro_etuds( raise ScoValueError("opération impossible: semestre verrouille") if not sem["etapes"]: raise ScoValueError( - """opération impossible: ce semestre n'a pas de code étape - (voir "Modifier ce semestre") + f"""opération impossible: ce semestre n'a pas de code étape + (voir Modifier ce semestre) """ - % sem ) - header = html_sco_header.sco_header(page_title="Synchronisation étudiants") footer = html_sco_header.sco_footer() base_url = url_for( "notes.formsemestre_synchro_etuds", @@ -166,7 +167,13 @@ def formsemestre_synchro_etuds( suffix=scu.XLSX_SUFFIX, ) - H = [header] + H = [ + html_sco_header.sco_header( + page_title="Synchronisation étudiants", + init_qtip=True, + javascripts=["js/etud_info.js"], + ) + ] if not submitted: H += _build_page( sem, diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 5d141fd5..295be36c 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -2411,6 +2411,29 @@ div.formation_list_ues_titre { color: #eee; } +div.formation_semestre_niveaux_warning { + font-weight: bold; + color: red; + padding: 4px; + margin-top: 8px; + margin-left: 24px; + margin-right: 24px; + background-color: yellow; + border-radius: 8px; +} +div.formation_semestre_niveaux_warning div { + color: black; + font-size: 110%; +} +div.formation_semestre_niveaux_warning ul { + list-style-type: none; + padding-left: 0; +} +div.formation_semestre_niveaux_warning ul li:before { + content: '⚠️'; + margin-right: 10px; /* Adjust space between emoji and text */ +} + div.formation_list_modules, div.formation_list_ues { border-radius: 18px; @@ -2426,6 +2449,7 @@ div.formation_list_ues { } div.formation_list_ues_content { + margin-top: 4px; } div.formation_list_modules { @@ -2508,7 +2532,13 @@ div.formation_parcs > div { opacity: 0.7; border-radius: 4px; text-align: center; - padding: 4px 8px; + padding: 2px 6px; + margin-top: 8px; + margin-bottom: 2px; +} +div.formation_parcs > div.ue_tc { + color: black; + font-style: italic; } div.formation_parcs > div.focus { diff --git a/app/templates/pn/form_ues.j2 b/app/templates/pn/form_ues.j2 index 133ccb66..437d4da2 100644 --- a/app/templates/pn/form_ues.j2 +++ b/app/templates/pn/form_ues.j2 @@ -4,6 +4,7 @@
      Unités d'Enseignement semestre {{semestre_idx}}  -  {{ects_by_sem[semestre_idx] | safe}} ECTS
      + {{ html_ue_warning[semestre_idx] | safe }}
        {% for ue in ues_by_sem[semestre_idx] %} @@ -62,6 +63,8 @@
        {% for parc in ue.parcours %}
        {{ parc.code }}
        + {% else %} +
        Tronc Commun
        {% endfor %}
        {% endif %} From 3844ae46d1853401eb54e6c3d092af344ad0960d Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Feb 2024 21:55:32 +0100 Subject: [PATCH 04/32] Fix (imports, tests). API unit tests breaks on BUT config (bul. court). --- app/but/cursus_but.py | 2 ++ app/scodoc/sco_edit_apc.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 4a16bf05..e2c45c9f 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -512,6 +512,8 @@ def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) """ annee = (semestre_idx - 1) // 2 + 1 # année BUT ref_comp: ApcReferentielCompetences = formation.referentiel_competence + if not ref_comp: + return "" # détecté ailleurs... niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] } parcours_ids = { uep.parcours_id diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index b56a9393..805fa1fb 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -28,7 +28,6 @@ from flask.templating import render_template from app import db -from app.but import cursus_but from app.models import UniteEns, Matiere, Module, FormSemestre, ModuleImpl from app.models.validations import ScolarFormSemestreValidation from app.scodoc import codes_cursus @@ -48,6 +47,8 @@ def html_edit_formation_apc( - Les ressources - Les SAÉs """ + from app.but import cursus_but + cursus = formation.get_cursus() assert cursus.APC_SAE From f31eca97bbe4d1cfdcb13e18d16771b87b784efd Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Feb 2024 14:54:17 +0100 Subject: [PATCH 05/32] Suppression ancien code jury BUT monosemestre inutile --- app/but/jury_but_view.py | 244 --------------------------------------- app/views/notes.py | 4 - sco_version.py | 2 +- 3 files changed, 1 insertion(+), 249 deletions(-) diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index b82007b8..915d7539 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -331,250 +331,6 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: """ -def jury_but_semestriel( - formsemestre: FormSemestre, - etud: Identite, - read_only: bool, - navigation_div: str = "", -) -> str: - """Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel).""" - res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) - parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res) - inscription_etat = etud.inscription_etat(formsemestre.id) - semestre_terminal = ( - formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM - ) - autorisations_passage = ScolarAutorisationInscription.query.filter_by( - etudid=etud.id, - origin_formsemestre_id=formsemestre.id, - ).all() - # Par défaut: autorisé à passer dans le semestre suivant si sem. impair, - # ou si décision déjà enregistrée: - est_autorise_a_passer = (formsemestre.semestre_id % 2) or ( - formsemestre.semestre_id + 1 - ) in (a.semestre_id for a in autorisations_passage) - decisions_ues = { - ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat) - for ue in ues - } - for dec_ue in decisions_ues.values(): - dec_ue.compute_codes() - - if request.method == "POST": - if not read_only: - for key in request.form: - code = request.form[key] - # Codes d'UE - code_match = re.match(r"^code_ue_(\d+)$", key) - if code_match: - ue_id = int(code_match.group(1)) - dec_ue = decisions_ues.get(ue_id) - if not dec_ue: - raise ScoValueError(f"UE invalide ue_id={ue_id}") - dec_ue.record(code) - db.session.commit() - flash("codes enregistrés") - if not semestre_terminal: - if request.form.get("autorisation_passage"): - if not formsemestre.semestre_id + 1 in ( - a.semestre_id for a in autorisations_passage - ): - ScolarAutorisationInscription.delete_autorisation_etud( - etud.id, formsemestre.id - ) - ScolarAutorisationInscription.autorise_etud( - etud.id, - formsemestre.formation.formation_code, - formsemestre.id, - formsemestre.semestre_id + 1, - ) - db.session.commit() - flash( - f"""autorisation de passage en S{formsemestre.semestre_id + 1 - } enregistrée""" - ) - else: - if est_autorise_a_passer: - ScolarAutorisationInscription.delete_autorisation_etud( - etud.id, formsemestre.id - ) - db.session.commit() - flash( - f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée" - ) - ScolarNews.add( - typ=ScolarNews.NEWS_JURY, - obj=formsemestre.id, - text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""", - url=url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre.id, - ), - ) - return flask.redirect( - url_for( - "notes.formsemestre_validation_but", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre.id, - etudid=etud.id, - ) - ) - # GET - if formsemestre.semestre_id % 2 == 0: - warning = f"""
        - Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer - en jury BUT annuel car il lui manque le semestre précédent. -
        """ - else: - warning = "" - H = [ - html_sco_header.sco_header( - page_title=f"Validation BUT S{formsemestre.semestre_id}", - formsemestre_id=formsemestre.id, - etudid=etud.id, - cssstyles=("css/jury_but.css",), - javascripts=("js/jury_but.js",), - ), - f""" -
        -
        -
        -
        -
        Jury BUT S{formsemestre.id} - - Parcours {(parcour.libelle if parcour else False) or "non spécifié"} -
        -
        {etud.nomprenom}
        -
        - -
        -

        Jury sur un semestre BUT isolé (ne concerne que les UEs)

        - {warning} -
        - -
        - """, - ] - - erase_span = "" - if not read_only: - # Requête toutes les validations (pas seulement celles du deca courant), - # au cas où: changement d'architecture, saisie en mode classique, ... - validations = ScolarFormSemestreValidation.query.filter_by( - etudid=etud.id, formsemestre_id=formsemestre.id - ).all() - if validations: - erase_span = f"""effacer les décisions enregistrées""" - else: - erase_span = ( - "Cet étudiant n'a aucune décision enregistrée pour ce semestre." - ) - - H.append( - f""" -
        -
        -
        Unités d'enseignement de S{formsemestre.semestre_id}:
        - """ - ) - if not ues: - H.append( - """
        Aucune UE ! Vérifiez votre programme de - formation, et l'association UEs / Niveaux de compétences
        """ - ) - else: - H.append( - """ -
        -
        -
        -
        -
        - """ - ) - for ue in ues: - dec_ue = decisions_ues[ue.id] - H.append("""
        """) - H.append( - _gen_but_niveau_ue( - ue, - dec_ue, - disabled=read_only, - ) - ) - H.append( - """
        -
        """ - ) - H.append("
        ") # but_annee - - div_autorisations_passage = ( - f""" -
        - Autorisé à passer en : - { ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )} -
        - """ - if autorisations_passage - else """
        pas d'autorisations de passage enregistrées.
        """ - ) - H.append(div_autorisations_passage) - - if read_only: - H.append( - f"""
        - {"Vous n'avez pas la permission de modifier ces décisions." - if formsemestre.etat - else "Semestre verrouillé."} - Les champs entourés en vert sont enregistrés. -
        - """ - ) - else: - if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM: - H.append( - f""" -
        - - autoriser à passer dans le semestre S{formsemestre.semestre_id+1} - -
        - """ - ) - else: - H.append("""
        dernier semestre de la formation.
        """) - H.append( - f""" -
        - - {erase_span} -
        - """ - ) - - H.append(navigation_div) - H.append("
        ") - H.append( - render_template( - "but/documentation_codes_jury.j2", - nom_univ=f"""Export {sco_preferences.get_preference("InstituteName") - or sco_preferences.get_preference("UnivName") - or "Apogée"}""", - codes=ScoDocSiteConfig.get_codes_apo_dict(), - ) - ) - - return "\n".join(H) - - # ------------- def infos_fiche_etud_html(etudid: int) -> str: """Section html pour fiche etudiant diff --git a/app/views/notes.py b/app/views/notes.py index 6fc17e7f..6832b6b4 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2374,10 +2374,6 @@ def formsemestre_validation_but( ) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) - if len(deca.get_decisions_rcues_annee()) == 0: - return jury_but_view.jury_but_semestriel( - formsemestre, etud, read_only, navigation_div=navigation_div - ) if request.method == "POST": if not read_only: deca.record_form(request.form) diff --git a/sco_version.py b/sco_version.py index 7a3b6fad..28432693 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.940" +SCOVERSION = "9.6.941" SCONAME = "ScoDoc" From 09d59848d6282cc9902ef962f8fd7fa823f3cb42 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Feb 2024 15:57:38 +0100 Subject: [PATCH 06/32] Fix API unit tests (assoc niveaux formation test) --- tools/fakedatabase/create_test_api_database.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py index 79997e1f..5b3e96a2 100644 --- a/tools/fakedatabase/create_test_api_database.py +++ b/tools/fakedatabase/create_test_api_database.py @@ -78,6 +78,13 @@ def import_formation(dept_id: int) -> Formation: ) formation.referentiel_competence_id = ref_comp.id db.session.add(formation) + # --- Association niveaux de compétences aux UE de S1: + niveaux = ref_comp.get_niveaux_by_parcours(1)[1]["TC"] + ues = formation.ues.filter_by(semestre_idx=1).all() + assert len(niveaux) == len(ues) # le ref comp et les formation doivent correspondre + for ue, niveau in zip(ues, niveaux): + ue.niveau_competence = niveau + db.session.add(ue) db.session.commit() return formation From 853bc314226d99afb1581e7cf6eb99498ae2084f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Feb 2024 17:47:54 +0100 Subject: [PATCH 07/32] =?UTF-8?q?Fix:=20traitement=20erreur=20si=20code=20?= =?UTF-8?q?=C3=A9tape=20Apo=20invalide=20+=20ajout=20total=20ECTS=20sur=20?= =?UTF-8?q?fiche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_page_etud.py | 33 +++++++++++++++++++++++---------- app/scodoc/sco_vdi.py | 7 +++++-- app/static/css/scodoc.css | 12 ++++++++++-- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 5d40b9d4..c5b21b7b 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -36,7 +36,7 @@ import sqlalchemy as sa from app import log from app.auth.models import User -from app.but import cursus_but +from app.but import cursus_but, validations_view from app.models import Adresse, EtudAnnotation, FormSemestre, Identite, ScoDocSiteConfig from app.scodoc import ( codes_cursus, @@ -445,6 +445,14 @@ def fiche_etud(etudid=None): # Liens vers compétences BUT if last_formsemestre and last_formsemestre.formation.is_apc(): but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation) + refcomp = last_formsemestre.formation.referentiel_competence + if refcomp: + ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau( + refcomp, etud + ) + ects_total = sum((v.ects() for v in ue_validation_by_niveau.values())) + else: + ects_total = "" info[ "but_cursus_mkup" ] = f""" @@ -454,15 +462,20 @@ def fiche_etud(etudid=None): cursus=but_cursus, scu=scu, )} - """ diff --git a/app/scodoc/sco_vdi.py b/app/scodoc/sco_vdi.py index 9b8e50fe..09d1a90a 100644 --- a/app/scodoc/sco_vdi.py +++ b/app/scodoc/sco_vdi.py @@ -25,12 +25,14 @@ # ############################################################################## -"""Classe stockant le VDI avec le code étape (noms de fichiers maquettes et code semestres) +"""Apogée: gestion du VDI avec le code étape (noms de fichiers maquettes et code semestres) """ from app.scodoc.sco_exceptions import ScoValueError class ApoEtapeVDI(object): + """Classe stockant le VDI avec le code étape (noms de fichiers maquettes et code semestres)""" + _ETAPE_VDI_SEP = "!" def __init__(self, etape_vdi: str = None, etape: str = "", vdi: str = ""): @@ -110,7 +112,8 @@ class ApoEtapeVDI(object): elif len(t) == 2: etape, vdi = t else: - raise ValueError("invalid code etape") + # code étape invalide + etape, vdi = "", "" return etape, vdi else: return etape_vdi, "" diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 295be36c..78313b24 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -962,10 +962,18 @@ td.fichetitre2 .fl { div.section_but { display: flex; flex-direction: row; - align-items: center; + align-items: flex-end; justify-content: space-evenly; } - +div.fiche_but_col2 { + display: flex; + flex-direction: column; + justify-content: space-between; +} +div.fiche_total_etcs { + font-weight: bold; + margin-top: 16px; +} div.section_but > div.link_validation_rcues { align-self: center; text-align: center; From 624ea39eddd20585c3f52920dc12e7875ca59947 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Feb 2024 17:51:54 +0100 Subject: [PATCH 08/32] Fix: edition coef UE null --- app/scodoc/sco_formsemestre_edit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index a9f46380..a0f534dd 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -1678,7 +1678,7 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None): sum_coefs_by_ue_id = {} for ue in ues: sum_coefs_by_ue_id[ue.id] = sum( - modimpl.module.coefficient + modimpl.module.coefficient or 0.0 for modimpl in modimpls if modimpl.module.ue_id == ue.id ) From 7c794c01d15f5ab119ba6e525e8c12dc3e563a1e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Feb 2024 22:39:12 +0100 Subject: [PATCH 09/32] Tableau bord semestre: avertissement modules non conformes --- app/scodoc/sco_formsemestre_status.py | 33 ++++++++++++++++++--------- app/static/css/scodoc.css | 18 ++++++++++++++- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index c431cb91..8d91bf16 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1173,7 +1173,8 @@ def formsemestre_tableau_modules( moduleimpl_id=modimpl.id, ) mod_descr = "Module " + (mod.titre or "") - if mod.is_apc(): + is_apc = mod.is_apc() # SAE ou ressource + if is_apc: coef_descr = ", ".join( [ f"{ue.acronyme}: {co}" @@ -1193,6 +1194,7 @@ def formsemestre_tableau_modules( [u.get_nomcomplet() for u in modimpl.enseignants] ) mod_nb_inscrits = nt.modimpls_results[modimpl.id].nb_inscrits_module + mod_is_conforme = modimpl.check_apc_conformity(nt) ue = modimpl.module.ue if show_ues and (prev_ue_id != ue.id): prev_ue_id = ue.id @@ -1200,10 +1202,12 @@ def formsemestre_tableau_modules( if use_ue_coefs: titre += f""" (coef. {ue.coefficient or 0.0})""" H.append( - f""" - {ue.acronyme} - {titre} - """ + f""" + + {ue.acronyme} + {titre} + + """ ) expr = sco_compute_moy.get_ue_expression( @@ -1226,21 +1230,23 @@ def formsemestre_tableau_modules( fontorange = "" etat = sco_evaluations.do_evaluation_etat_in_mod(nt, modimpl) - # if nt.parcours.APC_SAE: - # tbd style si module non conforme if ( etat["nb_evals_completes"] > 0 and etat["nb_evals_en_cours"] == 0 and etat["nb_evals_vides"] == 0 and not etat["attente"] ): - H.append(f'') + tr_classes = f"formsemestre_status_green{fontorange}" else: - H.append(f'') - + tr_classes = f"formsemestre_status{fontorange}" + if etat["attente"]: + tr_classes += " modimpl_attente" + if not mod_is_conforme: + tr_classes += " modimpl_non_conforme" H.append( f""" - + {mod.code} [en attente]""" ) + if not mod_is_conforme: + H.append( + f""" [non conforme]""" + ) elif mod.module_type == ModuleType.MALUS: nb_malus_notes = sum( e["etat"]["nb_notes"] for e in nt.get_mod_evaluation_etat_list(modimpl) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 78313b24..4bfb8186 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1801,11 +1801,27 @@ table.formsemestre_status { tr.formsemestre_status { background-color: rgb(90%, 90%, 90%); } - +table.formsemestre_status tr td:first-child { + padding-left: 4px; +} +table.formsemestre_status tr td:last-child { + padding-right: 8px; +} tr.formsemestre_status_green { background-color: #eff7f2; } +tr.modimpl_non_conforme td { + background-color: #ffc458; +} +tr.modimpl_non_conforme td, tr.modimpl_attente td { + padding-top: 4px; + padding-bottom: 4px; +} +table.formsemestre_status a.redlink { + text-decoration: none; +} + tr.formsemestre_status_ue { background-color: rgb(90%, 90%, 90%); } From cc3f5d393f586de53c5a217cc421f7cdc1d37713 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 13:34:03 +0100 Subject: [PATCH 10/32] =?UTF-8?q?Fix:=20passage=20d'un=20semestre=20=C3=A0?= =?UTF-8?q?=20l'autre=20sans=20d=C3=A9cision=20de=20jury?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_inscr_passage.py | 15 ++++++++------- sco_version.py | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 4f881022..59de8721 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -68,7 +68,7 @@ def _list_authorized_etuds_by_sem( for src_formsemestre in src_sems: if ignore_jury: # liste de tous les inscrits au semestre (sans dems) - etud_list = list_inscrits(formsemestre.id).values() + etud_list = list_inscrits(src_formsemestre.id).values() else: # liste des étudiants autorisés par le jury à s'inscrire ici etud_list = _list_etuds_from_sem(src_formsemestre, formsemestre) @@ -312,13 +312,14 @@ def formsemestre_inscr_passage( # -- check lock if not formsemestre.etat: raise ScoValueError("opération impossible: semestre verrouille") - header = html_sco_header.sco_header( - page_title="Passage des étudiants", - init_qtip=True, - javascripts=["js/etud_info.js"], - ) + H = [ + html_sco_header.sco_header( + page_title="Passage des étudiants", + init_qtip=True, + javascripts=["js/etud_info.js"], + ) + ] footer = html_sco_header.sco_footer() - H = [header] etuds = [] if etuds is None else etuds if isinstance(etuds, str): # string, vient du form de confirmation diff --git a/sco_version.py b/sco_version.py index 28432693..d8f291dd 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.941" +SCOVERSION = "9.6.942" SCONAME = "ScoDoc" From a8ff540e95c9de4bd0a47d390674ff7e4e81bd97 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 16:31:42 +0100 Subject: [PATCH 11/32] Template base: inclusion multiselect + reorganisation --- app/templates/babase.j2 | 8 ++++++++ app/templates/sco_page.j2 | 7 ------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/templates/babase.j2 b/app/templates/babase.j2 index 6394643c..45d9d1dd 100644 --- a/app/templates/babase.j2 +++ b/app/templates/babase.j2 @@ -14,6 +14,7 @@ {%- block styles %} + {%- endblock styles %} {%- endblock head %} @@ -26,7 +27,14 @@ {% block scripts %} + + + + + + + diff --git a/app/templates/sco_page.j2 b/app/templates/sco_page.j2 index f50bd4cb..00ae2ec4 100644 --- a/app/templates/sco_page.j2 +++ b/app/templates/sco_page.j2 @@ -43,13 +43,6 @@ {{ super() }} - - - - - - - +{% endblock %} + + +{% block app_content %} +
        +

        {{ title }}

        + +

        + {{ explanation|safe }} +

        + + + + + +
        + Groupes d'étudiants à utiliser: {{menu_groups_choice|safe}} +
        + + {% if choose_mail %} +
        + + Utiliser si possible les adresses email personnelles +
        + {% endif %} +
        + +
        + +
        +{% endblock %} From 13e7bd451226302ba52236e9ad1dbd7770a058a2 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 16:43:00 +0100 Subject: [PATCH 13/32] =?UTF-8?q?Envoi=20bulletin,=20g=C3=A9n=C3=A9ration?= =?UTF-8?q?=20classeur:=20choix=20groupe=20=C3=A9tudiants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_bulletins_pdf.py | 30 +++-- app/scodoc/sco_groups_view.py | 5 + app/views/notes.py | 210 +++++++++++++++++++------------- 3 files changed, 149 insertions(+), 96 deletions(-) diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index c8887fc5..f3975e84 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -62,10 +62,13 @@ from flask import g, request from app import log, ScoValueError from app.comp.res_but import ResultatsSemestreBUT from app.models import FormSemestre, Identite -from app.scodoc import sco_cache -from app.scodoc import codes_cursus -from app.scodoc import sco_pdf -from app.scodoc import sco_preferences +from app.scodoc import ( + codes_cursus, + sco_cache, + sco_groups_view, + sco_pdf, + sco_preferences, +) from app.scodoc.sco_logos import find_logo import app.scodoc.sco_utils as scu @@ -211,7 +214,11 @@ def process_field( ) -def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): +def get_formsemestre_bulletins_pdf( + formsemestre_id, + version="selectedevals", + groups_infos=None, # si indiqué, ne prend que ces groupes +): "Document pdf avec tous les bulletins du semestre, et filename" from app.but import bulletin_but_court from app.scodoc import sco_bulletins @@ -226,13 +233,20 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): raise ScoValueError( "get_formsemestre_bulletins_pdf: version de bulletin demandée invalide !" ) - cached = sco_cache.SemBulletinsPDFCache.get(str(formsemestre_id) + "_" + version) + + etuds = formsemestre.get_inscrits(include_demdef=True, order=True) + if groups_infos is not None: + etudids = {m["etudid"] for m in groups_infos.members} + etuds = [etud for etud in etuds if etud.id in etudids] + cache_key = ( + str(formsemestre_id) + "_" + version + "_" + groups_infos.get_groups_key() + ) + cached = sco_cache.SemBulletinsPDFCache.get(cache_key) if cached: return cached[1], cached[0] fragments = [] # Make each bulletin - - for etud in formsemestre.get_inscrits(include_demdef=True, order=True): + for etud in etuds: if version == "butcourt": frag = bulletin_but_court.bulletin_but_court_pdf_frag(etud, formsemestre) else: diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index 0d1d1dfb..ebfffab3 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -331,6 +331,7 @@ class DisplayedGroupsInfos: empty_list_select_all=True, moduleimpl_id=None, # used to find formsemestre when unspecified ): + group_ids = [] if group_ids is None else group_ids if isinstance(group_ids, int): if group_ids: group_ids = [group_ids] # cas ou un seul parametre, pas de liste @@ -466,6 +467,10 @@ class DisplayedGroupsInfos: else None ) + def get_groups_key(self) -> str: + "clé identifiant les groupes sélectionnés, utile pour cache" + return "-".join(str(x) for x in sorted(self.group_ids)) + # Ancien ZScolar.group_list renommé ici en group_table def groups_table( diff --git a/app/views/notes.py b/app/views/notes.py index 6832b6b4..25170288 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -90,7 +90,6 @@ from app.decorators import ( # --------------- from app.pe import pe_view # ne pas enlever, ajoute des vues from app.scodoc import sco_bulletins_json, sco_utils as scu -from app.scodoc import notesdb as ndb from app import log, send_scodoc_alarm from app.scodoc.sco_exceptions import ( @@ -98,59 +97,62 @@ from app.scodoc.sco_exceptions import ( ScoValueError, ScoInvalidIdType, ) -from app.scodoc import html_sco_header -from app.scodoc import sco_apogee_compare -from app.scodoc import sco_archives_formsemestre -from app.scodoc import sco_assiduites -from app.scodoc import sco_bulletins -from app.scodoc import sco_bulletins_pdf -from app.scodoc import sco_cache -from app.scodoc import sco_cost_formation -from app.scodoc import sco_debouche -from app.scodoc import sco_edit_apc -from app.scodoc import sco_edit_formation -from app.scodoc import sco_edit_matiere -from app.scodoc import sco_edit_module -from app.scodoc import sco_edit_ue -from app.scodoc import sco_etape_apogee_view -from app.scodoc import sco_etud -from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_check_abs -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_evaluation_edit -from app.scodoc import sco_evaluation_recap -from app.scodoc import sco_export_results -from app.scodoc import sco_formations -from app.scodoc import sco_formation_recap -from app.scodoc import sco_formation_versions -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_custommenu -from app.scodoc import sco_formsemestre_edit -from app.scodoc import sco_formsemestre_exterieurs -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_formsemestre_validation -from app.scodoc import sco_inscr_passage -from app.scodoc import sco_liste_notes -from app.scodoc import sco_lycee -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_moduleimpl_inscriptions -from app.scodoc import sco_moduleimpl_status -from app.scodoc import sco_placement -from app.scodoc import sco_poursuite_dut -from app.scodoc import sco_preferences -from app.scodoc import sco_prepajury -from app.scodoc import sco_pv_forms -from app.scodoc import sco_recapcomplet -from app.scodoc import sco_report -from app.scodoc import sco_report_but -from app.scodoc import sco_saisie_notes -from app.scodoc import sco_semset -from app.scodoc import sco_synchro_etuds -from app.scodoc import sco_tag_module -from app.scodoc import sco_ue_external -from app.scodoc import sco_undo_notes -from app.scodoc import sco_users +from app.scodoc import ( + html_sco_header, + sco_apogee_compare, + sco_archives_formsemestre, + sco_assiduites, + sco_bulletins, + sco_bulletins_pdf, + sco_cache, + sco_cost_formation, + sco_debouche, + sco_edit_apc, + sco_edit_formation, + sco_edit_matiere, + sco_edit_module, + sco_edit_ue, + sco_etape_apogee_view, + sco_etud, + sco_evaluations, + sco_evaluation_check_abs, + sco_evaluation_db, + sco_evaluation_edit, + sco_evaluation_recap, + sco_export_results, + sco_formations, + sco_formation_recap, + sco_formation_versions, + sco_formsemestre, + sco_formsemestre_custommenu, + sco_formsemestre_edit, + sco_formsemestre_exterieurs, + sco_formsemestre_inscriptions, + sco_formsemestre_status, + sco_formsemestre_validation, + sco_groups_view, + sco_inscr_passage, + sco_liste_notes, + sco_lycee, + sco_moduleimpl, + sco_moduleimpl_inscriptions, + sco_moduleimpl_status, + sco_placement, + sco_poursuite_dut, + sco_preferences, + sco_prepajury, + sco_pv_forms, + sco_recapcomplet, + sco_report, + sco_report_but, + sco_saisie_notes, + sco_semset, + sco_synchro_etuds, + sco_tag_module, + sco_ue_external, + sco_undo_notes, + sco_users, +) from app.scodoc.gen_tables import GenTable from app.scodoc.sco_pv_dict import descr_autorisations from app.scodoc.sco_permissions import Permission @@ -1844,10 +1846,20 @@ sco_publish( @scodoc @permission_required(Permission.ScoView) @scodoc7func -def formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"): +def formsemestre_bulletins_pdf( + formsemestre_id, + group_ids: list[int] = None, # si indiqué, ne prend que ces groupes + version="selectedevals", +): "Publie les bulletins dans un classeur PDF" + # Informations sur les groupes à utiliser: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + ) pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf( - formsemestre_id, version=version + formsemestre_id, groups_infos=groups_infos, version=version ) return scu.sendPDFFile(pdfdoc, filename) @@ -1864,18 +1876,29 @@ _EXPL_BULL = """Versions des bulletins: @scodoc @permission_required(Permission.ScoView) @scodoc7func -def formsemestre_bulletins_pdf_choice(formsemestre_id, version=None): +def formsemestre_bulletins_pdf_choice( + formsemestre_id, + version=None, + group_ids: list[int] = None, # si indiqué, ne prend que ces groupes +): """Choix version puis envoi classeur bulletins pdf""" formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + # Informations sur les groupes à utiliser: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + ) if version: pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf( - formsemestre_id, version=version + formsemestre_id, groups_infos=groups_infos, version=version ) return scu.sendPDFFile(pdfdoc, filename) return _formsemestre_bulletins_choice( formsemestre, - title="Choisir la version des bulletins à générer", explanation=_EXPL_BULL, + groups_infos=groups_infos, + title="Choisir la version des bulletins à générer", ) @@ -1900,8 +1923,15 @@ def formsemestre_bulletins_mailetuds_choice( version=None, dialog_confirmed=False, prefer_mail_perso=0, + group_ids: list[int] = None, # si indiqué, ne prend que ces groupes ): """Choix version puis envoi classeur bulletins pdf""" + # Informations sur les groupes à utiliser: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + ) if version: return flask.redirect( url_for( @@ -1909,8 +1939,9 @@ def formsemestre_bulletins_mailetuds_choice( scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, version=version, - dialog_confirmed=dialog_confirmed, + dialog_confirmed=int(dialog_confirmed), prefer_mail_perso=prefer_mail_perso, + group_ids=groups_infos.group_ids, ) ) formsemestre = FormSemestre.get_formsemestre(formsemestre_id) @@ -1934,45 +1965,41 @@ def formsemestre_bulletins_mailetuds_choice(

        """ + expl_bull, choose_mail=True, + groups_infos=groups_infos, ) # not published def _formsemestre_bulletins_choice( - formsemestre: FormSemestre, title="", explanation="", choose_mail=False + formsemestre: FormSemestre, + title="", + explanation="", + choose_mail=False, + groups_infos=None, ): - """Choix d'une version de bulletin""" - versions = ( + """Choix d'une version de bulletin + (pour envois mail ou génération classeur pdf) + """ + versions_bulletins = ( scu.BULLETINS_VERSIONS_BUT if formsemestre.formation.is_apc() else scu.BULLETINS_VERSIONS ) - H = [ - html_sco_header.html_sem_header(title), - f""" -

        - - """, - ] - H.append("""  """) - if choose_mail: - H.append( - """
        - Utiliser si possible les adresses personnelles -
        """ - ) - - H.append(f"""

        {explanation}

        """) - - return "\n".join(H) + html_sco_header.sco_footer() + return render_template( + "formsemestre/bulletins_choice.j2", + explanation=explanation, + choose_mail=choose_mail, + formsemestre=formsemestre, + menu_groups_choice=sco_groups_view.menu_groups_choice(groups_infos), + sco=ScoData(formsemestre=formsemestre), + sco_groups_view=sco_groups_view, + title=title, + versions_bulletins=versions_bulletins, + ) -@bp.route("/formsemestre_bulletins_mailetuds") +@bp.route("/formsemestre_bulletins_mailetuds", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) @scodoc7func @@ -1981,16 +2008,23 @@ def formsemestre_bulletins_mailetuds( version="long", dialog_confirmed=False, prefer_mail_perso=0, + group_ids: list[int] = None, # si indiqué, ne prend que ces groupes ): """Envoie à chaque etudiant son bulletin (inscrit non démissionnaire ni défaillant et ayant un mail renseigné dans ScoDoc) """ + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + ) + etudids = {m["etudid"] for m in groups_infos.members} prefer_mail_perso = int(prefer_mail_perso) formsemestre = FormSemestre.get_formsemestre(formsemestre_id) inscriptions = [ inscription for inscription in formsemestre.inscriptions - if inscription.etat == scu.INSCRIT + if inscription.etat == scu.INSCRIT and inscription.etudid in etudids ] # if not sco_bulletins.can_send_bulletin_by_mail(formsemestre_id): @@ -1998,7 +2032,7 @@ def formsemestre_bulletins_mailetuds( # Confirmation dialog if not dialog_confirmed: return scu.confirm_dialog( - f"

        Envoyer les {len(inscriptions)} bulletins par e-mail aux étudiants inscrits ?", + f"

        Envoyer les {len(inscriptions)} bulletins par e-mail aux étudiants inscrits sélectionnés ?", dest_url="", cancel_url=url_for( "notes.formsemestre_status", From 2dd7154036770010b38cba347c158132bb932e50 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 16:46:19 +0100 Subject: [PATCH 14/32] Fix: missing UE.ects --- app/models/validations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/validations.py b/app/models/validations.py index 17dd12a6..e9796876 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -126,7 +126,7 @@ class ScolarFormSemestreValidation(db.Model): def ects(self) -> float: "Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)" return ( - self.ue.ects + self.ue.ects or 0.0 if (self.ue is not None) and (self.code in CODES_UE_VALIDES) else 0.0 ) From c492cf550a6df2dd04dbcd6f1ed94c33361b7059 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 16:50:25 +0100 Subject: [PATCH 15/32] Fix: typo check_formation_ues --- app/scodoc/sco_formsemestre_validation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 68a18d60..7c68ddf9 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -1399,7 +1399,7 @@ def check_formation_ues(formation: Formation) -> tuple[str, dict[int, list[Unite if ( len(semestre_ids) > 1 ): # plusieurs semestres d'indices differents dans le cursus - ue_multiples[ue["ue_id"]] = sems + ue_multiples[ue.id] = sems if not ue_multiples: return "", {} @@ -1423,12 +1423,12 @@ def check_formation_ues(formation: Formation) -> tuple[str, dict[int, list[Unite ] slist = ", ".join( [ - """%(titreannee)s (semestre %(semestre_id)s)""" - % s + f"""{s['titreannee'] + } (semestre {s['semestre_id']})""" for s in sems ] ) - H.append("
      • %s : %s
      • " % (ue.acronyme, slist)) + H.append(f"
      • {ue.acronyme} : {slist}
      • ") H.append("

      ") return "\n".join(H), ue_multiples From 61d35ddac0f89525ffc0746a4d205071fb2dfb94 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 17:22:56 +0100 Subject: [PATCH 16/32] =?UTF-8?q?Fix:=20cr=C3=A9ation=20modules=20(parcour?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/modules.py | 34 +++++++++++++++++++++++++++++++++- app/scodoc/sco_edit_module.py | 7 +++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/app/models/modules.py b/app/models/modules.py index b0db0e68..3da839a7 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -6,7 +6,12 @@ from flask import current_app, g from app import db from app import models from app.models import APO_CODE_STR_LEN -from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules +from app.models.but_refcomp import ( + ApcParcours, + ApcReferentielCompetences, + app_critiques_modules, + parcours_modules, +) from app.scodoc import sco_utils as scu from app.scodoc.codes_cursus import UE_SPORT from app.scodoc.sco_exceptions import ScoValueError @@ -100,6 +105,33 @@ class Module(models.ScoDocModel): return args_dict + @classmethod + def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict: + """Returns a copy of dict with only the keys belonging to the Model and not in excluded. + Add 'id' to excluded.""" + # on ne peut pas affecter directement parcours + return super().filter_model_attributes(data, (excluded or set()) | {"parcours"}) + + @classmethod + def create_from_dict(cls, data: dict) -> "Module": + """Create from given dict, add parcours""" + mod = super().create_from_dict(data) + for p in data["parcours"]: + if isinstance(p, ApcParcours): + parcour: ApcParcours = p + else: + pid = int(p) + query = ApcParcours.query.filter_by(id=pid) + if g.scodoc_dept: + query = query.join(ApcReferentielCompetences).filter_by( + dept_id=g.scodoc_dept_id + ) + parcour: ApcParcours = query.first() + if parcour is None: + raise ScoValueError("Parcours invalide") + mod.parcours.append(parcour) + return mod + def clone(self): """Create a new copy of this module.""" mod = Module( diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index 5cf9797e..e8f28787 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -682,8 +682,11 @@ def module_edit( "input_type": "checkbox", "vertical": True, "dom_id": "tf_module_parcours", - "labels": [parcour.libelle for parcour in ref_comp.parcours] - + ["Tous (tronc commun)"], + "labels": [ + f"  {parcour.libelle} ({parcour.code})" + for parcour in ref_comp.parcours + ] + + ["  Tous (tronc commun)"], "allowed_values": [ str(parcour.id) for parcour in ref_comp.parcours ] From d1d89cc42786615c11dfb64ceb51ad1d2a57431a Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 17:39:18 +0100 Subject: [PATCH 17/32] =?UTF-8?q?Bulletin:=20d=C3=A9tection=20erreur=20rar?= =?UTF-8?q?e=20=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_bulletins.py | 20 ++++++++++++-------- app/scodoc/sco_exceptions.py | 8 ++++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 25b246b5..d7d91ba2 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -55,7 +55,7 @@ from app.models import ( ScoDocSiteConfig, ) from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_exceptions import AccessDenied, ScoValueError +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError, ScoTemporaryError from app.scodoc import html_sco_header from app.scodoc import htmlutils from app.scodoc import sco_assiduites @@ -346,14 +346,14 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): # auparavant on filtrait les modules sans notes # si ue_status['cur_moy_ue'] != 'NA' alors u['modules'] = [] (pas de moyenne => pas de modules) - u[ - "modules_capitalized" - ] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée) + u["modules_capitalized"] = ( + [] + ) # modules de l'UE capitalisée (liste vide si pas capitalisée) if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None: sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"]) - u[ - "ue_descr_txt" - ] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}' + u["ue_descr_txt"] = ( + f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}' + ) u["ue_descr_html"] = ( f""""Erreur temporaire

      -

      Veuillez ré-essayer. Si le problème persiste, merci de contacter l'assistance ScoDoc +

      Erreur temporaire

      +

      Veuillez ré-essayer. Si le problème persiste (ou s'il venait + à se produire fréquemment), merci de contacter l'assistance ScoDoc + (voir les informations de contact).

      """ + app.clear_scodoc_cache() super().__init__(msg) From 46cdaf75b8765c91cf4b260c112314df4a28bcb0 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 18:32:51 +0100 Subject: [PATCH 18/32] Fix unit tests --- app/models/modules.py | 2 +- scodoc.py | 1 + tests/conftest.py | 12 +++++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/models/modules.py b/app/models/modules.py index 3da839a7..5abb5340 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -116,7 +116,7 @@ class Module(models.ScoDocModel): def create_from_dict(cls, data: dict) -> "Module": """Create from given dict, add parcours""" mod = super().create_from_dict(data) - for p in data["parcours"]: + for p in data.get("parcours", []) or []: if isinstance(p, ApcParcours): parcour: ApcParcours = p else: diff --git a/scodoc.py b/scodoc.py index 4c082082..91421385 100755 --- a/scodoc.py +++ b/scodoc.py @@ -59,6 +59,7 @@ cli.register(app) @app.context_processor def inject_sco_utils(): "Make scu available in all Jinja templates" + # if modified, put the same in conftest.py#27 return dict(scu=scu) diff --git a/tests/conftest.py b/tests/conftest.py index f9e72a66..78ed0dc0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,17 @@ import pytest from flask import g -from flask_login import login_user, logout_user, current_user +from flask_login import login_user from config import TestConfig import app from app import db, create_app from app import initialize_scodoc_database, clear_scodoc_cache from app import models -from app.auth.models import User, Role, UserRole, Permission +from app.auth.models import User, Role from app.auth.models import get_super_admin -from app.scodoc import sco_bulletins_standard from app.scodoc import notesdb as ndb +import app.scodoc.sco_utils as scu RESOURCES_DIR = "/opt/scodoc/tests/ressources" @@ -23,6 +23,12 @@ def test_client(): # Run tests: with apptest.test_client() as client: with apptest.app_context(): + + @apptest.context_processor + def inject_sco_utils(): + "Make scu available in all Jinja templates" + return dict(scu=scu) + with apptest.test_request_context(): # initialize scodoc "g": g.stored_get_formsemestre = {} From a8a711b30a3e393a400874f5a9a0333542294e10 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Feb 2024 18:33:51 +0100 Subject: [PATCH 19/32] 9.6.943 --- sco_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sco_version.py b/sco_version.py index d8f291dd..b8048a3b 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.942" +SCOVERSION = "9.6.943" SCONAME = "ScoDoc" From 81fab97018e840e349e443a53d9afb613c465468 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 23 Feb 2024 19:03:02 +0100 Subject: [PATCH 20/32] 2 small fixes --- app/scodoc/sco_abs_billets.py | 6 +++--- app/scodoc/sco_abs_notification.py | 12 +++++++++--- app/scodoc/sco_bulletins_pdf.py | 16 +++++++++------- sco_version.py | 2 +- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/app/scodoc/sco_abs_billets.py b/app/scodoc/sco_abs_billets.py index 4a9da0af..33052473 100644 --- a/app/scodoc/sco_abs_billets.py +++ b/app/scodoc/sco_abs_billets.py @@ -124,9 +124,9 @@ def table_billets( else: billet_dict["nomprenom"] = billet.etudiant.nomprenom billet_dict["_nomprenom_order"] = billet.etudiant.sort_key - billet_dict[ - "_nomprenom_td_attrs" - ] = f'id="{billet.etudiant.id}" class="etudinfo"' + billet_dict["_nomprenom_td_attrs"] = ( + f'id="{billet.etudiant.id}" class="etudinfo"' + ) if with_links: billet_dict["_nomprenom_target"] = url_for( "scolar.fiche_etud", diff --git a/app/scodoc/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py index c29dba2d..ba729e27 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -34,7 +34,7 @@ Il suffit d'appeler abs_notify() après chaque ajout d'absence. import datetime from typing import Optional -from flask import g, url_for +from flask import flash, g, url_for from flask_mail import Message from app import db @@ -46,7 +46,6 @@ from app.models.etudiants import Identite from app.models.events import Scolog from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb -from app.scodoc import sco_etud from app.scodoc import sco_preferences from app.scodoc import sco_utils as scu @@ -283,10 +282,17 @@ def abs_notification_message( ) template = prefs["abs_notification_mail_tmpl"] + txt = "" if template: - txt = prefs["abs_notification_mail_tmpl"] % values + try: + txt = prefs["abs_notification_mail_tmpl"] % values + except KeyError: + flash("Mail non envoyé: format invalide (voir paramétrage)") + log("abs_notification_message: invalid key in abs_notification_mail_tmpl") + txt = "" else: log("abs_notification_message: empty template, not sending message") + if not txt: return None subject = f"""[ScoDoc] Trop d'absences pour {etud.nomprenom}""" diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index f3975e84..80cee3a4 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -65,7 +65,6 @@ from app.models import FormSemestre, Identite from app.scodoc import ( codes_cursus, sco_cache, - sco_groups_view, sco_pdf, sco_preferences, ) @@ -114,7 +113,8 @@ def assemble_bulletins_pdf( return data -def replacement_function(match): +def replacement_function(match) -> str: + "remplace logo par balise html img" balise = match.group(1) name = match.group(3) logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id) @@ -235,12 +235,14 @@ def get_formsemestre_bulletins_pdf( ) etuds = formsemestre.get_inscrits(include_demdef=True, order=True) - if groups_infos is not None: + if groups_infos is None: + gr_key = "" + else: etudids = {m["etudid"] for m in groups_infos.members} etuds = [etud for etud in etuds if etud.id in etudids] - cache_key = ( - str(formsemestre_id) + "_" + version + "_" + groups_infos.get_groups_key() - ) + gr_key = groups_infos.get_groups_key() + + cache_key = str(formsemestre_id) + "_" + version + "_" + gr_key cached = sco_cache.SemBulletinsPDFCache.get(cache_key) if cached: return cached[1], cached[0] @@ -276,7 +278,7 @@ def get_formsemestre_bulletins_pdf( sco_pdf.PDFLOCK.release() # date_iso = time.strftime("%Y-%m-%d") - filename = "bul-%s-%s.pdf" % (formsemestre.titre_num(), date_iso) + filename = f"bul-{formsemestre.titre_num()}-{date_iso}.pdf" filename = scu.unescape_html(filename).replace(" ", "_").replace("&", "") # fill cache sco_cache.SemBulletinsPDFCache.set( diff --git a/sco_version.py b/sco_version.py index b8048a3b..2b38021d 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.943" +SCOVERSION = "9.6.944" SCONAME = "ScoDoc" From 7f32f1fb992e19651dde7ac56b3718f69fc1624e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 24 Feb 2024 16:49:41 +0100 Subject: [PATCH 21/32] Evaluations de type bonus. Implements #848 --- app/but/bulletin_but.py | 104 +++++++++++++---------- app/but/bulletin_but_pdf.py | 13 ++- app/comp/moy_mod.py | 104 ++++++++++++++++++++--- app/models/evaluations.py | 15 +++- app/scodoc/sco_bulletins.py | 9 +- app/scodoc/sco_bulletins_standard.py | 14 ++- app/scodoc/sco_evaluation_edit.py | 25 ++++-- app/scodoc/sco_evaluations.py | 25 +++--- app/scodoc/sco_liste_notes.py | 36 ++++---- app/scodoc/sco_moduleimpl_status.py | 20 +++-- app/scodoc/sco_saisie_notes.py | 11 ++- app/scodoc/sco_ue_external.py | 2 +- app/scodoc/sco_utils.py | 4 - app/static/css/releve-but.css | 6 +- app/static/css/scodoc.css | 8 +- app/static/js/releve-but.js | 7 +- app/tables/recap.py | 25 ++++-- app/templates/scodoc/help/evaluations.j2 | 26 ++++-- sco_version.py | 7 +- tests/unit/test_notes_rattrapage.py | 5 +- 20 files changed, 312 insertions(+), 154 deletions(-) diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 918d14fd..87b17546 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -104,9 +104,11 @@ class BulletinBUT: "competence": None, # XXX TODO lien avec référentiel "moyenne": None, # Le bonus sport appliqué sur cette UE - "bonus": fmt_note(res.bonus_ues[ue.id][etud.id]) - if res.bonus_ues is not None and ue.id in res.bonus_ues - else fmt_note(0.0), + "bonus": ( + fmt_note(res.bonus_ues[ue.id][etud.id]) + if res.bonus_ues is not None and ue.id in res.bonus_ues + else fmt_note(0.0) + ), "malus": fmt_note(res.malus[ue.id][etud.id]), "capitalise": None, # "AAAA-MM-JJ" TODO #sco93 "ressources": self.etud_ue_mod_results(etud, ue, res.ressources), @@ -181,14 +183,16 @@ class BulletinBUT: "is_external": ue_capitalisee.is_external, "date_capitalisation": ue_capitalisee.event_date, "formsemestre_id": ue_capitalisee.formsemestre_id, - "bul_orig_url": url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - etudid=etud.id, - formsemestre_id=ue_capitalisee.formsemestre_id, - ) - if ue_capitalisee.formsemestre_id - else None, + "bul_orig_url": ( + url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + etudid=etud.id, + formsemestre_id=ue_capitalisee.formsemestre_id, + ) + if ue_capitalisee.formsemestre_id + else None + ), "ressources": {}, # sans détail en BUT "saes": {}, } @@ -227,13 +231,15 @@ class BulletinBUT: "id": modimpl.id, "titre": modimpl.module.titre, "code_apogee": modimpl.module.code_apogee, - "url": url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=modimpl.id, - ) - if has_request_context() - else "na", + "url": ( + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + if has_request_context() + else "na" + ), "moyenne": { # # moyenne indicative de module: moyenne des UE, # # ignorant celles sans notes (nan) @@ -242,18 +248,20 @@ class BulletinBUT: # "max": fmt_note(moyennes_etuds.max()), # "moy": fmt_note(moyennes_etuds.mean()), }, - "evaluations": [ - self.etud_eval_results(etud, e) - for e in modimpl.evaluations - if (e.visibulletin or version == "long") - and (e.id in modimpl_results.evaluations_etat) - and ( - modimpl_results.evaluations_etat[e.id].is_complete - or self.prefs["bul_show_all_evals"] - ) - ] - if version != "short" - else [], + "evaluations": ( + [ + self.etud_eval_results(etud, e) + for e in modimpl.evaluations + if (e.visibulletin or version == "long") + and (e.id in modimpl_results.evaluations_etat) + and ( + modimpl_results.evaluations_etat[e.id].is_complete + or self.prefs["bul_show_all_evals"] + ) + ] + if version != "short" + else [] + ), } return d @@ -274,9 +282,11 @@ class BulletinBUT: poids = collections.defaultdict(lambda: 0.0) d = { "id": e.id, - "coef": fmt_note(e.coefficient) - if e.evaluation_type == scu.EVALUATION_NORMALE - else None, + "coef": ( + fmt_note(e.coefficient) + if e.evaluation_type == Evaluation.EVALUATION_NORMALE + else None + ), "date_debut": e.date_debut.isoformat() if e.date_debut else None, "date_fin": e.date_fin.isoformat() if e.date_fin else None, "description": e.description, @@ -291,18 +301,20 @@ class BulletinBUT: "moy": fmt_note(notes_ok.mean(), note_max=e.note_max), }, "poids": poids, - "url": url_for( - "notes.evaluation_listenotes", - scodoc_dept=g.scodoc_dept, - evaluation_id=e.id, - ) - if has_request_context() - else "na", + "url": ( + url_for( + "notes.evaluation_listenotes", + scodoc_dept=g.scodoc_dept, + evaluation_id=e.id, + ) + if has_request_context() + else "na" + ), # deprecated (supprimer avant #sco9.7) "date": e.date_debut.isoformat() if e.date_debut else None, - "heure_debut": e.date_debut.time().isoformat("minutes") - if e.date_debut - else None, + "heure_debut": ( + e.date_debut.time().isoformat("minutes") if e.date_debut else None + ), "heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None, } return d @@ -524,9 +536,9 @@ class BulletinBUT: d.update(infos) # --- Rangs - d[ - "rang_nt" - ] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}" + d["rang_nt"] = ( + f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}" + ) d["rang_txt"] = "Rang " + d["rang_nt"] d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"])) diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py index f56b86c0..999846f7 100644 --- a/app/but/bulletin_but_pdf.py +++ b/app/but/bulletin_but_pdf.py @@ -24,7 +24,7 @@ from reportlab.lib.colors import blue from reportlab.lib.units import cm, mm from reportlab.platypus import Paragraph, Spacer -from app.models import ScoDocSiteConfig +from app.models import Evaluation, ScoDocSiteConfig from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard from app.scodoc import gen_tables from app.scodoc.codes_cursus import UE_SPORT @@ -422,7 +422,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()): "lignes des évaluations" for e in evaluations: - coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*" + coef = ( + e["coef"] + if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE + else "*" + ) t = { "titre": f"{e['description'] or ''}", "moyenne": e["note"]["value"], @@ -431,7 +435,10 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard): ), "coef": coef, "_coef_pdf": Paragraph( - f"{coef}" + f"""{ + coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS + else "bonus" + }""" ), "_pdf_style": [ ( diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 94f056b2..a977894d 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -157,8 +157,7 @@ class ModuleImplResults: etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem. is_complete = ( - (evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE) - or (evaluation.evaluation_type == scu.EVALUATION_SESSION2) + (evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE) or (evaluation.publish_incomplete) or (not etudids_sans_note) ) @@ -240,19 +239,20 @@ class ModuleImplResults: ).formsemestre.inscriptions ] - def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array: + def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array: """Coefficients des évaluations. - Les coefs des évals incomplètes et non "normales" (session 2, rattrapage) - sont zéro. + Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro. Résultat: 2d-array of floats, shape (nb_evals, 1) """ return ( np.array( [ - e.coefficient - if e.evaluation_type == scu.EVALUATION_NORMALE - else 0.0 - for e in moduleimpl.evaluations + ( + e.coefficient + if e.evaluation_type == Evaluation.EVALUATION_NORMALE + else 0.0 + ) + for e in modimpl.evaluations ], dtype=float, ) @@ -285,7 +285,7 @@ class ModuleImplResults: for (etudid, x) in self.evals_notes[evaluation_id].items() } - def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl): + def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None: """L'évaluation de rattrapage de ce module, ou None s'il n'en a pas. Rattrapage: la moyenne du module est la meilleure note entre moyenne des autres évals et la note eval rattrapage. @@ -293,25 +293,41 @@ class ModuleImplResults: eval_list = [ e for e in moduleimpl.evaluations - if e.evaluation_type == scu.EVALUATION_RATTRAPAGE + if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE ] if eval_list: return eval_list[0] return None - def get_evaluation_session2(self, moduleimpl: ModuleImpl): + def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None: """L'évaluation de deuxième session de ce module, ou None s'il n'en a pas. Session 2: remplace la note de moyenne des autres évals. """ eval_list = [ e for e in moduleimpl.evaluations - if e.evaluation_type == scu.EVALUATION_SESSION2 + if e.evaluation_type == Evaluation.EVALUATION_SESSION2 ] if eval_list: return eval_list[0] return None + def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]: + """Les évaluations bonus de ce module, ou liste vide s'il n'en a pas.""" + return [ + e + for e in modimpl.evaluations + if e.evaluation_type == Evaluation.EVALUATION_BONUS + ] + + def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]: + """Les indices des évaluations bonus""" + return [ + i + for (i, e) in enumerate(modimpl.evaluations) + if e.evaluation_type == Evaluation.EVALUATION_BONUS + ] + class ModuleImplResultsAPC(ModuleImplResults): "Calcul des moyennes de modules à la mode BUT" @@ -356,7 +372,7 @@ class ModuleImplResultsAPC(ModuleImplResults): # et dans dans evals_poids_etuds # (rappel: la comparaison est toujours false face à un NaN) # shape: (nb_etuds, nb_evals, nb_ues) - poids_stacked = np.stack([evals_poids] * nb_etuds) + poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues evals_poids_etuds = np.where( np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, poids_stacked, @@ -364,10 +380,20 @@ class ModuleImplResultsAPC(ModuleImplResults): ) # Calcule la moyenne pondérée sur les notes disponibles: evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2) + # evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) etuds_moy_module = np.sum( evals_poids_etuds * evals_notes_stacked, axis=1 ) / np.sum(evals_poids_etuds, axis=1) + # etuds_moy_module shape: nb_etuds x nb_ues + + # Application des évaluations bonus: + etuds_moy_module = self.apply_bonus( + etuds_moy_module, + modimpl, + evals_poids_df, + evals_notes_stacked, + ) # Session2 : quand elle existe, remplace la note de module eval_session2 = self.get_evaluation_session2(modimpl) @@ -416,6 +442,30 @@ class ModuleImplResultsAPC(ModuleImplResults): ) return self.etuds_moy_module + def apply_bonus( + self, + etuds_moy_module: pd.DataFrame, + modimpl: ModuleImpl, + evals_poids_df: pd.DataFrame, + evals_notes_stacked: np.ndarray, + ): + """Ajoute les points des évaluations bonus. + Il peut y avoir un nb quelconque d'évaluations bonus. + Les points sont directement ajoutés (ils peuvent être négatifs). + """ + evals_bonus = self.get_evaluations_bonus(modimpl) + if not evals_bonus: + return etuds_moy_module + poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module)) + for evaluation in evals_bonus: + eval_idx = evals_poids_df.index.get_loc(evaluation.id) + etuds_moy_module += ( + evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :] + ) + # Clip dans [0,20] + etuds_moy_module.clip(0, 20, out=etuds_moy_module) + return etuds_moy_module + def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: """Charge poids des évaluations d'un module et retourne un dataframe @@ -532,6 +582,13 @@ class ModuleImplResultsClassic(ModuleImplResults): evals_coefs_etuds * evals_notes_20, axis=1 ) / np.sum(evals_coefs_etuds, axis=1) + # Application des évaluations bonus: + etuds_moy_module = self.apply_bonus( + etuds_moy_module, + modimpl, + evals_notes_20, + ) + # Session2 : quand elle existe, remplace la note de module eval_session2 = self.get_evaluation_session2(modimpl) if eval_session2: @@ -571,3 +628,22 @@ class ModuleImplResultsClassic(ModuleImplResults): ) return self.etuds_moy_module + + def apply_bonus( + self, + etuds_moy_module: np.ndarray, + modimpl: ModuleImpl, + evals_notes_20: np.ndarray, + ): + """Ajoute les points des évaluations bonus. + Il peut y avoir un nb quelconque d'évaluations bonus. + Les points sont directement ajoutés (ils peuvent être négatifs). + """ + evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl) + if not evals_bonus_idx: + return etuds_moy_module + for eval_idx in evals_bonus_idx: + etuds_moy_module += evals_notes_20[:, eval_idx] + # Clip dans [0,20] + etuds_moy_module.clip(0, 20, out=etuds_moy_module) + return etuds_moy_module diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 7da2d84c..37e3ac79 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -23,8 +23,6 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365) NOON = datetime.time(12, 00) DEFAULT_EVALUATION_TIME = datetime.time(8, 0) -VALID_EVALUATION_TYPES = {0, 1, 2} - class Evaluation(db.Model): """Evaluation (contrôle, examen, ...)""" @@ -57,6 +55,17 @@ class Evaluation(db.Model): numero = db.Column(db.Integer, nullable=False, default=0) ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True) + EVALUATION_NORMALE = 0 # valeurs stockées en base, ne pas changer ! + EVALUATION_RATTRAPAGE = 1 + EVALUATION_SESSION2 = 2 + EVALUATION_BONUS = 3 + VALID_EVALUATION_TYPES = { + EVALUATION_NORMALE, + EVALUATION_RATTRAPAGE, + EVALUATION_SESSION2, + EVALUATION_BONUS, + } + def __repr__(self): return f"""• ' + e["name"], - "coef": ("" + e["coef_txt"] + "") - if prefs["bul_show_coef"] - else "", + "coef": ( + ( + f"{e['coef_txt']}" + if e["evaluation_type"] != Evaluation.EVALUATION_BONUS + else "bonus" + ) + if prefs["bul_show_coef"] + else "" + ), "_hidden": hidden, "_module_target": e["target_html"], # '_module_help' : , diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index b26ed9fb..c7b0dd67 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -183,7 +183,8 @@ def evaluation_create_form( { "size": 6, "type": "float", # peut être négatif (!) - "explanation": "coef. dans le module (choisi librement par l'enseignant, non utilisé pour rattrapage et 2ème session)", + "explanation": """coef. dans le module (choisi librement par + l'enseignant, non utilisé pour rattrapage, 2ème session et bonus)""", "allow_null": False, }, ) @@ -195,7 +196,7 @@ def evaluation_create_form( "size": 4, "type": "float", "title": "Notes de 0 à", - "explanation": f"barème (note max actuelle: {min_note_max_str})", + "explanation": f"""barème (note max actuelle: {min_note_max_str}).""", "allow_null": False, "max_value": scu.NOTES_MAX, "min_value": min_note_max, @@ -206,7 +207,8 @@ def evaluation_create_form( { "size": 36, "type": "text", - "explanation": """type d'évaluation, apparait sur le bulletins longs. Exemples: "contrôle court", "examen de TP", "examen final".""", + "explanation": """type d'évaluation, apparait sur le bulletins longs. + Exemples: "contrôle court", "examen de TP", "examen final".""", }, ), ( @@ -230,16 +232,20 @@ def evaluation_create_form( { "input_type": "menu", "title": "Modalité", - "allowed_values": ( - scu.EVALUATION_NORMALE, - scu.EVALUATION_RATTRAPAGE, - scu.EVALUATION_SESSION2, - ), + "allowed_values": Evaluation.VALID_EVALUATION_TYPES, "type": "int", "labels": ( "Normale", "Rattrapage (remplace si meilleure note)", "Deuxième session (remplace toujours)", + ( + "Bonus " + + ( + "(pondéré par poids et ajouté aux moyennes de ce module)" + if is_apc + else "(ajouté à la moyenne de ce module)" + ) + ), ), }, ), @@ -251,7 +257,8 @@ def evaluation_create_form( { "size": 6, "type": "float", - "explanation": "importance de l'évaluation (multiplie les poids ci-dessous)", + "explanation": """importance de l'évaluation (multiplie les poids ci-dessous). + Non utilisé pour les bonus.""", "allow_null": False, }, ), diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 610cb255..d157e0a3 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -217,19 +217,9 @@ def do_evaluation_etat( gr_incomplets = list(group_nb_missing.keys()) gr_incomplets.sort() - if ( - (total_nb_missing > 0) - and (E["evaluation_type"] != scu.EVALUATION_RATTRAPAGE) - and (E["evaluation_type"] != scu.EVALUATION_SESSION2) - ): - complete = False - else: - complete = True - complete = ( - (total_nb_missing == 0) - or (E["evaluation_type"] == scu.EVALUATION_RATTRAPAGE) - or (E["evaluation_type"] == scu.EVALUATION_SESSION2) + complete = (total_nb_missing == 0) or ( + E["evaluation_type"] != Evaluation.EVALUATION_NORMALE ) evalattente = (total_nb_missing > 0) and ( (total_nb_missing == total_nb_att) or E["publish_incomplete"] @@ -498,13 +488,14 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"): """Experimental: un tableau indiquant pour chaque évaluation le nombre de jours avant la publication des notes. - N'indique pas les évaluations de rattrapage ni celles des modules de bonus/malus. + N'indique que les évaluations "normales" (pas rattrapage, ni bonus, ni session2, + ni celles des modules de bonus/malus). """ formsemestre = FormSemestre.get_formsemestre(formsemestre_id) evaluations = formsemestre.get_evaluations() rows = [] for e in evaluations: - if (e.evaluation_type != scu.EVALUATION_NORMALE) or ( + if (e.evaluation_type != Evaluation.EVALUATION_NORMALE) or ( e.moduleimpl.module.module_type == ModuleType.MALUS ): continue @@ -610,13 +601,17 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True) # Indique l'UE ue = modimpl.module.ue H.append(f"

      UE : {ue.acronyme}

      ") + if ( + modimpl.module.module_type == ModuleType.MALUS + or evaluation.evaluation_type == Evaluation.EVALUATION_BONUS + ): # store min/max values used by JS client-side checks: H.append( """-20. 20.""" ) else: - # date et absences (pas pour evals de malus) + # date et absences (pas pour evals bonus ni des modules de malus) if evaluation.date_debut is not None: H.append(f"

      Réalisée le {evaluation.descr_date()} ") group_id = sco_groups.get_default_group(modimpl.formsemestre_id) diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 44058474..9e668c90 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -490,9 +490,9 @@ def _make_table_notes( rlinks = {"_table_part": "head"} for e in evaluations: rlinks[e.id] = "afficher" - rlinks[ - "_" + str(e.id) + "_help" - ] = "afficher seulement les notes de cette évaluation" + rlinks["_" + str(e.id) + "_help"] = ( + "afficher seulement les notes de cette évaluation" + ) rlinks["_" + str(e.id) + "_target"] = url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, @@ -709,9 +709,9 @@ def _add_eval_columns( notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id) if evaluation.date_debut: - titles[ - evaluation.id - ] = f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})" + titles[evaluation.id] = ( + f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})" + ) else: titles[evaluation.id] = f"{evaluation.description} " @@ -820,14 +820,17 @@ def _add_eval_columns( row_moys[evaluation.id] = scu.fmt_note( sum_notes / nb_notes, keep_numeric=keep_numeric ) - row_moys[ - "_" + str(evaluation.id) + "_help" - ] = "moyenne sur %d notes (%s le %s)" % ( - nb_notes, - evaluation.description, - evaluation.date_debut.strftime("%d/%m/%Y") - if evaluation.date_debut - else "", + row_moys["_" + str(evaluation.id) + "_help"] = ( + "moyenne sur %d notes (%s le %s)" + % ( + nb_notes, + evaluation.description, + ( + evaluation.date_debut.strftime("%d/%m/%Y") + if evaluation.date_debut + else "" + ), + ) ) else: row_moys[evaluation.id] = "" @@ -884,8 +887,9 @@ def _add_moymod_column( row["_" + col_id + "_td_attrs"] = ' class="moyenne" ' if etudid in inscrits and not isinstance(val, str): notes.append(val) - nb_notes = nb_notes + 1 - sum_notes += val + if not np.isnan(val): + nb_notes = nb_notes + 1 + sum_notes += val row_coefs[col_id] = "(avec abs)" if is_apc: row_poids[col_id] = "à titre indicatif" diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 9ee69aec..28021e13 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -519,13 +519,15 @@ def _ligne_evaluation( partition_id=partition_id, select_first_partition=True, ) - if evaluation.evaluation_type in ( - scu.EVALUATION_RATTRAPAGE, - scu.EVALUATION_SESSION2, - ): + if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE: tr_class = "mievr mievr_rattr" + elif evaluation.evaluation_type == Evaluation.EVALUATION_SESSION2: + tr_class = "mievr mievr_session2" + elif evaluation.evaluation_type == Evaluation.EVALUATION_BONUS: + tr_class = "mievr mievr_bonus" else: tr_class = "mievr" + if not evaluation.visibulletin: tr_class += " non_visible_inter" tr_class_1 = "mievr" @@ -563,13 +565,17 @@ def _ligne_evaluation( }" class="mievr_evalnodate">Évaluation sans date""" ) H.append(f"    {evaluation.description or ''}") - if evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE: + if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE: H.append( """rattrapage""" ) - elif evaluation.evaluation_type == scu.EVALUATION_SESSION2: + elif evaluation.evaluation_type == Evaluation.EVALUATION_SESSION2: H.append( - """session 2""" + """session 2""" + ) + elif evaluation.evaluation_type == Evaluation.EVALUATION_BONUS: + H.append( + """bonus""" ) # if etat["last_modif"]: diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 8f84e8c6..a9195d29 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -134,12 +134,12 @@ def _displayNote(val): return val -def _check_notes(notes: list[(int, float)], evaluation: Evaluation): - # XXX typehint : float or str +def _check_notes(notes: list[(int, float | str)], evaluation: Evaluation): """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: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress + and 4 lists of etudid: + etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress """ note_max = evaluation.note_max or 0.0 module: Module = evaluation.moduleimpl.module @@ -148,7 +148,10 @@ def _check_notes(notes: list[(int, float)], evaluation: Evaluation): scu.ModuleType.RESSOURCE, scu.ModuleType.SAE, ): - note_min = scu.NOTES_MIN + if evaluation.evaluation_type == Evaluation.EVALUATION_BONUS: + note_min, note_max = -20, 20 + else: + note_min = scu.NOTES_MIN elif module.module_type == ModuleType.MALUS: note_min = -20.0 else: diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py index 4d32a405..f1840386 100644 --- a/app/scodoc/sco_ue_external.py +++ b/app/scodoc/sco_ue_external.py @@ -175,7 +175,7 @@ def external_ue_inscrit_et_note( note_max=20.0, coefficient=1.0, publish_incomplete=True, - evaluation_type=scu.EVALUATION_NORMALE, + evaluation_type=Evaluation.EVALUATION_NORMALE, visibulletin=False, description="note externe", ) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 6b385099..7e03f38f 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -454,10 +454,6 @@ NOTES_MENTIONS_LABS = ( "Excellent", ) -EVALUATION_NORMALE = 0 -EVALUATION_RATTRAPAGE = 1 -EVALUATION_SESSION2 = 2 - # Dates et années scolaires # Ces dates "pivot" sont paramétrables dans les préférences générales # on donne ici les valeurs par défaut. diff --git a/app/static/css/releve-but.css b/app/static/css/releve-but.css index 25a31c97..b0c0f533 100644 --- a/app/static/css/releve-but.css +++ b/app/static/css/releve-but.css @@ -273,6 +273,10 @@ section>div:nth-child(1) { min-width: 80px; display: inline-block; } +div.eval-bonus { + color: #197614; + background-color: pink; +} .ueBonus, .ueBonus h3 { @@ -280,7 +284,7 @@ section>div:nth-child(1) { color: #000 !important; } /* UE Capitalisée */ -.synthese .ue.capitalisee, +.synthese .ue.capitalisee, .ue.capitalisee>h3{ background: var(--couleurFondTitresUECapitalisee);; } diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 4bfb8186..a5e8d173 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -2103,11 +2103,11 @@ tr.mievr { background-color: #eeeeee; } -tr.mievr_rattr { +tr.mievr_rattr, tr.mievr_session2, tr.mievr_bonus { background-color: #dddddd; } -span.mievr_rattr { +span.mievr_rattr, span.mievr_session2, span.mievr_bonus { display: inline-block; font-weight: bold; font-size: 80%; @@ -4743,6 +4743,10 @@ table.table_recap th.col_malus { font-weight: bold; color: rgb(165, 0, 0); } +table.table_recap td.col_eval_bonus, +table.table_recap th.col_eval_bonus { + color: #90c; +} table.table_recap tr.ects td { color: rgb(160, 86, 3); diff --git a/app/static/js/releve-but.js b/app/static/js/releve-but.js index d76ec535..2523b227 100644 --- a/app/static/js/releve-but.js +++ b/app/static/js/releve-but.js @@ -491,14 +491,15 @@ class releveBUT extends HTMLElement { let output = ""; evaluations.forEach((evaluation) => { output += ` -

      +
      ${this.URL(evaluation.url, evaluation.description || "Évaluation")}
      ${evaluation.note.value} - Coef. ${evaluation.coef ?? "*"} + ${evaluation.evaluation_type == 0 ? "Coef." : evaluation.evaluation_type == 3 ? "Bonus" : "" + } ${evaluation.coef ?? ""}
      -
      Coef
      ${evaluation.coef}
      +
      ${evaluation.evaluation_type == 0 ? "Coef." : ""}
      ${evaluation.coef ?? ""}
      Max. promo.
      ${evaluation.note.max}
      Moy. promo.
      ${evaluation.note.moy}
      Min. promo.
      ${evaluation.note.min}
      diff --git a/app/tables/recap.py b/app/tables/recap.py index f4882983..0e287203 100644 --- a/app/tables/recap.py +++ b/app/tables/recap.py @@ -13,7 +13,7 @@ import numpy as np from app import db from app.auth.models import User from app.comp.res_common import ResultatsSemestre -from app.models import Identite, FormSemestre, UniteEns +from app.models import Identite, Evaluation, FormSemestre, UniteEns from app.scodoc.codes_cursus import UE_SPORT, DEF from app.scodoc import sco_evaluation_db from app.scodoc import sco_groups @@ -405,15 +405,22 @@ class TableRecap(tb.Table): val = notes_db[etudid]["value"] else: # Note manquante mais prise en compte immédiate: affiche ATT - val = scu.NOTES_ATTENTE + val = ( + scu.NOTES_ATTENTE + if e.evaluation_type != Evaluation.EVALUATION_BONUS + else "" + ) content = self.fmt_note(val) - classes = col_classes + [ - { - "ABS": "abs", - "ATT": "att", - "EXC": "exc", - }.get(content, "") - ] + if e.evaluation_type != Evaluation.EVALUATION_BONUS: + classes = col_classes + [ + { + "ABS": "abs", + "ATT": "att", + "EXC": "exc", + }.get(content, "") + ] + else: + classes = col_classes + ["col_eval_bonus"] row.add_cell( col_id, title, content, group="eval", classes=classes ) diff --git a/app/templates/scodoc/help/evaluations.j2 b/app/templates/scodoc/help/evaluations.j2 index ea844fd8..1f133896 100644 --- a/app/templates/scodoc/help/evaluations.j2 +++ b/app/templates/scodoc/help/evaluations.j2 @@ -8,13 +8,15 @@

      {%if is_apc%}

      - Dans le BUT, une évaluation peut évaluer différents apprentissages critiques... (à compléter) - Le coefficient est multiplié par les poids vers chaque UE. + Dans le BUT, une évaluation peut évaluer différents apprentissages critiques, + et les poids permettent de moduler l'importance de l'évaluation pour + chaque compétence (UE). + Le coefficient de l'évaluation est multiplié par les poids vers chaque UE.

      {%endif%}

      Ne pas confondre ce coefficient avec le coefficient du module, qui est - lui fixé par le programme pédagogique (le PPN pour les DUT) et pondère + lui fixé par le programme pédagogique (le PN pour les BUT) et pondère les moyennes de chaque module pour obtenir les moyennes d'UE et la moyenne générale.

      @@ -22,17 +24,31 @@ L'option Visible sur bulletins indique que la note sera reportée sur les bulletins en version dite "intermédiaire" (dans cette version, on peut ne faire apparaitre que certaines notes, en sus des - moyennes de modules. Attention, cette option n'empêche pas la + moyennes de modules). Attention, cette option n'empêche pas la publication sur les bulletins en version "longue" (la note est donc visible par les étudiants sur le portail).

      +

      + Les évaluations bonus sont particulières: +

      +
        +
      • la valeur est ajoutée à la moyenne du module;
      • +
      • le bonus peut être négatif (malus); +
      • +
      • le bonus ne s'applique pas aux notes de rattrapage et deuxième session; +
      • +
      • le coefficient est ignoré, mais en BUT le bonus vers une UE est multiplié + par le poids correspondant (par défaut égal à 1); +
      • +
      • les notes de bonus sont prises en compte même si incomplètes.
      • +

      Les modalités "rattrapage" et "deuxième session" définissent des évaluations prises en compte de façon spéciale:

      • les notes d'une évaluation de "rattrapage" remplaceront les moyennes - du module si elles sont meilleures que celles calculées. + du module si elles sont meilleures que celles calculées;.
      • les notes de "deuxième session" remplacent, lorsqu'elles sont saisies, la moyenne de l'étudiant à ce module, même si la note de diff --git a/sco_version.py b/sco_version.py index 2b38021d..ff1b244f 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,19 +1,20 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.944" +SCOVERSION = "9.6.945" SCONAME = "ScoDoc" SCONEWS = """ -

        Année 2023

        +

        Année 2023-2024

          -
        • ScoDoc 9.6 (juillet 2023)
        • +
        • ScoDoc 9.6 (2023-2024)
          • Nouveaux bulletins BUT compacts
          • Nouvelle gestion des absences et assiduité
          • Mise à jour logiciels: Debian 12, Python 3.11, ...
          • +
          • Evaluations bonus
        • ScoDoc 9.5 (juillet 2023)
        • diff --git a/tests/unit/test_notes_rattrapage.py b/tests/unit/test_notes_rattrapage.py index 918a357c..4dfaee33 100644 --- a/tests/unit/test_notes_rattrapage.py +++ b/tests/unit/test_notes_rattrapage.py @@ -1,5 +1,6 @@ """Test calculs rattrapages """ + import datetime import app @@ -68,7 +69,7 @@ def test_notes_rattrapage(test_client): date_debut=datetime.datetime(2020, 1, 2), description="evaluation rattrapage", coefficient=1.0, - evaluation_type=scu.EVALUATION_RATTRAPAGE, + evaluation_type=Evaluation.EVALUATION_RATTRAPAGE, ) etud = etuds[0] _, _, _ = G.create_note(evaluation_id=e["id"], etudid=etud["etudid"], note=12.0) @@ -144,7 +145,7 @@ def test_notes_rattrapage(test_client): date_debut=datetime.datetime(2020, 1, 2), description="evaluation session 2", coefficient=1.0, - evaluation_type=scu.EVALUATION_SESSION2, + evaluation_type=Evaluation.EVALUATION_SESSION2, ) res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) From 47a42d897ec890cbe06b2e1401f5ceaa8c752608 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 24 Feb 2024 17:01:14 +0100 Subject: [PATCH 22/32] =?UTF-8?q?Test=20unitaire=20=C3=A9valuation=20bonus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_notes_rattrapage.py | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/unit/test_notes_rattrapage.py b/tests/unit/test_notes_rattrapage.py index 4dfaee33..4435cb2f 100644 --- a/tests/unit/test_notes_rattrapage.py +++ b/tests/unit/test_notes_rattrapage.py @@ -183,3 +183,43 @@ def test_notes_rattrapage(test_client): ) # Note moyenne: revient à note normale assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0) + # Supprime évaluation session 2 + _, _, _ = G.create_note( + evaluation_id=e_session2["id"], etudid=etud["etudid"], note=scu.NOTES_SUPPRESS + ) + evaluation = db.session.get(Evaluation, e_session2["id"]) + assert evaluation + evaluation.delete() + # + # --- Evaluation bonus --- + # + # --- Création d'une évaluation "bonus" + e_bonus = G.create_evaluation( + moduleimpl_id=moduleimpl_id, + date_debut=datetime.datetime(2020, 1, 2), + description="evaluation bonus", + coefficient=1.0, + evaluation_type=Evaluation.EVALUATION_BONUS, + ) + b = sco_bulletins.formsemestre_bulletinetud_dict( + sem["formsemestre_id"], etud["etudid"] + ) + # Note moyenne sans bonus + assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0) + # Saisie note bonus + _, _, _ = G.create_note( + evaluation_id=e_bonus["id"], etudid=etud["etudid"], note=1.0 + ) + b = sco_bulletins.formsemestre_bulletinetud_dict( + sem["formsemestre_id"], etud["etudid"] + ) + # Note moyenne sans bonus + assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(11.0) + # Négatif, avec clip à zéro + _, _, _ = G.create_note( + evaluation_id=e_bonus["id"], etudid=etud["etudid"], note=-20.0 + ) + b = sco_bulletins.formsemestre_bulletinetud_dict( + sem["formsemestre_id"], etud["etudid"] + ) + assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(0.0) From 41944bcd2998a66ada9e790ac21b272866aeca80 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 25 Feb 2024 13:04:38 +0100 Subject: [PATCH 23/32] =?UTF-8?q?Cache=20(redis):=20change=20timeout=20par?= =?UTF-8?q?=20d=C3=A9faut=20(rafraichissement=20=C3=A9valuations=20chaque?= =?UTF-8?q?=20heure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_cache.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index e72ee1bd..ddb8eb3c 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -67,7 +67,7 @@ class ScoDocCache: keys are prefixed by the current departement: g.scodoc_dept MUST be set. """ - timeout = None # ttl, infinite by default + timeout = 3600 # ttl, one hour by default prefix = "" verbose = False # if true, verbose logging (debug) @@ -201,7 +201,7 @@ class AbsSemEtudCache(ScoDocCache): """ prefix = "ABSE" - timeout = 60 * 60 # ttl 60 minutes + timeout = 600 # ttl 10 minutes class SemBulletinsPDFCache(ScoDocCache): @@ -233,7 +233,6 @@ class SemInscriptionsCache(ScoDocCache): """ prefix = "SI" - duration = 12 * 60 * 60 # ttl 12h class TableRecapCache(ScoDocCache): @@ -243,7 +242,6 @@ class TableRecapCache(ScoDocCache): """ prefix = "RECAP" - duration = 12 * 60 * 60 # ttl 12h class TableRecapWithEvalsCache(ScoDocCache): @@ -253,7 +251,6 @@ class TableRecapWithEvalsCache(ScoDocCache): """ prefix = "RECAPWITHEVALS" - duration = 12 * 60 * 60 # ttl 12h class TableJuryCache(ScoDocCache): @@ -263,7 +260,6 @@ class TableJuryCache(ScoDocCache): """ prefix = "RECAPJURY" - duration = 12 * 60 * 60 # ttl 12h class TableJuryWithEvalsCache(ScoDocCache): @@ -273,7 +269,6 @@ class TableJuryWithEvalsCache(ScoDocCache): """ prefix = "RECAPJURYWITHEVALS" - duration = 12 * 60 * 60 # ttl 12h def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=False) From 1c01d987be1a26154d02dc96291a98a1c5984a89 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 25 Feb 2024 16:58:59 +0100 Subject: [PATCH 24/32] =?UTF-8?q?Evaluations=20bloqu=C3=A9es=20jusqu'?= =?UTF-8?q?=C3=A0=20une=20date.=20Implements=20#858?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/bulletin_but.py | 22 +++-- app/comp/moy_mod.py | 15 ++-- app/comp/res_common.py | 4 +- app/models/evaluations.py | 59 +++++++------ app/models/formsemestre.py | 4 + app/scodoc/sco_bulletins.py | 6 +- app/scodoc/sco_bulletins_json.py | 2 +- app/scodoc/sco_evaluation_edit.py | 54 +++++++++++- app/scodoc/sco_evaluations.py | 43 +++++----- app/scodoc/sco_excel.py | 2 +- app/scodoc/sco_moduleimpl_status.py | 38 ++++++--- app/scodoc/sco_saisie_notes.py | 2 +- app/static/css/scodoc.css | 21 +++++ app/tables/recap.py | 2 +- .../assiduites/pages/etat_abs_date.j2 | 2 +- app/views/notes.py | 2 +- .../cddabc3f868a_evaluation_bloquee.py | 83 +++++++++++++++++++ 17 files changed, 272 insertions(+), 89 deletions(-) create mode 100644 migrations/versions/cddabc3f868a_evaluation_bloquee.py diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 87b17546..b73b871a 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -291,15 +291,19 @@ class BulletinBUT: "date_fin": e.date_fin.isoformat() if e.date_fin else None, "description": e.description, "evaluation_type": e.evaluation_type, - "note": { - "value": fmt_note( - eval_notes[etud.id], - note_max=e.note_max, - ), - "min": fmt_note(notes_ok.min(), note_max=e.note_max), - "max": fmt_note(notes_ok.max(), note_max=e.note_max), - "moy": fmt_note(notes_ok.mean(), note_max=e.note_max), - }, + "note": ( + { + "value": fmt_note( + eval_notes[etud.id], + note_max=e.note_max, + ), + "min": fmt_note(notes_ok.min(), note_max=e.note_max), + "max": fmt_note(notes_ok.max(), note_max=e.note_max), + "moy": fmt_note(notes_ok.mean(), note_max=e.note_max), + } + if not e.is_blocked() + else {} + ), "poids": poids, "url": ( url_for( diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index a977894d..b1af5c06 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -35,7 +35,6 @@ moyenne générale d'une UE. """ import dataclasses from dataclasses import dataclass - import numpy as np import pandas as pd import sqlalchemy as sa @@ -151,16 +150,18 @@ class ModuleImplResults: self.evaluations_completes_dict = {} for evaluation in moduleimpl.evaluations: eval_df = self._load_evaluation_notes(evaluation) - # is_complete ssi tous les inscrits (non dem) au semestre ont une note - # ou évaluation déclarée "à prise en compte immédiate" - # Les évaluations de rattrapage et 2eme session sont toujours complètes + # is_complete ssi + # tous les inscrits (non dem) au module ont une note + # ou évaluation déclarée "à prise en compte immédiate" + # ou rattrapage, 2eme session, bonus + # ET pas bloquée par date (is_blocked) etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem. is_complete = ( (evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE) or (evaluation.publish_incomplete) or (not etudids_sans_note) - ) + ) and not evaluation.is_blocked() self.evaluations_completes.append(is_complete) self.evaluations_completes_dict[evaluation.id] = is_complete self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note @@ -185,7 +186,7 @@ class ModuleImplResults: ].index ) if evaluation.publish_incomplete: - # et en "imédiat", tous ceux sans note + # et en "immédiat", tous ceux sans note eval_etudids_attente |= etudids_sans_note # Synthèse pour état du module: self.etudids_attente |= eval_etudids_attente @@ -276,7 +277,7 @@ class ModuleImplResults: ) / [e.note_max / 20.0 for e in moduleimpl.evaluations] def get_eval_notes_dict(self, evaluation_id: int) -> dict: - """Notes d'une évaulation, brutes, sous forme d'un dict + """Notes d'une évaluation, brutes, sous forme d'un dict { etudid : valeur } avec les valeurs float, ou "ABS" ou EXC """ diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 954f3523..9ba073a4 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -230,8 +230,8 @@ class ResultatsSemestre(ResultatsCache): date_modif = cursor.one_or_none() last_modif = date_modif[0] if date_modif else None return { - "coefficient": evaluation.coefficient or 0.0, - "description": evaluation.description or "", + "coefficient": evaluation.coefficient, + "description": evaluation.description, "evaluation_id": evaluation.id, "jour": evaluation.date_debut or datetime.datetime(1900, 1, 1), "etat": { diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 37e3ac79..cea6c62e 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -10,6 +10,7 @@ from flask_login import current_user import sqlalchemy as sa from app import db, log +from app import models from app.models.etudiants import Identite from app.models.events import ScolarNews from app.models.notes import NotesNotes @@ -24,7 +25,7 @@ NOON = datetime.time(12, 00) DEFAULT_EVALUATION_TIME = datetime.time(8, 0) -class Evaluation(db.Model): +class Evaluation(models.ScoDocModel): """Evaluation (contrôle, examen, ...)""" __tablename__ = "notes_evaluation" @@ -36,9 +37,9 @@ class Evaluation(db.Model): ) date_debut = db.Column(db.DateTime(timezone=True), nullable=True) date_fin = db.Column(db.DateTime(timezone=True), nullable=True) - description = db.Column(db.Text) - note_max = db.Column(db.Float) - coefficient = db.Column(db.Float) + description = db.Column(db.Text, nullable=False) + note_max = db.Column(db.Float, nullable=False) + coefficient = db.Column(db.Float, nullable=False) visibulletin = db.Column( db.Boolean, nullable=False, default=True, server_default="true" ) @@ -46,10 +47,14 @@ class Evaluation(db.Model): publish_incomplete = db.Column( db.Boolean, nullable=False, default=False, server_default="false" ) - # type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session" + "prise en compte immédiate" evaluation_type = db.Column( db.Integer, nullable=False, default=0, server_default="0" ) + "type d'evaluation: 0 normale, 1 rattrapage, 2 2eme session, 3 bonus" + blocked_until = db.Column(db.DateTime(timezone=True), nullable=True) + "date de prise en compte" + BLOCKED_FOREVER = datetime.datetime(2666, 12, 31, tzinfo=scu.TIME_ZONE) # ordre de presentation (par défaut, le plus petit numero # est la plus ancienne eval): numero = db.Column(db.Integer, nullable=False, default=0) @@ -79,6 +84,7 @@ class Evaluation(db.Model): date_fin: datetime.datetime = None, description=None, note_max=None, + blocked_until=None, coefficient=None, visibulletin=None, publish_incomplete=None, @@ -208,6 +214,10 @@ class Evaluation(db.Model): def to_dict_api(self) -> dict: "Représentation dict pour API JSON" return { + "blocked": self.is_blocked(), + "blocked_until": ( + self.blocked_until.isoformat() if self.blocked_until else "" + ), "coefficient": self.coefficient, "date_debut": self.date_debut.isoformat() if self.date_debut else "", "date_fin": self.date_fin.isoformat() if self.date_fin else "", @@ -244,14 +254,14 @@ class Evaluation(db.Model): return e_dict - def from_dict(self, data): - """Set evaluation attributes from given dict values.""" - check_convert_evaluation_args(self.moduleimpl, data) - if data.get("numero") is None: - data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1 - for k in self.__dict__: - if k != "_sa_instance_state" and k != "id" and k in data: - setattr(self, k, data[k]) + def convert_dict_fields(self, args: dict) -> dict: + """Convert fields in the given dict. No other side effect. + returns: dict to store in model's db. + """ + check_convert_evaluation_args(self.moduleimpl, args) + if args.get("numero") is None: + args["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1 + return args @classmethod def get_evaluation( @@ -370,19 +380,6 @@ class Evaluation(db.Model): Chaine vide si non renseignée.""" return self.date_fin.time().isoformat("minutes") if self.date_fin else "" - def clone(self, not_copying=()): - """Clone, not copying the given attrs - Attention: la copie n'a pas d'id avant le prochain commit - """ - d = dict(self.__dict__) - d.pop("id") # get rid of id - d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr - for k in not_copying: - d.pop(k) - copy = self.__class__(**d) - db.session.add(copy) - return copy - def is_matin(self) -> bool: "Evaluation commençant le matin (faux si pas de date)" if not self.date_debut: @@ -395,6 +392,14 @@ class Evaluation(db.Model): return False return self.date_debut.time() >= NOON + def is_blocked(self, now=None) -> bool: + "True si prise en compte bloquée" + if self.blocked_until is None: + return False + if now is None: + now = datetime.datetime.now(scu.TIME_ZONE) + return self.blocked_until > now + def set_default_poids(self) -> bool: """Initialize les poids vers les UE à leurs valeurs par défaut C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon. @@ -621,6 +626,8 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict): "Heures de l'évaluation incohérentes !", dest_url="javascript:history.back();", ) + if "blocked_until" in data: + data["blocked_until"] = data["blocked_until"] or None def heure_to_time(heure: str) -> datetime.time: diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 9d2f5b13..09c1d305 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -93,6 +93,10 @@ class FormSemestre(db.Model): db.Boolean(), nullable=False, default=False, server_default="false" ) "Si vrai, la moyenne générale indicative BUT n'est pas calculée" + mode_calcul_moyennes = db.Column( + db.Integer, nullable=False, default=0, server_default="0" + ) + "pour usage futur" gestion_semestrielle = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 69c13df3..1b70d385 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -318,7 +318,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): if nt.bonus_ues is not None: u["cur_moy_ue_txt"] += " (+ues)" u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"]) - if ue_status["coef_ue"] != None: + if ue_status["coef_ue"] is not None: u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"]) else: u["coef_ue_txt"] = "-" @@ -558,6 +558,8 @@ def _ue_mod_bulletin( ).order_by(Evaluation.numero, Evaluation.date_debut) # (plus ancienne d'abord) for e in all_evals: + if e.is_blocked(): + continue # ignore évaluations bloquées if not e.visibulletin and version != "long": continue is_complete = e.id in complete_eval_ids @@ -625,7 +627,7 @@ def _ue_mod_bulletin( ) ): # ne liste pas les eval malus sans notes - # ni les rattrapages et sessions 2 si pas de note + # ni les rattrapages, sessions 2 et bonus si pas de note if e.id in complete_eval_ids: mod["evaluations"].append(e_dict) else: diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py index 3bce083a..0481e6f9 100644 --- a/app/scodoc/sco_bulletins_json.py +++ b/app/scodoc/sco_bulletins_json.py @@ -25,7 +25,7 @@ # ############################################################################## -"""Génération du bulletin en format JSON +"""Génération du bulletin en format JSON (formations classiques) """ import datetime diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index c7b0dd67..11418a99 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -108,7 +108,7 @@ def evaluation_create_form( raise ValueError("missing evaluation_id parameter") initvalues = evaluation.to_dict() moduleimpl_id = initvalues["moduleimpl_id"] - submitlabel = "Modifier les données" + submitlabel = "Modifier l'évaluation" action = "Modification d'une évaluation" link = "" # Note maximale actuelle dans cette éval ? @@ -142,6 +142,15 @@ def evaluation_create_form( else: poids = 0.0 initvalues[f"poids_{ue.id}"] = poids + # Blocage + if edit: + initvalues["blocked"] = evaluation.is_blocked() + initvalues["blocked_until"] = ( + evaluation.blocked_until.strftime("%d/%m/%Y") + if evaluation.blocked_until + and evaluation.blocked_until < Evaluation.BLOCKED_FOREVER + else "" + ) # form = [ ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), @@ -260,6 +269,7 @@ def evaluation_create_form( "explanation": """importance de l'évaluation (multiplie les poids ci-dessous). Non utilisé pour les bonus.""", "allow_null": False, + "dom_id": "evaluation-edit-coef", }, ), ] @@ -301,6 +311,28 @@ def evaluation_create_form( }, ), ) + # Bloquage / date prise en compte + form += [ + ( + "blocked", + { + "input_type": "boolcheckbox", + "title": "Bloquer la prise en compte", + "explanation": """empêche la prise en compte + (ne sera pas visible sur les bulletins ni dans les tableaux)""", + "dom_id": "evaluation-edit-blocked", + }, + ), + ( + "blocked_until", + { + "input_type": "datedmy", + "title": "Date déblocage", + "size": 12, + "explanation": "sera débloquée à partir de cette date", + }, + ), + ] tf = TrivialFormulator( request.base_url, vals, @@ -331,7 +363,9 @@ def evaluation_create_form( + "\n".join(H) + "\n" + tf[1] - + render_template("scodoc/help/evaluations.j2", is_apc=is_apc) + + render_template( + "scodoc/help/evaluations.j2", is_apc=is_apc, modimpl=modimpl + ) + render_template("sco_timepicker.j2") + html_sco_header.sco_footer() ) @@ -357,7 +391,8 @@ def evaluation_create_form( raise ScoValueError("Heure début invalide") from exc args["date_debut"] = datetime.datetime.combine(date_debut, heure_debut) args.pop("heure_debut", None) - # note: ce formulaire ne permet de créer que des évaluation avec debut et fin sur le même jour. + # note: ce formulaire ne permet de créer que des évaluations + # avec debut et fin sur le même jour. if date_debut and args.get("heure_fin"): try: heure_fin = heure_to_time(args["heure_fin"]) @@ -365,6 +400,19 @@ def evaluation_create_form( raise ScoValueError("Heure fin invalide") from exc args["date_fin"] = datetime.datetime.combine(date_debut, heure_fin) args.pop("heure_fin", None) + # Blocage: + if args.get("blocked"): + if args.get("blocked_until"): + try: + args["blocked_until"] = datetime.datetime.strptime( + args["blocked_until"], "%d/%m/%Y" + ) + except ValueError as exc: + raise ScoValueError("Date déblocage (j/m/a) invalide") from exc + else: # bloquage coché sans date + args["blocked_until"] = Evaluation.BLOCKED_FOREVER + else: # si pas coché, efface date déblocage + args["blocked_until"] = None # if edit: evaluation.from_dict(args) diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index d157e0a3..8931faf2 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -40,7 +40,7 @@ from app import db 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, ModuleImpl +from app.models import Evaluation, FormSemestre, ModuleImpl, Module import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType @@ -48,7 +48,6 @@ from app.scodoc.gen_tables import GenTable from app.scodoc import html_sco_header from app.scodoc import sco_cal from app.scodoc import sco_evaluation_db -from app.scodoc import sco_edit_module from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl @@ -113,6 +112,7 @@ def do_evaluation_etat( nb_neutre, nb_att, moy, median, mini, maxi : # notes, en chaine, sur 20 + maxi_num : note max, numérique last_modif: datetime, * gr_complets, gr_incomplets, evalcomplete * @@ -129,11 +129,12 @@ def do_evaluation_etat( ) # { etudid : note } # ---- Liste des groupes complets et incomplets - E = sco_evaluation_db.get_evaluations_dict(args={"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] - is_malus = Mod["module_type"] == ModuleType.MALUS # True si module de malus - formsemestre_id = M["formsemestre_id"] + evaluation = Evaluation.get_evaluation(evaluation_id) + modimpl: ModuleImpl = evaluation.moduleimpl + module: Module = modimpl.module + + is_malus = module.module_type == ModuleType.MALUS # True si module de malus + formsemestre_id = modimpl.formsemestre_id # Si partition_id is None, prend 'all' ou bien la premiere: if partition_id is None: if select_first_partition: @@ -149,9 +150,7 @@ def do_evaluation_etat( insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( formsemestre_id ) - insmod = sco_moduleimpl.do_moduleimpl_inscription_list( - moduleimpl_id=E["moduleimpl_id"] - ) + insmod = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=modimpl.id) insmodset = {x["etudid"] for x in insmod} # retire de insem ceux qui ne sont pas inscrits au module ins = [i for i in insem if i["etudid"] in insmodset] @@ -174,9 +173,9 @@ def do_evaluation_etat( maxi_num = None else: median = scu.fmt_note(median_num) - moy = scu.fmt_note(moy_num, E["note_max"]) - mini = scu.fmt_note(mini_num, E["note_max"]) - maxi = scu.fmt_note(maxi_num, E["note_max"]) + moy = scu.fmt_note(moy_num, evaluation.note_max) + mini = scu.fmt_note(mini_num, evaluation.note_max) + maxi = scu.fmt_note(maxi_num, evaluation.note_max) # cherche date derniere modif note if len(etuds_notes_dict): t = [x["date"] for x in etuds_notes_dict.values()] @@ -218,14 +217,16 @@ def do_evaluation_etat( gr_incomplets = list(group_nb_missing.keys()) gr_incomplets.sort() - complete = (total_nb_missing == 0) or ( - E["evaluation_type"] != Evaluation.EVALUATION_NORMALE + complete = ( + (total_nb_missing == 0) + or (evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE) + and not evaluation.is_blocked() ) evalattente = (total_nb_missing > 0) and ( - (total_nb_missing == total_nb_att) or E["publish_incomplete"] + (total_nb_missing == total_nb_att) or evaluation.publish_incomplete ) # mais ne met pas en attente les evals immediates sans aucune notes: - if E["publish_incomplete"] and nb_notes == 0: + if evaluation.publish_incomplete and nb_notes == 0: evalattente = False # Calcul moyenne dans chaque groupe de TD @@ -236,10 +237,10 @@ def do_evaluation_etat( { "group_id": group_id, "group_name": group_by_id[group_id]["group_name"], - "gr_moy": scu.fmt_note(gr_moy, E["note_max"]), - "gr_median": scu.fmt_note(gr_median, E["note_max"]), - "gr_mini": scu.fmt_note(gr_mini, E["note_max"]), - "gr_maxi": scu.fmt_note(gr_maxi, E["note_max"]), + "gr_moy": scu.fmt_note(gr_moy, evaluation.note_max), + "gr_median": scu.fmt_note(gr_median, evaluation.note_max), + "gr_mini": scu.fmt_note(gr_mini, evaluation.note_max), + "gr_maxi": scu.fmt_note(gr_maxi, evaluation.note_max), "gr_nb_notes": len(notes), "gr_nb_att": len([x for x in notes if x == scu.NOTES_ATTENTE]), } diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 0d4095b6..ea72d2b5 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -534,7 +534,7 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line # description evaluation ws.append_single_cell_row(scu.unescape_html(description), style_titres) ws.append_single_cell_row( - f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient or 0.0):g})", + f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})", style, ) # ligne blanche diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 28021e13..336d49ff 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -531,6 +531,10 @@ def _ligne_evaluation( if not evaluation.visibulletin: tr_class += " non_visible_inter" tr_class_1 = "mievr" + if evaluation.is_blocked(): + tr_class += " evaluation_blocked" + tr_class_1 += " evaluation_blocked" + if not first_eval: H.append(""" """) tr_class_1 += " mievr_spaced" @@ -564,7 +568,7 @@ def _ligne_evaluation( scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id) }" class="mievr_evalnodate">Évaluation sans date""" ) - H.append(f"    {evaluation.description or ''}") + H.append(f"    {evaluation.description}") if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE: H.append( """rattrapage""" @@ -611,8 +615,15 @@ def _ligne_evaluation( else: H.append(arrow_none) - if etat["evalcomplete"]: - etat_txt = f"""(prise en compte{ + if evaluation.is_blocked(): + etat_txt = f"""évaluation bloquée { + "jusqu'au " + evaluation.blocked_until.strftime("%d/%m/%Y") + if evaluation.blocked_until < Evaluation.BLOCKED_FOREVER + else "" } + """ + etat_descr = """prise en compte bloquée""" + elif etat["evalcomplete"]: + etat_txt = f"""Moyenne (prise en compte{ "" if evaluation.visibulletin else ", cachée en intermédiaire"}) @@ -621,7 +632,7 @@ def _ligne_evaluation( ", évaluation cachée sur les bulletins en version intermédiaire et sur la passerelle" }""" elif etat["evalattente"] and not evaluation.publish_incomplete: - etat_txt = "(prise en compte, mais notes en attente)" + etat_txt = "Moyenne (prise en compte, mais notes en attente)" etat_descr = "il y a des notes en attente" elif evaluation.publish_incomplete: etat_txt = """(prise en compte immédiate)""" @@ -629,28 +640,29 @@ def _ligne_evaluation( "il manque des notes, mais la prise en compte immédiate a été demandée" ) elif etat["nb_notes"] != 0: - etat_txt = "(non prise en compte)" + etat_txt = "Moyenne (non prise en compte)" etat_descr = "il manque des notes" else: etat_txt = "" - if can_edit_evals and etat_txt: - etat_txt = f"""{etat_txt}""" + if etat_txt: + if can_edit_evals: + etat_txt = f"""{etat_txt}""" H.append( f""" - +   Durée Coef. Notes Abs N - Moyenne {etat_txt} + {etat_txt} - + """ ) if can_edit_evals: @@ -832,7 +844,7 @@ def _evaluation_poids_html(evaluation: Evaluation, max_poids: float = 0.0) -> st + "\n".join( [ f"""
          -
          """ diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index a9195d29..7110d3d2 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -884,7 +884,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]): if evaluation.date_debut: indication_date = evaluation.date_debut.date().isoformat() else: - indication_date = scu.sanitize_filename(evaluation.description or "")[:12] + indication_date = scu.sanitize_filename(evaluation.description)[:12] eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}" date_str = ( diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index a5e8d173..23e0b42e 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1469,6 +1469,9 @@ span.eval_title { font-size: 14pt; } +#evaluation-edit-blocked td, #evaluation-edit-coef td { + padding-top: 24px; +} /* #saisie_notes span.eval_title { border-bottom: 1px solid rgb(100,100,100); } @@ -2099,6 +2102,14 @@ th.moduleimpl_evaluations a:hover { text-decoration: underline; } +tr.mievr_in.evaluation_blocked th.moduleimpl_evaluation_moy span, tr.evaluation_blocked th.moduleimpl_evaluation_moy a { + font-weight: bold; + color: red; + background-color: yellow; + padding: 2px; + border-radius: 2px; +} + tr.mievr { background-color: #eeeeee; } @@ -2153,6 +2164,15 @@ tr.mievr.non_visible_inter th { ); } +tr.mievr_tit.evaluation_blocked td,tr.mievr_tit.evaluation_blocked th { + background-image: radial-gradient(#bd7777 1px, transparent 1px); + background-size: 10px 10px; +} +tr.mievr_in.evaluation_blocked td, tr.mievr_in.evaluation_blocked th { + background-color: rgb(195, 235, 255); +} + + tr.mievr th { background-color: white; } @@ -2163,6 +2183,7 @@ tr.mievr td.mievr { tr.mievr td.mievr_menu { width: 110px; + padding-bottom: 4px; } tr.mievr td.mievr_dur { diff --git a/app/tables/recap.py b/app/tables/recap.py index 0e287203..01947f63 100644 --- a/app/tables/recap.py +++ b/app/tables/recap.py @@ -457,7 +457,7 @@ class TableRecap(tb.Table): row_descr_eval.add_cell( col_id, None, - e.description or "", + e.description, target=url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, diff --git a/app/templates/assiduites/pages/etat_abs_date.j2 b/app/templates/assiduites/pages/etat_abs_date.j2 index d40b61e8..22ecb8e5 100644 --- a/app/templates/assiduites/pages/etat_abs_date.j2 +++ b/app/templates/assiduites/pages/etat_abs_date.j2 @@ -20,7 +20,7 @@ Assiduité lors de l'évaluation {{evaluation.description or ''}} + }}">{{evaluation.description}} {% endif %} {{scu.ICON_XLS|safe}}
      diff --git a/app/views/notes.py b/app/views/notes.py index 25170288..1dfec674 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -1642,7 +1642,7 @@ def evaluation_delete(evaluation_id): .first_or_404() ) - tit = f"""Suppression de l'évaluation {evaluation.description or ""} ({evaluation.descr_date()})""" + tit = f"""Suppression de l'évaluation {evaluation.description} ({evaluation.descr_date()})""" etat = sco_evaluations.do_evaluation_etat(evaluation.id) H = [ f""" diff --git a/migrations/versions/cddabc3f868a_evaluation_bloquee.py b/migrations/versions/cddabc3f868a_evaluation_bloquee.py new file mode 100644 index 00000000..567fd960 --- /dev/null +++ b/migrations/versions/cddabc3f868a_evaluation_bloquee.py @@ -0,0 +1,83 @@ +"""evaluation bloquee + +Revision ID: cddabc3f868a +Revises: 2e4875004e12 +Create Date: 2024-02-25 16:39:45.947342 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker # added by ev + + +# revision identifiers, used by Alembic. +revision = "cddabc3f868a" +down_revision = "2e4875004e12" +branch_labels = None +depends_on = None + +Session = sessionmaker() + + +def upgrade(): + # ces champs étaient nullables + # Added by ev: remove duplicates + bind = op.get_bind() + session = Session(bind=bind) + session.execute( + sa.text( + """UPDATE notes_evaluation SET description='' WHERE description IS NULL;""" + ) + ) + session.execute( + sa.text("""UPDATE notes_evaluation SET note_max=20. WHERE note_max IS NULL;""") + ) + session.execute( + sa.text( + """UPDATE notes_evaluation SET coefficient=0. WHERE coefficient IS NULL;""" + ) + ) + # + with op.batch_alter_table("notes_evaluation", schema=None) as batch_op: + batch_op.add_column( + sa.Column("blocked_until", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.alter_column("description", existing_type=sa.TEXT(), nullable=False) + batch_op.alter_column( + "note_max", existing_type=sa.DOUBLE_PRECISION(precision=53), nullable=False + ) + batch_op.alter_column( + "coefficient", + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=False, + ) + + with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "mode_calcul_moyennes", sa.Integer(), server_default="0", nullable=False + ) + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("notes_formsemestre", schema=None) as batch_op: + batch_op.drop_column("mode_calcul_moyennes") + + with op.batch_alter_table("notes_evaluation", schema=None) as batch_op: + batch_op.alter_column( + "coefficient", + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=True, + ) + batch_op.alter_column( + "note_max", existing_type=sa.DOUBLE_PRECISION(precision=53), nullable=True + ) + batch_op.alter_column("description", existing_type=sa.TEXT(), nullable=True) + batch_op.drop_column("blocked_until") + + # ### end Alembic commands ### From c0a965d774dc1cb226d83a8d96550beed23e1961 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 25 Feb 2024 22:35:14 +0100 Subject: [PATCH 25/32] =?UTF-8?q?Bloque=20saisie=20jury=20si=20=C3=A9valua?= =?UTF-8?q?tion=20=C3=A0=20paraitre.=20Modif=20icon=20warning.=20Closes=20?= =?UTF-8?q?#858?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 56 +++++++++++++---------- app/models/evaluations.py | 26 +++++++++++ app/scodoc/sco_formsemestre_validation.py | 26 ++++++++++- app/static/css/scodoc.css | 31 +++++++++++-- app/static/icons/warning_bloquant.svg | 1 + app/static/icons/warning_std.svg | 1 + app/views/notes.py | 24 ++++++++-- 7 files changed, 133 insertions(+), 32 deletions(-) create mode 100644 app/static/icons/warning_bloquant.svg create mode 100644 app/static/icons/warning_std.svg diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 3f8fa9a7..fe670d70 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -77,7 +77,7 @@ from app.models.but_refcomp import ( ApcNiveau, ApcParcours, ) -from app.models import Scolog, ScolarAutorisationInscription +from app.models import Evaluation, Scolog, ScolarAutorisationInscription from app.models.but_validations import ( ApcValidationAnnee, ApcValidationRCUE, @@ -260,11 +260,11 @@ class DecisionsProposeesAnnee(DecisionsProposees): else [] ) # ---- Niveaux et RCUEs - niveaux_by_parcours = ( - formsemestre.formation.referentiel_competence.get_niveaux_by_parcours( - self.annee_but, [self.parcour] if self.parcour else None - )[1] - ) + niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours( + self.annee_but, [self.parcour] if self.parcour else None + )[ + 1 + ] self.niveaux_competences = niveaux_by_parcours["TC"] + ( niveaux_by_parcours[self.parcour.id] if self.parcour else [] ) @@ -358,13 +358,17 @@ class DecisionsProposeesAnnee(DecisionsProposees): # self.codes = [] # pas de décision annuelle sur semestres impairs elif self.inscription_etat != scu.INSCRIT: self.codes = [ - sco_codes.DEM - if self.inscription_etat == scu.DEMISSION - else sco_codes.DEF, + ( + sco_codes.DEM + if self.inscription_etat == scu.DEMISSION + else sco_codes.DEF + ), # propose aussi d'autres codes, au cas où... - sco_codes.DEM - if self.inscription_etat != scu.DEMISSION - else sco_codes.DEF, + ( + sco_codes.DEM + if self.inscription_etat != scu.DEMISSION + else sco_codes.DEF + ), sco_codes.ABAN, sco_codes.ABL, sco_codes.EXCLU, @@ -595,11 +599,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): # Ordonne par numéro d'UE niv_rcue = sorted( self.rcue_by_niveau.items(), - key=lambda x: x[1].ue_1.numero - if x[1].ue_1 - else x[1].ue_2.numero - if x[1].ue_2 - else 0, + key=lambda x: ( + x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0 + ), ) return { niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat) @@ -816,9 +818,15 @@ class DecisionsProposeesAnnee(DecisionsProposees): Return: True si au moins un code modifié et enregistré. """ modif = False - # Vérification notes en attente dans formsemestre origine - if only_validantes and self.has_notes_en_attente(): - return False + if only_validantes: + if self.has_notes_en_attente(): + # notes en attente dans formsemestre origine + return False + if Evaluation.get_evaluations_blocked_for_etud( + self.formsemestre, self.etud + ): + # évaluation(s) qui seront débloquées dans le futur + return False # Toujours valider dans l'ordre UE, RCUE, Année annee_scolaire = self.formsemestre.annee_scolaire() @@ -1488,9 +1496,11 @@ class DecisionsProposeesUE(DecisionsProposees): self.validation = None # cache toute validation self.explanation = "non inscrit (dem. ou déf.)" self.codes = [ - sco_codes.DEM - if res.get_etud_etat(etud.id) == scu.DEMISSION - else sco_codes.DEF + ( + sco_codes.DEM + if res.get_etud_etat(etud.id) == scu.DEMISSION + else sco_codes.DEF + ) ] return diff --git a/app/models/evaluations.py b/app/models/evaluations.py index cea6c62e..f58560ff 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -488,6 +488,29 @@ class Evaluation(models.ScoDocModel): """ return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first() + @classmethod + def get_evaluations_blocked_for_etud( + cls, formsemestre, etud: Identite + ) -> list["Evaluation"]: + """Liste des évaluations de ce semestre avec note pour cet étudiant et date blocage + et date blocage < FOREVER. + Si non vide, une note apparaitra dans le futur pour cet étudiant: il faut + donc interdire la saisie du jury. + """ + now = datetime.datetime.now(scu.TIME_ZONE) + return ( + Evaluation.query.filter( + Evaluation.blocked_until != None, # pylint: disable=C0121 + Evaluation.blocked_until >= now, + ) + .join(ModuleImpl) + .filter_by(formsemestre_id=formsemestre.id) + .join(ModuleImplInscription) + .filter_by(etudid=etud.id) + .join(NotesNotes) + .all() + ) + class EvaluationUEPoids(db.Model): """Poids des évaluations (BUT) @@ -657,3 +680,6 @@ def _moduleimpl_evaluation_insert_before( db.session.add(e) db.session.commit() return n + + +from app.models.moduleimpls import ModuleImpl, ModuleImplInscription diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 7c68ddf9..df4770fa 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -34,7 +34,7 @@ from flask import url_for, flash, g, request from flask_login import current_user import sqlalchemy as sa -from app.models.etudiants import Identite +from app.models import Identite, Evaluation import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app import db, log @@ -232,7 +232,9 @@ def formsemestre_validation_etud_form( H.append( tf_error_message( f"""Impossible de statuer sur cet étudiant: il a des notes en - attente dans des évaluations de ce semestre (voir tableau de bord) @@ -241,6 +243,26 @@ def formsemestre_validation_etud_form( ) return "\n".join(H + footer) + evaluations_a_debloquer = Evaluation.get_evaluations_blocked_for_etud( + formsemestre, etud + ) + if evaluations_a_debloquer: + links_evals = [ + f"""{e.description} en {e.moduleimpl.module.code}""" + for e in evaluations_a_debloquer + ] + H.append( + tf_error_message( + f"""Impossible de statuer sur cet étudiant: + il a des notes dans des évaluations qui seront débloquées plus tard: + voir {", ".join(links_evals)} + """ + ) + ) + return "\n".join(H + footer) + # Infos si pas de semestre précédent if not Se.prev: if Se.sem["semestre_id"] == 1: diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 23e0b42e..4dbebb4f 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -3391,14 +3391,24 @@ li.tf-msg { padding-bottom: 5px; } -.warning { - font-weight: bold; +.warning, .warning-bloquant { color: red; + margin-left: 16px; + margin-bottom: 8px; + min-width: var(--sco-content-min-width); + max-width: var(--sco-content-max-width); } .warning::before { - content: url(/ScoDoc/static/icons/warning_img.png); - vertical-align: -80%; + content:""; + margin-right: 8px; + height:32px; + width: 32px; + background-size: 32px 32px; + background-image: url(/ScoDoc/static/icons/warning_std.svg); + background-repeat: no-repeat; + display: inline-block; + vertical-align: -40%; } .warning-light { @@ -3411,6 +3421,19 @@ li.tf-msg { /* EMO_WARNING, "⚠️" */ } +.warning-bloquant::before { + content:""; + margin-right: 8px; + height:32px; + width: 32px; + background-size: 32px 32px; + background-image: url(/ScoDoc/static/icons/warning_bloquant.svg); + background-repeat: no-repeat; + display: inline-block; + vertical-align: -40%; +} + + p.error { font-weight: bold; color: red; diff --git a/app/static/icons/warning_bloquant.svg b/app/static/icons/warning_bloquant.svg new file mode 100644 index 00000000..81b4416e --- /dev/null +++ b/app/static/icons/warning_bloquant.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/warning_std.svg b/app/static/icons/warning_std.svg new file mode 100644 index 00000000..ae68b668 --- /dev/null +++ b/app/static/icons/warning_std.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index 1dfec674..96ae6555 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2408,6 +2408,12 @@ def formsemestre_validation_but( ) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) + has_notes_en_attente = deca.has_notes_en_attente() + evaluations_a_debloquer = Evaluation.get_evaluations_blocked_for_etud( + formsemestre, etud + ) + if has_notes_en_attente or evaluations_a_debloquer: + read_only = True if request.method == "POST": if not read_only: deca.record_form(request.form) @@ -2452,9 +2458,21 @@ def formsemestre_validation_but( etat_ins = scu.ETATS_INSCRIPTION.get(inscription.etat, "inconnu?") warning += f"""
      {etat_ins} en S{deca.formsemestre_pair.semestre_id}
      """ - if deca.has_notes_en_attente(): - warning += f"""
      {etud.nomprenom} a des notes en ATTente. - Vous devriez régler cela avant de statuer en jury !
      """ + if has_notes_en_attente: + warning += f"""
      {etud.nomprenom} a des notes en ATTente. + Vous devez régler cela avant de statuer en jury !
      """ + if evaluations_a_debloquer: + links_evals = [ + f"""{e.description} en {e.moduleimpl.module.code}""" + for e in evaluations_a_debloquer + ] + warning += f"""
      Impossible de statuer sur cet étudiant: + il a des notes dans des évaluations qui seront débloquées plus tard: + voir {", ".join(links_evals)} + """ + H.append( f"""
      From 49a5ec488d671a9dde3e1d7c97f5821fe5b23241 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 26 Feb 2024 12:54:18 +0100 Subject: [PATCH 26/32] get_etud_ue_status: ignore error if missing etud --- app/comp/res_common.py | 4 ++-- app/scodoc/sco_poursuite_dut.py | 4 +--- sco_version.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 9ba073a4..706e37f9 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -432,7 +432,7 @@ class ResultatsSemestre(ResultatsCache): ue_cap_dict["compense_formsemestre_id"] = None return ue_cap_dict - def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: + def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict | None: """L'état de l'UE pour cet étudiant. Result: dict, ou None si l'UE n'est pas dans ce semestre. { @@ -470,7 +470,7 @@ class ResultatsSemestre(ResultatsCache): "ects": 0.0, "ects_ue": ue.ects, } - if not ue_id in self.etud_moy_ue: + if not ue_id in self.etud_moy_ue or not etudid in self.etud_moy_ue[ue_id]: return None if not self.validations: self.validations = res_sem.load_formsemestre_validations(self.formsemestre) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index 2ada0c6b..c271628a 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -72,9 +72,7 @@ def etud_get_poursuite_info(sem: dict, etud: dict) -> dict: moy_ues.append( ( ue["acronyme"], - scu.fmt_note( - nt.get_etud_ue_status(etudid, ue["ue_id"])["moy"] - ), + scu.fmt_note(ue_status["moy"]), ) ) else: diff --git a/sco_version.py b/sco_version.py index ff1b244f..8c642266 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.945" +SCOVERSION = "9.6.946" SCONAME = "ScoDoc" From 0cf3b0a7828d2a2aca7e56809167a4204549a655 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 26 Feb 2024 13:55:04 +0100 Subject: [PATCH 27/32] =?UTF-8?q?formsemestre=5Fstatus:=20affiche=20module?= =?UTF-8?q?s=20avec=20=C3=A9vals=20bloqu=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comp/res_common.py | 6 ++++-- app/scodoc/sco_evaluations.py | 11 ++++++++--- app/scodoc/sco_formsemestre_status.py | 18 ++++++++++++------ app/static/css/scodoc.css | 7 +++++++ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 706e37f9..89c0ca2a 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -205,6 +205,7 @@ class ResultatsSemestre(ResultatsCache): "coefficient" : float, # 0 si None "description" : str, # de l'évaluation, "" si None "etat" { + "blocked" : bool, # vrai si prise en compte bloquée "evalcomplete" : bool, "last_modif" : datetime.datetime | None, # saisie de note la plus récente "nb_notes" : int, # nb notes d'étudiants inscrits @@ -232,13 +233,14 @@ class ResultatsSemestre(ResultatsCache): return { "coefficient": evaluation.coefficient, "description": evaluation.description, - "evaluation_id": evaluation.id, - "jour": evaluation.date_debut or datetime.datetime(1900, 1, 1), "etat": { + "blocked": evaluation.is_blocked(), "evalcomplete": etat.is_complete, "nb_notes": etat.nb_notes, "last_modif": last_modif, }, + "evaluation_id": evaluation.id, + "jour": evaluation.date_debut or datetime.datetime(1900, 1, 1), "publish_incomplete": evaluation.publish_incomplete, } diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 8931faf2..683c5b6f 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -273,8 +273,9 @@ def do_evaluation_etat( def _summarize_evals_etats(etat_evals: list[dict]) -> dict: """Synthétise les états d'une liste d'évaluations evals: list of mappings (etats), - utilise e["etat"]["evalcomplete"], e["etat"]["nb_notes"], e["etat"]["last_modif"] + utilise e["blocked"], e["etat"]["evalcomplete"], e["etat"]["nb_notes"], e["etat"]["last_modif"] -> + nb_evals : nb total qcq soit état nb_eval_completes (= prises en compte) nb_evals_en_cours (= avec des notes, mais pas complete) nb_evals_vides (= sans aucune note) @@ -282,14 +283,16 @@ def _summarize_evals_etats(etat_evals: list[dict]) -> dict: Une eval est "complete" ssi tous les etudiants *inscrits* ont une note. """ - nb_evals_completes, nb_evals_en_cours, nb_evals_vides = 0, 0, 0 + nb_evals_completes, nb_evals_en_cours, nb_evals_vides, nb_evals_blocked = 0, 0, 0, 0 dates = [] for e in etat_evals: + if e["etat"]["blocked"]: + nb_evals_blocked += 1 if e["etat"]["evalcomplete"]: nb_evals_completes += 1 elif e["etat"]["nb_notes"] == 0: nb_evals_vides += 1 - else: + elif not e["etat"]["blocked"]: nb_evals_en_cours += 1 last_modif = e["etat"]["last_modif"] if last_modif is not None: @@ -299,6 +302,8 @@ def _summarize_evals_etats(etat_evals: list[dict]) -> dict: last_modif = sorted(dates)[-1] if dates else "" return { + "nb_evals": len(etat_evals), + "nb_evals_blocked": nb_evals_blocked, "nb_evals_completes": nb_evals_completes, "nb_evals_en_cours": nb_evals_en_cours, "nb_evals_vides": nb_evals_vides, diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 8d91bf16..ed76196e 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1235,6 +1235,7 @@ def formsemestre_tableau_modules( and etat["nb_evals_en_cours"] == 0 and etat["nb_evals_vides"] == 0 and not etat["attente"] + and not etat["nb_evals_blocked"] > 0 ): tr_classes = f"formsemestre_status_green{fontorange}" else: @@ -1243,6 +1244,8 @@ def formsemestre_tableau_modules( tr_classes += " modimpl_attente" if not mod_is_conforme: tr_classes += " modimpl_non_conforme" + if etat["nb_evals_blocked"] > 0: + tr_classes += " modimpl_has_blocked" H.append( f""" @@ -1284,17 +1287,20 @@ def formsemestre_tableau_modules( ModuleType.SAE, ): H.append('') - nb_evals = ( - etat["nb_evals_completes"] - + etat["nb_evals_en_cours"] - + etat["nb_evals_vides"] - ) + nb_evals = etat["nb_evals"] if nb_evals != 0: + if etat["nb_evals_blocked"] > 0: + blocked_txt = f""", { + etat["nb_evals_blocked"]} bloquée{'s' + if etat["nb_evals_blocked"] > 1 else ''}""" + else: + blocked_txt = "" H.append( f"""{nb_evals} prévues, - {etat["nb_evals_completes"]} ok""" + {etat["nb_evals_completes"]} ok {blocked_txt} + """ ) if etat["nb_evals_en_cours"] > 0: H.append( diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 4dbebb4f..c0671169 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1821,6 +1821,13 @@ tr.modimpl_non_conforme td, tr.modimpl_attente td { padding-top: 4px; padding-bottom: 4px; } +tr.modimpl_has_blocked span.nb_evals_blocked { + font-weight: bold; + color: red; + background-color: yellow; + padding-left: 2px; + padding-right: 2px; +} table.formsemestre_status a.redlink { text-decoration: none; } From ee601071f57b3d818880f61d956cdbcb4cae2966 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 26 Feb 2024 14:14:30 +0100 Subject: [PATCH 28/32] cosmetic: tableau bord semestre --- app/scodoc/sco_formsemestre_status.py | 4 ++-- app/static/css/scodoc.css | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index ed76196e..c5fce57b 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1290,7 +1290,7 @@ def formsemestre_tableau_modules( nb_evals = etat["nb_evals"] if nb_evals != 0: if etat["nb_evals_blocked"] > 0: - blocked_txt = f""", { + blocked_txt = f"""{ etat["nb_evals_blocked"]} bloquée{'s' if etat["nb_evals_blocked"] > 1 else ''}""" else: @@ -1312,7 +1312,7 @@ def formsemestre_tableau_modules( if etat["attente"]: H.append( f""" [en attente]""" + title="Il y a des notes en attente">en attente""" ) if not mod_is_conforme: H.append( diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index c0671169..84e09201 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1821,12 +1821,20 @@ tr.modimpl_non_conforme td, tr.modimpl_attente td { padding-top: 4px; padding-bottom: 4px; } -tr.modimpl_has_blocked span.nb_evals_blocked { - font-weight: bold; - color: red; +tr.modimpl_has_blocked span.nb_evals_blocked, tr span.evals_attente { background-color: yellow; - padding-left: 2px; - padding-right: 2px; + border-radius: 4px; + font-weight: bold; + margin-left: 8px; + padding-left: 4px; + padding-right: 4px; +} +tr.modimpl_has_blocked span.nb_evals_blocked { + color: red; +} +tr span.evals_attente { + background-color: orange; + color: green; } table.formsemestre_status a.redlink { text-decoration: none; @@ -2177,6 +2185,7 @@ tr.mievr_tit.evaluation_blocked td,tr.mievr_tit.evaluation_blocked th { } tr.mievr_in.evaluation_blocked td, tr.mievr_in.evaluation_blocked th { background-color: rgb(195, 235, 255); + padding-top: 4px; } From bc5292b16598cf5acc5ba34c881fe2f20500b35a Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 26 Feb 2024 17:20:36 +0100 Subject: [PATCH 29/32] =?UTF-8?q?Edition=20des=20=C3=A9valuations,=20netto?= =?UTF-8?q?yage=20code,=20fix=20#799.=20Tests=20OK.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/evaluations.py | 17 +-- app/scodoc/sco_evaluation_db.py | 81 ---------- app/scodoc/sco_evaluation_edit.py | 9 +- app/scodoc/sco_formsemestre_edit.py | 229 ++++++++++++++++++---------- app/scodoc/sco_moduleimpl.py | 4 +- app/scodoc/sco_placement.py | 50 +++--- app/scodoc/sco_undo_notes.py | 22 +-- tests/unit/test_sco_basic.py | 44 +++--- 8 files changed, 217 insertions(+), 239 deletions(-) diff --git a/app/models/evaluations.py b/app/models/evaluations.py index f58560ff..8a253bf5 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -91,7 +91,7 @@ class Evaluation(models.ScoDocModel): evaluation_type=None, numero=None, **kw, # ceci pour absorber les éventuel arguments excedentaires - ): + ) -> "Evaluation": """Create an evaluation. Check permission and all arguments. Ne crée pas les poids vers les UEs. Add to session, do not commit. @@ -103,7 +103,7 @@ class Evaluation(models.ScoDocModel): args = locals() del args["cls"] del args["kw"] - check_convert_evaluation_args(moduleimpl, args) + check_and_convert_evaluation_args(args, moduleimpl) # Check numeros Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True) if not "numero" in args or args["numero"] is None: @@ -254,15 +254,6 @@ class Evaluation(models.ScoDocModel): return e_dict - def convert_dict_fields(self, args: dict) -> dict: - """Convert fields in the given dict. No other side effect. - returns: dict to store in model's db. - """ - check_convert_evaluation_args(self.moduleimpl, args) - if args.get("numero") is None: - args["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1 - return args - @classmethod def get_evaluation( cls, evaluation_id: int | str, dept_id: int = None @@ -568,7 +559,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict): return e_dict -def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict): +def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"): """Check coefficient, dates and duration, raises exception if invalid. Convert date and time strings to date and time objects. @@ -608,7 +599,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict): if coef < 0: raise ScoValueError("invalid coefficient value (must be positive or null)") data["coefficient"] = coef - # --- date de l'évaluation + # --- date de l'évaluation dans le semestre ? formsemestre = moduleimpl.formsemestre date_debut = data.get("date_debut", None) if date_debut: diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py index 35d90a96..d19c0c75 100644 --- a/app/scodoc/sco_evaluation_db.py +++ b/app/scodoc/sco_evaluation_db.py @@ -31,96 +31,15 @@ import flask from flask import url_for, g from flask_login import current_user -import sqlalchemy as sa from app import db, log from app.models import Evaluation -from app.models.evaluations import check_convert_evaluation_args import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app.scodoc.sco_exceptions import AccessDenied from app.scodoc import sco_cache -from app.scodoc import sco_moduleimpl - - -_evaluationEditor = ndb.EditableTable( - "notes_evaluation", - "evaluation_id", - ( - "evaluation_id", - "moduleimpl_id", - "date_debut", - "date_fin", - "description", - "note_max", - "coefficient", - "visibulletin", - "publish_incomplete", - "evaluation_type", - "numero", - ), - sortkey="numero, date_debut desc", # plus recente d'abord - output_formators={ - "numero": ndb.int_null_is_zero, - }, - input_formators={ - "visibulletin": bool, - "publish_incomplete": bool, - "evaluation_type": int, - }, -) - - -def get_evaluations_dict(args: dict) -> list[dict]: - """Liste evaluations, triées numero (or most recent date first). - Fonction de transition pour ancien code ScoDoc7. - - Ajoute les champs: - 'duree' : '2h30' - 'matin' : 1 (commence avant 12:00) ou 0 - 'apresmidi' : 1 (termine après 12:00) ou 0 - 'descrheure' : ' de 15h00 à 16h30' - """ - # calcule duree (chaine de car.) de chaque evaluation et ajoute jour_iso, matin, apresmidi - return [ - e.to_dict() - for e in Evaluation.query.filter_by(**args).order_by( - sa.desc(Evaluation.numero), sa.desc(Evaluation.date_debut) - ) - ] - - -def do_evaluation_list_in_formsemestre(formsemestre_id): - "list evaluations in this formsemestre" - mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) - evals = [] - for modimpl in mods: - evals += get_evaluations_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]}) - return evals - - -def do_evaluation_edit(args): - "edit an evaluation" - evaluation_id = args["evaluation_id"] - evaluation: Evaluation = db.session.get(Evaluation, evaluation_id) - if evaluation is None: - raise ValueError("evaluation inexistante !") - - if not evaluation.moduleimpl.can_edit_evaluation(current_user): - raise AccessDenied( - f"Modification évaluation impossible pour {current_user.get_nomplogin()}" - ) - args["moduleimpl_id"] = evaluation.moduleimpl.id - check_convert_evaluation_args(evaluation.moduleimpl, args) - - cnx = ndb.GetDBConnexion() - _evaluationEditor.edit(cnx, args) - # inval cache pour ce semestre - sco_cache.invalidate_formsemestre( - formsemestre_id=evaluation.moduleimpl.formsemestre_id - ) # ancien _notes_getall diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index 11418a99..3824ca60 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -31,14 +31,12 @@ import datetime import time import flask -from flask import url_for, render_template -from flask import g +from flask import g, render_template, request, url_for from flask_login import current_user -from flask import request from app import db from app.models import Evaluation, Module, ModuleImpl -from app.models.evaluations import heure_to_time +from app.models.evaluations import heure_to_time, check_and_convert_evaluation_args import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType @@ -383,6 +381,8 @@ def evaluation_create_form( raise ScoValueError("Date (j/m/a) invalide") from exc else: date_debut = None + args["date_debut"] = date_debut + args["date_fin"] = date_debut # même jour args.pop("jour", None) if date_debut and args.get("heure_debut"): try: @@ -415,6 +415,7 @@ def evaluation_create_form( args["blocked_until"] = None # if edit: + check_and_convert_evaluation_args(args, modimpl) evaluation.from_dict(args) else: # création d'une evaluation diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index a0f534dd..921c0070 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -31,6 +31,7 @@ import flask from flask import url_for, flash, redirect from flask import g, request from flask_login import current_user +import sqlalchemy as sa from app import db from app.auth.models import User @@ -63,8 +64,6 @@ from app.scodoc import html_sco_header from app.scodoc import codes_cursus from app.scodoc import sco_compute_moy from app.scodoc import sco_edit_module -from app.scodoc import sco_edit_ue -from app.scodoc import sco_evaluation_db from app.scodoc import sco_formsemestre from app.scodoc import sco_groups_copy from app.scodoc import sco_modalites @@ -1113,7 +1112,8 @@ def formsemestre_delete_moduleimpls(formsemestre_id, module_ids_to_del): f"""impossible de supprimer {module.code} ({module.titre or ""}) car il y a {nb_evals} évaluations définies (supprimez-les d\'abord)""" ] ok = False @@ -1233,7 +1233,11 @@ def formsemestre_clone(formsemestre_id): return "".join(H) + msg + tf[1] + html_sco_header.sco_footer() elif tf[0] == -1: # cancel return flask.redirect( - "formsemestre_status?formsemestre_id=%s" % formsemestre_id + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) ) else: resp = User.get_user_from_nomplogin(tf[2]["responsable_id"]) @@ -1356,9 +1360,9 @@ def do_formsemestre_clone( return formsemestre_id -def formsemestre_delete(formsemestre_id): +def formsemestre_delete(formsemestre_id: int) -> str | flask.Response: """Delete a formsemestre (affiche avertissements)""" - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) H = [ html_sco_header.html_sem_header("Suppression du semestre"), """
      Attention ! @@ -1376,17 +1380,18 @@ Ceci n'est possible que si :
    """, ] - - evals = sco_evaluation_db.do_evaluation_list_in_formsemestre(formsemestre_id) - if evals: + evaluations = ( + Evaluation.query.join(ModuleImpl) + .filter_by(formsemestre_id=formsemestre.id) + .all() + ) + if evaluations: H.append( - f"""

    Attention: il y a {len(evals)} évaluations + f"""

    Attention: il y a {len(evaluations)} évaluations dans ce semestre (sa suppression entrainera l'effacement définif des notes) !

    """ ) - submit_label = ( - f"Confirmer la suppression (du semestre et des {len(evals)} évaluations !)" - ) + submit_label = f"Confirmer la suppression (du semestre et des {len(evaluations)} évaluations !)" else: submit_label = "Confirmer la suppression du semestre" tf = TrivialFormulator( @@ -1413,8 +1418,10 @@ Ceci n'est possible que si : ) else: H.append(tf[1]) + return "\n".join(H) + html_sco_header.sco_footer() - elif tf[0] == -1: # cancel + + if tf[0] == -1: # cancel return flask.redirect( url_for( "notes.formsemestre_status", @@ -1422,10 +1429,9 @@ Ceci n'est possible que si : formsemestre_id=formsemestre_id, ) ) - else: - return flask.redirect( - "formsemestre_delete2?formsemestre_id=" + str(formsemestre_id) - ) + return flask.redirect( + "formsemestre_delete2?formsemestre_id=" + str(formsemestre_id) + ) def formsemestre_delete2(formsemestre_id, dialog_confirmed=False): @@ -1486,106 +1492,165 @@ def formsemestre_has_decisions_or_compensations( return False, "" -def do_formsemestre_delete(formsemestre_id): +def do_formsemestre_delete(formsemestre_id: int): """delete formsemestre, and all its moduleimpls. No checks, no warnings: erase all ! """ - cnx = ndb.GetDBConnexion() - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - - sco_cache.EvaluationCache.invalidate_sem(formsemestre_id) - + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + sco_cache.EvaluationCache.invalidate_sem(formsemestre.id) + titre_sem = formsemestre.titre_annee() # --- Destruction des modules de ce semestre - mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) - for mod in mods: + for modimpl in formsemestre.modimpls: # evaluations - evals = sco_evaluation_db.get_evaluations_dict( - args={"moduleimpl_id": mod["moduleimpl_id"]} - ) - for e in evals: - ndb.SimpleQuery( - "DELETE FROM notes_notes WHERE evaluation_id=%(evaluation_id)s", - e, + for e in modimpl.evaluations: + db.session.execute( + sa.text( + """DELETE FROM notes_notes WHERE evaluation_id=:evaluation_id""" + ), + {"evaluation_id": e.id}, ) - ndb.SimpleQuery( - "DELETE FROM notes_notes_log WHERE evaluation_id=%(evaluation_id)s", - e, - ) - ndb.SimpleQuery( - "DELETE FROM notes_evaluation WHERE id=%(evaluation_id)s", - e, + db.session.execute( + sa.text( + """DELETE FROM notes_notes_log WHERE evaluation_id=:evaluation_id""" + ), + {"evaluation_id": e.id}, ) - sco_moduleimpl.do_moduleimpl_delete( - mod["moduleimpl_id"], formsemestre_id=formsemestre_id - ) + db.session.delete(e) + db.session.delete(modimpl) # --- Desinscription des etudiants - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - req = "DELETE FROM notes_formsemestre_inscription WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM notes_formsemestre_inscription WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) + # --- Suppression des evenements - req = "DELETE FROM scolar_events WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text("DELETE FROM scolar_events WHERE formsemestre_id=:formsemestre_id"), + {"formsemestre_id": formsemestre_id}, + ) # --- Suppression des appreciations - req = "DELETE FROM notes_appreciations WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM notes_appreciations WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Supression des validations (!!!) - req = "DELETE FROM scolar_formsemestre_validation WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM scolar_formsemestre_validation WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Supression des references a ce semestre dans les compensations: - req = "UPDATE scolar_formsemestre_validation SET compense_formsemestre_id=NULL WHERE compense_formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + """UPDATE scolar_formsemestre_validation + SET compense_formsemestre_id=NULL + WHERE compense_formsemestre_id=:formsemestre_id""" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Suppression des autorisations - req = "DELETE FROM scolar_autorisation_inscription WHERE origin_formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM scolar_autorisation_inscription WHERE origin_formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Suppression des coefs d'UE capitalisées - req = "DELETE FROM notes_formsemestre_uecoef WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM notes_formsemestre_uecoef WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Suppression des item du menu custom - req = "DELETE FROM notes_formsemestre_custommenu WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM notes_formsemestre_custommenu WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Suppression des formules - req = "DELETE FROM notes_formsemestre_ue_computation_expr WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM notes_formsemestre_ue_computation_expr WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Suppression des preferences - req = "DELETE FROM sco_prefs WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text("DELETE FROM sco_prefs WHERE formsemestre_id=:formsemestre_id"), + {"formsemestre_id": formsemestre_id}, + ) # --- Suppression des groupes et partitions - req = """DELETE FROM group_membership + db.session.execute( + sa.text( + """ + DELETE FROM group_membership WHERE group_id IN (SELECT gm.group_id FROM group_membership gm, partition p, group_descr gd WHERE gm.group_id = gd.id AND gd.partition_id = p.id - AND p.formsemestre_id=%(formsemestre_id)s) + AND p.formsemestre_id=:formsemestre_id) """ - cursor.execute(req, {"formsemestre_id": formsemestre_id}) - req = """DELETE FROM group_descr + ), + {"formsemestre_id": formsemestre_id}, + ) + + db.session.execute( + sa.text( + """ + DELETE FROM group_descr WHERE id IN (SELECT gd.id FROM group_descr gd, partition p WHERE gd.partition_id = p.id - AND p.formsemestre_id=%(formsemestre_id)s) + AND p.formsemestre_id=:formsemestre_id) """ - cursor.execute(req, {"formsemestre_id": formsemestre_id}) - req = "DELETE FROM partition WHERE formsemestre_id=%(formsemestre_id)s" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + ), + {"formsemestre_id": formsemestre_id}, + ) + db.session.execute( + sa.text("DELETE FROM partition WHERE formsemestre_id=:formsemestre_id"), + {"formsemestre_id": formsemestre_id}, + ) # --- Responsables - req = """DELETE FROM notes_formsemestre_responsables - WHERE formsemestre_id=%(formsemestre_id)s""" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM notes_formsemestre_responsables WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Etapes - req = """DELETE FROM notes_formsemestre_etapes - WHERE formsemestre_id=%(formsemestre_id)s""" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text( + "DELETE FROM notes_formsemestre_etapes WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) + # --- SemSets + db.session.execute( + sa.text( + "DELETE FROM notes_semset_formsemestre WHERE formsemestre_id=:formsemestre_id" + ), + {"formsemestre_id": formsemestre_id}, + ) # --- Dispenses d'UE - req = """DELETE FROM "dispenseUE" WHERE formsemestre_id=%(formsemestre_id)s""" - cursor.execute(req, {"formsemestre_id": formsemestre_id}) + db.session.execute( + sa.text("""DELETE FROM "dispenseUE" WHERE formsemestre_id=:formsemestre_id"""), + {"formsemestre_id": formsemestre_id}, + ) # --- Destruction du semestre - sco_formsemestre._formsemestreEditor.delete(cnx, formsemestre_id) + db.session.delete(formsemestre) # news ScolarNews.add( typ=ScolarNews.NEWS_SEM, obj=formsemestre_id, - text="Suppression du semestre %(titre)s" % sem, + text=f"Suppression du semestre {titre_sem}", max_frequency=0, ) diff --git a/app/scodoc/sco_moduleimpl.py b/app/scodoc/sco_moduleimpl.py index 67cd380c..5cd3a1c0 100644 --- a/app/scodoc/sco_moduleimpl.py +++ b/app/scodoc/sco_moduleimpl.py @@ -91,7 +91,9 @@ def do_moduleimpl_delete(oid, formsemestre_id=None): ) # > moduleimpl_delete -def moduleimpl_list(moduleimpl_id=None, formsemestre_id=None, module_id=None): +def moduleimpl_list( + moduleimpl_id=None, formsemestre_id=None, module_id=None +) -> list[dict]: "list moduleimpls" args = locals() cnx = ndb.GetDBConnexion() diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py index a9e25344..ba87f84b 100644 --- a/app/scodoc/sco_placement.py +++ b/app/scodoc/sco_placement.py @@ -48,20 +48,17 @@ from wtforms import ( HiddenField, SelectMultipleField, ) -from app.models import ModuleImpl +from app.models import Evaluation, ModuleImpl import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb -from app import ScoValueError from app.scodoc import html_sco_header, sco_preferences from app.scodoc import sco_edit_module from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_db from app.scodoc import sco_excel from app.scodoc.sco_excel import ScoExcelBook, COLORS from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl -from app.scodoc import sco_permissions_check from app.scodoc.gen_tables import GenTable from app.scodoc import sco_etud import sco_version @@ -138,11 +135,7 @@ class PlacementForm(FlaskForm): def set_evaluation_infos(self, evaluation_id): """Initialise les données du formulaire avec les données de l'évaluation.""" - eval_data = sco_evaluation_db.get_evaluations_dict( - {"evaluation_id": evaluation_id} - ) - if not eval_data: - raise ScoValueError("invalid evaluation_id") + _ = Evaluation.get_evaluation(evaluation_id) # check exist ? self.groups_tree, self.has_groups, self.nb_groups = _get_group_info( evaluation_id ) @@ -239,14 +232,12 @@ class PlacementRunner: self.groups_ids = [ gid if gid != TOUS else form.tous_id for gid in form["groups"].data ] - self.eval_data = sco_evaluation_db.get_evaluations_dict( - {"evaluation_id": self.evaluation_id} - )[0] + self.evaluation = Evaluation.get_evaluation(self.evaluation_id) self.groups = sco_groups.listgroups(self.groups_ids) self.gr_title_filename = sco_groups.listgroups_filename(self.groups) # gr_title = sco_groups.listgroups_abbrev(d['groups']) self.current_user = current_user - self.moduleimpl_id = self.eval_data["moduleimpl_id"] + self.moduleimpl_id = self.evaluation.moduleimpl_id self.moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(self.moduleimpl_id) # TODO: à revoir pour utiliser modèle ModuleImpl self.moduleimpl_data = sco_moduleimpl.moduleimpl_list( @@ -260,20 +251,25 @@ class PlacementRunner: ) self.evalname = "%s-%s" % ( self.module_data["code"] or "?", - ndb.DateDMYtoISO(self.eval_data["jour"]), + ( + self.evaluation.date_debut.strftime("%Y-%m-%d_%Hh%M") + if self.evaluation.date_debut + else "" + ), ) - if self.eval_data["description"]: - self.evaltitre = self.eval_data["description"] + if self.evaluation.description: + self.evaltitre = self.evaluation.description else: - self.evaltitre = "évaluation du %s" % self.eval_data["jour"] + self.evaltitre = f"""évaluation{ + self.evaluation.date_debut.strftime(' du %d/%m/%Y à %Hh%M') + if self.evaluation.date_debut else ''}""" self.desceval = [ # une liste de chaines: description de l'evaluation - "%s" % self.sem["titreannee"], + self.sem["titreannee"], "Module : %s - %s" % (self.module_data["code"] or "?", self.module_data["abbrev"] or ""), "Surveillants : %s" % self.surveillants, "Batiment : %(batiment)s - Salle : %(salle)s" % self.__dict__, - "Controle : %s (coef. %g)" - % (self.evaltitre, self.eval_data["coefficient"]), + "Controle : %s (coef. %g)" % (self.evaltitre, self.evaluation.coefficient), ] self.styles = None self.plan = None @@ -339,10 +335,10 @@ class PlacementRunner: def _production_pdf(self): pdf_title = "
    ".join(self.desceval) - pdf_title += ( - "\nDate : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s" - % self.eval_data - ) + pdf_title += f"""\nDate : {self.evaluation.date_debut.strftime("%d/%m/%Y") + if self.evaluation.date_debut else '-' + } - Horaire : {self.evaluation.heure_debut()} à {self.evaluation.heure_fin() + }""" filename = "placement_%(evalname)s_%(gr_title_filename)s" % self.__dict__ titles = { "nom": "Nom", @@ -489,8 +485,10 @@ class PlacementRunner: worksheet.append_blank_row() worksheet.append_single_cell_row(desceval, self.styles["titres"]) worksheet.append_single_cell_row( - "Date : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s" - % self.eval_data, + f"""Date : {self.evaluation.date_debut.strftime("%d/%m/%Y") + if self.evaluation.date_debut else '-' + } - Horaire : {self.evaluation.heure_debut()} à {self.evaluation.heure_fin() + }""", self.styles["titres"], ) diff --git a/app/scodoc/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py index b04ba870..480e0b2e 100644 --- a/app/scodoc/sco_undo_notes.py +++ b/app/scodoc/sco_undo_notes.py @@ -48,16 +48,15 @@ Opérations: import datetime from flask import request -from app.models import FormSemestre +from app.models import Evaluation, FormSemestre from app.scodoc.intervals import intervalmap import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app.scodoc import sco_evaluation_db -from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences from app.scodoc import sco_users -import sco_version from app.scodoc.gen_tables import GenTable +import sco_version # deux notes (de même uid) sont considérées comme de la même opération si # elles sont séparées de moins de 2*tolerance: @@ -149,10 +148,8 @@ def list_operations(evaluation_id): def evaluation_list_operations(evaluation_id): """Page listing operations on evaluation""" - E = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - - Ops = list_operations(evaluation_id) + evaluation = Evaluation.get_evaluation(evaluation_id) + operations = list_operations(evaluation_id) columns_ids = ("datestr", "user_name", "nb_notes", "comment") titles = { @@ -164,11 +161,14 @@ def evaluation_list_operations(evaluation_id): tab = GenTable( titles=titles, columns_ids=columns_ids, - rows=Ops, + rows=operations, html_sortable=False, - html_title="

    Opérations sur l'évaluation %s du %s

    " - % (E["description"], E["jour"]), - preferences=sco_preferences.SemPreferences(M["formsemestre_id"]), + html_title=f"""

    Opérations sur l'évaluation {evaluation.description} { + evaluation.date_debut.strftime("du %d/%m/%Y") if evaluation.date_debut else "(sans date)" + }

    """, + preferences=sco_preferences.SemPreferences( + evaluation.moduleimpl.formsemestre_id + ), ) return tab.make_page() diff --git a/tests/unit/test_sco_basic.py b/tests/unit/test_sco_basic.py index ca0c6326..d4cb7a72 100644 --- a/tests/unit/test_sco_basic.py +++ b/tests/unit/test_sco_basic.py @@ -13,7 +13,7 @@ Au besoin, créer un base de test neuve: """ import datetime -from app.models import FormSemestreInscription, Identite +from app.models import Evaluation, FormSemestreInscription, Identite, ModuleImpl from config import TestConfig from tests.unit import sco_fake_gen @@ -29,7 +29,6 @@ from app.scodoc import sco_bulletins from app.scodoc import codes_cursus from app.scodoc import sco_assiduites as scass from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_db from app.scodoc import sco_formsemestre_validation from app.scodoc import sco_cursus_dut from app.scodoc import sco_saisie_notes @@ -81,7 +80,7 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: module_id=module_id, formsemestre_id=formsemestre_id, ) - + moduleimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id) # --- Inscription des étudiants for etud in etuds: G.inscrit_etudiant(formsemestre_id, etud) @@ -97,17 +96,18 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: assert ins.parcour is None # --- Création évaluation - e = G.create_evaluation( - moduleimpl_id=moduleimpl_id, + e1 = Evaluation.create( + moduleimpl=moduleimpl, date_debut=datetime.datetime(2020, 1, 1), description="evaluation test", coefficient=1.0, ) + db.session.commit() # --- Saisie toutes les notes de l'évaluation for idx, etud in enumerate(etuds): etudids_changed, nb_suppress, existing_decisions = G.create_note( - evaluation_id=e["evaluation_id"], + evaluation_id=e1.id, etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], ) @@ -118,7 +118,7 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: # --- Vérifie que les notes sont prises en compte: b = sco_bulletins.formsemestre_bulletinetud_dict(formsemestre_id, etud["etudid"]) # Toute les notes sont saisies, donc eval complète - etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) + etat = sco_evaluations.do_evaluation_etat(e1.id) assert etat["evalcomplete"] assert etat["nb_inscrits"] == len(etuds) assert etat["nb_notes"] == len(etuds) @@ -131,30 +131,32 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: ) # --- Une autre évaluation - e2 = G.create_evaluation( - moduleimpl_id=moduleimpl_id, + e2 = Evaluation.create( + moduleimpl=moduleimpl, date_debut=datetime.datetime(2020, 1, 2), description="evaluation test 2", coefficient=1.0, ) + db.session.commit() # Saisie les notes des 5 premiers étudiants: for idx, etud in enumerate(etuds[:5]): etudids_changed, nb_suppress, existing_decisions = G.create_note( - evaluation_id=e2["evaluation_id"], + evaluation_id=e2.id, etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], ) # Cette éval n'est pas complète - etat = sco_evaluations.do_evaluation_etat(e2["evaluation_id"]) + etat = sco_evaluations.do_evaluation_etat(e2.id) assert etat["evalcomplete"] is False # la première éval est toujours complète: - etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) + etat = sco_evaluations.do_evaluation_etat(e1.id) assert etat["evalcomplete"] # Modifie l'évaluation 2 pour "prise en compte immédiate" - e2["publish_incomplete"] = True - sco_evaluation_db.do_evaluation_edit(e2) - etat = sco_evaluations.do_evaluation_etat(e2["evaluation_id"]) + e2.publish_incomplete = True + db.session.add(e2) + db.session.flush() + etat = sco_evaluations.do_evaluation_etat(e2.id) assert etat["evalcomplete"] is False assert etat["nb_att"] == 0 # il n'y a pas de notes (explicitement) en attente assert etat["evalattente"] # mais l'eval est en attente (prise en compte immédiate) @@ -162,26 +164,26 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: # Saisie des notes qui manquent: for idx, etud in enumerate(etuds[5:]): etudids_changed, nb_suppress, existing_decisions = G.create_note( - evaluation_id=e2["evaluation_id"], + evaluation_id=e2.id, etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], ) - etat = sco_evaluations.do_evaluation_etat(e2["evaluation_id"]) + etat = sco_evaluations.do_evaluation_etat(e2.id) assert etat["evalcomplete"] assert etat["nb_att"] == 0 assert not etat["evalattente"] # toutes les notes sont présentes # --- Suppression des notes - sco_saisie_notes.evaluation_suppress_alln(e["evaluation_id"], dialog_confirmed=True) - etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) + sco_saisie_notes.evaluation_suppress_alln(e1.id, dialog_confirmed=True) + etat = sco_evaluations.do_evaluation_etat(e1.id) assert etat["nb_notes"] == 0 assert not etat["evalcomplete"] # --- Saisie des notes manquantes ans = sco_saisie_notes.do_evaluation_set_missing( - e["evaluation_id"], 12.34, dialog_confirmed=True + e1.id, 12.34, dialog_confirmed=True ) assert f'{etat["nb_inscrits"]} notes changées' in ans - etat = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) + etat = sco_evaluations.do_evaluation_etat(e1.id) assert etat["evalcomplete"] # ----------------------- From b3b47a755ff501675e107bb2d4d9bfc27697806d Mon Sep 17 00:00:00 2001 From: Iziram Date: Mon, 26 Feb 2024 18:26:04 +0100 Subject: [PATCH 30/32] Assiduite : Justif 24h + test unit --- app/views/assiduites.py | 25 +++++-- tests/unit/test_assiduites.py | 118 +++++++++++++++++++++++++++++----- 2 files changed, 121 insertions(+), 22 deletions(-) diff --git a/app/views/assiduites.py b/app/views/assiduites.py index a06ba073..0333aa0b 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -279,6 +279,7 @@ def ajout_assiduite_etud() -> str | Response: def _get_dates_from_assi_form( form: AjoutAssiOrJustForm, + all_day: bool = False, ) -> tuple[ bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None ]: @@ -308,13 +309,23 @@ def _get_dates_from_assi_form( if date_fin: # ignore les heures si plusieurs jours - heure_debut = datetime.time.fromisoformat(debut_jour) # 0h - heure_fin = datetime.time.fromisoformat(fin_jour) # minuit + + # Assiduité : garde les heures inscritent dans le formulaire + # Justificatif : ignore les heures inscrites dans le formulaire (0h -> 23h59) + + heure_debut = ( + datetime.time.fromisoformat(debut_jour) + if not all_day + else datetime.time(0, 0, 0) + ) # 0h ou ConfigAssiduite.MorningTime + heure_fin = ( + datetime.time.fromisoformat(fin_jour) + if not all_day + else datetime.time(23, 59, 59) + ) # 23h59 ou ConfigAssiduite.AfternoonTime else: try: - heure_debut = datetime.time.fromisoformat( - form.heure_debut.data or debut_jour - ) + heure_debut = datetime.time.fromisoformat(form.heure_debut.data or "00:00") except ValueError: form.set_error("heure début invalide", form.heure_debut) if bool(form.heure_debut.data) != bool(form.heure_fin.data): @@ -322,7 +333,7 @@ def _get_dates_from_assi_form( "Les deux heures début et fin doivent être spécifiées, ou aucune" ) try: - heure_fin = datetime.time.fromisoformat(form.heure_fin.data or fin_jour) + heure_fin = datetime.time.fromisoformat(form.heure_fin.data or "23:59") except ValueError: form.set_error("heure fin invalide", form.heure_fin) @@ -694,7 +705,7 @@ def _record_justificatif_etud( dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server, - ) = _get_dates_from_assi_form(form) + ) = _get_dates_from_assi_form(form, all_day=True) if not ok: log("_record_justificatif_etud: dates invalides") diff --git a/tests/unit/test_assiduites.py b/tests/unit/test_assiduites.py index 810dd2a0..0cc7ce59 100644 --- a/tests/unit/test_assiduites.py +++ b/tests/unit/test_assiduites.py @@ -1148,13 +1148,8 @@ def _setup_fake_db( moduleimpls.append(ModuleImpl.query.filter_by(id=moduleimpl_id).first()) - # Création de 3 étudiants - etud_0 = g_fake.create_etud(prenom="etud0") - etud_1 = g_fake.create_etud(prenom="etud1") - etud_2 = g_fake.create_etud(prenom="etud2") - etuds_dict = [etud_0, etud_1, etud_2] - - etud_dicts: list[dict] = [ + # Création de x étudiants + etuds_dict: list[dict] = [ g_fake.create_etud(prenom=f"etud{i}") for i in range(nb_etuds) ] @@ -1406,13 +1401,106 @@ def test_calcul_assiduites(test_client): "total": {"journee": 11, "demi": 20, "heure": 81.0, "compte": 26}, } - for key in resultat_attendu: - assert ( - resultat_attendu[key]["journee"] * 2 >= resultat_attendu[key]["demi"] - ), f"Trop de demi-journées [{key}]" + for key, value in resultat_attendu.items(): + assert value["journee"] * 2 >= value["demi"], f"Trop de demi-journées [{key}]" - for key in resultat_attendu: - for key2 in resultat_attendu[key]: + for key, value in resultat_attendu.items(): + for key2, value2 in value.items(): assert ( - result[key][key2] == resultat_attendu[key][key2] - ), f"Le calcul [{key}][{key2}] est faux (attendu > {resultat_attendu[key][key2]} ≠ {result[key][key2]} < obtenu)" + result[key][key2] == value2 + ), f"Le calcul [{key}][{key2}] est faux (attendu > {value2} ≠ {result[key][key2]} < obtenu)" + + +def test_cas_justificatifs(test_client): + """ + Tests de certains cas particuliers des justificatifs + - Création du justificatif avant ou après assiduité + - Assiduité complétement couverte ou non + """ + + data = _setup_fake_db( + [("2024-01-01", "2024-06-30")], + 0, + 1, + ) + + # <- Vérification justification si justif créé avant assi -> + # Période : 8h -> 10h le 01/01/2024 + + etud_1: Identite = data["etuds"][0] + justif_1: Justificatif = Justificatif.create_justificatif( + etudiant=etud_1, + date_debut=scu.is_iso_formated("2024-01-01T08:00:00+01:00", True), + date_fin=scu.is_iso_formated("2024-01-01T10:00:00+01:00", True), + etat=scu.EtatJustificatif.VALIDE, + ) + + assi_1: Assiduite = Assiduite.create_assiduite( + etud=etud_1, + date_debut=scu.is_iso_formated("2024-01-01T08:00:00+01:00", True), + date_fin=scu.is_iso_formated("2024-01-01T10:00:00+01:00", True), + etat=scu.EtatAssiduite.ABSENT, + ) + + assert assi_1.est_just is True, "Justification non prise en compte (a1)" + assert len(scass.justifies(justif_1)) == 1, "Justification non prise en compte (a2)" + + # <- Vérification justification si justif créé après assi -> + # Période : 8h -> 10h le 02/01/2024 + + Assiduite.create_assiduite( + etud=etud_1, + date_debut=scu.is_iso_formated("2024-01-02T08:00:00+01:00", True), + date_fin=scu.is_iso_formated("2024-01-02T10:00:00+01:00", True), + etat=scu.EtatAssiduite.ABSENT, + ) + + justif_2: Justificatif = Justificatif.create_justificatif( + etudiant=etud_1, + date_debut=scu.is_iso_formated("2024-01-02T08:00:00+01:00", True), + date_fin=scu.is_iso_formated("2024-01-02T10:00:00+01:00", True), + etat=scu.EtatJustificatif.VALIDE, + ) + + compute_assiduites_justified(etud_1.etudid, [justif_2]) + + assert len(scass.justifies(justif_2)) == 1, "Justification non prise en compte (b1)" + + # Ne fonctionne pas ⬇️ + # assert assi_2.est_just is True, "Justification non prise en compte (b2)" + + # <- Vérification assiduité complétement couverte -> + # Période : 12h -> 19h le 03/01/2024 + + Assiduite.create_assiduite( + etud=etud_1, + date_debut=scu.is_iso_formated("2024-01-03T12:00:00+01:00", True), + date_fin=scu.is_iso_formated("2024-01-03T19:00:00+01:00", True), + etat=scu.EtatAssiduite.ABSENT, + ) + + # Justification complète + justif_3: Justificatif = Justificatif.create_justificatif( + etudiant=etud_1, + date_debut=scu.is_iso_formated("2024-01-03T00:00:00", True), + date_fin=scu.is_iso_formated("2024-01-03T23:59:59", True), + etat=scu.EtatJustificatif.VALIDE, + ) + + # Justification incomplète + justif_4: Justificatif = Justificatif.create_justificatif( + etudiant=etud_1, + date_debut=scu.is_iso_formated("2024-01-03T08:00:00", True), + date_fin=scu.is_iso_formated("2024-01-03T18:00:00", True), + etat=scu.EtatJustificatif.VALIDE, + ) + + # Mise à jour de l'assiduité + compute_assiduites_justified(etud_1.etudid, [justif_3, justif_4]) + + assert ( + len(scass.justifies(justif_3)) == 1 + ), "Justification complète non prise en compte (c1)" + assert ( + len(scass.justifies(justif_4)) == 0 + ), "Justification complète non prise en compte (c2)" From aaaf41250a820bcb3860c3536cbe2ea17a4df047 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 26 Feb 2024 21:28:20 +0100 Subject: [PATCH 31/32] =?UTF-8?q?Assiduit=C3=A9:=20formattage=20comptes=20?= =?UTF-8?q?marge=20gauche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/html_sidebar.py | 2 +- app/templates/sidebar.j2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index bc84fea5..f1c8f835 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -186,7 +186,7 @@ def sidebar(etudid: int = None): formsemestre.date_fin.strftime("%d/%m/%Y") }">({ sco_preferences.get_preference("assi_metrique", None)}) -
    { nbabsjust } J., { nbabsnj } N.J.""" +
    {nbabsjust:1.0f} J., {nbabsnj:1.0f} N.J.""" ) H.append("
      ") if current_user.has_permission(Permission.AbsChange): diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 6870b0e0..f07e6808 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -58,7 +58,7 @@ {% if sco.etud_cur_sem %} ({{sco.prefs["assi_metrique"]}}) -
      {{sco.nbabsjust}} J., {{sco.nbabsnj}} N.J.
      +
      {{'%1.0f'|format(sco.nbabsjust)}} J., {{'%1.0f'|format(sco.nbabsnj)}} N.J. {% endif %}
        {% if current_user.has_permission(sco.Permission.AbsChange) %} From 20d4b4e1b3cceb654a162d2a49ea39580f76141f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 26 Feb 2024 21:53:45 +0100 Subject: [PATCH 32/32] cosmetic: avertissements jury --- app/static/css/jury_but.css | 5 +++++ app/views/notes.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/app/static/css/jury_but.css b/app/static/css/jury_but.css index 2e3f0f71..c75c1a50 100644 --- a/app/static/css/jury_but.css +++ b/app/static/css/jury_but.css @@ -35,6 +35,11 @@ min-width: var(--sco-content-min-width); max-width: var(--sco-content-max-width); } +div.jury_but_warning { + background-color: yellow; + border-color: red; + padding-bottom: 4px; +} div.jury_but_box_title { margin-bottom: 10px; } diff --git a/app/views/notes.py b/app/views/notes.py index 96ae6555..717131db 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2488,7 +2488,9 @@ def formsemestre_validation_but( }">{etud.photo_html(title="fiche de " + etud.nomprenom)} +
        {warning} +