diff --git a/app/views/assiduites.py b/app/views/assiduites.py
index 0c137c673..8fba46a06 100644
--- a/app/views/assiduites.py
+++ b/app/views/assiduites.py
@@ -78,8 +78,9 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
-from app.scodoc import sco_groups_view
+from app.scodoc import sco_groups_view, sco_groups
from app.scodoc import sco_etud
+from app.scodoc import sco_excel
from app.scodoc import sco_find_etud
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_utils as scu
@@ -2218,9 +2219,291 @@ def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
)
+@bp.route("feuille_abs_hebdo", methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.AbsChange)
+def feuille_abs_hebdo():
+ """
+ GET : Renvoie un tableau excel pour permettre la saisie des absences
+ POST: Enregistre les absences saisies
+ Affiche un choix de semaine si "week" n'est pas renseigné
+ """
+
+ # Récupération des groupes
+ group_ids: str = request.args.get("group_ids", "")
+ if group_ids == "":
+ raise ScoValueError("Paramètre 'group_ids' manquant", dest_url=request.referrer)
+
+ # Vérification du semestre
+ formsemestre_id: int = request.args.get("formsemestre_id", -1)
+ formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
+
+ # Vériication de la semaine
+ week: str = request.args.get("week", datetime.datetime.now().strftime("%G-W%V"))
+
+ regex_iso8601 = r"^\d{4}-W\d{2}$"
+ if week and not re.match(regex_iso8601, week):
+ raise ScoValueError("Semaine invalide", dest_url=request.referrer)
+
+ fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
+ fs_fin_iso8601 = formsemestre.date_fin.strftime("%Y-W%W")
+
+ # Utilisation de la propriété de la norme iso 8601
+ # les chaines sont triables par ordre alphanumérique croissant
+ # et produiront le même ordre que les dates par ordre chronologique croissant
+ if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601:
+ if week:
+ flash(
+ """La semaine n'est pas dans le semestre,
+ choisissez la semaine sur laquelle saisir l'assiduité"""
+ )
+ return sco_gen_cal.calendrier_choix_date(
+ date_debut=formsemestre.date_debut,
+ date_fin=formsemestre.date_fin,
+ url=url_for(
+ "assiduites.feuilles_abs_hebdo",
+ scodoc_dept=g.scodoc_dept,
+ formsemestre_id=formsemestre_id,
+ group_ids=group_ids,
+ week="placeholder",
+ ),
+ mode="semaine",
+ titre="Choix de la semaine",
+ )
+
+ # Vérification des groupes
+ group_ids = group_ids.split(",") if group_ids != "" else []
+
+ groups_infos = sco_groups_view.DisplayedGroupsInfos(
+ group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
+ )
+ if not groups_infos.members:
+ return (
+ html_sco_header.sco_header(
+ page_title="Assiduité: feuille saisie hebdomadaire"
+ )
+ + "
Aucun étudiant !
"
+ + html_sco_header.sco_footer()
+ )
+
+ # Gestion des jours
+ jours: dict[str, list[str]] = {
+ "lun": [
+ "Lundi",
+ datetime.datetime.strptime(week + "-1", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "mar": [
+ "Mardi",
+ datetime.datetime.strptime(week + "-2", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "mer": [
+ "Mercredi",
+ datetime.datetime.strptime(week + "-3", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "jeu": [
+ "Jeudi",
+ datetime.datetime.strptime(week + "-4", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "ven": [
+ "Vendredi",
+ datetime.datetime.strptime(week + "-5", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "sam": [
+ "Samedi",
+ datetime.datetime.strptime(week + "-6", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "dim": [
+ "Dimanche",
+ datetime.datetime.strptime(week + "-7", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ }
+
+ non_travail = sco_preferences.get_preference("non_travail")
+ non_travail = non_travail.replace(" ", "").split(",")
+
+ hebdo_jours: list[tuple[bool, str]] = []
+ for key, val in jours.items():
+ hebdo_jours.append((key in non_travail, val))
+
+ if request.method == "POST":
+ flash("Les absences ont bien été enregistrées")
+ return redirect(request.referrer)
+
+ filename = f"feuille_signal_abs_{week}"
+ xls = _excel_feuille_abs(
+ formsemestre=formsemestre, groups_infos=groups_infos, hebdo_jours=hebdo_jours
+ )
+ return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
+
+
# --- Fonctions internes ---
+def _excel_feuille_abs(
+ formsemestre: FormSemestre,
+ groups_infos: sco_groups_view.DisplayedGroupsInfos,
+ hebdo_jours: list[tuple[bool, str]],
+):
+ """
+ Génère un fichier excel pour la saisie des absences hebdomadaires
+
+ Colonnes :
+ - A : [formsemestre_id][etudid...]
+ - B : [nom][nom...]
+ - C : [prenom][prenom...]
+ - D : [groupes][groupe...]
+ - 2 colonnes (matin/aprem) par jour de la semaine
+ """
+
+ ws = sco_excel.ScoExcelSheet("Saisie_ABS")
+
+ # == Préparation des données ==
+ lines: list[tuple] = []
+
+ for membre in groups_infos.members:
+ etudid = membre["etudid"]
+ groups = sco_groups.get_etud_groups(etudid, formsemestre_id=formsemestre.id)
+ grc = sco_groups.listgroups_abbrev(groups)
+ line = [
+ str(etudid),
+ membre["nom"].upper(),
+ membre["prenom"].lower().capitalize(),
+ membre["etat"],
+ grc,
+ ]
+ line += ["" for _ in range(len(hebdo_jours) * 2)]
+ lines.append(line)
+
+ # == Préparation du fichier ==
+
+ # colonnes
+ lettres: str = "EFGHIJKLMNOPQRSTUVWXYZ"
+ # ajuste largeurs colonnes (unite inconnue, empirique)
+ ws.set_column_dimension_width("A", 11.0 / 7) # codes
+ # ws.set_column_dimension_hidden("A", True) # codes
+ ws.set_column_dimension_width("B", 164.00 / 7) # noms
+ ws.set_column_dimension_width("C", 109.0 / 7) # prenoms
+ ws.set_column_dimension_width("D", 164.0 / 7) # groupes
+
+ i: int = 0
+ for jour in hebdo_jours:
+ ws.set_column_dimension_width(lettres[i], 100.0 / 7)
+ ws.set_column_dimension_width(lettres[i + 1], 100.0 / 7)
+ if jour[0]:
+ ws.set_column_dimension_hidden(lettres[i], True)
+ ws.set_column_dimension_hidden(lettres[i + 1], True)
+ i += 2
+
+ # fontes
+ font_base = sco_excel.Font(name="Arial", size=12)
+ font_bold = sco_excel.Font(name="Arial", bold=True)
+ font_italic = sco_excel.Font(
+ name="Arial", size=12, italic=True, color=sco_excel.COLORS.RED.value
+ )
+ font_titre = sco_excel.Font(name="Arial", bold=True, size=14)
+ font_purple = sco_excel.Font(name="Arial", color=sco_excel.COLORS.PURPLE.value)
+ font_brown = sco_excel.Font(name="Arial", color=sco_excel.COLORS.BROWN.value)
+
+ # bordures
+ side_thin = sco_excel.Side(border_style="thin", color=sco_excel.COLORS.BLACK.value)
+ border_top = sco_excel.Border(top=side_thin)
+ border_right = sco_excel.Border(right=side_thin)
+ border_sides = sco_excel.Border(left=side_thin, right=side_thin, bottom=side_thin)
+
+ # fonds
+ fill_light_yellow = sco_excel.PatternFill(
+ patternType="solid", fgColor=sco_excel.COLORS.LIGHT_YELLOW.value
+ )
+
+ # styles
+ style_titres = {"font": font_titre}
+ style_expl = {"font": font_italic}
+
+ style_ro = { # cells read-only
+ "font": font_purple,
+ "border": border_right,
+ }
+ style_dem = {
+ "font": font_brown,
+ "border": border_top,
+ }
+ style_nom = { # style pour nom, prenom, groupe
+ "font": font_base,
+ "border": border_top,
+ }
+ style_abs = {
+ "font": font_bold,
+ "fill": fill_light_yellow,
+ "border": border_sides,
+ }
+
+ # filtre
+ filter_top = 6
+ filter_bottom = filter_top + len(lines)
+ filter_left = "A"
+ filter_right = lettres[len(hebdo_jours) * 2 - 1]
+ ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")
+
+ # == Ecritures statiques ==
+ ws.append_single_cell_row(
+ "Saisir les assiduités dans les cases jaunes", style=style_expl
+ )
+ ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
+ # Nom du semestre
+ ws.append_single_cell_row(
+ scu.unescape_html(formsemestre.titre_annee()), style_titres
+ )
+ # ligne blanche
+ ws.append_blank_row()
+
+ # == Ecritures dynamiques ==
+ # Ecriture des entêtes
+ row = [ws.make_cell("", style=style_titres) for _ in range(4)]
+ for jour in hebdo_jours:
+ row.append(ws.make_cell(" ".join(jour[1]), style=style_titres))
+ row.append(ws.make_cell("", style=style_titres))
+ ws.append_row(row)
+
+ row = [
+ ws.make_cell(f"!{formsemestre.id}", style=style_ro),
+ ws.make_cell("Nom", style=style_titres),
+ ws.make_cell("Prénom", style=style_titres),
+ ws.make_cell("Groupe", style=style_titres),
+ ]
+
+ for jour in hebdo_jours:
+ row.append(ws.make_cell("Matin", style=style_titres))
+ row.append(ws.make_cell("Après-Midi", style=style_titres))
+
+ ws.append_row(row)
+
+ # Ecriture des données
+ for line in lines:
+ st = style_nom
+ if line[3] != "I":
+ st = style_dem
+ if line[3] == "D": # demissionnaire
+ s = "DEM"
+ else:
+ s = line[3] # etat autre
+ else:
+ s = line[4] # groupes TD/TP/...
+ ws.append_row(
+ [
+ ws.make_cell("!" + line[0], style_ro), # code
+ ws.make_cell(line[1], st),
+ ws.make_cell(line[2], st),
+ ws.make_cell(s, st),
+ ]
+ + [ws.make_cell(" ", style_abs) for _ in range(len(hebdo_jours) * 2)]
+ )
+
+ # ligne blanche
+ ws.append_blank_row()
+
+ return ws.generate()
+
+
def _dateiso_to_datefr(date_iso: str) -> str:
"""
_dateiso_to_datefr Transforme une date iso en date format français