# -*- 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
#   module codé par Matthias Hartmann, 2023
#
##############################################################################

import datetime
import re
from typing import Any

from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response
from flask_login import current_user
from flask_sqlalchemy.query import Query

from app import db, log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
    scodoc,
    permission_required,
)
from app.forms.assiduite.ajout_assiduite_etud import (
    AjoutAssiOrJustForm,
    AjoutAssiduiteEtudForm,
    AjoutJustificatifEtudForm,
    ChoixDateForm,
)
from app.models import (
    Assiduite,
    Departement,
    Evaluation,
    FormSemestre,
    GroupDescr,
    Identite,
    Justificatif,
    ModuleImpl,
    ScoDocSiteConfig,
)
from app.scodoc.codes_cursus import UE_STANDARD
from app.auth.models import User
from app.models.assiduites import get_assiduites_justif, compute_assiduites_justified
from app.tables.list_etuds import RowEtud, TableEtud
import app.tables.liste_assiduites as liste_assi

from app.views import assiduites_bp as bp
from app.views import ScoData

# ---------------
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_etud
from app.scodoc import sco_find_etud
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError


from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver


CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS

# --------------------------------------------------------------------
#
#   Assiduité (/ScoDoc/<dept>/Scolarite/Assiduites/...)
#
# --------------------------------------------------------------------


