ScoDoc/app/scodoc/sco_evaluations.py

698 lines
25 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 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 datetime
import operator
import time
import flask
from flask import url_for
from flask import g
from flask_login import current_user
from flask import request
from app import log
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
import sco_version
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_abs
from app.scodoc import sco_cache
from app.scodoc import sco_edit_module
from app.scodoc import sco_edit_ue
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_news
from app.scodoc import sco_permissions_check
from app.scodoc import sco_preferences
from app.scodoc import sco_users
# --------------------------------------------------------------------
#
# 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 = ListMedian(notes)
mini = min(notes)
maxi = max(notes)
return moy, median, mini, maxi
def ListMedian(L):
"""Median of a list L"""
n = len(L)
if not n:
raise ValueError("empty list")
L.sort()
if n % 2:
return L[n // 2]
else:
return (L[n // 2] + L[n // 2 - 1]) / 2
# --------------------------------------------------------------------
def do_evaluation_etat(evaluation_id, partition_id=None, select_first_partition=False):
"""donne infos sur l'état de l'évaluation
{ nb_inscrits, nb_notes, nb_abs, nb_neutre, nb_att,
moyenne, mediane, mini, maxi,
date_last_modif, 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.do_evaluation_list(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 = 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]
# 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 = "", ""
median_num, moy_num = None, None
mini, maxi = "", ""
mini_num, maxi_num = None, None
else:
median = scu.fmt_note(median_num)
moy = scu.fmt_note(moy_num)
mini = scu.fmt_note(mini_num)
maxi = scu.fmt_note(maxi_num)
# 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)
GrNbMissing = scu.DictDefault() # group_id : nb notes manquantes
GrNotes = scu.DictDefault(defaultvalue=[]) # group_id: liste notes valides
TotalNbMissing = 0
TotalNbAtt = 0
groups = {} # 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 groups:
groups[group["group_id"]] = group
#
isMissing = False
if i["etudid"] in etuds_notes_dict:
val = etuds_notes_dict[i["etudid"]]["value"]
if val == scu.NOTES_ATTENTE:
isMissing = True
TotalNbAtt += 1
if group:
GrNotes[group["group_id"]].append(val)
else:
if group:
_ = GrNotes[group["group_id"]] # create group
isMissing = True
if isMissing:
TotalNbMissing += 1
if group:
GrNbMissing[group["group_id"]] += 1
gr_incomplets = [x for x in GrNbMissing.keys()]
gr_incomplets.sort()
if (
(TotalNbMissing > 0)
and (E["evaluation_type"] != scu.EVALUATION_RATTRAPAGE)
and (E["evaluation_type"] != scu.EVALUATION_SESSION2)
and not is_malus
):
complete = False
else:
complete = True
if (
TotalNbMissing > 0
and ((TotalNbMissing == TotalNbAtt) or E["publish_incomplete"])
and not is_malus
):
evalattente = True
else:
evalattente = False
# 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 in GrNotes.keys():
notes = GrNotes[group_id]
gr_moy, gr_median, gr_mini, gr_maxi = notes_moyenne_median_mini_maxi(notes)
gr_moyennes.append(
{
"group_id": group_id,
"group_name": groups[group_id]["group_name"],
"gr_moy_num": gr_moy,
"gr_moy": scu.fmt_note(gr_moy),
"gr_median_num": gr_median,
"gr_median": scu.fmt_note(gr_median),
"gr_mini": scu.fmt_note(gr_mini),
"gr_maxi": scu.fmt_note(gr_maxi),
"gr_mini_num": gr_mini,
"gr_maxi_num": gr_maxi,
"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"))
# retourne mapping
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,
"moy_num": moy_num,
"median": median,
"mini": mini,
"mini_num": mini_num,
"maxi": maxi,
"maxi_num": maxi_num,
"median_num": median_num,
"last_modif": last_modif,
"gr_incomplets": gr_incomplets,
"gr_moyennes": gr_moyennes,
"groups": groups,
"evalcomplete": complete,
"evalattente": evalattente,
"is_malus": is_malus,
}
def do_evaluation_list_in_sem(formsemestre_id, with_etat=True):
"""Liste les evaluations de tous les modules de ce semestre.
Donne pour chaque eval son état (voir do_evaluation_etat)
{ evaluation_id,nb_inscrits, nb_notes, nb_abs, nb_neutre, moy, median, last_modif ... }
Exemple:
[ {
'coefficient': 1.0,
'description': 'QCM et cas pratiques',
'etat': {'evalattente': False,
'evalcomplete': True,
'evaluation_id': 'GEAEVAL82883',
'gr_incomplets': [],
'gr_moyennes': [{'gr_median': '12.00',
'gr_median_num' : 12.,
'gr_moy': '11.88',
'gr_moy_num' : 11.88,
'gr_nb_att': 0,
'gr_nb_notes': 166,
'group_id': 'GEAG266762',
'group_name': None}],
'groups': {'GEAG266762': {'etudid': 'GEAEID80603',
'group_id': 'GEAG266762',
'group_name': None,
'partition_id': 'GEAP266761'}
},
'last_modif': datetime.datetime(2015, 12, 3, 15, 15, 16),
'median': '12.00',
'moy': '11.84',
'nb_abs': 2,
'nb_att': 0,
'nb_inscrits': 166,
'nb_neutre': 0,
'nb_notes': 168,
'nb_notes_total': 169
},
'evaluation_id': 'GEAEVAL82883',
'evaluation_type': 0,
'heure_debut': datetime.time(8, 0),
'heure_fin': datetime.time(9, 30),
'jour': datetime.date(2015, 11, 3), // vide => 1/1/1
'moduleimpl_id': 'GEAMIP80490',
'note_max': 20.0,
'numero': 0,
'publish_incomplete': 0,
'visibulletin': 1} ]
"""
req = """SELECT E.id AS evaluation_id, E.*
FROM notes_evaluation E, notes_moduleimpl MI
WHERE MI.formsemestre_id = %(formsemestre_id)s
and MI.id = E.moduleimpl_id
ORDER BY MI.id, numero desc, jour desc, heure_debut DESC
"""
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(req, {"formsemestre_id": formsemestre_id})
res = cursor.dictfetchall()
# etat de chaque evaluation:
for r in res:
r["jour"] = r["jour"] or datetime.date(1900, 1, 1) # pour les comparaisons
if with_etat:
r["etat"] = do_evaluation_etat(r["evaluation_id"])
return res
def _eval_etat(evals):
"""evals: list of mappings (etats)
-> nb_eval_completes, nb_evals_en_cours,
nb_evals_vides, 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 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"])
if len(dates):
dates = scu.sort_dates(dates)
last_modif = dates[-1] # date de derniere modif d'une note dans un module
else:
last_modif = ""
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_id):
"""-> nb_eval_completes, nb_evals_en_cours, nb_evals_vides,
date derniere modif, attente"""
nt = sco_cache.NotesTableCache.get(
formsemestre_id
) # > liste evaluations et moduleimpl en attente
evals = nt.get_sem_evaluation_etat_list()
etat = _eval_etat(evals)
# Ajoute information sur notes en attente
etat["attente"] = len(nt.get_moduleimpls_attente()) > 0
return etat
def do_evaluation_etat_in_mod(nt, moduleimpl_id):
""""""
evals = nt.get_mod_evaluation_etat_list(moduleimpl_id)
etat = _eval_etat(evals)
etat["attente"] = moduleimpl_id in [
m["moduleimpl_id"] for m in nt.get_moduleimpls_attente()
] # > liste moduleimpl en attente
return etat
def formsemestre_evaluations_cal(formsemestre_id):
"""Page avec calendrier de toutes les evaluations de ce semestre"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > liste evaluations
evals = nt.get_sem_evaluation_etat_list()
nb_evals = len(evals)
color_incomplete = "#FF6060"
color_complete = "#A0FFA0"
color_futur = "#70E0FF"
today = time.strftime("%Y-%m-%d")
year = int(sem["annee_debut"])
if sem["mois_debut_ord"] < 8:
year -= 1 # calendrier septembre a septembre
events = {} # (day, halfday) : event
for e in evals:
etat = e["etat"]
if not e["jour"]:
continue
day = e["jour"].strftime("%Y-%m-%d")
mod = sco_moduleimpl.moduleimpl_withmodule_list(
moduleimpl_id=e["moduleimpl_id"]
)[0]
txt = mod["module"]["code"] or mod["module"]["abbrev"] or "eval"
if e["heure_debut"]:
debut = e["heure_debut"].strftime("%Hh%M")
else:
debut = "?"
if e["heure_fin"]:
fin = e["heure_fin"].strftime("%Hh%M")
else:
fin = "?"
description = "%s, de %s à %s" % (mod["module"]["titre"], debut, fin)
if etat["evalcomplete"]:
color = color_complete
else:
color = color_incomplete
if day > today:
color = color_futur
href = "moduleimpl_status?moduleimpl_id=%s" % e["moduleimpl_id"]
# if e['heure_debut'].hour < 12:
# halfday = True
# else:
# halfday = False
if not day in events:
# events[(day,halfday)] = [day, txt, color, href, halfday, description, mod]
events[day] = [day, txt, color, href, description, mod]
else:
e = events[day]
if e[-1]["moduleimpl_id"] != mod["moduleimpl_id"]:
# plusieurs evals de modules differents a la meme date
e[1] += ", " + txt
e[4] += ", " + description
if not etat["evalcomplete"]:
e[2] = color_incomplete
if day > today:
e[2] = color_futur
CalHTML = sco_abs.YearTable(
year, events=list(events.values()), halfday=False, pad_width=None
)
H = [
html_sco_header.html_sem_header(
"Evaluations du semestre",
sem,
cssstyles=["css/calabs.css"],
),
'<div class="cal_evaluations">',
CalHTML,
"</div>",
"<p>soit %s évaluations planifiées;" % nb_evals,
"""<ul><li>en <span style="background-color: %s">rouge</span> les évaluations passées auxquelles il manque des notes</li>
<li>en <span style="background-color: %s">vert</span> les évaluations déjà notées</li>
<li>en <span style="background-color: %s">bleu</span> les évaluations futures</li></ul></p>"""
% (color_incomplete, color_complete, color_futur),
"""<p><a href="formsemestre_evaluations_delai_correction?formsemestre_id=%s" class="stdlink">voir les délais de correction</a></p>
"""
% (formsemestre_id,),
html_sco_header.sco_footer(),
]
return "\n".join(H)
def evaluation_date_first_completion(evaluation_id):
"""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 = do_evaluation_list(args={"evaluation_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, format="html"):
"""Experimental: un tableau indiquant pour chaque évaluation
le nombre de jours avant la publication des notes.
N'indique pas les évaluations de ratrapage ni celles des modules de bonus/malus.
"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > liste evaluations
evals = nt.get_sem_evaluation_etat_list()
T = []
for e in evals:
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=e["moduleimpl_id"])[0]
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
if (e["evaluation_type"] != scu.EVALUATION_NORMALE) or (
Mod["module_type"] == ModuleType.MALUS
):
continue
e["date_first_complete"] = evaluation_date_first_completion(e["evaluation_id"])
if e["date_first_complete"]:
e["delai_correction"] = (e["date_first_complete"].date() - e["jour"]).days
else:
e["delai_correction"] = None
e["module_code"] = Mod["code"]
e["_module_code_target"] = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=M["moduleimpl_id"],
)
e["module_titre"] = Mod["titre"]
e["responsable_id"] = M["responsable_id"]
e["responsable_nomplogin"] = sco_users.user_info(M["responsable_id"])[
"nomplogin"
]
e["_jour_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e["evaluation_id"],
)
T.append(e)
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=T,
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="Généré par %s le " % sco_version.SCONAME
+ scu.timedate_human_repr()
+ "",
filename=scu.make_filename("evaluations_delais_" + sem["titreannee"]),
)
return tab.make_page(format=format)
# -------------- VIEWS
def evaluation_describe(evaluation_id="", edit_in_place=True):
"""HTML description of evaluation, for page headers
edit_in_place: allow in-place editing when permitted (not implemented)
"""
from app.scodoc import sco_saisie_notes
E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
moduleimpl_id = E["moduleimpl_id"]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
formsemestre_id = M["formsemestre_id"]
u = sco_users.user_info(M["responsable_id"])
resp = u["prenomnom"]
nomcomplet = u["nomcomplet"]
can_edit = sco_permissions_check.can_edit_notes(
current_user, moduleimpl_id, allow_ens=False
)
link = (
'<span class="evallink"><a class="stdlink" href="evaluation_listenotes?moduleimpl_id=%s">voir toutes les notes du module</a></span>'
% moduleimpl_id
)
mod_descr = (
'<a href="moduleimpl_status?moduleimpl_id=%s">%s %s</a> <span class="resp">(resp. <a title="%s">%s</a>)</span> %s'
% (moduleimpl_id, Mod["code"], Mod["titre"], nomcomplet, resp, link)
)
etit = E["description"] or ""
if etit:
etit = ' "' + etit + '"'
if Mod["module_type"] == ModuleType.MALUS:
etit += ' <span class="eval_malus">(points de malus)</span>'
H = [
'<span class="eval_title">Evaluation%s</span><p><b>Module : %s</b></p>'
% (etit, mod_descr)
]
if Mod["module_type"] == ModuleType.MALUS:
# Indique l'UE
ue = sco_edit_ue.ue_list(args={"ue_id": Mod["ue_id"]})[0]
H.append("<p><b>UE : %(acronyme)s</b></p>" % ue)
# 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 E["jour"]:
jour = E["jour"]
H.append("<p>Réalisée le <b>%s</b> " % (jour))
if E["heure_debut"] != E["heure_fin"]:
H.append("de %s à %s " % (E["heure_debut"], E["heure_fin"]))
group_id = sco_groups.get_default_group(formsemestre_id)
H.append(
f"""<span class="noprint"><a href="{url_for(
'absences.EtatAbsencesDate',
scodoc_dept=g.scodoc_dept,
group_ids=group_id,
date=E["jour"]
)
}">(absences ce jour)</a></span>"""
)
else:
jour = "<em>pas de date</em>"
H.append("<p>Réalisée le <b>%s</b> " % (jour))
H.append(
'</p><p>Coefficient dans le module: <b>%s</b>, notes sur <span id="eval_note_max">%g</span> '
% (E["coefficient"], E["note_max"])
)
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>
<a 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>"