ScoDoc/app/scodoc/sco_formsemestre_status.py

1471 lines
54 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@viennet.net
#
##############################################################################
"""Tableau de bord semestre
"""
import datetime
from flask import current_app
from flask import g
from flask import request
from flask import flash, redirect, render_template, url_for
from flask_login import current_user
from app import log
from app.comp import res_sem
from app.comp.res_common import ResultatsSemestre
from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, Module, ModuleImpl, NotesNotes
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import (
ScoValueError,
ScoInvalidDateError,
ScoInvalidIdType,
)
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_abs
from app.scodoc import sco_archives
from app.scodoc import sco_bulletins
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_compute_moy
from app.scodoc import sco_edit_ue
from app.scodoc import sco_evaluations
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formations
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_permissions_check
from app.scodoc import sco_preferences
from app.scodoc import sco_users
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html
import sco_version
def _build_menu_stats(formsemestre_id):
"Définition du menu 'Statistiques'"
return [
{
"title": "Statistiques...",
"endpoint": "notes.formsemestre_report_counts",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "Suivi de cohortes",
"endpoint": "notes.formsemestre_suivi_cohorte",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": "Graphe des parcours",
"endpoint": "notes.formsemestre_graph_parcours",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": "Codes des parcours",
"endpoint": "notes.formsemestre_suivi_parcours",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": "Lycées d'origine",
"endpoint": "notes.formsemestre_etuds_lycees",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": 'Table "poursuite"',
"endpoint": "notes.formsemestre_poursuite_report",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": "Documents Avis Poursuite Etudes (xp)",
"endpoint": "notes.pe_view_sem_recap",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True, # current_app.config["TESTING"] or current_app.config["DEBUG"],
},
{
"title": 'Table "débouchés"',
"endpoint": "notes.report_debouche_date",
"enabled": True,
},
{
"title": "Estimation du coût de la formation",
"endpoint": "notes.formsemestre_estim_cost",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": "Indicateurs de suivi annuel BUT",
"endpoint": "notes.formsemestre_but_indicateurs",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
]
def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
"""HTML to render menubar"""
formsemestre_id = formsemestre.id
if formsemestre.etat:
change_lock_msg = "Verrouiller"
else:
change_lock_msg = "Déverrouiller"
formation = formsemestre.formation
# L'utilisateur est-il resp. du semestre ?
is_responsable = current_user.id in (u.id for u in formsemestre.responsables)
# A le droit de changer le semestre (déverrouiller, préférences bul., ...):
has_perm_change_sem = current_user.has_permission(Permission.ScoImplement) or (
formsemestre.resp_can_edit and is_responsable
)
# Peut modifier le semestre (si n'est pas verrouillé):
can_modify_sem = has_perm_change_sem and formsemestre.etat
menu_semestre = [
{
"title": "Tableau de bord",
"endpoint": "notes.formsemestre_status",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "Tableau de bord du semestre",
},
{
"title": f"Voir la formation {formation.acronyme} (v{formation.version})",
"endpoint": "notes.ue_table",
"args": {
"formation_id": formation.id,
"semestre_idx": formsemestre.semestre_id,
},
"enabled": True,
"helpmsg": "Tableau de bord du semestre",
},
{
"title": "Modifier le semestre",
"endpoint": "notes.formsemestre_editwithmodules",
"args": {
"formation_id": formation.id,
"formsemestre_id": formsemestre_id,
},
"enabled": can_modify_sem,
"helpmsg": "Modifie le contenu du semestre (modules)",
},
{
"title": "Préférences du semestre",
"endpoint": "scolar.formsemestre_edit_preferences",
"args": {"formsemestre_id": formsemestre_id},
"enabled": can_modify_sem,
"helpmsg": "Préférences du semestre",
},
{
"title": "Réglages bulletins",
"endpoint": "notes.formsemestre_edit_options",
"args": {"formsemestre_id": formsemestre_id},
"enabled": has_perm_change_sem,
"helpmsg": "Change les options",
},
{
"title": change_lock_msg,
"endpoint": "notes.formsemestre_change_lock",
"args": {"formsemestre_id": formsemestre_id},
"enabled": has_perm_change_sem,
"helpmsg": "",
},
{
"title": "Description du semestre",
"endpoint": "notes.formsemestre_description",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "",
},
{
"title": "Vérifier absences aux évaluations",
"endpoint": "notes.formsemestre_check_absences_html",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "",
},
{
"title": "Lister tous les enseignants",
"endpoint": "notes.formsemestre_enseignants_list",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "",
},
{
"title": "Cloner ce semestre",
"endpoint": "notes.formsemestre_clone",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoImplement),
"helpmsg": "",
},
{
"title": "Associer à une nouvelle version du programme",
"endpoint": "notes.formsemestre_associate_new_version",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoChangeFormation)
and formsemestre.etat,
"helpmsg": "",
},
{
"title": "Supprimer ce semestre",
"endpoint": "notes.formsemestre_delete",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoImplement),
"helpmsg": "",
},
]
# debug :
if current_app.config["ENV"] == "development":
menu_semestre.append(
{
"title": "Vérifier l'intégrité",
"endpoint": "notes.check_sem_integrity",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
}
)
menu_inscriptions = [
{
"title": "Voir les inscriptions aux modules",
"endpoint": "notes.moduleimpl_inscriptions_stats",
"args": {"formsemestre_id": formsemestre_id},
}
]
menu_inscriptions += [
{
"title": "Passage des étudiants depuis d'autres semestres",
"endpoint": "notes.formsemestre_inscr_passage",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoEtudInscrit)
and formsemestre.etat,
},
{
"title": "Synchroniser avec étape Apogée",
"endpoint": "notes.formsemestre_synchro_etuds",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoView)
and sco_preferences.get_preference("portal_url")
and formsemestre.etat,
},
{
"title": "Inscrire un étudiant",
"endpoint": "notes.formsemestre_inscription_with_modules_etud",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoEtudInscrit)
and formsemestre.etat,
},
{
"title": "Importer des étudiants dans ce semestre (table Excel)",
"endpoint": "scolar.form_students_import_excel",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoEtudInscrit)
and formsemestre.etat,
},
{
"title": "Import/export des données admission",
"endpoint": "scolar.form_students_import_infos_admissions",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoView),
},
{
"title": "Resynchroniser données identité",
"endpoint": "scolar.formsemestre_import_etud_admission",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ScoEtudChangeAdr)
and sco_preferences.get_preference("portal_url"),
},
{
"title": "Exporter table des étudiants",
"endpoint": "scolar.groups_view",
"args": {
"format": "allxls",
"group_ids": sco_groups.get_default_group(
formsemestre_id, fix_if_missing=True
),
},
},
{
"title": "Vérifier inscriptions multiples",
"endpoint": "notes.formsemestre_inscrits_ailleurs",
"args": {"formsemestre_id": formsemestre_id},
},
]
menu_groupes = [
{
"title": "Listes, photos, feuilles...",
"endpoint": "scolar.groups_view",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "Accès aux listes des groupes d'étudiants",
},
{
"title": "Créer/modifier les partitions...",
"endpoint": "scolar.edit_partition_form",
"args": {"formsemestre_id": formsemestre_id},
"enabled": sco_groups.sco_permissions_check.can_change_groups(
formsemestre_id
),
},
]
# 1 item / partition:
partitions = sco_groups.get_partitions_list(formsemestre_id, with_default=False)
submenu = []
enabled = (
sco_groups.sco_permissions_check.can_change_groups(formsemestre_id)
and partitions
)
for partition in partitions:
submenu.append(
{
"title": str(partition["partition_name"]),
"endpoint": "scolar.affect_groups",
"args": {"partition_id": partition["partition_id"]},
"enabled": enabled,
}
)
menu_groupes.append(
{"title": "Modifier les groupes", "submenu": submenu, "enabled": enabled}
)
menu_groupes.append(
{
"title": "Expérimental: éditeur de partitions",
"endpoint": "scolar.partition_editor",
"args": {"formsemestre_id": formsemestre_id},
"enabled": sco_groups.sco_permissions_check.can_change_groups(
formsemestre_id
),
"helpmsg": "Une spécialité de Mulhouse",
},
)
menu_notes = [
{
"title": "Tableau des moyennes (et liens bulletins)",
"endpoint": "notes.formsemestre_recapcomplet",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "État des évaluations",
"endpoint": "notes.evaluations_recap",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "Saisie des notes",
"endpoint": "notes.formsemestre_status",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "Tableau de bord du semestre",
},
{
"title": "Classeur PDF des bulletins",
"endpoint": "notes.formsemestre_bulletins_pdf_choice",
"args": {"formsemestre_id": formsemestre_id},
"helpmsg": "PDF regroupant tous les bulletins",
},
{
"title": "Envoyer à chaque étudiant son bulletin par e-mail",
"endpoint": "notes.formsemestre_bulletins_mailetuds_choice",
"args": {"formsemestre_id": formsemestre_id},
"enabled": sco_bulletins.can_send_bulletin_by_mail(formsemestre_id),
},
{
"title": "Calendrier des évaluations",
"endpoint": "notes.formsemestre_evaluations_cal",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "Lister toutes les saisies de notes",
"endpoint": "notes.formsemestre_list_saisies_notes",
"args": {"formsemestre_id": formsemestre_id},
},
]
menu_jury = [
{
"title": "Voir les décisions du jury",
"endpoint": "notes.formsemestre_pvjury",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "Générer feuille préparation Jury",
"endpoint": "notes.feuille_preparation_jury",
"args": {"formsemestre_id": formsemestre_id},
},
{
"title": "Saisie des décisions du jury",
"endpoint": "notes.formsemestre_saisie_jury",
"args": {
"formsemestre_id": formsemestre_id,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre_id),
},
{
"title": "Éditer les PV et archiver les résultats",
"endpoint": "notes.formsemestre_archive",
"args": {"formsemestre_id": formsemestre_id},
"enabled": sco_permissions_check.can_edit_pv(formsemestre_id),
},
{
"title": "Documents archivés",
"endpoint": "notes.formsemestre_list_archives",
"args": {"formsemestre_id": formsemestre_id},
"enabled": sco_archives.PVArchive.list_obj_archives(formsemestre_id),
},
]
menu_stats = _build_menu_stats(formsemestre_id)
H = [
'<ul id="sco_menu">',
htmlutils.make_menu("Semestre", menu_semestre),
htmlutils.make_menu("Inscriptions", menu_inscriptions),
htmlutils.make_menu("Groupes", menu_groupes),
htmlutils.make_menu("Notes", menu_notes),
htmlutils.make_menu("Jury", menu_jury),
htmlutils.make_menu("Statistiques", menu_stats),
formsemestre_custommenu_html(formsemestre_id),
"</ul>",
]
return "\n".join(H)
def retreive_formsemestre_from_request() -> int:
"""Cherche si on a de quoi déduire le semestre affiché à partir des
arguments de la requête:
formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id
Returns None si pas défini.
"""
if request.method == "GET":
args = request.args
elif request.method == "POST":
args = request.form
else:
return None
formsemestre_id = None
# Search formsemestre
group_ids = args.get("group_ids", [])
if "formsemestre_id" in args:
formsemestre_id = args["formsemestre_id"]
elif "moduleimpl_id" in args and args["moduleimpl_id"]:
modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=args["moduleimpl_id"])
if not modimpl:
return None # suppressed ?
modimpl = modimpl[0]
formsemestre_id = modimpl["formsemestre_id"]
elif "evaluation_id" in args:
E = sco_evaluation_db.do_evaluation_list(
{"evaluation_id": args["evaluation_id"]}
)
if not E:
return None # evaluation suppressed ?
E = E[0]
modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
formsemestre_id = modimpl["formsemestre_id"]
elif "group_id" in args:
group = sco_groups.get_group(args["group_id"])
formsemestre_id = group["formsemestre_id"]
elif group_ids:
if group_ids:
if isinstance(group_ids, str):
group_id = group_ids
else:
# prend le semestre du 1er groupe de la liste:
group_id = group_ids[0]
group = sco_groups.get_group(group_id)
formsemestre_id = group["formsemestre_id"]
elif "partition_id" in args:
partition = sco_groups.get_partition(args["partition_id"])
formsemestre_id = partition["formsemestre_id"]
if not formsemestre_id:
return None # no current formsemestre
return int(formsemestre_id)
# Element HTML decrivant un semestre (barre de menu et infos)
def formsemestre_page_title(formsemestre_id=None):
"""Element HTML decrivant un semestre (barre de menu et infos)
Cherche dans la requete si un semestre est défini (formsemestre_id ou moduleimpl ou evaluation ou group)
"""
formsemestre_id = (
formsemestre_id
if formsemestre_id is not None
else retreive_formsemestre_from_request()
)
#
if not formsemestre_id:
return ""
try:
formsemestre_id = int(formsemestre_id)
except ValueError:
log(f"formsemestre_id: invalid type {formsemestre_id:r}")
return ""
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
h = render_template(
"formsemestre_page_title.html",
formsemestre=formsemestre,
scu=scu,
sem_menu_bar=formsemestre_status_menubar(formsemestre),
)
return h
def fill_formsemestre(sem):
"""Add some useful fields to help display formsemestres"""
sem["notes_url"] = scu.NotesURL()
formsemestre_id = sem["formsemestre_id"]
if not sem["etat"]:
sem[
"locklink"
] = f"""<a href="{url_for('notes.formsemestre_change_lock',
scodoc_dept=g.scodoc_dept,formsemestre_id=formsemestre_id )
}">{scu.icontag("lock_img", border="0", title="Semestre verrouillé")}</a>"""
else:
sem["locklink"] = ""
if sco_preferences.get_preference("bul_display_publication", formsemestre_id):
if sem["bul_hide_xml"]:
eyeicon = scu.icontag("hide_img", border="0", title="Bulletins NON publiés")
else:
eyeicon = scu.icontag("eye_img", border="0", title="Bulletins publiés")
sem[
"eyelink"
] = f"""<a href="{
url_for('notes.formsemestre_change_publication_bul',
scodoc_dept=g.scodoc_dept,formsemestre_id=formsemestre_id)
}">{eyeicon}</a>"""
else:
sem["eyelink"] = ""
F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0]
sem["formation"] = F
parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"])
if sem["semestre_id"] != -1:
sem["num_sem"] = f""", {parcours.SESSION_NAME} {sem["semestre_id"]}"""
else:
sem["num_sem"] = "" # formation sans semestres
if sem["modalite"]:
sem["modalitestr"] = f""" en {sem["modalite"]}"""
else:
sem["modalitestr"] = ""
sem["etape_apo_str"] = "Code étape Apogée: " + (
sco_formsemestre.formsemestre_etape_apo_str(sem) or "Pas de code étape"
)
inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
args={"formsemestre_id": formsemestre_id}
)
sem["nbinscrits"] = len(inscrits)
uresps = [
sco_users.user_info(responsable_id) for responsable_id in sem["responsables"]
]
sem["resp"] = ", ".join([u["prenomnom"] for u in uresps])
sem["nomcomplet"] = ", ".join([u["nomcomplet"] for u in uresps])
# Description du semestre sous forme de table exportable
def formsemestre_description_table(
formsemestre_id: int, with_evals=False, with_parcours=False
):
"""Description du semestre sous forme de table exportable
Liste des modules et de leurs coefficients
"""
formsemestre: FormSemestre = FormSemestre.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id)
F = sco_formations.formation_list(args={"formation_id": formsemestre.formation_id})[
0
]
parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"])
# --- Colonnes à proposer:
columns_ids = ["UE", "Code", "Module"]
if with_parcours:
columns_ids += ["parcours"]
if not formsemestre.formation.is_apc():
columns_ids += ["Coef."]
ues = [] # liste des UE, seulement en APC pour les coefs
else:
ues = formsemestre.query_ues().all()
columns_ids += [f"ue_{ue.id}" for ue in ues]
if sco_preferences.get_preference("bul_show_ects", formsemestre_id):
columns_ids += ["ects"]
columns_ids += ["Inscrits", "Responsable", "Enseignants"]
if with_evals:
columns_ids += [
"jour",
"description",
"coefficient",
"evalcomplete_str",
"publish_incomplete_str",
]
titles = {title: title for title in columns_ids}
titles.update({f"ue_{ue.id}": ue.acronyme for ue in ues})
titles["ects"] = "ECTS"
titles["jour"] = "Evaluation"
titles["description"] = ""
titles["coefficient"] = "Coef. éval."
titles["evalcomplete_str"] = "Complète"
titles["parcours"] = "Parcours"
titles["publish_incomplete_str"] = "Toujours Utilisée"
title = f"{parcours.SESSION_NAME.capitalize()} {formsemestre.titre_mois()}"
R = []
sum_coef = 0
sum_ects = 0
last_ue_id = None
for modimpl in formsemestre.modimpls_sorted:
# Ligne UE avec ECTS:
ue = modimpl.module.ue
if ue.id != last_ue_id:
last_ue_id = ue.id
if ue.ects is None:
ects_str = "-"
else:
sum_ects += ue.ects
ects_str = ue.ects
ue_info = {
"UE": ue.acronyme,
"ects": ects_str,
"Module": ue.titre,
"_css_row_class": "table_row_ue",
"_UE_td_attrs": f'style="background-color: {ue.color} !important;"'
if ue.color
else "",
}
if use_ue_coefs:
ue_info["Coef."] = ue.coefficient
ue_info["Coef._class"] = "ue_coef"
R.append(ue_info)
mod_inscrits = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=modimpl.id
)
enseignants = ", ".join(ens.get_prenomnom() for ens in modimpl.enseignants)
l = {
"UE": modimpl.module.ue.acronyme,
"_UE_td_attrs": ue_info["_UE_td_attrs"],
"Code": modimpl.module.code or "",
"Module": modimpl.module.abbrev or modimpl.module.titre,
"_Module_class": "scotext",
"Inscrits": len(mod_inscrits),
"Responsable": sco_users.user_info(modimpl.responsable_id)["nomprenom"],
"_Responsable_class": "scotext",
"Enseignants": enseignants,
"_Enseignants_class": "scotext",
"Coef.": modimpl.module.coefficient,
# 'ECTS' : M['module']['ects'],
# Lien sur titre -> module
"_Module_target": url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
),
"_Code_target": url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
),
}
if modimpl.module.coefficient is not None:
sum_coef += modimpl.module.coefficient
coef_dict = modimpl.module.get_ue_coef_dict()
for ue in ues:
l[f"ue_{ue.id}"] = coef_dict.get(ue.id, 0.0) or ""
if with_parcours:
l["parcours"] = ", ".join(
sorted([pa.code for pa in modimpl.module.parcours])
)
R.append(l)
if with_evals:
# Ajoute lignes pour evaluations
evals = nt.get_mod_evaluation_etat_list(modimpl.id)
evals.reverse() # ordre chronologique
# Ajoute etat:
for e in evals:
e["UE"] = l["UE"]
e["_UE_td_attrs"] = l["_UE_td_attrs"]
e["Code"] = l["Code"]
e["_css_row_class"] = "evaluation"
e["Module"] = "éval."
# Cosmetic: conversions pour affichage
if e["etat"]["evalcomplete"]:
e["evalcomplete_str"] = "Oui"
e["_evalcomplete_str_td_attrs"] = 'style="color: green;"'
else:
e["evalcomplete_str"] = "Non"
e["_evalcomplete_str_td_attrs"] = 'style="color: red;"'
if e["publish_incomplete"]:
e["publish_incomplete_str"] = "Oui"
e["_publish_incomplete_str_td_attrs"] = 'style="color: green;"'
else:
e["publish_incomplete_str"] = "Non"
e["_publish_incomplete_str_td_attrs"] = 'style="color: red;"'
# Poids vers UEs (en APC)
evaluation: Evaluation = Evaluation.query.get(e["evaluation_id"])
for ue_id, poids in evaluation.get_ue_poids_dict().items():
e[f"ue_{ue_id}"] = poids or ""
e[f"_ue_{ue_id}_class"] = "poids"
e[f"_ue_{ue_id}_help"] = "poids vers l'UE"
R += evals
sums = {"_css_row_class": "moyenne sortbottom", "ects": sum_ects, "Coef.": sum_coef}
R.append(sums)
return GenTable(
columns_ids=columns_ids,
rows=R,
titles=titles,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=title,
html_caption=title,
html_class="table_leftalign formsemestre_description",
base_url="%s?formsemestre_id=%s&with_evals=%s"
% (request.base_url, formsemestre_id, with_evals),
page_title=title,
html_title=html_sco_header.html_sem_header(
"Description du semestre", with_page_header=False
),
pdf_title=title,
preferences=sco_preferences.SemPreferences(formsemestre_id),
)
def formsemestre_description(
formsemestre_id, format="html", with_evals=False, with_parcours=False
):
"""Description du semestre sous forme de table exportable
Liste des modules et de leurs coefficients
"""
with_evals = int(with_evals)
tab = formsemestre_description_table(
formsemestre_id, with_evals=with_evals, with_parcours=with_parcours
)
tab.html_before_table = f"""
<form name="f" method="get" action="{request.base_url}">
<input type="hidden" name="formsemestre_id" value="{formsemestre_id}"></input>
<input type="checkbox" name="with_evals" value="1" onchange="document.f.submit()"
{ "checked" if with_evals else "" }
>indiquer les évaluations</input>
<input type="checkbox" name="with_parcours" value="1" onchange="document.f.submit()"
{ "checked" if with_parcours else "" }
>indiquer les parcours BUT</input>
"""
return tab.make_page(format=format)
# genere liste html pour accès aux groupes de ce semestre
def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
# construit l'URL "destination"
# (a laquelle on revient apres saisie absences)
destination = url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
#
H = []
# pas de menu absences si pas autorise:
if with_absences and not current_user.has_permission(Permission.ScoAbsChange):
with_absences = False
#
H.append(
f"""<h3>Listes de {formsemestre.titre}
<span class="infostitresem">({formsemestre.mois_debut()} - {formsemestre.mois_fin()})</span></h3>"""
)
weekday = datetime.datetime.today().weekday()
try:
if with_absences:
first_monday = sco_abs.ddmmyyyy(
formsemestre.date_debut.strftime("%d/%m/%Y")
).prev_monday()
form_abs_tmpl = f"""
<td>
<a href="%(url_etat)s">absences</a>
</td>
<td>
<form action="{url_for(
"absences.SignaleAbsenceGrSemestre", scodoc_dept=g.scodoc_dept
)}" method="get">
<input type="hidden" name="datefin" value="{
formsemestre.date_fin.strftime("%d/%m/%Y")}"/>
<input type="hidden" name="group_ids" value="%(group_id)s"/>
<input type="hidden" name="destination" value="{destination}"/>
<input type="submit" value="Saisir abs des" />
<select name="datedebut" class="noprint">
"""
date = first_monday
for idx, jour in enumerate(sco_abs.day_names()):
form_abs_tmpl += f"""<option value="{date}" {
'selected' if idx == weekday else ''
}>{jour}s</option>"""
date = date.next_day()
form_abs_tmpl += f"""
</select>
<a href="{
url_for("absences.choix_semaine", scodoc_dept=g.scodoc_dept)
}?group_id=%(group_id)s">saisie par semaine</a>
</form></td>
"""
else:
form_abs_tmpl = ""
except ScoInvalidDateError: # dates incorrectes dans semestres ?
form_abs_tmpl = ""
#
H.append('<div id="grouplists">')
# Genere liste pour chaque partition (categorie de groupes)
for partition in sco_groups.get_partitions_list(formsemestre.id):
if not partition["partition_name"]:
H.append("<h4>Tous les étudiants</h4>")
else:
H.append("<h4>Groupes de %(partition_name)s</h4>" % partition)
groups = sco_groups.get_partition_groups(partition)
if groups:
H.append("<table>")
for group in groups:
n_members = len(sco_groups.get_group_members(group["group_id"]))
group["url_etat"] = url_for(
"absences.EtatAbsencesGr",
group_ids=group["group_id"],
debut=formsemestre.date_debut.strftime("%d/%m/%Y"),
fin=formsemestre.date_fin.strftime("%d/%m/%Y"),
scodoc_dept=g.scodoc_dept,
)
if group["group_name"]:
group["label"] = "groupe %(group_name)s" % group
else:
group["label"] = "liste"
H.append(
f"""
<tr class="listegroupelink">
<td>
<a href="{
url_for("scolar.groups_view",
group_ids=group["group_id"],
scodoc_dept=g.scodoc_dept,
)
}">{group["label"]}</a>
</td><td>
</td>
<td>({n_members} étudiants)</td>
"""
)
if with_absences:
H.append(form_abs_tmpl % group)
H.append("</tr>")
H.append("</table>")
else:
H.append('<p class="help indent">Aucun groupe dans cette partition')
if sco_groups.sco_permissions_check.can_change_groups(formsemestre.id):
H.append(
f""" (<a href="{url_for("scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=partition["partition_id"])
}" class="stdlink">créer</a>)"""
)
H.append("</p>")
if sco_groups.sco_permissions_check.can_change_groups(formsemestre.id):
H.append(
f"""<h4><a
href="{
url_for("scolar.edit_partition_form",
formsemestre_id=formsemestre.id,
scodoc_dept=g.scodoc_dept,
)
}">Ajouter une partition</a></h4>"""
)
H.append("</div>")
return "\n".join(H)
def html_expr_diagnostic(diagnostics):
"""Affiche messages d'erreur des formules utilisateurs"""
H = []
H.append('<div class="ue_warning">Erreur dans des formules utilisateurs:<ul>')
last_id, last_msg = None, None
for diag in diagnostics:
if "moduleimpl_id" in diag:
mod = sco_moduleimpl.moduleimpl_withmodule_list(
moduleimpl_id=diag["moduleimpl_id"]
)[0]
H.append(
'<li>module <a href="moduleimpl_status?moduleimpl_id=%s">%s</a>: %s</li>'
% (
diag["moduleimpl_id"],
mod["module"]["abbrev"] or mod["module"]["code"] or "?",
diag["msg"],
)
)
else:
if diag["ue_id"] != last_id or diag["msg"] != last_msg:
ue = sco_edit_ue.ue_list({"ue_id": diag["ue_id"]})[0]
H.append(
'<li>UE "%s": %s</li>'
% (ue["acronyme"] or ue["titre"] or "?", diag["msg"])
)
last_id, last_msg = diag["ue_id"], diag["msg"]
H.append("</ul></div>")
return "".join(H)
def formsemestre_status_head(formsemestre_id=None, page_title=None):
"""En-tête HTML des pages "semestre" """
sem = FormSemestre.query.get(formsemestre_id)
if not sem:
raise ScoValueError("Semestre inexistant (il a peut être été supprimé ?)")
formation = sem.formation
parcours = formation.get_parcours()
page_title = page_title or "Modules de "
H = [
html_sco_header.html_sem_header(
page_title, with_page_header=False, with_h2=False
),
f"""<table>
<tr><td class="fichetitre2">Formation: </td><td>
<a href="{url_for('notes.ue_table',
scodoc_dept=g.scodoc_dept, formation_id=sem.formation.id)}"
class="discretelink" title="Formation {
formation.acronyme}, v{formation.version}">{formation.titre}</a>
""",
]
if sem.semestre_id >= 0:
H.append(", %s %s" % (parcours.SESSION_NAME, sem.semestre_id))
if sem.modalite:
H.append(f"&nbsp;en {sem.modalite}")
if sem.etapes:
H.append(
f"""&nbsp;&nbsp;&nbsp;(étape <b><tt>{
sem.etapes_apo_str() or "-"
}</tt></b>)"""
)
H.append("</td></tr>")
if sem.parcours:
H.append(
f"""
<tr><td class="fichetitre2">Parcours: </td>
<td style="color: blue;">{', '.join(parcours.code for parcours in sem.parcours)}</td>
</tr>
"""
)
evals = sco_evaluations.do_evaluation_etat_in_sem(formsemestre_id)
H.append(
'<tr><td class="fichetitre2">Évaluations: </td><td> %(nb_evals_completes)s ok, %(nb_evals_en_cours)s en cours, %(nb_evals_vides)s vides'
% evals
)
if evals["last_modif"]:
H.append(
" <em>(dernière note saisie le %s)</em>"
% evals["last_modif"].strftime("%d/%m/%Y à %Hh%M")
)
H.append("</td></tr>")
if evals["attente"]:
H.append(
"""<tr><td class="fichetitre2"></td><td class="redboldtext">
Il y a des notes en attente ! Le classement des étudiants n'a qu'une valeur indicative.
</td></tr>"""
)
H.append("</table>")
sem_warning = ""
if sem.bul_hide_xml:
sem_warning += "Bulletins non publiés sur le portail. "
if sem.block_moyennes:
sem_warning += "Calcul des moyennes bloqué !"
if sem_warning:
H.append('<p class="fontorange"><em>' + sem_warning + "</em></p>")
if sem.semestre_id >= 0 and not sem.est_sur_une_annee():
H.append(
'<p class="fontorange"><em>Attention: ce semestre couvre plusieurs années scolaires !</em></p>'
)
return "".join(H)
def formsemestre_status(formsemestre_id=None):
"""Tableau de bord semestre HTML"""
# porté du DTML
if formsemestre_id is not None and not isinstance(formsemestre_id, int):
raise ScoInvalidIdType(
"formsemestre_bulletinetud: formsemestre_id must be an integer !"
)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
modimpls = sco_moduleimpl.moduleimpl_withmodule_list(
formsemestre_id=formsemestre_id
)
nt = res_sem.load_formsemestre_results(formsemestre)
# Construit la liste de tous les enseignants de ce semestre:
mails_enseignants = set(u.email for u in formsemestre.responsables)
for modimpl in modimpls:
mails_enseignants.add(sco_users.user_info(modimpl["responsable_id"])["email"])
mails_enseignants |= set(
[sco_users.user_info(m["ens_id"])["email"] for m in modimpl["ens"]]
)
can_edit = formsemestre.can_be_edited_by(current_user)
use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id)
H = [
html_sco_header.sco_header(
page_title=f"{formsemestre.sem_modalite()} {formsemestre.titre_annee()}"
),
'<div class="formsemestre_status">',
formsemestre_status_head(
formsemestre_id=formsemestre_id, page_title="Tableau de bord"
),
formsemestre_warning_etuds_sans_note(formsemestre, nt),
"""<p><b style="font-size: 130%">Tableau de bord: </b>
<span class="help">cliquez sur un module pour saisir des notes</span>
</p>""",
]
if nt.expr_diagnostics:
H.append(html_expr_diagnostic(nt.expr_diagnostics))
if nt.parcours.APC_SAE:
# BUT: tableau ressources puis SAE
ressources = [
m for m in modimpls if m["module"]["module_type"] == ModuleType.RESSOURCE
]
saes = [m for m in modimpls if m["module"]["module_type"] == ModuleType.SAE]
autres = [
m
for m in modimpls
if m["module"]["module_type"] not in (ModuleType.RESSOURCE, ModuleType.SAE)
]
H += [
f"""
<div class="tableau_modules">
{_TABLEAU_MODULES_HEAD}
<tr class="formsemestre_status_cat">
<td colspan="5">
<span class="status_module_cat">Ressources</span>
</td>
</tr>
{formsemestre_tableau_modules(
ressources, nt, formsemestre_id, can_edit=can_edit, show_ues=False
)}
<tr class="formsemestre_status_cat">
<td colspan="5">
<span class="status_module_cat">SAÉs</span>
</td>
</tr>""",
formsemestre_tableau_modules(
saes, nt, formsemestre_id, can_edit=can_edit, show_ues=False
),
]
if autres:
H += [
"""<tr class="formsemestre_status_cat">
<td colspan="5">
<span class="status_module_cat">Autres modules</span>
</td></tr>""",
formsemestre_tableau_modules(
autres, nt, formsemestre_id, can_edit=can_edit, show_ues=False
),
]
H += [_TABLEAU_MODULES_FOOT, "</div>"]
else:
# formations classiques: groupe par UE
H += [
"<p>",
_TABLEAU_MODULES_HEAD,
formsemestre_tableau_modules(
modimpls,
nt,
formsemestre_id,
can_edit=can_edit,
use_ue_coefs=use_ue_coefs,
),
_TABLEAU_MODULES_FOOT,
"</p>",
]
if use_ue_coefs and not formsemestre.formation.is_apc():
H.append(
"""
<p class="infop">utilise les coefficients d'UE pour calculer la moyenne générale.</p>
"""
)
# --- LISTE DES ETUDIANTS
H += [
'<div id="groupes">',
_make_listes_sem(formsemestre),
"</div>",
]
# --- Lien mail enseignants:
adrlist = list(mails_enseignants - {None, ""})
if adrlist:
H.append(
'<p><a class="stdlink" href="mailto:?cc=%s">Courrier aux %d enseignants du semestre</a></p>'
% (",".join(adrlist), len(adrlist))
)
return "".join(H) + html_sco_header.sco_footer()
_TABLEAU_MODULES_HEAD = """
<table class="formsemestre_status">
<tr>
<th class="formsemestre_status">Code</th>
<th class="formsemestre_status">Module</th>
<th class="formsemestre_status">Inscrits</th>
<th class="resp">Responsable</th>
<th class="coef">Coefs.</th>
<th class="evals">Évaluations</th>
</tr>
"""
_TABLEAU_MODULES_FOOT = """</table>"""
def formsemestre_tableau_modules(
modimpls: list[dict],
nt,
formsemestre_id: int,
can_edit=True,
show_ues=True,
use_ue_coefs=False,
) -> str:
"Lignes table HTML avec modules du semestre"
H = []
prev_ue_id = None
for modimpl in modimpls:
mod: Module = Module.query.get(modimpl["module_id"])
mod_descr = "Module " + (mod.titre or "")
if mod.is_apc():
coef_descr = ", ".join(
[f"{ue.acronyme}: {co}" for ue, co in mod.ue_coefs_list()]
)
if coef_descr:
mod_descr += " Coefs: " + coef_descr
else:
mod_descr += " (pas de coefficients) "
else:
mod_descr += ", coef. " + str(mod.coefficient)
mod_ens = sco_users.user_info(modimpl["responsable_id"])["nomcomplet"]
if modimpl["ens"]:
mod_ens += " (resp.), " + ", ".join(
[sco_users.user_info(e["ens_id"])["nomcomplet"] for e in modimpl["ens"]]
)
mod_inscrits = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=modimpl["moduleimpl_id"]
)
ue = modimpl["ue"]
if show_ues and (prev_ue_id != ue["ue_id"]):
prev_ue_id = ue["ue_id"]
titre = ue["titre"]
if use_ue_coefs:
titre += " <b>(coef. %s)</b>" % (ue["coefficient"] or 0.0)
H.append(
f"""<tr class="formsemestre_status_ue"><td colspan="4">
<span class="status_ue_acro">{ue["acronyme"]}</span>
<span class="status_ue_title">{titre}</span>
</td><td colspan="2">"""
)
expr = sco_compute_moy.get_ue_expression(
formsemestre_id, ue["ue_id"], html_quote=True
)
if expr:
H.append(
f""" <span class="formula" title="mode de calcul de la moyenne d'UE">{expr}</span>
<span class="warning">formule inutilisée en 9.2: <a href="{
url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, ue_id=ue["ue_id"] )
}
">supprimer</a></span>"""
)
H.append("</td></tr>")
if modimpl["ue"]["type"] != sco_codes_parcours.UE_STANDARD:
fontorange = " fontorange" # style css additionnel
else:
fontorange = ""
etat = sco_evaluations.do_evaluation_etat_in_mod(nt, modimpl["moduleimpl_id"])
# if nt.parcours.APC_SAE:
# tbd style si module non conforme
if (
etat["nb_evals_completes"] > 0
and etat["nb_evals_en_cours"] == 0
and etat["nb_evals_vides"] == 0
):
H.append(f'<tr class="formsemestre_status_green{fontorange}">')
else:
H.append(f'<tr class="formsemestre_status{fontorange}">')
H.append(
f"""<td class="formsemestre_status_code""><a
href="{url_for('notes.moduleimpl_status',
scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl['moduleimpl_id'])}"
title="{mod_descr}" class="stdlink">{mod.code}</a></td>"""
)
H.append(
f"""<td class="scotext"><a href="{
url_for( "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl["moduleimpl_id"]
) }" title="{mod_descr}" class="formsemestre_status_link">{mod.abbrev or mod.titre or ""}</a>
</td>
<td class="formsemestre_status_inscrits">{len(mod_inscrits)}</td>
<td class="resp scotext">
<a class="discretelink" href="{
url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl["moduleimpl_id"]
) }" title="{mod_ens}">{ sco_users.user_info(modimpl["responsable_id"])["prenomnom"] }</a>
</td>
<td>
"""
)
if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE):
coefs = mod.ue_coefs_list()
H.append(f'<a class="invisible_link" href="#" title="{mod_descr}">')
for coef in coefs:
if coef[1] > 0:
H.append(
f"""<span class="mod_coef_indicator"
title="{coef[0].acronyme}"
style="background: {
coef[0].color if coef[0].color is not None else 'blue'
}"></span>"""
)
else:
H.append("""<span class="mod_coef_indicator_zero"></span>""")
H.append("</a>")
H.append("</td>")
if mod.module_type in (
None, # ne devrait pas être nécessaire car la migration a remplacé les NULLs
ModuleType.STANDARD,
ModuleType.RESSOURCE,
ModuleType.SAE,
):
H.append('<td class="evals">')
nb_evals = (
etat["nb_evals_completes"]
+ etat["nb_evals_en_cours"]
+ etat["nb_evals_vides"]
)
if nb_evals != 0:
H.append(
'<a href="moduleimpl_status?moduleimpl_id=%s" class="formsemestre_status_link">%s prévues, %s ok</a>'
% (modimpl["moduleimpl_id"], nb_evals, etat["nb_evals_completes"])
)
if etat["nb_evals_en_cours"] > 0:
H.append(
', <span><a class="redlink" href="moduleimpl_status?moduleimpl_id=%s" title="Il manque des notes">%s en cours</a></span>'
% (modimpl["moduleimpl_id"], etat["nb_evals_en_cours"])
)
if etat["attente"]:
H.append(
' <span><a class="redlink" href="moduleimpl_status?moduleimpl_id=%s" title="Il y a des notes en attente">[en attente]</a></span>'
% modimpl["moduleimpl_id"]
)
elif mod.module_type == ModuleType.MALUS:
nb_malus_notes = sum(
[
e["etat"]["nb_notes"]
for e in nt.get_mod_evaluation_etat_list(modimpl["moduleimpl_id"])
]
)
H.append(
"""<td class="malus">
<a href="moduleimpl_status?moduleimpl_id=%s" class="formsemestre_status_link">malus (%d notes)</a>
"""
% (modimpl["moduleimpl_id"], nb_malus_notes)
)
else:
raise ValueError(f"Invalid module_type {mod.module_type}") # a bug
H.append("</td></tr>")
return "\n".join(H)
# Expérimental
def get_formsemestre_etudids_sans_notes(
formsemestre: FormSemestre, res: ResultatsSemestre
) -> set[int]:
"""Les étudis d'étudiants de ce semestre n'ayant aucune note
alors que d'autres en ont.
"""
# Il y a-t-il des notes déjà saisies ?
nb_notes_sem = (
NotesNotes.query.join(Evaluation)
.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.count()
)
if not nb_notes_sem:
return
etudids_sans_notes = set.intersection(
*[
set.intersection(*m_res.evals_etudids_sans_note.values())
for m_res in res.modimpls_results.values()
if m_res.evals_etudids_sans_note
]
)
nb_sans_notes = len(etudids_sans_notes)
if nb_sans_notes > 0 and nb_sans_notes < len(
formsemestre.get_inscrits(include_demdef=False)
):
return etudids_sans_notes
return set()
def formsemestre_warning_etuds_sans_note(
formsemestre: FormSemestre, res: ResultatsSemestre
) -> str:
"""Vérifie si on est dans la situation où certains (mais pas tous) étudiants
n'ont aucune note alors que d'autres en ont.
Ce cas se produit typiquement quand on inscrit un étudiant en cours de semestre.
Il est alors utile de proposer de mettre toutes ses notes à ABS, ATT ou EXC
pour éviter de laisser toutes les évaluations "incomplètes".
"""
etudids_sans_notes = get_formsemestre_etudids_sans_notes(formsemestre, res)
if not etudids_sans_notes:
return ""
nb_sans_notes = len(etudids_sans_notes)
if nb_sans_notes < 5:
# peu d'étudiants, affiche leurs noms
etuds: list[Identite] = sorted(
[Identite.query.get(etudid) for etudid in etudids_sans_notes],
key=lambda e: e.sort_key,
)
noms = ", ".join(
[
f"""<a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}" class="discretelink">{etud.nomprenom}</a>"""
for etud in etuds
]
)
msg_etuds = (
f"""{noms} n'{"a" if nb_sans_notes == 1 else "ont"} aucune note&nbsp;:"""
)
else:
msg_etuds = f"""{nb_sans_notes} étudiants n'ont aucune note&nbsp;:"""
return f"""<div class="warning_inscriptions_notes">Attention: {msg_etuds}
<a class="stdlink" href="{url_for(
"notes.formsemestre_note_etuds_sans_notes",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)}">{"lui" if nb_sans_notes == 1 else "leur"}
<span title="pour ne pas bloquer les autres étudiants, il est souvent préférable
que les nouveaux aient des notes provisoires">affecter des notes</a>.
</div>
"""
def formsemestre_note_etuds_sans_notes(formsemestre_id: int, code: str = None):
"""Vue affichant les étudiants sans notes"""
formsemestre: FormSemestre = FormSemestre.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
res: ResultatsSemestre = res_sem.load_formsemestre_results(formsemestre)
etudids_sans_notes = get_formsemestre_etudids_sans_notes(formsemestre, res)
etuds: list[Identite] = sorted(
[Identite.query.get(etudid) for etudid in etudids_sans_notes],
key=lambda e: e.sort_key,
)
if request.method == "POST":
if not code in ("ATT", "EXC", "ABS"):
raise ScoValueError("code invalide: doit être ATT, ABS ou EXC")
for etud in etuds:
formsemestre.etud_set_all_missing_notes(etud, code)
flash(f"Notes de {len(etuds)} étudiants affectées à {code}")
return redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
)
noms = "</li><li>".join(
[
f"""<a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}" class="discretelink">{etud.nomprenom}</a>"""
for etud in etuds
]
)
return f"""
{html_sco_header.sco_header(
page_title=f"{formsemestre.sem_modalite()} {formsemestre.titre_annee()}"
)}
<div class="formsemestre_status">
{formsemestre_status_head(
formsemestre_id=formsemestre_id, page_title="Étudiants sans notes"
)}
</div>
<h3>Étudiants sans notes:</h3>
<ul>
<li>{noms}</li>
</ul>
<form method="post">
<input type="hidden" name="formsemestre_id" value="{formsemestre.id}">
Mettre toutes les notes de ces étudiants à&nbsp;:
<select name="code">
<option value="ABS">ABS (absent, compte zéro)</option>
<option value="ATT" selected>ATT (en attente)</option>
<option value="EXC">EXC (neutralisée)</option>
</select>
<input type="submit" name="enregistrer">
</form>
{html_sco_header.sco_footer()}
"""