@bp.route("/")
@bp.route("/bilan_dept")
@scodoc
@permission_required(Permission.AbsChange)
def bilan_dept():
    """Gestionnaire assiduités, page principale"""

    # Gestion des billets d'absences
    if current_user.has_permission(
        Permission.AbsChange
    ) and sco_preferences.get_preference("handle_billets_abs"):
        billets = f"""
            <h2 style="margin-top: 30px;">Billets d'absence</h2>
            <ul><li><a href="{url_for("absences.list_billets", scodoc_dept=g.scodoc_dept)
            }">Traitement des billets d'absence en attente</a>
            </li></ul>
            """
    else:
        billets = ""
    # Récupération du département
    dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first()

    # Récupération d'un formsemestre
    # (pour n'afficher que les justificatifs liés au formsemestre)
    formsemestre_id = request.args.get("formsemestre_id", "")
    formsemestre = None
    if formsemestre_id:
        try:
            formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
            annee = formsemestre.annee_scolaire()
        except AttributeError:
            formsemestre_id = ""

    # <=> Génération du tableau <=>

    # Récupération des étudiants du département / groupe
    etudids: list[int] = [etud.id for etud in dept.etudiants]  # cas département
    group_ids = request.args.get("group_ids", "")
    if group_ids and formsemestre:
        groups_infos = sco_groups_view.DisplayedGroupsInfos(
            group_ids.split(","),
            formsemestre_id=formsemestre.id,
            select_all_when_unspecified=True,
        )

        if groups_infos.members:
            etudids = [m["etudid"] for m in groups_infos.members]

    # justificatifs (en attente ou modifiés avec les semestres associés)
    justificatifs_query: Query = Justificatif.query.filter(
        Justificatif.etat.in_(
            [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
        ),
        Justificatif.etudid.in_(etudids),
    )
    # Filtrage par semestre si formsemestre_id != ""
    if formsemestre:
        justificatifs_query = justificatifs_query.filter(
            Justificatif.date_debut >= formsemestre.date_debut,
            Justificatif.date_debut <= formsemestre.date_fin,
        )

    data = liste_assi.AssiJustifData(
        assiduites_query=None,
        justificatifs_query=justificatifs_query,
    )

    fname: str = "Bilan Département"
    cache_key: str = "tableau-dept"
    titre: str = "Justificatifs en attente ou modifiés"

    if formsemestre:
        fname += f" {formsemestre.titre_annee()}"
        cache_key += f"-{formsemestre.id}"
        titre += f" {formsemestre.titre_annee()}"

    if group_ids:
        cache_key += f" {group_ids}"

    table = _prepare_tableau(
        data,
        afficher_etu=True,
        filename=fname,
        titre=titre,
        cache_key=cache_key,
    )

    if not table[0]:
        return table[1]

    # Peuplement du template jinja
    return render_template(
        "assiduites/pages/bilan_dept.j2",
        tableau=table[1],
        search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"),
        billets=billets,
        sco=ScoData(formsemestre=formsemestre),
    )


@bp.route("/ajout_assiduite_etud", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def ajout_assiduite_etud() -> str | Response:
    """
    ajout_assiduite_etud Saisie d'une assiduité d'un étudiant

    Args:
        etudid (int): l'identifiant de l'étudiant
        date_deb, date_fin: heures début et fin (ISO sans timezone)
        moduleimpl_id
        evaluation_id : si présent, mode "évaluation"
        fmt: si xls, renvoie le tableau des assiduités enregistrées
    Returns:
        str: l'html généré
    """
    etudid: int = request.args.get("etudid", -1)
    etud = Identite.get_etud(etudid)

    # Gestion évaluations (appel à la page depuis les évaluations)
    evaluation_id: int | None = request.args.get("evaluation_id")
    saisie_eval = evaluation_id is not None
    moduleimpl_id: int | None = request.args.get("moduleimpl_id", "")

    redirect_url: str = (
        "#"
        if not saisie_eval
        else url_for(
            "notes.evaluation_check_absences_html",
            evaluation_id=evaluation_id,
            scodoc_dept=g.scodoc_dept,
        )
    )

    form = AjoutAssiduiteEtudForm(request.form)
    # On dresse la liste des modules de l'année scolaire en cours
    # auxquels est inscrit l'étudiant pour peupler le menu "module"
    modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
    choices = {
        "": [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
    }
    for formsemestre_id in modimpls_by_formsemestre:
        formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
        # indique le nom du semestre dans le menu (optgroup)
        choices[formsemestre.titre_annee()] = [
            (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
            for m in modimpls_by_formsemestre[formsemestre_id]
            if m.module.ue.type == UE_STANDARD
        ]
    form.modimpl.choices = choices

    if form.validate_on_submit():
        if form.cancel.data:  # cancel button
            return redirect(redirect_url)
        ok = _record_assiduite_etud(etud, form)
        if ok:
            flash("enregistré")
            return redirect(redirect_url)

    # Le tableau des assiduités+justificatifs déjà en base:
    is_html, tableau = _prepare_tableau(
        liste_assi.AssiJustifData.from_etudiants(
            etud,
        ),
        filename=f"assiduite-{etud.nom or ''}",
        afficher_etu=False,
        filtre=liste_assi.AssiFiltre(type_obj=1),
        options=liste_assi.AssiDisplayOptions(show_module=True),
        cache_key=f"tableau-etud-{etud.id}",
    )
    if not is_html:
        return tableau

    return render_template(
        "assiduites/pages/ajout_assiduite_etud.j2",
        etud=etud,
        form=form,
        moduleimpl_id=moduleimpl_id,
        redirect_url=redirect_url,
        sco=ScoData(etud),
        tableau=tableau,
        scu=scu,
    )


def _get_dates_from_assi_form(
    form: AjoutAssiOrJustForm,
) -> tuple[
    bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None
]:
    """Prend les dates et heures du form, les vérifie
    puis converti en deux datetime, en timezone du serveur.
    Ramène ok=True si ok.
    Met des messages d'erreur dans le form.
    """
    debut_jour = ScoDocSiteConfig.get("assi_morning_time", "08:00")
    fin_jour = ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
    date_fin = None
    # On commence par convertir individuellement tous les champs
    try:
        date_debut = datetime.datetime.strptime(form.date_debut.data, "%d/%m/%Y")
    except ValueError:
        date_debut = None
        form.set_error("date début invalide", form.date_debut)
    try:
        date_fin = (
            datetime.datetime.strptime(form.date_fin.data, "%d/%m/%Y")
            if form.date_fin.data
            else None
        )
    except ValueError:
        date_fin = None
        form.set_error("date fin invalide", form.date_fin)

    if date_fin:
        # ignore les heures si plusieurs jours
        heure_debut = datetime.time.fromisoformat(debut_jour)  # 0h
        heure_fin = datetime.time.fromisoformat(fin_jour)  # minuit
    else:
        try:
            heure_debut = datetime.time.fromisoformat(
                form.heure_debut.data or debut_jour
            )
        except ValueError:
            form.set_error("heure début invalide", form.heure_debut)
        if bool(form.heure_debut.data) != bool(form.heure_fin.data):
            form.set_error(
                "Les deux heures début et fin doivent être spécifiées, ou aucune"
            )
        try:
            heure_fin = datetime.time.fromisoformat(form.heure_fin.data or fin_jour)
        except ValueError:
            form.set_error("heure fin invalide", form.heure_fin)

    if not form.ok:
        return False, None, None, None

    # Vérifie cohérence des dates/heures
    dt_debut = datetime.datetime.combine(date_debut, heure_debut)
    dt_fin = datetime.datetime.combine(date_fin or date_debut, heure_fin)
    if dt_fin <= dt_debut:
        form.set_error("dates début/fin incohérentes")
    # La date de dépôt (si vide, la date actuelle)
    try:
        dt_entry_date = (
            datetime.datetime.strptime(form.entry_date.data, "%d/%m/%Y")
            if form.entry_date.data
            else datetime.datetime.now()  # local tz
        )
    except ValueError:
        dt_entry_date = None
        form.set_error("format de date de dépôt invalide", form.entry_date)
    # L'heure de dépôt
    try:
        entry_time = datetime.time.fromisoformat(
            form.entry_time.data or datetime.datetime.now().time().isoformat("seconds")
        )
    except ValueError:
        dt_entry_date = None
        form.set_error("format d'heure de dépôt invalide", form.entry_date)
    if dt_entry_date:
        dt_entry_date = datetime.datetime.combine(dt_entry_date, entry_time)
    # Ajoute time zone serveur
    dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut)
    dt_fin_tz_server = scu.TIME_ZONE.localize(dt_fin)
    dt_entry_date_tz_server = (
        scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None
    )
    return form.ok, dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server


def _record_assiduite_etud(
    etud: Identite,
    form: AjoutAssiduiteEtudForm,
) -> bool:
    """Enregistre les données du formulaire de saisie assiduité.
    Returns ok if successfully recorded, else put error info in the form.
    Format attendu des données du formulaire:
        form.assi_etat.data   :  'absent'
        form.date_debut.data  : '05/12/2023'
        form.heure_debut.data : '09:06' (heure locale du serveur)
    """
    (
        ok,
        dt_debut_tz_server,
        dt_fin_tz_server,
        dt_entry_date_tz_server,
    ) = _get_dates_from_assi_form(form)
    # Le module (avec "autre")
    mod_data = form.modimpl.data
    if mod_data:
        if mod_data == "autre":
            moduleimpl_id = "autre"
        else:
            try:
                moduleimpl_id = int(mod_data)
            except ValueError:
                form.modimpl.error("choix de module invalide")
                ok = False
    else:
        moduleimpl_id = None

    if not ok:
        return False

    external_data = None
    moduleimpl: ModuleImpl | None = None
    match moduleimpl_id:
        case "autre":
            external_data = {"module": "Autre"}
        case None:
            moduleimpl = None
        case _:
            moduleimpl = ModuleImpl.query.get(moduleimpl_id)
    try:
        assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data)

        ass = Assiduite.create_assiduite(
            etud,
            dt_debut_tz_server,
            dt_fin_tz_server,
            assi_etat,
            description=form.description.data,
            entry_date=dt_entry_date_tz_server,
            external_data=external_data,
            moduleimpl=moduleimpl,
            notify_mail=True,
            user_id=current_user.id,
        )
        db.session.add(ass)
        db.session.commit()

        if assi_etat != scu.EtatAssiduite.PRESENT and form.est_just.data:
            # si la case "justifiée est cochée alors on créé un justificatif de même période"
            justi: Justificatif = Justificatif.create_justificatif(
                etudiant=etud,
                date_debut=dt_debut_tz_server,
                date_fin=dt_fin_tz_server,
                etat=scu.EtatJustificatif.VALIDE,
                user_id=current_user.id,
            )

            # On met à jour les assiduités en fonction du nouveau justificatif
            compute_assiduites_justified(etud.id, [justi])

        # Invalider cache
        scass.simple_invalidate_cache(ass.to_dict(), etud.id)

        return True
    except ScoValueError as exc:
        form.set_error(f"Erreur: {exc.args[0]}")
        return False


@bp.route("/liste_assiduites_etud")
@scodoc
@permission_required(Permission.ScoView)
def liste_assiduites_etud():
    """
    liste_assiduites_etud Affichage de toutes les assiduites et justificatifs d'un etudiant
    Args:
        etudid (int): l'identifiant de l'étudiant

    Returns:
        str: l'html généré
    """

    # Récupération de l'étudiant concerné
    etudid = request.args.get("etudid", -1)
    etud: Identite = Identite.query.get_or_404(etudid)
    if etud.dept_id != g.scodoc_dept_id:
        abort(404, "étudiant inexistant dans ce département")

    # Gestion d'une assiduité unique (redirigé depuis le calendrier) TODO-Assiduites
    assiduite_id: int = request.args.get("assiduite_id", -1)

    # Préparation de la page
    tableau = _prepare_tableau(
        liste_assi.AssiJustifData.from_etudiants(
            etud,
        ),
        filename=f"assiduites-justificatifs-{etud.id}",
        afficher_etu=False,
        filtre=liste_assi.AssiFiltre(type_obj=0),
        options=liste_assi.AssiDisplayOptions(show_module=True),
        cache_key=f"tableau-etud-{etud.id}",
    )
    if not tableau[0]:
        return tableau[1]
    # Page HTML:
    return render_template(
        "assiduites/pages/liste_assiduites.j2",
        assi_id=assiduite_id,
        etud=etud,
        tableau=tableau[1],
        sco=ScoData(etud),
    )


@bp.route("/bilan_etud")
@scodoc
@permission_required(Permission.ScoView)
def bilan_etud():
    """
    bilan_etud Affichage de toutes les assiduites et justificatifs d'un etudiant
    Args:
        etudid (int): l'identifiant de l'étudiant

    Returns:
        str: l'html généré
    """
    # Récupération de l'étudiant
    etudid = request.args.get("etudid", -1)
    etud: Identite = Identite.query.get_or_404(etudid)
    if etud.dept_id != g.scodoc_dept_id:
        abort(404, "étudiant inexistant dans ce département")

    # Gestion des dates du bilan (par défaut l'année scolaire)
    date_debut = scu.date_debut_annee_scolaire().strftime("%d/%m/%Y")
    date_fin: str = scu.date_fin_annee_scolaire().strftime("%d/%m/%Y")

    # Récupération de la métrique d'assiduité
    assi_metric = scu.translate_assiduites_metric(
        sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
    )

    # Récupération des assiduités et justificatifs de l'étudiant
    data = liste_assi.AssiJustifData(
        etud.assiduites.filter(
            Assiduite.etat != scu.EtatAssiduite.PRESENT, Assiduite.est_just == False
        ),
        etud.justificatifs.filter(
            Justificatif.etat.in_(
                [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
            )
        ),
    )

    table = _prepare_tableau(
        data,
        afficher_etu=False,
        filename=f"Bilan assiduité {etud.nomprenom}",
        titre="Bilan de l'assiduité de l'étudiant",
        cache_key=f"tableau-etud-{etud.id}-bilan",
    )

    if not table[0]:
        return table[1]

    # Génération de la page HTML
    return render_template(
        "assiduites/pages/bilan_etud.j2",
        assi_limit_annee=sco_preferences.get_preference(
            "assi_limit_annee",
            dept_id=g.scodoc_dept_id,
        ),
        assi_metric=assi_metric,
        assi_seuil=_get_seuil(),
        date_debut=date_debut,
        date_fin=date_fin,
        sco=ScoData(etud),
        tableau=table[1],
    )


@bp.route("/edit_justificatif_etud/<int:justif_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def edit_justificatif_etud(justif_id: int):
    """
    Edition d'un justificatif.
    Il faut de plus la permission pour voir/modifier la raison.

    Args:
        justif_id (int): l'identifiant du justificatif

    Returns:
        str: l'html généré
    """
    justif = Justificatif.get_justificatif(justif_id)
    form = AjoutJustificatifEtudForm(obj=justif)
    # Set the default value for the etat field
    if request.method == "GET":
        form.date_debut.data = justif.date_debut.strftime("%d/%m/%Y")
        form.date_fin.data = justif.date_fin.strftime("%d/%m/%Y")
        if form.date_fin.data == form.date_debut.data:
            # un seul jour: pas de date de fin, indique les heures
            form.date_fin.data = ""
            form.heure_debut.data = justif.date_debut.strftime("%H:%M")
            form.heure_fin.data = justif.date_fin.strftime("%H:%M")
        form.entry_date.data = (
            justif.entry_date.strftime("%d/%m/%Y") if justif.entry_date else ""
        )
        form.entry_time.data = (
            justif.entry_date.strftime("%H:%M") if justif.entry_date else ""
        )
        form.etat.data = str(justif.etat)

    redirect_url = url_for(
        "assiduites.liste_assiduites_etud",
        scodoc_dept=g.scodoc_dept,
        etudid=justif.etudiant.id,
    )
    if form.validate_on_submit():
        if _record_justificatif_etud(justif.etudiant, form, justif):
            return redirect(redirect_url)

    # Fichiers
    filenames, nb_files = justif.get_fichiers()

    return render_template(
        "assiduites/pages/ajout_justificatif_etud.j2",
        assi_limit_annee=sco_preferences.get_preference(
            "assi_limit_annee",
            dept_id=g.scodoc_dept_id,
        ),
        can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView)
        or current_user.id == justif.user_id,
        etud=justif.etudiant,
        filenames=filenames,
        form=form,
        justif=justif,
        nb_files=nb_files,
        title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}",
        redirect_url=redirect_url,
        sco=ScoData(justif.etudiant),
        scu=scu,
    )


@bp.route("/ajout_justificatif_etud", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def ajout_justificatif_etud():
    """
    ajout_justificatif_etud : Affichage et création des justificatifs de l'étudiant
    Args:
        etudid (int): l'identifiant de l'étudiant

    Returns:
        str: l'html généré
    """
    etud = Identite.get_etud(request.args.get("etudid"))
    redirect_url = url_for(
        "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
    )

    form = AjoutJustificatifEtudForm()
    if form.validate_on_submit():
        if form.cancel.data:  # cancel button
            return redirect(redirect_url)
        ok = _record_justificatif_etud(etud, form)
        if ok:
            return redirect(redirect_url)

    is_html, tableau = _prepare_tableau(
        liste_assi.AssiJustifData.from_etudiants(
            etud,
        ),
        filename=f"justificatifs-{etud.nom or ''}",
        afficher_etu=False,
        filtre=liste_assi.AssiFiltre(type_obj=2),
        options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True),
        afficher_options=False,
        titre="Justificatifs enregistrés pour cet étudiant",
        cache_key=f"tableau-etud-{etud.id}",
    )
    if not is_html:
        return tableau

    return render_template(
        "assiduites/pages/ajout_justificatif_etud.j2",
        assi_limit_annee=sco_preferences.get_preference(
            "assi_limit_annee",
            dept_id=g.scodoc_dept_id,
        ),
        etud=etud,
        form=form,
        title=f"Ajout justificatif absence pour {etud.html_link_fiche()}",
        redirect_url=redirect_url,
        sco=ScoData(etud),
        scu=scu,
        tableau=tableau,
    )


def _record_justificatif_etud(
    etud: Identite, form: AjoutJustificatifEtudForm, justif: Justificatif | None = None
) -> bool:
    """Enregistre les données du formulaire de saisie justificatif (et ses fichiers).
    Returns ok if successfully recorded, else put error info in the form.
    Format attendu des données du formulaire:
        form.assi_etat.data   :  'absent'
        form.date_debut.data  : '05/12/2023'
        form.heure_debut.data : '09:06' (heure locale du serveur)
    Si justif, modifie le justif existant, sinon en crée un nouveau
    """
    (
        ok,
        dt_debut_tz_server,
        dt_fin_tz_server,
        dt_entry_date_tz_server,
    ) = _get_dates_from_assi_form(form)

    if not ok:
        log("_record_justificatif_etud: dates invalides")
        form.set_error("Erreur: dates invalides")
        return False
    if not form.etat.data:
        log("_record_justificatif_etud: etat invalide")
        form.set_error("Erreur: état invalide")
        return False
    etat = int(form.etat.data)
    if not scu.EtatJustificatif.is_valid_etat(etat):
        log(f"_record_justificatif_etud: etat invalide ({etat})")
        form.set_error("Erreur: état invalide")
        return False

    try:
        message = ""
        if justif:
            form.date_debut.data = dt_debut_tz_server
            form.date_fin.data = dt_fin_tz_server
            form.entry_date.data = dt_entry_date_tz_server
            if justif.edit_from_form(form):
                message = "Justificatif modifié"
            else:
                message = "Pas de modification"
        else:
            justif = Justificatif.create_justificatif(
                etud,
                dt_debut_tz_server,
                dt_fin_tz_server,
                etat=etat,
                raison=form.raison.data,
                entry_date=dt_entry_date_tz_server,
                user_id=current_user.id,
            )
            message = "Justificatif créé"
        db.session.add(justif)
        if not _upload_justificatif_files(justif, form):
            flash("Erreur enregistrement fichiers")
            log("problem in _upload_justificatif_files, rolling back")
            db.session.rollback()
            return False
        db.session.commit()
        # FIX TEMPORAIRE:
        # on reprend toutes les assiduités et tous les justificatifs
        # pour utiliser le "reset" (remise en "non_just") des assiduités
        # (à terme, il faudrait ne recalculer que les assiduités impactées)
        # VOIR TODO dans compute_assiduites_justified
        compute_assiduites_justified(etud.id, reset=True)
        scass.simple_invalidate_cache(justif.to_dict(), etud.id)
        flash(message)
        return True
    except ScoValueError as exc:
        log(f"_record_justificatif_etud: erreur {exc.args[0]}")
        db.session.rollback()
        form.set_error(f"Erreur: {exc.args[0]}")
        return False


def _upload_justificatif_files(
    just: Justificatif, form: AjoutJustificatifEtudForm
) -> bool:
    """Enregistre les fichiers du formulaire de création de justificatif"""
    # Utilisation de l'archiver de justificatifs
    archiver: JustificatifArchiver = JustificatifArchiver()
    archive_name: str = just.fichier
    try:
        # On essaye de sauvegarder les fichiers
        for file in form.fichiers.data or []:
            archive_name, _ = archiver.save_justificatif(
                just.etudiant,
                filename=file.filename,
                data=file.stream.read(),
                archive_name=archive_name,
                user_id=current_user.id,
            )
            flash(f"Fichier {file.filename} enregistré")
        if form.fichiers.data:
            # On actualise l'archive du justificatif
            just.fichier = archive_name
            db.session.add(just)
            db.session.commit()
        return True
    except ScoValueError as exc:
        log(
            f"_upload_justificatif_files: error on {file.filename} for etud {just.etudid}"
        )
        form.set_error(f"Erreur sur fichier justificatif: {exc.args[0]}")
        return False


@bp.route("/calendrier_assi_etud")
@scodoc
@permission_required(Permission.ScoView)
def calendrier_assi_etud():
    """
    Affichage d'un calendrier de l'assiduité de l'étudiant
    Args:
        etudid (int): l'identifiant de l'étudiant

    Returns:
        str: l'html généré
    """

    # Récupération de l'étudiant
    etudid = request.args.get("etudid", -1)
    etud: Identite = Identite.query.get_or_404(etudid)
    if etud.dept_id != g.scodoc_dept_id:
        abort(404, "étudiant inexistant dans ce département")

    # Options
    mode_demi: bool = scu.to_bool(request.args.get("mode_demi", "t"))
    show_pres: bool = scu.to_bool(request.args.get("show_pres", "f"))
    show_reta: bool = scu.to_bool(request.args.get("show_reta", "f"))
    annee: int = int(request.args.get("annee", scu.annee_scolaire()))

    # Récupération des années d'étude de l'étudiant
    annees: list[int] = []
    for ins in etud.formsemestre_inscriptions:
        annees.extend(
            (ins.formsemestre.date_debut.year, ins.formsemestre.date_fin.year)
        )
    annees = sorted(annees, reverse=True)

    # Transformation en une liste "json"
    # (sera utilisé pour générer le selecteur d'année)
    annees_str: str = "["
    for ann in annees:
        annees_str += f"{ann},"
    annees_str += "]"

    calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee)

    # Peuplement du template jinja
    return render_template(
        "assiduites/pages/calendrier_assi_etud.j2",
        sco=ScoData(etud),
        annee=annee,
        nonworkdays=_non_work_days(),
        annees=annees_str,
        calendrier=calendrier,
        mode_demi=mode_demi,
        show_pres=show_pres,
        show_reta=show_reta,
    )


@bp.route("/choix_date", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def choix_date() -> str:
    formsemestre_id = request.args.get("formsemestre_id")
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)

    group_ids = request.args.get("group_ids")
    moduleimpl_id = request.args.get("moduleimpl_id")
    form = ChoixDateForm(request.form)

    if form.validate_on_submit():
        if form.cancel.data:
            return redirect(url_for("scodoc.index"))
        # Vérifier si date dans semestre
        ok: bool = False
        try:
            date: datetime.date = datetime.datetime.strptime(
                form.date.data, "%d/%m/%Y"
            ).date()
            if date < formsemestre.date_debut or date > formsemestre.date_fin:
                form.set_error(
                    "La date sélectionnée n'est pas dans le semestre.", form.date
                )
            else:
                ok = True
        except ValueError:
            form.set_error("Date invalide", form.date)

        if ok:
            return redirect(
                url_for(
                    "assiduites.signal_assiduites_group"
                    if request.args.get("readonly") is None
                    else "assiduites.visu_assiduites_group",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                    group_ids=group_ids,
                    moduleimpl_id=moduleimpl_id,
                    jour=date.isoformat(),
                )
            )

    return render_template(
        "assiduites/pages/choix_date.j2",
        form=form,
        sco=ScoData(formsemestre=formsemestre),
        deb=formsemestre.date_debut.strftime("%d/%m/%Y"),
        fin=formsemestre.date_fin.strftime("%d/%m/%Y"),
    )


@bp.route("/signal_assiduites_group")
@scodoc
@permission_required(Permission.AbsChange)
def signal_assiduites_group():
    """
    signal_assiduites_group Saisie des assiduités des groupes pour le jour donné

    Returns:
        str: l'html généré
    """
    # Récupération des paramètres de l'url
    #  formsemestre_id est optionnel si modimpl est indiqué
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    moduleimpl_id: int = request.args.get("moduleimpl_id")
    date: str = request.args.get("jour", datetime.date.today().isoformat())
    heures: list[str] = [
        request.args.get("heure_deb", ""),
        request.args.get("heure_fin", ""),
    ]
    group_ids: list[int] = request.args.get("group_ids", None)
    if group_ids is None:
        group_ids = []
    else:
        group_ids = group_ids.split(",")
        map(str, group_ids)

    # Vérification du moduleimpl_id
    try:
        moduleimpl_id = int(moduleimpl_id)
    except (TypeError, ValueError):
        moduleimpl_id = None
    if moduleimpl_id is not None and moduleimpl_id >= 0:
        modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
    else:
        modimpl = None
    # Vérification du formsemestre_id
    try:
        formsemestre_id = int(formsemestre_id)
    except (TypeError, ValueError):
        formsemestre_id = None

    if (formsemestre_id < 0 or formsemestre_id is None) and modimpl:
        # si le module est spécifié mais pas le semestre:
        formsemestre_id = modimpl.formsemestre_id

    # Gestion des groupes
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids,
        moduleimpl_id=moduleimpl_id,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
    )
    if not groups_infos.members:
        return (
            html_sco_header.sco_header(page_title="Saisie journalière de l'assiduité")
            + "<h3>Aucun étudiant ! </h3>"
            + html_sco_header.sco_footer()
        )

    # --- Filtrage par formsemestre ---
    formsemestre_id = groups_infos.formsemestre_id

    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if formsemestre.dept_id != g.scodoc_dept_id:
        abort(404, "groupes inexistants dans ce département")

    # Vérification du forçage du module
    require_module = sco_preferences.get_preference("forcer_module", formsemestre_id)

    # Récupération des étudiants des groupes
    etuds = [
        sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
        for m in groups_infos.members
    ]

    # --- Vérification de la date ---
    real_date = scu.is_iso_formated(date, True).date()

    if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
        # Si le jour est hors semestre, renvoyer vers choix date
        return redirect(
            url_for(
                "assiduites.choix_date",
                formsemestre_id=formsemestre_id,
                group_ids=group_ids,
                moduleimpl_id=moduleimpl_id,
                scodoc_dept=g.scodoc_dept,
            )
        )

    # --- Restriction en fonction du moduleimpl_id ---
    if moduleimpl_id:
        mod_inscrits = {
            x["etudid"]
            for x in sco_moduleimpl.do_moduleimpl_inscription_list(
                moduleimpl_id=moduleimpl_id
            )
        }
        etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
        if etuds_inscrits_module:
            etuds = etuds_inscrits_module
        else:
            # Si aucun etudiant n'est inscrit au module choisi...
            moduleimpl_id = None

    # Récupération du nom des/du groupe(s)

    if groups_infos.tous_les_etuds_du_sem:
        gr_tit = "en"
    else:
        if len(groups_infos.group_ids) > 1:
            grp = "des groupes"
        else:
            grp = "du groupe"
        gr_tit = (
            grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
        )

    # Récupération du semestre en dictionnaire
    sem = formsemestre.to_dict()

    # Page HTML
    return render_template(
        "assiduites/pages/signal_assiduites_group.j2",
        date=_dateiso_to_datefr(date),
        defdem=_get_etuds_dem_def(formsemestre),
        forcer_module=sco_preferences.get_preference(
            "forcer_module",
            formsemestre_id=formsemestre_id,
            dept_id=g.scodoc_dept_id,
        ),
        formsemestre_date_debut=str(formsemestre.date_debut),
        formsemestre_date_fin=str(formsemestre.date_fin),
        formsemestre_id=formsemestre_id,
        gr_tit=gr_tit,
        grp=sco_groups_view.menu_groups_choice(groups_infos),
        minitimeline=_mini_timeline(),
        moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
        nonworkdays=_non_work_days(),
        readonly="false",
        sco=ScoData(formsemestre=formsemestre),
        sem=sem["titre_num"],
        timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
        title="Saisie journalière des assiduités",
    )


