# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Emmanuel Viennet emmanuel.viennet@gmail.com # ############################################################################## """Evaluations""" import collections import datetime import operator from flask import url_for from flask import g from flask import request from flask_login import current_user 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, Module import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType 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_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_gen_cal from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences from app.scodoc import sco_users import sco_version # -------------------------------------------------------------------- # # MISC AUXILIARY FUNCTIONS # # -------------------------------------------------------------------- def notes_moyenne_median_mini_maxi(notes): "calcule moyenne et mediane d'une liste de valeurs (floats)" notes = [ x for x in notes if (x != None) and (x != scu.NOTES_NEUTRALISE) and (x != scu.NOTES_ATTENTE) ] n = len(notes) if not n: return None, None, None, None moy = sum(notes) / n median = list_median(notes) mini = min(notes) maxi = max(notes) return moy, median, mini, maxi def list_median(a_list: list): """Median of a list L""" n = len(a_list) if not n: raise ValueError("empty list") a_list.sort() if n % 2: return a_list[n // 2] return (a_list[n // 2] + a_list[n // 2 - 1]) / 2 # -------------------------------------------------------------------- def do_evaluation_etat( evaluation_id: int, partition_id: int = None, select_first_partition=False ) -> dict: """Donne infos sur l'état de l'évaluation. Ancienne fonction, lente: préférer ModuleImplResults pour tout calcul. XXX utilisée par de très nombreuses fonctions, dont - _eval_etat<do_evaluation_etat_in_sem (en cours de remplacement) - _eval_etat<do_evaluation_etat_in_mod<formsemestre_tableau_modules qui a seulement besoin de nb_evals_completes, nb_evals_en_cours, nb_evals_vides, attente renvoie: { nb_inscrits : inscrits au module nb_notes nb_abs, 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 * } evalcomplete est vrai si l'eval est complete (tous les inscrits à ce module ont des notes) evalattente est vrai s'il ne manque que des notes en attente """ nb_inscrits = len( sco_groups.do_evaluation_listeetuds_groups(evaluation_id, getallstudents=True) ) etuds_notes_dict = sco_evaluation_db.do_evaluation_get_all_notes( evaluation_id ) # { etudid : note } # ---- Liste des groupes complets et incomplets 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: partitions = sco_groups.get_partitions_list(formsemestre_id) partition = partitions[0] else: partition = sco_groups.get_default_partition(formsemestre_id) partition_id = partition["partition_id"] # Il faut considerer les inscriptions au semestre # (pour avoir l'etat et le groupe) et aussi les inscriptions # au module (pour gerer les modules optionnels correctement) insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( formsemestre_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] # Nombre de notes valides d'étudiants inscrits au module # (car il peut y avoir des notes d'étudiants désinscrits depuis l'évaluation) etudids_avec_note = insmodset.intersection(etuds_notes_dict) nb_notes = len(etudids_avec_note) # toutes saisies, y compris chez des non-inscrits: nb_notes_total = len(etuds_notes_dict) notes = [etuds_notes_dict[etudid]["value"] for etudid in etudids_avec_note] nb_abs = len([x for x in notes if x is None]) nb_neutre = len([x for x in notes if x == scu.NOTES_NEUTRALISE]) nb_att = len([x for x in notes if x == scu.NOTES_ATTENTE]) moy_num, median_num, mini_num, maxi_num = notes_moyenne_median_mini_maxi(notes) if moy_num is None: median, moy = "", "" mini, maxi = "", "" maxi_num = None else: median = scu.fmt_note(median_num) 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()] last_modif = max(t) else: last_modif = None # On considere une note "manquante" lorsqu'elle n'existe pas # ou qu'elle est en attente (ATT) group_nb_missing = collections.defaultdict(int) # group_id : nb notes manquantes group_notes = collections.defaultdict(list) # group_id: liste notes valides total_nb_missing = 0 total_nb_att = 0 group_by_id = {} # group_id : group etud_groups = sco_groups.get_etud_groups_in_partition(partition_id) for i in ins: group = etud_groups.get(i["etudid"], None) if group and not group["group_id"] in group_by_id: group_by_id[group["group_id"]] = group # is_missing = False if i["etudid"] in etuds_notes_dict: val = etuds_notes_dict[i["etudid"]]["value"] if val == scu.NOTES_ATTENTE: is_missing = True total_nb_att += 1 if group: group_notes[group["group_id"]].append(val) else: if group: _ = group_notes[group["group_id"]] # create group is_missing = True if is_missing: total_nb_missing += 1 if group: group_nb_missing[group["group_id"]] += 1 gr_incomplets = list(group_nb_missing.keys()) gr_incomplets.sort() 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 evaluation.publish_incomplete ) # mais ne met pas en attente les evals immediates sans aucune notes: if evaluation.publish_incomplete and nb_notes == 0: evalattente = False # Calcul moyenne dans chaque groupe de TD gr_moyennes = [] # group : {moy,median, nb_notes} for group_id, notes in group_notes.items(): gr_moy, gr_median, gr_mini, gr_maxi = notes_moyenne_median_mini_maxi(notes) gr_moyennes.append( { "group_id": group_id, "group_name": group_by_id[group_id]["group_name"], "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]), } ) gr_moyennes.sort(key=operator.itemgetter("group_name")) return { "evaluation_id": evaluation_id, "nb_inscrits": nb_inscrits, "nb_notes": nb_notes, # nb notes etudiants inscrits "nb_notes_total": nb_notes_total, # nb de notes (incluant desinscrits) "nb_abs": nb_abs, "nb_neutre": nb_neutre, "nb_att": nb_att, "moy": moy, # chaine formattée, sur 20 "median": median, "mini": mini, "maxi": maxi, "maxi_num": maxi_num, # note maximale, en nombre "last_modif": last_modif, "gr_incomplets": gr_incomplets, "gr_moyennes": gr_moyennes, "groups": group_by_id, "evalcomplete": complete, "evalattente": evalattente, "is_malus": is_malus, } 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["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) nb_evals_attente (= avec des notes en ATTente et pas bloquée) date derniere modif Une eval est "complete" ssi tous les etudiants *inscrits* ont une note. """ ( nb_evals_completes, nb_evals_en_cours, nb_evals_vides, nb_evals_blocked, nb_evals_attente, ) = (0, 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 elif not e["etat"]["blocked"]: nb_evals_en_cours += 1 if e["etat"]["nb_attente"] and not e["etat"]["blocked"]: nb_evals_attente += 1 last_modif = e["etat"]["last_modif"] if last_modif is not None: dates.append(e["etat"]["last_modif"]) # date de derniere modif d'une note dans un module last_modif = sorted(dates)[-1] if dates else "" return { "nb_evals": len(etat_evals), "nb_evals_attente": nb_evals_attente, "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, "last_modif": last_modif, } def do_evaluation_etat_in_sem(formsemestre: FormSemestre) -> dict: """-> { nb_eval_completes, nb_evals_en_cours, nb_evals_vides, date derniere modif, attente } """ # Note: utilisé par # - formsemestre_status_head # nb_evals_completes, nb_evals_en_cours, nb_evals_vides, last_modif # pour la ligne # Évaluations: 20 ok, 8 en cours, 5 vides (dernière note saisie le 11/01/2024 à 19h49) # attente # # - gen_formsemestre_recapcomplet_xml # - gen_formsemestre_recapcomplet_json # nb_evals_completes, nb_evals_en_cours, nb_evals_vides, last_modif # # "nb_evals_completes" # "nb_evals_en_cours" # "nb_evals_vides" # "last_modif" # "attente" nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) evaluations_etats = nt.get_evaluations_etats() # raccordement moche... etat = _summarize_evals_etats([{"etat": v} for v in evaluations_etats.values()]) # Ajoute information sur notes en attente etat["attente"] = len(nt.get_moduleimpls_attente()) > 0 return etat def do_evaluation_etat_in_mod(nt, modimpl: ModuleImpl): """état des évaluations dans ce module""" etat_evals = nt.get_mod_evaluation_etat_list(modimpl) etat = _summarize_evals_etats(etat_evals) # Il y a-t-il des notes en attente dans ce module ? etat["attente"] = nt.modimpls_results[modimpl.id].en_attente return etat class JourEval(sco_gen_cal.Jour): """ Représentation d'un jour dans un calendrier d'évaluations """ COLOR_INCOMPLETE = "#FF6060" COLOR_COMPLETE = "#A0FFA0" COLOR_FUTUR = "#70E0FF" def __init__( self, date: datetime.date, evaluations: list[Evaluation], parent: "CalendrierEval", ): super().__init__(date) self.evaluations: list[Evaluation] = evaluations self.evaluations.sort(key=lambda e: e.date_debut) self.parent: "CalendrierEval" = parent def get_html(self) -> str: htmls = [] for e in self.evaluations: url: str = url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=e.moduleimpl_id, ) title: str = ( e.moduleimpl.module.code or e.moduleimpl.module.abbrev or "éval." ) htmls.append( f"""<a href="{url}" style="{self._get_eval_style(e)}" title="{self._get_eval_title(e)}" class="stdlink" >{title}</a>""" ) return ", ".join(htmls) def _get_eval_style(self, e: Evaluation) -> str: color: str = "" # Etat (notes completes) de l'évaluation: modimpl_result = self.parent.nt.modimpls_results[e.moduleimpl.id] if modimpl_result.evaluations_etat[e.id].is_complete: color = JourEval.COLOR_COMPLETE else: color = JourEval.COLOR_INCOMPLETE if e.date_debut > datetime.datetime.now(scu.TIME_ZONE): color = JourEval.COLOR_FUTUR return f"background-color: {color};" def _get_eval_title(self, e: Evaluation) -> str: heure_debut_txt, heure_fin_txt = "", "" if e.date_debut != e.date_fin: heure_debut_txt = ( e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else "" ) heure_fin_txt = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else "" title = f"{e.description or e.moduleimpl.module.titre_str()}" if heure_debut_txt: title += f" de {heure_debut_txt} à {heure_fin_txt}" return title class CalendrierEval(sco_gen_cal.Calendrier): """ Représentation des évaluations d'un semestre dans un calendrier """ def __init__(self, year: int, evals: list[Evaluation], nt: NotesTableCompat): # On prend du 01/09 au 31/08 date_debut: datetime.datetime = datetime.datetime(year, 9, 1, 0, 0) date_fin: datetime.datetime = datetime.datetime(year + 1, 8, 31, 23, 59) super().__init__(date_debut, date_fin) # évalutions du semestre self.evals: dict[datetime.date, list[Evaluation]] = {} for e in evals: if e.date_debut is not None: day = e.date_debut.date() if day not in self.evals: self.evals[day] = [] self.evals[day].append(e) self.nt: NotesTableCompat = nt def instanciate_jour(self, date: datetime.date) -> JourEval: return JourEval(date, self.evals.get(date, []), parent=self) # View def formsemestre_evaluations_cal(formsemestre_id): """Page avec calendrier de toutes les evaluations de ce semestre""" formsemestre = FormSemestre.get_formsemestre(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) evaluations = formsemestre.get_evaluations() nb_evals = len(evaluations) year = formsemestre.annee_scolaire() cal = CalendrierEval(year, evaluations, nt) cal_html = cal.get_html() return f""" { html_sco_header.html_sem_header( "Evaluations du semestre", cssstyles=["css/calabs.css"], ) } <div class="cal_evaluations"> { cal_html } </div> <p>soit {nb_evals} évaluations planifiées; </p> <ul> <li>en <span style= "background-color: {JourEval.COLOR_INCOMPLETE}">rouge</span> les évaluations passées auxquelles il manque des notes </li> <li>en <span style= "background-color: {JourEval.COLOR_COMPLETE}">vert</span> les évaluations déjà notées </li> <li>en <span style= "background-color: {JourEval.COLOR_FUTUR}">bleu</span> les évaluations futures </li> </ul> <p><a href="{ url_for("notes.formsemestre_evaluations_delai_correction", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id ) }" class="stdlink">voir les délais de correction</a> </p> { html_sco_header.sco_footer() } """ def evaluation_date_first_completion(evaluation_id) -> datetime.datetime: """Première date à laquelle l'évaluation a été complète ou None si actuellement incomplète """ etat = do_evaluation_etat(evaluation_id) if not etat["evalcomplete"]: return None # XXX inachevé ou à revoir ? # Il faut considerer les inscriptions au semestre # (pour avoir l'etat et le groupe) et aussi les inscriptions # au module (pour gerer les modules optionnels correctement) # E = get_evaluation_dict({"id":evaluation_id})[0] # M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] # formsemestre_id = M["formsemestre_id"] # insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( formsemestre_id) # insmod = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=E["moduleimpl_id"]) # insmodset = set([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] notes = list( sco_evaluation_db.do_evaluation_get_all_notes( evaluation_id, filter_suppressed=False ).values() ) notes_log = list( sco_evaluation_db.do_evaluation_get_all_notes( evaluation_id, filter_suppressed=False, table="notes_notes_log" ).values() ) date_premiere_note = {} # etudid : date for note in notes + notes_log: etudid = note["etudid"] if etudid in date_premiere_note: date_premiere_note[etudid] = min(note["date"], date_premiere_note[etudid]) else: date_premiere_note[etudid] = note["date"] if not date_premiere_note: return None # complete mais aucun etudiant non démissionnaires # complet au moment du max (date la plus tardive) des premieres dates de saisie return max(date_premiere_note.values()) 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 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 != Evaluation.EVALUATION_NORMALE) or ( e.moduleimpl.module.module_type == ModuleType.MALUS ): continue date_first_complete = evaluation_date_first_completion(e.id) if date_first_complete and e.date_fin: delai_correction = (date_first_complete.date() - e.date_fin.date()).days else: delai_correction = None rows.append( { "date_first_complete": date_first_complete, "delai_correction": delai_correction, "jour": ( e.date_debut.strftime(scu.DATE_FMT) if e.date_debut else "sans date" ), "_jour_target": url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, evaluation_id=e.id, ), "module_code": e.moduleimpl.module.code, "_module_code_target": url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=e.moduleimpl.id, ), "module_titre": e.moduleimpl.module.abbrev or e.moduleimpl.module.titre or "", "responsable_id": e.moduleimpl.responsable_id, "responsable_nomplogin": sco_users.user_info( e.moduleimpl.responsable_id )["nomplogin"], } ) columns_ids = ( "module_code", "module_titre", "responsable_nomplogin", "jour", "date_first_complete", "delai_correction", "description", ) titles = { "module_code": "Code", "module_titre": "Module", "responsable_nomplogin": "Responsable", "jour": "Date", "date_first_complete": "Fin saisie", "delai_correction": "Délai", "description": "Description", } tab = GenTable( titles=titles, columns_ids=columns_ids, rows=rows, html_class="table_leftalign table_coldate", html_sortable=True, html_title="<h2>Correction des évaluations du semestre</h2>", caption="Correction des évaluations du semestre", preferences=sco_preferences.SemPreferences(formsemestre_id), base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id), origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""", filename=scu.make_filename("evaluations_delais_" + formsemestre.titre_annee()), table_id="formsemestre_evaluations_delai_correction", ) return tab.make_page(fmt=fmt) # -------------- VIEWS def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True) -> str: """HTML description of evaluation, for page headers edit_in_place: allow in-place editing when permitted (not implemented) """ evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id) modimpl = evaluation.moduleimpl responsable: User = db.session.get(User, modimpl.responsable_id) resp_nomprenom = responsable.get_prenomnom() resp_nomcomplet = responsable.get_nomcomplet() can_edit = modimpl.can_edit_notes(current_user, allow_ens=False) mod_descr = f"""<a class="stdlink" href="{url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id, )}">{modimpl.module.code or ""} {modimpl.module.abbrev or modimpl.module.titre or "?"}</a> <span class="resp">(resp. <a title="{resp_nomcomplet}">{resp_nomprenom}</a>)</span> <span class="evallink"><a class="stdlink" href="{url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id) }">voir toutes les notes du module</a></span> """ eval_titre = f' "{evaluation.description}"' if evaluation.description else "" if modimpl.module.module_type == ModuleType.MALUS: eval_titre += ' <span class="eval_malus">(points de malus)</span>' H = [ f"""<span class="eval_title">Évaluation{eval_titre}</span> <p><b>Module : {mod_descr}</b> </p>""" ] if modimpl.module.module_type == ModuleType.MALUS: # Indique l'UE ue = modimpl.module.ue H.append(f"<p><b>UE : {ue.acronyme}</b></p>") 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( """<span id="eval_note_min" class="sco-hidden">-20.</span> <span id="eval_note_max" class="sco-hidden">20.</span>""" ) else: # date et absences (pas pour evals bonus ni des modules de malus) if evaluation.date_debut is not None: H.append(f"<p>Réalisée le <b>{evaluation.descr_date()}</b> ") group_id = sco_groups.get_default_group(modimpl.formsemestre_id) H.append( f"""<span class="evallink"><a class="stdlink" href="{url_for( 'assiduites.etat_abs_date', scodoc_dept=g.scodoc_dept, group_ids=group_id, evaluation_id=evaluation.id, date_debut=evaluation.date_debut.isoformat(), date_fin=evaluation.date_fin.isoformat() if evaluation.date_fin else "", ) }">absences ce jour</a> </span> <span class="evallink"><a class="stdlink" href="{url_for( 'notes.evaluation_check_absences_html', scodoc_dept=g.scodoc_dept, evaluation_id = evaluation.id) }">vérifier notes vs absences</a> </span> """ ) else: H.append("<p><em>sans date</em> ") H.append( f"""</p><p>Coefficient dans le module: <b>{evaluation.coefficient or "0"}</b>, notes sur <span id="eval_note_max">{(evaluation.note_max or 0):g}</span> """ ) H.append('<span id="eval_note_min" class="sco-hidden">0.</span>') if can_edit: H.append( f""" <a class="stdlink" href="{url_for( "notes.evaluation_edit", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id) }">modifier l'évaluation</a> """ ) if link_saisie: H.append( f""" <a style="margin-left: 12px;" class="stdlink" href="{url_for( "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id) }">saisie des notes</a> """ ) H.append("</p>") return '<div class="eval_description">' + "\n".join(H) + "</div>"