ScoDocMM/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
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
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
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
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
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"]
# 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=E["moduleimpl_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, E["note_max"])
mini = scu.fmt_note(mini_num, E["note_max"])
maxi = scu.fmt_note(maxi_num, E["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()
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)
)
evalattente = (total_nb_missing > 0) and (
(total_nb_missing == total_nb_att) or E["publish_incomplete"]
)
# mais ne met pas en attente les evals immediates sans aucune notes:
if E["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, 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_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["etat"]["evalcomplete"], e["etat"]["nb_notes"], e["etat"]["last_modif"]
->
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 = 0, 0, 0
dates = []
for e in etat_evals:
if e["etat"]["evalcomplete"]:
nb_evals_completes += 1
elif e["etat"]["nb_notes"] == 0:
nb_evals_vides += 1
else:
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_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 pas les évaluations de rattrapage 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 (
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>")
# 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 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>"