@bp.route("/visu_assiduites_group")
@scodoc
@permission_required(Permission.ScoView)
def visu_assiduites_group():
    """
    Visualisation des assiduités des groupes pour le jour donné
    dans le formsemestre_id et le moduleimpl_id
    Returns:
        str: l'html généré
    """

    # Récupération des paramètres de la requête
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    moduleimpl_id: int = request.args.get("moduleimpl_id")
    date: str = request.args.get("jour", datetime.date.today().isoformat())
    group_ids: list[int] = request.args.get("group_ids", None)
    if group_ids is None:
        group_ids = []
    else:
        group_ids = group_ids.split(",")
        map(str, group_ids)

    # Vérification du moduleimpl_id
    if moduleimpl_id is not None:
        try:
            moduleimpl_id = int(moduleimpl_id)
        except (TypeError, ValueError) as exc:
            raise ScoValueError("identifiant de moduleimpl invalide") from exc
    # Vérification du formsemestre_id
    if formsemestre_id is not None:
        try:
            formsemestre_id = int(formsemestre_id)
        except (TypeError, ValueError) as exc:
            raise ScoValueError("identifiant de formsemestre invalide") from exc

    # Récupérations des/du groupe(s)
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id
    )
    if not groups_infos.members:
        return (
            html_sco_header.sco_header(page_title="Saisie journalière de l'assiduité")
            + "<h3>Aucun étudiant ! </h3>"
            + html_sco_header.sco_footer()
        )

    # --- Filtrage par formsemestre ---
    formsemestre_id = groups_infos.formsemestre_id

    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if formsemestre.dept_id != g.scodoc_dept_id:
        abort(404, "groupes inexistants dans ce département")

    # Vérfication du forçage du module
    require_module = sco_preferences.get_preference("forcer_module", formsemestre_id)

    # Récupération des étudiants du/des groupe(s)
    etuds = [
        sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
        for m in groups_infos.members
    ]

    # --- Vérification de la date ---
    real_date = scu.is_iso_formated(date, True).date()
    if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
        # Si le jour est hors semestre, renvoyer vers choix date
        return redirect(
            url_for(
                "assiduites.choix_date",
                formsemestre_id=formsemestre_id,
                group_ids=group_ids,
                moduleimpl_id=moduleimpl_id,
                scodoc_dept=g.scodoc_dept,
                readonly="true",
            )
        )

    # --- Restriction en fonction du moduleimpl_id ---
    if moduleimpl_id:
        mod_inscrits = {
            x["etudid"]
            for x in sco_moduleimpl.do_moduleimpl_inscription_list(
                moduleimpl_id=moduleimpl_id
            )
        }
        etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
        if etuds_inscrits_module:
            etuds = etuds_inscrits_module
        else:
            # Si aucun etudiant n'est inscrit au module choisi...
            moduleimpl_id = None

    # --- Génération du HTML ---

    if groups_infos.tous_les_etuds_du_sem:
        gr_tit = "en"
    else:
        if len(groups_infos.group_ids) > 1:
            grp = "des groupes"
        else:
            grp = "du groupe"
        gr_tit = (
            grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
        )

    # Récupération du semestre en dictionnaire
    sem = formsemestre.to_dict()

    return render_template(
        "assiduites/pages/signal_assiduites_group.j2",
        date=_dateiso_to_datefr(date),
        defdem=_get_etuds_dem_def(formsemestre),
        forcer_module=sco_preferences.get_preference(
            "forcer_module",
            formsemestre_id=formsemestre_id,
            dept_id=g.scodoc_dept_id,
        ),
        formsemestre_date_debut=str(formsemestre.date_debut),
        formsemestre_date_fin=str(formsemestre.date_fin),
        formsemestre_id=formsemestre_id,
        gr_tit=gr_tit,
        grp=sco_groups_view.menu_groups_choice(groups_infos),
        minitimeline=_mini_timeline(),
        moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
        nonworkdays=_non_work_days(),
        sem=sem["titre_num"],
        timeline=_timeline(),
        readonly="true",
        sco=ScoData(formsemestre=formsemestre),
        title="Saisie journalière de l'assiduité",
    )


