# -*- 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 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"""{title}""" ) 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"], ) }
{ cal_html }

soit {nb_evals} évaluations planifiées;

voir les délais de correction

{ 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="

Correction des évaluations du semestre

", 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()), ) 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"""{modimpl.module.code or ""} {modimpl.module.abbrev or modimpl.module.titre or "?"} (resp. {resp_nomprenom}) voir toutes les notes du module """ eval_titre = f' "{evaluation.description}"' if evaluation.description else "" if modimpl.module.module_type == ModuleType.MALUS: eval_titre += ' (points de malus)' H = [ f"""Évaluation{eval_titre}

Module : {mod_descr}

""" ] if modimpl.module.module_type == ModuleType.MALUS: # 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 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) H.append( f"""absences ce jour vérifier notes vs absences """ ) else: H.append("

sans date ") H.append( f"""

Coefficient dans le module: {evaluation.coefficient or "0"}, notes sur {(evaluation.note_max or 0):g} """ ) H.append('0.') if can_edit: H.append( f""" modifier l'évaluation """ ) if link_saisie: H.append( f""" saisie des notes """ ) H.append("

") return '
' + "\n".join(H) + "
"