ScoDoc-PE/app/scodoc/sco_evaluations.py

670 lines
25 KiB
Python

# -*- 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_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)
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 = 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
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_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
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)
color_incomplete = "#FF6060"
color_complete = "#A0FFA0"
color_futur = "#70E0FF"
year = formsemestre.annee_scolaire()
events = {} # (day, halfday) : event
for e in evaluations:
if e.date_debut is None:
continue # éval. sans date
txt = e.moduleimpl.module.code or e.moduleimpl.module.abbrev or "éval."
if e.date_debut == e.date_fin:
heure_debut_txt, heure_fin_txt = "?", "?"
else:
heure_debut_txt = e.date_debut.strftime("%Hh%M") if e.date_debut else "?"
heure_fin_txt = e.date_fin.strftime("%Hh%M") if e.date_fin else "?"
description = f"""{
e.moduleimpl.module.titre
}, de {heure_debut_txt} à {heure_fin_txt}"""
# Etat (notes completes) de l'évaluation:
modimpl_result = nt.modimpls_results[e.moduleimpl.id]
if modimpl_result.evaluations_etat[e.id].is_complete:
color = color_complete
else:
color = color_incomplete
if e.date_debut > datetime.datetime.now(scu.TIME_ZONE):
color = color_futur
href = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=e.moduleimpl_id,
)
day = e.date_debut.date().isoformat() # yyyy-mm-dd
event = events.get(day)
if not event:
events[day] = [day, txt, color, href, description, e.moduleimpl]
else:
if event[-1].id != e.moduleimpl.id:
# plusieurs evals de modules differents a la meme date
event[1] += ", " + txt
event[4] += ", " + description
if color == color_incomplete:
event[2] = color_incomplete
if color == color_futur:
event[2] = color_futur
cal_html = sco_cal.YearTable(
year, events=list(events.values()), halfday=False, pad_width=None
)
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: {color_incomplete}">rouge</span>
les évaluations passées auxquelles il manque des notes
</li>
<li>en <span style=
"background-color: {color_complete}">vert</span>
les évaluations déjà notées
</li>
<li>en <span style=
"background-color: {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("%d/%m/%Y") 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,
"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()),
)
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>"