class RowEtudWithAssi(RowEtud):
    """Ligne de la table d'étudiants avec colonne Assiduité"""

    def __init__(
        self,
        table: TableEtud,
        etud: Identite,
        etat_assiduite: str,
        est_just: bool,
        *args,
        **kwargs,
    ):
        super().__init__(table, etud, *args, **kwargs)
        self.etat_assiduite = etat_assiduite
        self.est_just = est_just
        # remplace lien vers fiche par lien vers calendrier
        self.target_url = url_for(
            "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
        )
        self.target_title = f"Calendrier de {etud.nomprenom}"

    def add_etud_cols(self):
        """Ajoute colonnes pour cet étudiant"""
        super().add_etud_cols()
        self.add_cell(
            "assi-type",
            "Présence",
            self.etat_assiduite,
            "assi-type",
        )
        self.classes += ["row-assiduite", self.etat_assiduite.lower()]

        if self.est_just:
            self.classes += ["justifiee"]


@bp.route("/etat_abs_date")
@scodoc
@permission_required(Permission.ScoView)
def etat_abs_date():
    """Tableau de l'état d'assiduité d'un ou plusieurs groupes
    sur la plage de dates date_debut, date_fin.
        group_ids   : ids de(s) groupe(s)
        date_debut, date_fin: format ISO
        evaluation_id: optionnel, évaluation concernée, pour titre et liens.
        date_debut, date_fin en ISO
        fmt : format export (xls, défaut html)
    """

    # Récupération des paramètres de la requête
    date_debut_str = request.args.get("date_debut")
    date_fin_str = request.args.get("date_fin")
    fmt = request.args.get("fmt", "html")
    group_ids = request.args.getlist("group_ids", int)
    evaluation_id = request.args.get("evaluation_id")
    evaluation: Evaluation = (
        Evaluation.query.get_or_404(evaluation_id)
        if evaluation_id is not None
        else None
    )
    # Vérification des dates
    try:
        date_debut = datetime.datetime.fromisoformat(date_debut_str)
    except ValueError as exc:
        raise ScoValueError("date_debut invalide") from exc
    try:
        date_fin = datetime.datetime.fromisoformat(date_fin_str)
    except ValueError as exc:
        raise ScoValueError("date_fin invalide") from exc

    # Les groupes:
    groups = [GroupDescr.query.get_or_404(group_id) for group_id in group_ids]
    # Les étudiants de tous les groupes sélectionnés, flat list
    etuds = [
        etud for gr_etuds in [group.etuds for group in groups] for etud in gr_etuds
    ]

    # Récupération des assiduites des étudiants
    assiduites: Assiduite = Assiduite.query.filter(
        Assiduite.etudid.in_([etud.id for etud in etuds])
    )
    # Filtrage des assiduités en fonction des dates données
    assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)

    # Génération table
    table = TableEtud(row_class=RowEtudWithAssi)
    for etud in sorted(etuds, key=lambda e: e.sort_key):
        # On récupère l'état de la première assiduité sur la période
        assi = assiduites.filter_by(etudid=etud.id).first()
        etat = ""
        if assi is not None:
            if assi.etat != scu.EtatAssiduite.PRESENT:
                etat = scu.EtatAssiduite.inverse().get(assi.etat).name
            row = table.row_class(table, etud, etat, assi.est_just)
            row.add_etud_cols()
            table.add_row(row)

    if fmt.startswith("xls"):
        return scu.send_file(
            table.excel(),
            filename=f"assiduite-eval-{date_debut.isoformat()}",
            mime=scu.XLSX_MIMETYPE,
            suffix=scu.XLSX_SUFFIX,
        )
    return render_template(
        "assiduites/pages/etat_abs_date.j2",
        date_debut=date_debut,
        date_fin=date_fin,
        evaluation=evaluation,
        etuds=etuds,
        group_title=", ".join(gr.get_nom_with_part("tous") for gr in groups),
        sco=ScoData(),
        table=table,
    )


