ScoDoc/app/scodoc/sco_formsemestre_status.py

1501 lines
56 KiB
Python
Executable File

# -*- 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@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 db, log
from app.but.cursus_but import formsemestre_warning_apc_setup
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,
Formation,
FormSemestre,
Identite,
Module,
ModuleImpl,
NotesNotes,
)
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import (
ScoValueError,
ScoInvalidIdType,
)
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_archives_formsemestre
from app.scodoc import sco_bulletins
from app.scodoc import codes_cursus
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_preferences
from app.scodoc import sco_users
from app.scodoc.gen_tables import GenTable
from app.scodoc.html_sidebar import retreive_formsemestre_from_request
from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html
import sco_version
def _build_menu_stats(formsemestre: FormSemestre):
"Définition du menu 'Statistiques'"
formsemestre_id = formsemestre.id
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 cursus",
"endpoint": "notes.formsemestre_graph_cursus",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": "Codes des cursus",
"endpoint": "notes.formsemestre_suivi_cursus",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
},
{
"title": "Lycées d'origine",
"endpoint": "notes.formsemestre_etuds_lycees",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.ViewEtudData),
},
{
"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": formsemestre.formation.is_apc(),
# 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.EditFormSemestre) 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": "Assiduité du semestre",
# "endpoint": "assiduites.liste_assiduites_formsemestre",
# "args": {"formsemestre_id": formsemestre_id},
# "enabled": True,
# "helpmsg": "Tableau de l'assiduité et des justificatifs 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": {
"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_flip_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": "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.EditFormSemestre),
"helpmsg": "",
},
{
"title": "Associer à une nouvelle version du programme",
"endpoint": "notes.formsemestre_associate_new_version",
"args": {
"formsemestre_id": formsemestre_id,
"formation_id": formsemestre.formation_id,
},
"enabled": current_user.has_permission(Permission.EditFormation)
and formsemestre.etat,
"helpmsg": "",
},
{
"title": "Supprimer ce semestre",
"endpoint": "notes.formsemestre_delete",
"args": {"formsemestre_id": formsemestre_id},
"enabled": current_user.has_permission(Permission.EditFormSemestre),
"helpmsg": "",
},
{
"title": "Expérimental: emploi du temps",
"endpoint": "notes.formsemestre_edt",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "",
},
]
# debug :
if current_app.config["DEBUG"]:
menu_semestre.append(
{
"title": "Vérifier l'intégrité",
"endpoint": "notes.check_sem_integrity",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
}
)
menu_inscriptions = [
{
"title": (
"Gérer les inscriptions aux UE et modules"
if formsemestre.formation.is_apc()
else "Gérer 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.EtudInscrit)
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.EtudInscrit)
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.EtudInscrit)
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.EtudChangeAdr)
and sco_preferences.get_preference("portal_url"),
},
{
"title": "Exporter table des étudiants",
"endpoint": "scolar.groups_view",
"args": {
"fmt": "allxls",
"group_ids": sco_groups.get_default_group(
formsemestre_id, fix_if_missing=True
),
},
"enabled": current_user.has_permission(Permission.ViewEtudData),
},
{
"title": "Vérifier inscriptions multiples",
"endpoint": "notes.formsemestre_inscrits_ailleurs",
"args": {"formsemestre_id": formsemestre_id},
},
]
can_change_groups = formsemestre.can_change_groups()
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": "Modifier groupes et partitions",
"endpoint": "scolar.partition_editor",
"args": {"formsemestre_id": formsemestre_id},
"enabled": can_change_groups,
"helpmsg": "Editeur de partitions",
},
{
"title": "Ancienne page édition partitions",
"endpoint": "scolar.edit_partition_form",
"args": {"formsemestre_id": formsemestre_id},
"enabled": can_change_groups,
},
]
# 1 item / partition:
partitions = sco_groups.get_partitions_list(formsemestre_id, with_default=False)
submenu = []
enabled = can_change_groups 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": "Ancienne page édition groupes",
"submenu": submenu,
"enabled": enabled,
}
)
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": "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": "Saisie des décisions du jury",
"endpoint": "notes.formsemestre_recapcomplet",
"args": {
"formsemestre_id": formsemestre_id,
"mode_jury": 1,
},
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Générer feuille préparation Jury (non BUT)",
"endpoint": "notes.feuille_preparation_jury",
"args": {"formsemestre_id": formsemestre_id},
"enabled": not formsemestre.formation.is_apc(),
},
{
"title": "Éditer les PV et archiver les résultats",
"endpoint": "notes.formsemestre_archive",
"args": {"formsemestre_id": formsemestre_id},
"enabled": formsemestre.can_edit_pv(),
},
{
"title": "Documents archivés",
"endpoint": "notes.formsemestre_list_archives",
"args": {"formsemestre_id": formsemestre_id},
"enabled": sco_archives_formsemestre.PV_ARCHIVER.list_obj_archives(
formsemestre_id
),
},
]
menu_stats = _build_menu_stats(formsemestre)
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)
# 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
via (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.get_formsemestre(formsemestre_id)
return render_template(
"formsemestre_page_title.j2",
formsemestre=formsemestre,
scu=scu,
sem_menu_bar=formsemestre_status_menubar(formsemestre),
)
# ---------
# ancienne fonction ScoDoc7 à supprimer lorsqu'on utilisera les modèles
# utilisé seulement par export Apogée
def fill_formsemestre(sem: dict): # XXX OBSOLETE
"""Add some fields in formsemestres dicts"""
formsemestre_id = sem["formsemestre_id"]
F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0]
sem["formation"] = F
parcours = codes_cursus.get_cursus_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()
is_apc = formsemestre.formation.is_apc()
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id)
parcours = codes_cursus.get_cursus_from_code(formsemestre.formation.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.get_ues()
columns_ids += [f"ue_{ue.id}" for ue in ues]
if sco_preferences.get_preference("bul_show_ects", formsemestre_id) and not is_apc:
columns_ids += ["ects"]
columns_ids += ["Inscrits", "Responsable", "Enseignants"]
if with_evals:
columns_ids += [
"date_evaluation",
"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["date_evaluation"] = "Évaluation"
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()}"
rows = []
sum_coef = 0
sum_ects = 0
last_ue_id = None
formsemestre_parcours_ids = {p.id for p in formsemestre.parcours}
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,
"Code": "",
"ects": ects_str,
"Module": ue.titre,
"_css_row_class": "table_row_ue",
}
if use_ue_coefs and ue.type != UE_SPORT:
ue_info["Coef."] = ue.coefficient or "0."
ue_info["_Coef._class"] = "ue_coef"
if not ue.coefficient:
ue_info["_Coef._class"] += " ue_coef_nul"
if ue.color:
for k in list(ue_info.keys()):
if not k.startswith("_"):
ue_info[f"_{k}_td_attrs"] = (
f'style="background-color: {ue.color} !important;"'
)
if not is_apc:
# n'affiche la ligne UE qu'en formation classique
# car l'UE de rattachement n'a pas d'intérêt en BUT
rows.append(ue_info)
mod_nb_inscrits = nt.modimpls_results[modimpl.id].nb_inscrits_module
enseignants = ", ".join(ens.get_prenomnom() for ens in modimpl.enseignants)
row = {
"UE": modimpl.module.ue.acronyme,
"_UE_td_attrs": ue_info.get("_UE_td_attrs", ""),
"Code": modimpl.module.code or "",
"Module": modimpl.module.abbrev or modimpl.module.titre,
"_Module_class": "scotext",
"Inscrits": mod_nb_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:
row[f"ue_{ue.id}"] = coef_dict.get(ue.id, 0.0) or ""
if with_parcours:
# Intersection des parcours du module avec ceux du formsemestre
row["parcours"] = ", ".join(
[
pa.code
for pa in (
modimpl.module.parcours
if modimpl.module.parcours
else modimpl.formsemestre.parcours
)
if pa.id in formsemestre_parcours_ids
]
)
rows.append(row)
if with_evals:
# Ajoute lignes pour evaluations
evals = nt.get_mod_evaluation_etat_list(modimpl)
evals.reverse() # ordre chronologique
# Ajoute etat:
eval_rows = []
for eval_dict in evals:
e = eval_dict.copy()
e["_description_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e["evaluation_id"],
)
e["_date_evaluation_order"] = e["jour"].isoformat()
e["date_evaluation"] = (
e["jour"].strftime("%d/%m/%Y") if e["jour"] else ""
)
e["UE"] = row["UE"]
e["_UE_td_attrs"] = row["_UE_td_attrs"]
e["Code"] = row["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 = db.session.get(Evaluation, 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"
eval_rows.append(e)
rows += eval_rows
sums = {"_css_row_class": "moyenne sortbottom", "ects": sum_ects, "Coef.": sum_coef}
rows.append(sums)
return GenTable(
columns_ids=columns_ids,
rows=rows,
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=f"{request.base_url}?formsemestre_id={formsemestre_id}&with_evals={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, fmt="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(fmt=fmt)
# genere liste html pour accès aux groupes de ce semestre
def _make_listes_sem(formsemestre: FormSemestre) -> str:
"""La section avec les groupes et l'assiduité"""
H = []
# pas de menu absences si pas autorise:
can_edit_abs = current_user.has_permission(Permission.AbsChange)
#
H.append(
f"""<h3>Groupes et absences de {formsemestre.titre}
<span class="infostitresem">({
formsemestre.mois_debut()} - {formsemestre.mois_fin()
})</span></h3>"""
)
#
H.append('<div class="sem-groups-abs">')
# Genere liste pour chaque partition (categorie de groupes)
for partition in formsemestre.get_partitions_list():
groups = partition.groups.all()
effectifs = {g.id: g.get_nb_inscrits() for g in groups}
partition_is_empty = sum(effectifs.values()) == 0
H.append(
f"""
<div class="sem-groups-partition">
<div class="sem-groups-partition-titre">{
'Groupes de ' + partition.partition_name
if partition.partition_name else
'Tous les étudiants'}
</div>
<div class="sem-groups-partition-titre">{
"Assiduité" if not partition_is_empty else ""
}</div>
"""
)
if groups:
for group in groups:
n_members = effectifs[group.id]
if n_members == 0:
continue # skip empty groups
partition_is_empty = False
group_label = f"{group.group_name}" if group.group_name else "liste"
H.append(
f"""
<div class="sem-groups-list">
<div>
<a class="stdlink" href="{
url_for("scolar.groups_view",
group_ids=group.id,
scodoc_dept=g.scodoc_dept,
)
}">{group_label}
- {n_members} étudiants</a>
</div>
</div>
<div class="sem-groups-assi">
<div>
<a class="stdlink" href="{
url_for("assiduites.visu_assi_group",
scodoc_dept=g.scodoc_dept,
date_debut=formsemestre.date_debut.isoformat(),
date_fin=formsemestre.date_fin.isoformat(),
group_ids=group.id,
)}">
Bilan</a>
</div>
"""
)
if can_edit_abs:
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.visu_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
jour = datetime.date.today().isoformat(),
group_ids=group.id,
)}">
Visualiser</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
jour=datetime.date.today().isoformat(),
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisie journalière</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_diff",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisie différée</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.bilan_dept",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Justificatifs en attente</a>
</div>
"""
)
H.append("</div>") # /sem-groups-assi
if partition_is_empty:
H.append(
'<div class="help sem-groups-none">Aucun groupe peuplé dans cette partition'
)
if formsemestre.can_change_groups():
H.append(
f""" (<a href="{url_for("scolar.partition_editor",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
edit_partition=1)
}" class="stdlink">créer</a>)"""
)
H.append("</div>")
H.append("</div>") # /sem-groups-partition
if formsemestre.can_change_groups():
H.append(
f"""<h4><a class="stdlink"
href="{url_for("scolar.partition_editor",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
edit_partition=1)
}">Ajouter une partition</a></h4>"""
)
H.append("</div>")
return "\n".join(H)
def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None):
"""En-tête HTML des pages "semestre" """
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if not formsemestre:
raise ScoValueError("Semestre inexistant (il a peut être été supprimé ?)")
formation: Formation = formsemestre.formation
parcours = formation.get_cursus()
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=formsemestre.formation.id)}"
class="discretelink" title="Formation {
formation.acronyme}, v{formation.version}">{formation.titre}</a>
""",
]
if formsemestre.semestre_id >= 0:
H.append(f", {parcours.SESSION_NAME} {formsemestre.semestre_id}")
if formsemestre.modalite:
H.append(f"&nbsp;en {formsemestre.modalite}")
if formsemestre.etapes:
H.append(
f"""&nbsp;&nbsp;&nbsp;(étape <b><tt>{
formsemestre.etapes_apo_str() or "-"
}</tt></b>)"""
)
H.append("</td></tr>")
if formation.is_apc():
# Affiche les parcours BUT cochés. Si aucun, tous ceux du référentiel.
sem_parcours = formsemestre.get_parcours_apc()
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)
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>")
H.append("</table>")
warnings = []
if evals["attente"]:
warnings.append(
"""<span class="fontred">Il y a des notes en attente !</span>
Le classement des étudiants n'a qu'une valeur indicative."""
)
if formsemestre.bul_hide_xml:
warnings.append("""Bulletins non publiés sur la passerelle.""")
if formsemestre.block_moyennes:
warnings.append("Calcul des moyennes bloqué !")
if formsemestre.semestre_id >= 0 and not formsemestre.est_sur_une_annee():
warnings.append("""<em>Ce semestre couvre plusieurs années scolaires !</em>""")
if warnings:
H += [
f"""<div class="formsemestre_status_warning">{warning}</div>"""
for warning in warnings
]
return "".join(H)
def formsemestre_status(formsemestre_id=None, check_parcours=True):
"""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)
# S'assure que les groupes de parcours sont à jour:
if int(check_parcours):
formsemestre.setup_parcours_groups()
modimpls = formsemestre.modimpls_sorted
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 |= {u.email for u in modimpl.enseignants if u.email}
can_edit = formsemestre.can_be_edited_by(current_user)
can_change_all_notes = current_user.has_permission(Permission.EditAllNotes) or (
current_user.id in [resp.id for resp in formsemestre.responsables]
)
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_apc_setup(formsemestre, nt),
(
formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes
else ""
),
"""<p style="font-size: 130%"><b>Tableau de bord&nbsp;: </b>""",
]
if formsemestre.est_courant():
H.append(
"""<span class="help">cliquez sur un module pour saisir des notes</span>"""
)
elif datetime.date.today() > formsemestre.date_fin:
if formsemestre.etat:
H.append(
"""<span
class="formsemestre_status_warning">semestre terminé mais non verrouillé</span>"""
)
else:
H.append(
"""<span class="formsemestre_status_warning">semestre pas encore commencé</span>"""
)
H.append("</p>")
if sco_preferences.get_preference("bul_show_all_evals", formsemestre_id):
H.append(
"""<div class="formsemestre_status_warning"
>Toutes évaluations (même incomplètes) visibles</div>"""
)
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, 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, 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, can_edit=can_edit, show_ues=False
),
]
H += [_TABLEAU_MODULES_FOOT, "</div>"]
else:
# formations classiques: groupe par UE
# élimine les modules BUT qui aurait pu se glisser là suite à un
# changement de type de formation par exemple
modimpls_classic = [
m
for m in modimpls
if m.module.module_type not in (ModuleType.RESSOURCE, ModuleType.SAE)
]
H += [
"<p>",
_TABLEAU_MODULES_HEAD,
formsemestre_tableau_modules(
modimpls_classic,
nt,
formsemestre,
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 class="formsemestre-groupes">',
_make_listes_sem(formsemestre),
"</div>",
]
# --- Lien mail enseignants:
adrlist = list(mails_enseignants - {None, ""})
if adrlist:
H.append(
f"""<p>
<a class="stdlink" href="mailto:?cc={','.join(adrlist)}">Courrier aux {
len(adrlist)} enseignants du semestre</a>
</p>"""
)
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[ModuleImpl],
nt,
formsemestre: FormSemestre,
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 = modimpl.module
moduleimpl_status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
mod_descr = "Module " + (mod.titre or "")
is_apc = mod.is_apc() # SAE ou ressource
if is_apc:
coef_descr = ", ".join(
[
f"{ue.acronyme}: {co}"
for ue, co in mod.ue_coefs_list()
if isinstance(co, float) and co > 0
]
)
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.enseignants.count():
mod_ens += " (resp.), " + ", ".join(
[u.get_nomcomplet() for u in modimpl.enseignants]
)
mod_nb_inscrits = nt.modimpls_results[modimpl.id].nb_inscrits_module
mod_is_conforme = modimpl.check_apc_conformity(nt)
ue = modimpl.module.ue
if show_ues and (prev_ue_id != ue.id):
prev_ue_id = ue.id
titre = ue.titre or ""
if use_ue_coefs:
titre += f""" <b>(coef. {ue.coefficient or 0.0})</b>"""
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.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 ScoDoc 9: <a href="{
url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ue_id=ue.id )
}
">supprimer</a></span>"""
)
H.append("</td></tr>")
if ue.type != codes_cursus.UE_STANDARD:
fontorange = " fontorange" # style css additionnel
else:
fontorange = ""
etat = sco_evaluations.do_evaluation_etat_in_mod(nt, modimpl)
if (
etat["nb_evals_completes"] > 0
and etat["nb_evals_en_cours"] == 0
and etat["nb_evals_vides"] == 0
and not etat["attente"]
and not etat["nb_evals_blocked"] > 0
):
tr_classes = f"formsemestre_status_green{fontorange}"
else:
tr_classes = f"formsemestre_status{fontorange}"
if etat["attente"]:
tr_classes += " modimpl_attente"
if not mod_is_conforme:
tr_classes += " modimpl_non_conforme"
if etat["nb_evals_blocked"] > 0:
tr_classes += " modimpl_has_blocked"
H.append(
f"""
<tr class="{tr_classes}">
<td class="formsemestre_status_code"><a
href="{moduleimpl_status_url}"
title="{mod_descr}" class="stdlink">{mod.code}</a></td>
<td class="scotext"><a href="{moduleimpl_status_url}" title="{mod_descr}"
class="formsemestre_status_link">{mod.abbrev or mod.titre or ""}</a>
</td>
<td class="formsemestre_status_inscrits">{mod_nb_inscrits}</td>
<td class="resp scotext">
<a class="discretelink" href="{moduleimpl_status_url}" 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(ues=formsemestre.get_ues())
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"]
if nb_evals != 0:
if etat["nb_evals_blocked"] > 0:
blocked_txt = f"""<span class="nb_evals_blocked">{
etat["nb_evals_blocked"]} bloquée{'s'
if etat["nb_evals_blocked"] > 1 else ''}</span>"""
else:
blocked_txt = ""
H.append(
f"""<a href="{moduleimpl_status_url}"
title="les évaluations 'ok' sont celles prises en compte dans les calculs"
class="formsemestre_status_link">{nb_evals} prévues,
{etat["nb_evals_completes"]} ok {blocked_txt}
</a>"""
)
if etat["nb_evals_en_cours"] > 0:
H.append(
f""", <span><a class="redlink" href="{moduleimpl_status_url}"
title="Il manque des notes">{
etat["nb_evals_en_cours"]
} en cours</a></span>"""
)
if etat["attente"]:
H.append(
f""" <span><a class="redlink" href="{moduleimpl_status_url}"
title="Il y a des notes en attente"><span class="evals_attente">{
etat["nb_evals_attente"]
} en attente</span></a></span>"""
)
if not mod_is_conforme:
H.append(
f""" <span><a class="redlink" href="{moduleimpl_status_url}"
title="évaluations non conformes">[non conforme]</a></span>"""
)
elif mod.module_type == ModuleType.MALUS:
nb_malus_notes = sum(
e["etat"]["nb_notes"] for e in nt.get_mod_evaluation_etat_list(modimpl)
)
H.append(
f"""<td class="malus">
<a href="{moduleimpl_status_url}" class="formsemestre_status_link">malus
({nb_malus_notes} notes)</a>
"""
)
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 étudids 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 set()
notes_modimpls = [
set.intersection(*m_res.evals_etudids_sans_note.values())
for m_res in res.modimpls_results.values()
if m_res.evals_etudids_sans_note
]
if not notes_modimpls:
return set()
etudids_sans_notes = set.intersection(*notes_modimpls)
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.get_etud(etudid) for etudid in etudids_sans_notes],
key=lambda e: e.sort_key,
)
noms = ", ".join(
[
f"""<a href="{
url_for("scolar.fiche_etud", 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="formsemestre_status_warning">{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, etudid: int = None
):
"""Affichage et saisie des étudiants sans notes
Si etudid est spécifié, traite un seul étudiant."""
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)
if etudid:
etudids_sans_notes = etudids_sans_notes.intersection({etudid})
etuds: list[Identite] = sorted(
[Identite.get_etud(eid) for eid 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,
)
)
if not etuds:
if etudid is None:
message = """<h3>aucun étudiant sans notes</h3>"""
else:
flash(
f"""{Identite.get_etud(etudid).nomprenom}
a déjà des notes"""
)
return redirect(
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
else:
noms = "</li><li>".join(
[
f"""<a href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}" class="discretelink">{etud.nomprenom}</a>"""
for etud in etuds
]
)
message = f"""
<h3>Étudiants sans notes:</h3>
<ul>
<li>{noms}</li>
</ul>
"""
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>
{message}
<form method="post">
<input type="hidden" name="formsemestre_id" value="{formsemestre.id}">
<input type="hidden" name="etudid" value="{etudid or ""}">
Mettre toutes les notes de {"ces étudiants" if len(etuds)> 1 else "cet étudiant"}
à&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()}
"""