@bp.route("/visu_assi_group")
@scodoc
@permission_required(Permission.ScoView)
def visu_assi_group():
    """Visualisation de l'assiduité d'un groupe entre deux dates"""

    # Récupération des paramètres de la requête
    dates = {
        "debut": request.args.get("date_debut"),
        "fin": request.args.get("date_fin"),
    }
    fmt = request.args.get("fmt", "html")

    group_ids: list[int] = request.args.get("group_ids", None)
    if group_ids is None:
        group_ids = []
    else:
        group_ids = group_ids.split(",")
        map(str, group_ids)

    # Récupération des groupes, du semestre et des étudiants
    groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
    formsemestre = db.session.get(FormSemestre, groups_infos.formsemestre_id)
    etuds = etuds_sorted_from_ids([m["etudid"] for m in groups_infos.members])

    # Génération du tableau des assiduités
    table: TableAssi = TableAssi(
        etuds=etuds, dates=list(dates.values()), formsemestre=formsemestre
    )

    # Export en XLS
    if fmt.startswith("xls"):
        return scu.send_file(
            table.excel(),
            filename=f"assiduite-{groups_infos.groups_filename}",
            mime=scu.XLSX_MIMETYPE,
            suffix=scu.XLSX_SUFFIX,
        )

    # récupération du/des noms du/des groupes
    if groups_infos.tous_les_etuds_du_sem:
        gr_tit = ""
        grp = ""
    else:
        if len(groups_infos.group_ids) > 1:
            grp = "des groupes"
        else:
            grp = "du groupe"
        gr_tit = (
            grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
        )

    # Génération de la page
    return render_template(
        "assiduites/pages/visu_assi_group.j2",
        assi_metric=scu.translate_assiduites_metric(
            scu.translate_assiduites_metric(
                sco_preferences.get_preference(
                    "assi_metrique", dept_id=g.scodoc_dept_id
                ),
            ),
            inverse=False,
            short=False,
        ),
        date_debut=_dateiso_to_datefr(dates["debut"]),
        date_fin=_dateiso_to_datefr(dates["fin"]),
        gr_tit=gr_tit,
        group_ids=request.args.get("group_ids", None),
        sco=ScoData(formsemestre=groups_infos.get_formsemestre()),
        tableau=table.html(),
        title=f"Assiduité {grp} {groups_infos.groups_titles}",
    )


def _prepare_tableau(
    data: liste_assi.AssiJustifData,
    filename: str = "tableau-assiduites",
    afficher_etu: bool = True,
    filtre: liste_assi.AssiFiltre = None,
    options: liste_assi.AssiDisplayOptions = None,
    afficher_options: bool = True,
    titre="Évènements enregistrés pour cet étudiant",
    cache_key: str = "",
) -> tuple[bool, Response | str]:
    """
    Prépare un tableau d'assiduités / justificatifs

    Cette fonction récupère dans la requête les arguments :

    show_pres : bool -> Affiche les présences, par défaut False
    show_reta : bool -> Affiche les retard, par défaut False
    show_desc : bool -> Affiche les descriptions, par défaut False

    Returns:
        tuple[bool | Reponse|str ]:
            - bool : Vrai si la réponse est du Text/HTML
            - Reponse : du Text/HTML ou Une Reponse (téléchargement fichier)
    """

    show_pres: bool | str = request.args.get("show_pres", False)
    show_reta: bool | str = request.args.get("show_reta", False)
    show_desc: bool | str = request.args.get("show_desc", False)

    nb_ligne_page: int = request.args.get("nb_ligne_page")
    # Vérification de nb_ligne_page
    try:
        nb_ligne_page: int = int(nb_ligne_page)
    except (ValueError, TypeError):
        nb_ligne_page = liste_assi.ListeAssiJusti.NB_PAR_PAGE

    page_number: int = request.args.get("n_page", 1)
    # Vérification de page_number
    try:
        page_number: int = int(page_number)
    except (ValueError, TypeError):
        page_number = 1

    fmt = request.args.get("fmt", "html")

    # Ordre
    ordre: tuple[str, str | bool] = None
    ordre_col: str = request.args.get("order_col", None)
    ordre_tri: str = request.args.get("order", None)
    if ordre_col is not None and ordre_tri is not None:
        ordre = (ordre_col, ordre_tri == "ascending")

    if options is None:
        options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions()

    options.remplacer(
        page=page_number,
        nb_ligne_page=nb_ligne_page,
        show_pres=show_pres,
        show_reta=show_reta,
        show_desc=show_desc,
        show_etu=afficher_etu,
        order=ordre,
    )

    table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
        table_data=data,
        options=options,
        filtre=filtre,
        no_pagination=fmt.startswith("xls"),
        titre=cache_key,
    )

    if fmt.startswith("xls"):
        return False, scu.send_file(
            table.excel(),
            filename=filename,
            mime=scu.XLSX_MIMETYPE,
            suffix=scu.XLSX_SUFFIX,
        )

    return True, render_template(
        "assiduites/widgets/tableau.j2",
        table=table,
        total_pages=table.total_pages,
        options=options,
        afficher_options=afficher_options,
        titre=titre,
    )


@bp.route("/tableau_assiduite_actions", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def tableau_assiduite_actions():
    """Edition/suppression/information sur une assiduité ou un justificatif
    type = "assiduite" | "justificatif"
    action = "supprimer" | "details" | "justifier"
    """
    obj_type: str = request.args.get("type", "assiduite")
    action: str = request.args.get("action", "details")
    obj_id: str = int(request.args.get("obj_id", -1))

    objet: Assiduite | Justificatif
    objet_name = ""
    if obj_type == "assiduite":
        objet: Assiduite = Assiduite.query.get_or_404(obj_id)
        objet_name = scu.EtatAssiduite(objet.etat).version_lisible()
    else:
        objet: Justificatif = Justificatif.query.get_or_404(obj_id)
        objet_name = "Justificatif"

    # Suppression : attention, POST ou GET !
    if action == "supprimer":
        objet.supprime()
        flash(f"{objet_name} supprimé")

        return redirect(request.referrer)

    # Justification d'une assiduité depuis le tableau
    if action == "justifier" and obj_type == "assiduite":
        # Création du justificatif correspondant
        justificatif_correspondant: Justificatif = Justificatif.create_justificatif(
            etudiant=objet.etudiant,
            date_debut=objet.date_debut,
            date_fin=objet.date_fin,
            etat=scu.EtatJustificatif.VALIDE,
            user_id=current_user.id,
        )

        compute_assiduites_justified(objet.etudiant.id, [justificatif_correspondant])
        scass.simple_invalidate_cache(
            justificatif_correspondant.to_dict(), objet.etudiant.id
        )
        flash(f"{objet_name} justifiée")
        return redirect(request.referrer)

    if request.method == "GET":
        module: str | int = ""  # moduleimpl_id ou chaine libre

        if obj_type == "assiduite":
            # Construction du menu module
            module = _module_selector_multiple(objet.etudiant, objet.moduleimpl_id)

        return render_template(
            "assiduites/pages/tableau_assiduite_actions.j2",
            action=action,
            can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView)
            or (obj_type == "justificatif" and current_user.id == objet.user_id),
            etud=objet.etudiant,
            moduleimpl=module,
            obj_id=obj_id,
            objet_name=objet_name,
            objet=_preparer_objet(obj_type, objet),
            sco=ScoData(etud=objet.etudiant),
            title=f"Assiduité {objet.etudiant.nom_short}",
            # type utilisé dans les actions modifier / détails (modifier.j2, details.j2)
            type="Justificatif" if obj_type == "justificatif" else "Assiduité",
        )
    # ----- Cas POST
    if obj_type == "assiduite":
        try:
            _action_modifier_assiduite(objet)
        except ScoValueError as error:
            raise ScoValueError(error.args[0], request.referrer) from error
        flash("L'assiduité a bien été modifiée.")
    else:
        try:
            _action_modifier_justificatif(objet)
        except ScoValueError as error:
            raise ScoValueError(error.args[0], request.referrer) from error
        flash("Le justificatif a bien été modifié.")
    return redirect(request.form["table_url"])


def _action_modifier_assiduite(assi: Assiduite):
    form = request.form

    # Gestion de l'état
    etat = scu.EtatAssiduite.get(form["etat"])
    if etat is not None:
        assi.etat = etat
        if etat == scu.EtatAssiduite.PRESENT:
            assi.est_just = False
        else:
            assi.est_just = len(get_assiduites_justif(assi.assiduite_id, False)) > 0

    # Gestion de la description
    assi.description = form["description"]

    possible_moduleimpl_id: str = form["moduleimpl_select"]

    # Raise ScoValueError (si None et force module | Etudiant non inscrit | Module non reconnu)
    assi.set_moduleimpl(possible_moduleimpl_id)

    db.session.add(assi)
    db.session.commit()
    scass.simple_invalidate_cache(assi.to_dict(True), assi.etudid)


def _action_modifier_justificatif(justi: Justificatif):
    "Modifie le justificatif avec les valeurs dans le form"
    form = request.form

    # Gestion des Dates
    date_debut: datetime = scu.is_iso_formated(form["date_debut"], True)
    date_fin: datetime = scu.is_iso_formated(form["date_fin"], True)
    if date_debut is None or date_fin is None or date_fin < date_debut:
        raise ScoValueError("Dates invalides", request.referrer)
    justi.date_debut = date_debut
    justi.date_fin = date_fin

    # Gestion de l'état
    etat = scu.EtatJustificatif.get(form["etat"])
    if etat is not None:
        justi.etat = etat
    else:
        raise ScoValueError("État invalide", request.referrer)

    # Gestion de la raison
    justi.raison = form["raison"]

    # Gestion des fichiers
    files = request.files.getlist("justi_fich")
    if len(files) != 0:
        files = request.files.values()

        archive_name: str = justi.fichier
        # Utilisation de l'archiver de justificatifs
        archiver: JustificatifArchiver = JustificatifArchiver()

        for fich in files:
            archive_name, _ = archiver.save_justificatif(
                justi.etudiant,
                filename=fich.filename,
                data=fich.stream.read(),
                archive_name=archive_name,
                user_id=current_user.id,
            )

        justi.fichier = archive_name

    db.session.add(justi)
    db.session.commit()
    scass.compute_assiduites_justified(justi.etudid, reset=True)
    scass.simple_invalidate_cache(justi.to_dict(True), justi.etudid)


def _preparer_objet(
    obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False
) -> dict:
    "Préparation d'un objet pour simplifier l'affichage jinja"
    objet_prepare: dict = objet.to_dict()
    if obj_type == "assiduite":
        objet_prepare["etat"] = (
            scu.EtatAssiduite(objet.etat).version_lisible().capitalize()
        )
        objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower()
        objet_prepare["description"] = (
            "" if objet.description is None else objet.description
        )
        objet_prepare["description"] = objet_prepare["description"].strip()

        # Gestion du module
        objet_prepare["module"] = objet.get_module(True)

        # Gestion justification

        if not objet.est_just:
            objet_prepare["justification"] = {"est_just": False}
        else:
            objet_prepare["justification"] = {"est_just": True, "justificatifs": []}

            if not sans_gros_objet:
                justificatifs: list[int] = get_assiduites_justif(
                    objet.assiduite_id, False
                )
                for justi_id in justificatifs:
                    justi: Justificatif = Justificatif.query.get(justi_id)
                    objet_prepare["justification"]["justificatifs"].append(
                        _preparer_objet("justificatif", justi, sans_gros_objet=True)
                    )

    else:  # objet == "justificatif"
        justif: Justificatif = objet
        objet_prepare["etat"] = (
            scu.EtatJustificatif(justif.etat).version_lisible().capitalize()
        )
        objet_prepare["real_etat"] = scu.EtatJustificatif(justif.etat).name.lower()
        objet_prepare["raison"] = "" if justif.raison is None else justif.raison
        objet_prepare["raison"] = objet_prepare["raison"].strip()

        objet_prepare["justification"] = {"assiduites": [], "fichiers": {}}
        if not sans_gros_objet:
            assiduites: list[int] = scass.justifies(justif)
            for assi_id in assiduites:
                assi: Assiduite = Assiduite.query.get(assi_id)
                objet_prepare["justification"]["assiduites"].append(
                    _preparer_objet("assiduite", assi, sans_gros_objet=True)
                )

            # fichiers justificatifs archivés:
            filenames, nb_files = justif.get_fichiers()
            objet_prepare["justification"]["fichiers"] = {
                "total": nb_files,
                "filenames": filenames,
            }

    objet_prepare["date_fin"] = objet.date_fin.strftime("%d/%m/%y à %H:%M")
    objet_prepare["real_date_fin"] = objet.date_fin.isoformat()
    objet_prepare["date_debut"] = objet.date_debut.strftime("%d/%m/%y à %H:%M")
    objet_prepare["real_date_debut"] = objet.date_debut.isoformat()

    objet_prepare["entry_date"] = objet.entry_date.strftime("%d/%m/%y à %H:%M")

    objet_prepare["etud_nom"] = objet.etudiant.nomprenom

    if objet.user_id is not None:
        user: User = User.query.get(objet.user_id)
        objet_prepare["saisie_par"] = user.get_nomprenom()
    else:
        objet_prepare["saisie_par"] = "Inconnu"

    return objet_prepare


@bp.route("/signal_assiduites_diff")
@scodoc
@permission_required(Permission.AbsChange)
def signal_assiduites_diff():
    """TODO documenter
    Utilisé notamment par "Saisie différée" sur tableau de bord semetstre"
    """
    # Récupération des paramètres de la requête
    group_ids: list[int] = request.args.get("group_ids", None)
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)

    date: str = request.args.get("jour", datetime.date.today().isoformat())
    date_deb: str = request.args.get("date_deb")
    date_fin: str = request.args.get("date_fin")
    semaine: str = request.args.get("semaine")

    # Dans le cas où on donne une semaine plutot qu'un jour
    if semaine is not None:
        # On génère la semaine iso à partir de l'anne scolaire.
        semaine = (
            f"{scu.annee_scolaire()}-W{semaine}" if "W" not in semaine else semaine
        )
        # On met à jour les dates avec le date de debut et fin de semaine
        date_deb: datetime.date = datetime.datetime.strptime(
            semaine + "-1", "%Y-W%W-%w"
        )
        date_fin: datetime.date = date_deb + datetime.timedelta(days=6)

    etudiants: list[Identite] = []

    # --- Vérification de la date ---
    real_date = scu.is_iso_formated(date, True).date()

    if real_date < formsemestre.date_debut:
        date = formsemestre.date_debut.isoformat()
    elif real_date > formsemestre.date_fin:
        date = formsemestre.date_fin.isoformat()

    # Vérification des groupes
    if group_ids is None:
        group_ids = []
    else:
        group_ids = group_ids.split(",")
        map(str, group_ids)
    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é: saisie différée")
            + "<h3>Aucun étudiant ! </h3>"
            + html_sco_header.sco_footer()
        )

    # Récupération des étudiants
    etudiants.extend(
        [Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members]
    )
    etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key))

    # Génération de l'HTML

    header: str = html_sco_header.sco_header(
        page_title="Assiduité: saisie différée",
        init_qtip=True,
        cssstyles=[
            "css/assiduites.css",
        ],
        javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
        + [
            "js/assiduites.js",
            "js/date_utils.js",
            "js/etud_info.js",
        ],
    )

    if groups_infos.tous_les_etuds_du_sem:
        gr_tit = "en"
    else:
        if len(groups_infos.group_ids) > 1:
            grp = "des groupes"
        else:
            grp = "du groupe"
        gr_tit = (
            grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
        )

    return render_template(
        "assiduites/pages/signal_assiduites_diff.j2",
        defaultDates=_get_days_between_dates(date_deb, date_fin),
        defdem=_get_etuds_dem_def(formsemestre),
        diff=_differee(
            etudiants=etudiants,
            moduleimpl_select=_module_selector(
                formsemestre, request.args.get("moduleimpl_id", None)
            ),
            date=date,
            periode={
                "deb": formsemestre.date_debut.isoformat(),
                "fin": formsemestre.date_fin.isoformat(),
            },
        ),
        gr=gr_tit,
        nonworkdays=_non_work_days(),
        sco=ScoData(formsemestre=formsemestre),
        sem=formsemestre.titre_num(),
        timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"),
        timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"),
        timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"),
    )


@bp.route("/signale_evaluation_abs/<int:evaluation_id>/<int:etudid>")
@scodoc
@permission_required(Permission.AbsChange)
def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None):
    """
    Signale l'absence d'un étudiant à une évaluation
    Si la durée de l'évaluation est inférieure à 1 jour
    l'absence sera sur la période de l'évaluation
    sinon l'utilisateur sera redirigé vers la page de saisie des absences de l'étudiant
    """
    etud = Identite.get_etud(etudid)
    evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)

    delta: datetime.timedelta = evaluation.date_fin - evaluation.date_debut
    # Si l'évaluation dure plus qu'un jour alors on redirige vers la page de saisie etudiant
    if delta > datetime.timedelta(days=1):
        # rediriger vers page saisie
        return redirect(
            url_for(
                "assiduites.ajout_assiduite_etud",
                etudid=etudid,
                evaluation_id=evaluation.id,
                date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"),
                date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
                moduleimpl_id=evaluation.moduleimpl.id,
                saisie_eval="true",
                scodoc_dept=g.scodoc_dept,
            )
        )

    # Sinon on créé l'assiduité
    assiduite_unique: Assiduite | None = None
    try:
        assiduite_unique = Assiduite.create_assiduite(
            etud=etud,
            date_debut=scu.localize_datetime(evaluation.date_debut),
            date_fin=scu.localize_datetime(evaluation.date_fin),
            etat=scu.EtatAssiduite.ABSENT,
            moduleimpl=evaluation.moduleimpl,
        )
    except ScoValueError as exc:
        # En cas d'erreur
        msg: str = exc.args[0]
        if "Duplication" in msg:
            msg = """Une autre saisie concerne déjà cette période.
            En cliquant sur continuer vous serez redirigé vers la page de
            saisie de l'assiduité de l'étudiant."""
            dest: str = url_for(
                "assiduites.ajout_assiduite_etud",
                etudid=etudid,
                evaluation_id=evaluation.id,
                date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"),
                date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
                moduleimpl_id=evaluation.moduleimpl.id,
                saisie_eval="true",
                scodoc_dept=g.scodoc_dept,
                duplication="oui",
            )
            raise ScoValueError(msg, dest) from exc
    if assiduite_unique is not None:
        db.session.add(assiduite_unique)
        db.session.commit()

    # on flash puis on revient sur la page de l'évaluation
    flash("L'absence a bien été créée")
    # rediriger vers la page d'évaluation
    return redirect(
        url_for(
            "notes.evaluation_check_absences_html",
            evaluation_id=evaluation.id,
            scodoc_dept=g.scodoc_dept,
        )
    )


def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
    """Génère la liste des assiduités d'un étudiant pour le bulletin mail"""

    # On récupère la métrique d'assiduité
    metrique: str = scu.translate_assiduites_metric(
        sco_preferences.get_preference("assi_metrique", formsemestre_id=semestre.id),
    )

    # On récupère le nombre maximum de ligne d'assiduité
    max_nb: int = int(
        sco_preferences.get_preference(
            "bul_mail_list_abs_nb", formsemestre_id=semestre.id
        )
    )

    # On récupère les assiduités et les justificatifs de l'étudiant
    assiduites = scass.filter_by_formsemestre(
        etud.assiduites, Assiduite, semestre
    ).order_by(Assiduite.entry_date.desc())
    justificatifs = scass.filter_by_formsemestre(
        etud.justificatifs, Justificatif, semestre
    ).order_by(Justificatif.entry_date.desc())

    # On calcule les statistiques
    stats: dict = scass.get_assiduites_stats(
        assiduites, metric=metrique, filtered={"split": True}
    )

    # On sépare :
    # - abs_j = absences justifiées
    # - abs_nj = absences non justifiées
    # - retards = les retards
    # - justifs = les justificatifs

    abs_j: list[str] = [
        {"date": _get_date_str(assi.date_debut, assi.date_fin)}
        for assi in assiduites
        if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is True
    ]
    abs_nj: list[str] = [
        {"date": _get_date_str(assi.date_debut, assi.date_fin)}
        for assi in assiduites
        if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is False
    ]
    retards: list[str] = [
        {"date": _get_date_str(assi.date_debut, assi.date_fin)}
        for assi in assiduites
        if assi.etat == scu.EtatAssiduite.RETARD
    ]

    justifs: list[dict[str, str]] = [
        {
            "date": _get_date_str(justi.date_debut, justi.date_fin),
            "raison": "" if justi.raison is None else justi.raison,
            "etat": {
                scu.EtatJustificatif.VALIDE: "justificatif valide",
                scu.EtatJustificatif.NON_VALIDE: "justificatif invalide",
                scu.EtatJustificatif.ATTENTE: "justificatif en attente de validation",
                scu.EtatJustificatif.MODIFIE: "justificatif ayant été modifié",
            }.get(justi.etat),
        }
        for justi in justificatifs
    ]

    return render_template(
        "assiduites/widgets/liste_assiduites_mail.j2",
        abs_j=abs_j[:max_nb],
        abs_nj=abs_nj[:max_nb],
        retards=retards[:max_nb],
        justifs=justifs[:max_nb],
        stats=stats,
        metrique=scu.translate_assiduites_metric(metrique, short=True, inverse=False),
        metric=metrique,
    )


# --- Fonctions internes ---


def _dateiso_to_datefr(date_iso: str) -> str:
    """
    _dateiso_to_datefr Transforme une date iso en date format français

    Args:
        date_iso (str): date au format iso (YYYY-MM-DD)

    Raises:
        ValueError: Si l'argument `date_iso` n'est pas au bon format

    Returns:
        str: date au format français (DD/MM/YYYY)
    """

    regex_date_iso: str = r"^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])$"

    # Vérification de la date_iso
    if not re.match(regex_date_iso, date_iso):
        raise ValueError(
            f"La dateiso passée en paramètre [{date_iso}] n'est pas valide."
        )

    return f"{date_iso[8:10]}/{date_iso[5:7]}/{date_iso[0:4]}"


def _get_date_str(deb: datetime.datetime, fin: datetime.datetime) -> str:
    """
    _get_date_str transforme une période en chaîne lisible

    Args:
        deb (datetime.datetime): date de début
        fin (datetime.datetime): date de fin

    Returns:
        str:
            "le dd/mm/yyyy de hh:MM à hh:MM" si les deux date sont sur le même jour
            "du dd/mm/yyyy hh:MM audd/mm/yyyy hh:MM" sinon
    """
    if deb.date() == fin.date():
        temps = deb.strftime("%d/%m/%Y %H:%M").split(" ") + [fin.strftime("%H:%M")]
        return f"le {temps[0]} de {temps[1]} à {temps[2]}"
    return f'du {deb.strftime("%d/%m/%Y %H:%M")} au {fin.strftime("%d/%m/%Y %H:%M")}'


def _get_days_between_dates(deb: str, fin: str) -> str:
    """
    _get_days_between_dates récupère tous les jours entre deux dates

    Args:
        deb (str): date de début
        fin (str): date de fin

    Returns:
        str: une chaine json représentant une liste des jours
            ['date_iso','date_iso2', ...]
    """
    if deb is None or fin is None:
        return "null"
    try:
        if isinstance(deb, str) and isinstance(fin, str):
            date_deb: datetime.date = datetime.date.fromisoformat(deb)
            date_fin: datetime.date = datetime.date.fromisoformat(fin)
        else:
            date_deb, date_fin = deb.date(), fin.date()
    except ValueError:
        return "null"
    dates: list[str] = []
    while date_deb <= date_fin:
        dates.append(f'"{date_deb.isoformat()}"')
        date_deb = date_deb + datetime.timedelta(days=1)

    return f"[{','.join(dates)}]"


def _differee(
    etudiants: list[dict],
    moduleimpl_select: str,
    date: str = None,
    periode: dict[str, str] = None,
    formsemestre_id: int = None,
) -> str:
    """
    _differee Génère un tableau de saisie différé

    Args:
        etudiants (list[dict]): la liste des étudiants (représentés par des dictionnaires)
        moduleimpl_select (str): l'html représentant le selecteur de module
        date (str, optional): la première date à afficher. Defaults to None.
        periode (dict[str, str], optional):La période par défaut de la première colonne. Defaults to None.
        formsemestre_id (int, optional): l'id du semestre pour le selecteur de module. Defaults to None.

    Returns:
        str: le widget (html/css/js)
    """
    if date is None:
        date = datetime.date.today().isoformat()

    forcer_module = sco_preferences.get_preference(
        "forcer_module",
        formsemestre_id=formsemestre_id,
        dept_id=g.scodoc_dept_id,
    )

    assi_etat_defaut = sco_preferences.get_preference(
        "assi_etat_defaut",
        formsemestre_id=formsemestre_id,
        dept_id=g.scodoc_dept_id,
    )

    periode_defaut = sco_preferences.get_preference(
        "periode_defaut",
        formsemestre_id=formsemestre_id,
        dept_id=g.scodoc_dept_id,
    )

    return render_template(
        "assiduites/widgets/differee.j2",
        etudiants=etudiants,
        assi_etat_defaut=assi_etat_defaut,
        periode_defaut=periode_defaut,
        forcer_module=forcer_module,
        moduleimpl_select=moduleimpl_select,
        date=date,
        periode=periode,
    )


def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> str:
    """
    _module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre

    Args:
        formsemestre (FormSemestre): Le formsemestre d'où les moduleimpls seront pris.

    Returns:
        str: La représentation str d'un HTMLSelectElement
    """
    # récupération des ues du semestre
    ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
    ues = ntc.get_ues_stat_dict()

    modimpls_list: list[dict] = []
    for ue in ues:
        # Ajout des moduleimpl de chaque ue dans la liste des moduleimpls
        modimpls_list += ntc.get_modimpls_dict(ue_id=ue["ue_id"])

    # prévoie la sélection par défaut d'un moduleimpl s'il a été passé en paramètre
    selected = "" if moduleimpl_id is not None else "selected"

    modules: list[dict[str, str | int]] = []
    # Récupération de l'id et d'un nom lisible pour chaque moduleimpl
    for modimpl in modimpls_list:
        modname: str = (
            (modimpl["module"]["code"] or "")
            + " "
            + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "")
        )
        modules.append({"moduleimpl_id": modimpl["moduleimpl_id"], "name": modname})

    return render_template(
        "assiduites/widgets/moduleimpl_selector.j2",
        formsemestre_id=formsemestre.id,
        modules=modules,
        moduleimpl_id=moduleimpl_id,
        selected=selected,
    )


def _module_selector_multiple(
    etud: Identite, moduleimpl_id: int = None, only_form: FormSemestre = None
) -> str:
    """menu HTML <select> pour choix moduleimpl
    Prend les semestres de l'année, sauf si only_form est indiqué.
    """
    modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
    choices = {}
    for formsemestre_id in modimpls_by_formsemestre:
        formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
        if only_form is not None and formsemestre != only_form:
            continue
        # indique le nom du semestre dans le menu (optgroup)
        choices[formsemestre.titre_annee()] = [
            {
                "moduleimpl_id": m.id,
                "name": f"{m.module.code} {m.module.abbrev or m.module.titre or ''}",
            }
            for m in modimpls_by_formsemestre[formsemestre_id]
            if m.module.ue.type == UE_STANDARD
        ]

    return render_template(
        "assiduites/widgets/moduleimpl_selector_multiple.j2",
        choices=choices,
        formsemestre_id=only_form.id
        if only_form
        else list(modimpls_by_formsemestre.keys())[0],
        moduleimpl_id=moduleimpl_id,
    )


def _timeline(formsemestre_id: int = None, heures=None) -> str:
    """
    _timeline retourne l'html de la timeline

    Args:
        formsemestre_id (int, optional): un formsemestre. Defaults to None.
            Le formsemestre sert à obtenir la période par défaut de la timeline
            sinon ce sera de 2 heure dès le début de la timeline

    Returns:
        str: l'html en chaîne de caractères
    """
    return render_template(
        "assiduites/widgets/timeline.j2",
        t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
        t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
        tick_time=ScoDocSiteConfig.get("assi_tick_time", 15),
        periode_defaut=sco_preferences.get_preference(
            "periode_defaut", formsemestre_id
        ),
        heures=heures,
    )


def _mini_timeline() -> str:
    """
    _mini_timeline Retourne l'html lié au mini timeline d'assiduités

    Returns:
        str: l'html en chaîne de caractères
    """
    return render_template(
        "assiduites/widgets/minitimeline.j2",
        t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
        t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
    )


def _non_work_days() -> str:
    """Abbréviation des jours non travaillés: "'sam','dim'".
    donnés par les préférences du département
    """
    non_travail = sco_preferences.get_preference("non_travail")
    non_travail = non_travail.replace(" ", "").split(",")
    return ",".join([f"'{i.lower()}'" for i in non_travail])


def _get_seuil() -> int:
    """Seuil d'alerte des absences (en unité de la métrique),
    tel que fixé dans les préférences du département."""
    return sco_preferences.get_preference("assi_seuil", dept_id=g.scodoc_dept_id)


def _get_etuds_dem_def(formsemestre) -> str:
    """Une chaine json donnant les étudiants démissionnaires ou défaillants
    du formsemestre, sous la forme
    '{"516" : "D", ... }'
    """
    return (
        "{"
        + ", ".join(
            [
                f'"{ins.etudid}" : "{ins.etat}"'
                for ins in formsemestre.inscriptions
                if ins.etat != scu.INSCRIT
            ]
        )
        + "}"
    )


# --- Gestion du calendrier ---


def generate_calendar(
    etudiant: Identite,
    annee: int = None,
) -> dict[str, list["Jour"]]:
    # Si pas d'année alors on prend l'année scolaire en cours
    if annee is None:
        annee = scu.annee_scolaire()

    # On prend du 01/09 au 31/08
    date_debut: datetime.datetime = datetime.datetime(annee, 9, 1, 0, 0)
    date_fin: datetime.datetime = datetime.datetime(annee + 1, 8, 31, 23, 59)

    # Filtrage des assiduités et des justificatifs en fonction de la periode / année
    etud_assiduites: Query = scass.filter_by_date(
        etudiant.assiduites,
        Assiduite,
        date_deb=date_debut,
        date_fin=date_fin,
    )
    etud_justificatifs: Query = scass.filter_by_date(
        etudiant.justificatifs,
        Justificatif,
        date_deb=date_debut,
        date_fin=date_fin,
    )

    # Récupération des jours de l'année et de leurs assiduités/justificatifs
    annee_par_mois: dict[str, list[Jour]] = _organize_by_month(
        _get_dates_between(
            deb=date_debut.date(),
            fin=date_fin.date(),
        ),
        etud_assiduites,
        etud_justificatifs,
    )

    return annee_par_mois


class Jour:
    """Jour
    Jour du calendrier
    get_nom : retourne le numéro et le nom du Jour (ex: M19 / Mer 19)
    """

    def __init__(self, date: datetime.date, assiduites: Query, justificatifs: Query):
        self.date = date
        self.assiduites = assiduites
        self.justificatifs = justificatifs

    def get_nom(self, mode_demi: bool = True) -> str:
        str_jour: str = scu.DAY_NAMES[self.date.weekday()].capitalize()
        return f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour[:3]+' '}{self.date.day}"

    def get_date(self) -> str:
        return self.date.strftime("%d/%m/%Y")

    def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str:
        etat = ""
        est_just = ""

        if self.is_non_work():
            return "color nonwork"

        etat = self._get_color_assiduites_cascade(
            self._get_etats_from_assiduites(self.assiduites),
            show_pres=show_pres,
            show_reta=show_reta,
        )

        est_just = self._get_color_justificatifs_cascade(
            self._get_etats_from_justificatifs(self.justificatifs),
        )

        return f"color {etat} {est_just}"

    def get_demi_class(
        self, matin: bool, show_pres: bool = False, show_reta: bool = False
    ) -> str:
        # Transformation d'une heure "HH:MM" en time(h,m)
        str2time = lambda x: datetime.time(*list(map(int, x.split(":"))))

        heure_midi = str2time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))

        if matin:
            heure_matin = str2time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
            matin = (
                # date debut
                scu.localize_datetime(
                    datetime.datetime.combine(self.date, heure_matin)
                ),
                # date fin
                scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
            )
            assiduites_matin = [
                assi
                for assi in self.assiduites
                if scu.is_period_overlapping((assi.date_debut, assi.date_fin), matin)
            ]
            justificatifs_matin = [
                justi
                for justi in self.justificatifs
                if scu.is_period_overlapping((justi.date_debut, justi.date_fin), matin)
            ]

            etat = self._get_color_assiduites_cascade(
                self._get_etats_from_assiduites(assiduites_matin),
                show_pres=show_pres,
                show_reta=show_reta,
            )

            est_just = self._get_color_justificatifs_cascade(
                self._get_etats_from_justificatifs(justificatifs_matin),
            )

            return f"color {etat} {est_just}"

        heure_soir = str2time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))

        # séparation en demi journées
        aprem = (
            # date debut
            scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
            # date fin
            scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)),
        )

        assiduites_aprem = [
            assi
            for assi in self.assiduites
            if scu.is_period_overlapping((assi.date_debut, assi.date_fin), aprem)
        ]

        justificatifs_aprem = [
            justi
            for justi in self.justificatifs
            if scu.is_period_overlapping((justi.date_debut, justi.date_fin), aprem)
        ]

        etat = self._get_color_assiduites_cascade(
            self._get_etats_from_assiduites(assiduites_aprem),
            show_pres=show_pres,
            show_reta=show_reta,
        )

        est_just = self._get_color_justificatifs_cascade(
            self._get_etats_from_justificatifs(justificatifs_aprem),
        )

        return f"color {etat} {est_just}"

    def has_assiduites(self) -> bool:
        return self.assiduites.count() > 0

    def generate_minitimeline(self) -> str:
        # Récupérer le référenciel de la timeline
        str2time = lambda x: _time_to_timedelta(
            datetime.time(*list(map(int, x.split(":"))))
        )

        heure_matin: datetime.timedelta = str2time(
            ScoDocSiteConfig.get("assi_morning_time", "08:00")
        )
        heure_midi: datetime.timedelta = str2time(
            ScoDocSiteConfig.get("assi_lun_time", "13:00")
        )
        heure_soir: datetime.timedelta = str2time(
            ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
        )
        # longueur_timeline =  heure_soir - heure_matin
        longueur_timeline: datetime.timedelta = heure_soir - heure_matin

        # chaque block d'assiduité est défini par:
        # longueur = ( (fin-deb) / longueur_timeline ) * 100
        # emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100

        assiduite_blocks: list[dict[str, float | str]] = []

        for assi in self.assiduites:
            deb: datetime.timedelta = _time_to_timedelta(
                assi.date_debut.time()
                if assi.date_debut.date() == self.date
                else heure_matin
            )
            fin: datetime.timedelta = _time_to_timedelta(
                assi.date_fin.time()
                if assi.date_fin.date() == self.date
                else heure_soir
            )

            longueur: float = ((fin - deb) / longueur_timeline) * 100
            emplacement: float = ((deb - heure_matin) / longueur_timeline) * 100
            etat: str = scu.EtatAssiduite(assi.etat).name.lower()
            est_just: str = "est_just" if assi.est_just else ""

            assiduite_blocks.append(
                {
                    "longueur": longueur,
                    "emplacement": emplacement,
                    "etat": etat,
                    "est_just": est_just,
                    "bubble": _generate_assiduite_bubble(assi),
                    "id": assi.assiduite_id,
                }
            )

        return render_template(
            "assiduites/widgets/minitimeline_simple.j2",
            assi_blocks=assiduite_blocks,
        )

    def is_non_work(self):
        return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days(
            dept_id=g.scodoc_dept_id
        )

    def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]:
        return list(set([scu.EtatAssiduite(assi.etat) for assi in assiduites]))

    def _get_etats_from_justificatifs(
        self, justificatifs: Query
    ) -> list[scu.EtatJustificatif]:
        return list(set([scu.EtatJustificatif(justi.etat) for justi in justificatifs]))

    def _get_color_assiduites_cascade(
        self,
        etats: list[scu.EtatAssiduite],
        show_pres: bool = False,
        show_reta: bool = False,
    ) -> str:
        if scu.EtatAssiduite.ABSENT in etats:
            return "absent"
        if scu.EtatAssiduite.RETARD in etats and show_reta:
            return "retard"
        if scu.EtatAssiduite.PRESENT in etats and show_pres:
            return "present"

        return "sans_etat"

    def _get_color_justificatifs_cascade(
        self,
        etats: list[scu.EtatJustificatif],
    ) -> str:
        if scu.EtatJustificatif.VALIDE in etats:
            return "est_just"
        if scu.EtatJustificatif.ATTENTE in etats:
            return "attente"
        if scu.EtatJustificatif.MODIFIE in etats:
            return "modifie"
        if scu.EtatJustificatif.NON_VALIDE in etats:
            return "invalide"

        return ""


def _get_dates_between(deb: datetime.date, fin: datetime.date) -> list[datetime.date]:
    resultat = []
    date_actuelle = deb
    while date_actuelle <= fin:
        resultat.append(date_actuelle)
        date_actuelle += datetime.timedelta(days=1)
    return resultat


def _organize_by_month(days, assiduites, justificatifs) -> dict[str, list[Jour]]:
    """
    Organiser les dates par mois.
    """
    organized = {}
    for date in days:
        # Récupérer le mois en français
        month = scu.MONTH_NAMES_ABBREV[date.month - 1]
        # Ajouter le jour à la liste correspondante au mois
        if month not in organized:
            organized[month] = []

        date_assiduites: Query = scass.filter_by_date(
            assiduites,
            Assiduite,
            date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
            date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
        )

        date_justificatifs: Query = scass.filter_by_date(
            justificatifs,
            Justificatif,
            date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
            date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
        )
        # On génère un `Jour` composé d'une date, et des assiduités/justificatifs du jour
        jour: Jour = Jour(date, date_assiduites, date_justificatifs)

        organized[month].append(jour)

    return organized


def _time_to_timedelta(t: datetime.time) -> datetime.timedelta:
    if isinstance(t, datetime.timedelta):
        return t
    return datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second)


def _generate_assiduite_bubble(assiduite: Assiduite) -> str:
    # Récupérer informations modules impl
    moduleimpl_infos: str = assiduite.get_module(traduire=True)

    # Récupérer informations saisie
    saisie: str = assiduite.get_saisie()

    motif: str = assiduite.description if assiduite.description else ""

    return render_template(
        "assiduites/widgets/assiduite_bubble.j2",
        moduleimpl=moduleimpl_infos,
        etat=scu.EtatAssiduite(assiduite.etat).name.lower(),
        date_debut=assiduite.date_debut.strftime("%d/%m/%Y %H:%M"),
        date_fin=assiduite.date_fin.strftime("%d/%m/%Y %H:%M"),
        saisie=saisie,
        motif=motif,
    )
