ScoDoc/app/views/assiduites.py

1438 lines
44 KiB
Python

# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 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
from flask import g, request, render_template, flash
from flask import abort, url_for, redirect
from flask_login import current_user
from app import db
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
scodoc,
permission_required,
)
from app.models import (
FormSemestre,
Identite,
ScoDocSiteConfig,
Assiduite,
Justificatif,
Departement,
Evaluation,
)
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 safehtml
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
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
# --- UTILS ---
class HTMLElement:
"""Représentation d'un HTMLElement version Python"""
def __init__(self, tag: str, *attr, **kattr) -> None:
self.tag: str = tag
self.children: list["HTMLElement"] = []
self.self_close: bool = kattr.get("self_close", False)
self.text_content: str = kattr.get("text_content", "")
self.key_attributes: dict[str, any] = kattr
self.attributes: list[str] = list(attr)
def add(self, *child: "HTMLElement") -> None:
"""add child element to self"""
for kid in child:
self.children.append(kid)
def remove(self, child: "HTMLElement") -> None:
"""Remove child element from self"""
if child in self.children:
self.children.remove(child)
def __str__(self) -> str:
attr: list[str] = self.attributes
for att, val in self.key_attributes.items():
if att in ("self_close", "text_content"):
continue
if att != "cls":
attr.append(f'{att}="{val}"')
else:
attr.append(f'class="{val}"')
if not self.self_close:
head: str = f"<{self.tag} {' '.join(attr)}>{self.text_content}"
body: str = "\n".join(map(str, self.children))
foot: str = f"</{self.tag}>"
return head + body + foot
return f"<{self.tag} {' '.join(attr)}/>"
def __add__(self, other: str):
return str(self) + other
def __radd__(self, other: str):
return other + str(self)
class HTMLStringElement(HTMLElement):
"""Utilisation d'une chaine de caracètres pour représenter un element"""
def __init__(self, text: str) -> None:
self.text: str = text
HTMLElement.__init__(self, "textnode")
def __str__(self) -> str:
return self.text
class HTMLBuilder:
def __init__(self, *content: HTMLElement | str) -> None:
self.content: list[HTMLElement | str] = list(content)
def add(self, *element: HTMLElement | str):
self.content.extend(element)
def remove(self, element: HTMLElement | str):
if element in self.content:
self.content.remove(element)
def __str__(self) -> str:
return "\n".join(map(str, self.content))
def build(self) -> str:
return self.__str__()
# --------------------------------------------------------------------
#
# Assiduité (/ScoDoc/<dept>/Scolarite/Assiduites/...)
#
# --------------------------------------------------------------------
@bp.route("/")
@bp.route("/BilanDept")
@scodoc
@permission_required(Permission.ScoAbsChange)
def bilan_dept():
"""Gestionnaire assiduités, page principale"""
H = [
html_sco_header.sco_header(
page_title="Saisie de l'assiduité",
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=[
"css/assiduites.css",
],
),
"""<h2>Traitement de l'assiduité</h2>
<p class="help">
Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par
le semestre concerné (saisie par jour ou saisie différée).
</p>
""",
]
H.append(
"""<p class="help">Pour signaler, annuler ou justifier l'assiduité d'un seul étudiant,
choisissez d'abord la personne concernée&nbsp;:</p>"""
)
H.append(sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"))
if current_user.has_permission(
Permission.ScoAbsChange
) and sco_preferences.get_preference("handle_billets_abs"):
H.append(
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>
"""
)
dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first()
annees: list[int] = sorted(
[f.date_debut.year for f in dept.formsemestres],
reverse=True,
)
annee = scu.annee_scolaire()
annees_str: str = "["
for ann in annees:
annees_str += f"{ann},"
annees_str += "]"
formsemestre_id = request.args.get("formsemestre_id", "")
if formsemestre_id:
try:
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
annee = formsemestre.annee_scolaire()
except AttributeError:
formsemestre_id = ""
H.append(
render_template(
"assiduites/pages/bilan_dept.j2",
dept_id=g.scodoc_dept_id,
annee=annee,
annees=annees_str,
formsemestre_id=formsemestre_id,
group_id=request.args.get("group_id", ""),
),
)
H.append(html_sco_header.sco_footer())
return "\n".join(H)
# @bp.route("/ListeSemestre")
# @scodoc
# @permission_required(Permission.ScoView)
# def liste_assiduites_formsemestre():
# """
# 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é
# """
# formsemestre_id = request.args.get("formsemestre_id", -1)
# formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
# if formsemestre.dept_id != g.scodoc_dept_id:
# abort(404, "FormSemestre inexistant dans ce département")
# header: str = html_sco_header.sco_header(
# page_title="Liste des assiduités du semestre",
# init_qtip=True,
# javascripts=[
# "js/assiduites.js",
# "libjs/moment.new.min.js",
# "libjs/moment-timezone.js",
# ],
# cssstyles=CSSSTYLES
# + [
# "css/assiduites.css",
# ],
# )
# return HTMLBuilder(
# header,
# render_template(
# "assiduites/pages/liste_semestre.j2",
# sco=ScoData(formsemestre=formsemestre),
# sem=formsemestre.titre_annee(),
# formsemestre_id=formsemestre.id,
# ),
# ).build()
@bp.route("/SignaleAssiduiteEtud")
@scodoc
@permission_required(Permission.ScoAbsChange)
def signal_assiduites_etud():
"""
signal_assiduites_etud Saisie de l'assiduité d'un étudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
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")
date = request.args.get("date", datetime.date.today().isoformat())
# gestion évaluations
saisie_eval: bool = request.args.get("saisie_eval") is not None
date_deb: str = request.args.get("date_deb")
date_fin: str = request.args.get("date_fin")
moduleimpl_id: int = request.args.get("moduleimpl_id", "")
evaluation_id: int = request.args.get("evaluation_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,
)
)
header: str = html_sco_header.sco_header(
page_title="Saisie assiduité",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
"js/etud_info.js",
],
cssstyles=[
"css/assiduites.css",
],
)
# Gestion des horaires (journée, matin, soir)
morning = ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00")
lunch = ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00")
afternoon = ScoDocSiteConfig.assi_get_rounded_time(
"assi_afternoon_time", "18:00:00"
)
select = """
<select class="dynaSelect">
<option value="" selected> Non spécifié </option>
</select>
"""
return HTMLBuilder(
header,
_mini_timeline(),
render_template(
"assiduites/pages/signal_assiduites_etud.j2",
sco=ScoData(etud),
date=date,
morning=morning,
lunch=lunch,
timeline=_timeline(),
afternoon=afternoon,
nonworkdays=_non_work_days(),
forcer_module=sco_preferences.get_preference(
"forcer_module", dept_id=g.scodoc_dept_id
),
moduleimpl_select=_dynamic_module_selector(),
diff=_differee(
etudiants=[sco_etud.get_etud_info(etudid=etud.etudid, filled=True)[0]],
moduleimpl_select=select,
),
saisie_eval=saisie_eval,
date_deb=date_deb,
date_fin=date_fin,
redirect_url=redirect_url,
moduleimpl_id=moduleimpl_id,
),
).build()
@bp.route("/ListeAssiduitesEtud")
@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é
"""
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")
assiduite_id: int = request.args.get("assiduite_id", -1)
header: str = html_sco_header.sco_header(
page_title=f"Assiduité de {etud.nomprenom}",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
return HTMLBuilder(
header,
render_template(
"assiduites/pages/liste_assiduites.j2",
sco=ScoData(etud),
date=datetime.date.today().isoformat(),
assi_id=assiduite_id,
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
),
),
).build()
@bp.route("/BilanEtud")
@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é
"""
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")
header: str = html_sco_header.sco_header(
page_title=f"Bilan de l'assiduité de {etud.nomprenom}",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
date_debut: str = f"{scu.annee_scolaire()}-09-01"
date_fin: str = f"{scu.annee_scolaire()+1}-06-30"
assi_metric = scu.translate_assiduites_metric(
sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
)
return HTMLBuilder(
header,
render_template(
"assiduites/pages/bilan_etud.j2",
sco=ScoData(etud),
date_debut=date_debut,
date_fin=date_fin,
assi_metric=assi_metric,
assi_seuil=_get_seuil(),
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
),
),
).build()
@bp.route("/AjoutJustificatifEtud")
@scodoc
@permission_required(Permission.ScoAbsChange)
def ajout_justificatif_etud():
"""
ajout_justificatif_etud : Affichage et création/modification des justificatifs de l'étudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
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")
header: str = html_sco_header.sco_header(
page_title="Justificatifs",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
return HTMLBuilder(
header,
render_template(
"assiduites/pages/ajout_justificatif.j2",
sco=ScoData(etud),
assi_limit_annee=sco_preferences.get_preference(
"assi_limit_annee",
dept_id=g.scodoc_dept_id,
),
assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
assi_evening=ScoDocSiteConfig.get("assi_evening_time", "18:00"),
),
).build()
@bp.route("/CalendrierAssiduitesEtud")
@scodoc
@permission_required(Permission.ScoView)
def calendrier_etud():
"""
calendrier_etud : Affichage d'un calendrier des assiduités de l'étudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
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")
header: str = html_sco_header.sco_header(
page_title="Calendrier de l'assiduité",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
annees: list[int] = sorted(
[ins.formsemestre.date_debut.year for ins in etud.formsemestre_inscriptions],
reverse=True,
)
annees_str: str = "["
for ann in annees:
annees_str += f"{ann},"
annees_str += "]"
return HTMLBuilder(
header,
render_template(
"assiduites/pages/calendrier.j2",
sco=ScoData(etud),
annee=scu.annee_scolaire(),
nonworkdays=_non_work_days(),
minitimeline=_mini_timeline(),
annees=annees_str,
),
).build()
@bp.route("/SignalAssiduiteGr")
@scodoc
@permission_required(Permission.ScoAbsChange)
def signal_assiduites_group():
"""
signal_assiduites_group Saisie des assiduités des groupes pour le jour donnée
Returns:
str: l'html généré
"""
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
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
moduleimpl_id = None
# Vérification du formsemestre_id
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError):
formsemestre_id = None
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()
)
# --- URL DEFAULT ---
base_url: str = f"SignalAssiduiteGr?date={date}&{groups_infos.groups_query_args}"
# --- 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")
require_module = sco_preferences.get_preference(
"abs_require_module", formsemestre_id
)
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:
real_str = real_date.strftime("%d/%m/%Y")
form_deb = formsemestre.date_debut.strftime("%d/%m/%Y")
form_fin = formsemestre.date_fin.strftime("%d/%m/%Y")
raise ScoValueError(
f"Impossible de saisir l'assiduité pour le {real_str}"
+ f" : Jour en dehors du semestre ( {form_deb}{form_fin}) "
)
# --- 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 de l'HTML ---
sem = formsemestre.to_dict()
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>"
)
header: str = html_sco_header.sco_header(
page_title="Saisie journalière des assiduités",
init_qtip=True,
javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
+ [
# Voir fonctionnement JS
"js/etud_info.js",
"js/groups_view.js",
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
return HTMLBuilder(
header,
_mini_timeline(),
render_template(
"assiduites/pages/signal_assiduites_group.j2",
gr_tit=gr_tit,
sem=sem["titre_num"],
date=date,
formsemestre_id=formsemestre_id,
grp=sco_groups_view.menu_groups_choice(groups_infos),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
timeline=_timeline(),
nonworkdays=_non_work_days(),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
forcer_module=sco_preferences.get_preference(
"forcer_module",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
),
defdem=_get_etuds_dem_def(formsemestre),
readonly="false",
),
html_sco_header.sco_footer(),
).build()
@bp.route("/VisuAssiduiteGr")
@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é
"""
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
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()
)
# --- URL DEFAULT ---
base_url: str = f"SignalAssiduiteGr?date={date}&{groups_infos.groups_query_args}"
# --- 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")
require_module = sco_preferences.get_preference(
"abs_require_module", formsemestre_id
)
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:
date = formsemestre.date_debut.isoformat()
elif real_date > formsemestre.date_fin:
date = formsemestre.date_fin.isoformat()
# --- 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 de l'HTML ---
sem = formsemestre.to_dict()
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>"
)
header: str = html_sco_header.sco_header(
page_title="Saisie journalière de l'assiduité",
init_qtip=True,
javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
+ [
# Voir fonctionnement JS
"js/etud_info.js",
"js/abs_ajax.js",
"js/groups_view.js",
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
return HTMLBuilder(
header,
_mini_timeline(),
render_template(
"assiduites/pages/signal_assiduites_group.j2",
gr_tit=gr_tit,
sem=sem["titre_num"],
date=date,
formsemestre_id=formsemestre_id,
grp=sco_groups_view.menu_groups_choice(groups_infos),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
timeline=_timeline(),
nonworkdays=_non_work_days(),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
forcer_module=sco_preferences.get_preference(
"forcer_module",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
),
defdem=_get_etuds_dem_def(formsemestre),
readonly="true",
),
html_sco_header.sco_footer(),
).build()
@bp.route("/etat_abs_date")
@scodoc
@permission_required(Permission.ScoView)
def etat_abs_date():
"""date_debut, date_fin en ISO"""
date_debut_str = request.args.get("date_debut")
date_fin_str = request.args.get("date_fin")
title = request.args.get("desc")
group_ids: list[int] = request.args.get("group_ids", None)
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
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)
etuds = [
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
for m in groups_infos.members
]
assiduites: Assiduite = Assiduite.query.filter(
Assiduite.etudid.in_([e["etudid"] for e in etuds])
)
assiduites = scass.filter_by_date(
assiduites, Assiduite, date_debut, date_fin, False
)
etudiants: list[dict] = []
for etud in etuds:
assi = assiduites.filter_by(etudid=etud["etudid"]).first()
etat = ""
if assi is not None and assi.etat != 0:
etat = scu.EtatAssiduite.inverse().get(assi.etat).name
etudiant = {
"nom": f"""<a href="{url_for(
"assiduites.calendrier_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud["etudid"])
}"><font color="#A00000">{etud["nomprenom"]}</font></a>""",
"etat": etat,
}
etudiants.append(etudiant)
etudiants = list(sorted(etudiants, key=lambda x: x["nom"]))
header: str = html_sco_header.sco_header(
page_title=safehtml.html_to_safe_html(title),
init_qtip=True,
)
return HTMLBuilder(
header,
render_template(
"assiduites/pages/etat_absence_date.j2",
etudiants=etudiants,
group_title=groups_infos.groups_titles,
date_debut=date_debut,
date_fin=date_fin,
),
html_sco_header.sco_footer(),
).build()
@bp.route("/VisualisationAssiduitesGroupe")
@scodoc
@permission_required(Permission.ScoView)
def visu_assi_group():
"""Visualisation de l'assiduité d'un groupe entre deux dates"""
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)
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])
table: TableAssi = TableAssi(
etuds=etuds, dates=list(dates.values()), formsemestre=formsemestre
)
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,
)
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>"
)
return render_template(
"assiduites/pages/visu_assi.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=dates["debut"],
date_fin=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}",
)
@bp.route("/SignalAssiduiteDifferee")
@scodoc
@permission_required(Permission.ScoAbsChange)
def signal_assiduites_diff():
group_ids: list[int] = request.args.get("group_ids", None)
formsemestre_id: int = request.args.get("formsemestre_id", -1)
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")
if semaine is not None:
semaine = (
f"{scu.annee_scolaire()}-W{semaine}" if "W" not in semaine else 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[dict] = []
titre = None
# Vérification du formsemestre_id
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError):
formsemestre_id = None
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
# --- 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()
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)
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()
)
etudiants.extend(
[
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
for m in groups_infos.members
]
)
etudiants = list(sorted(etudiants, key=lambda x: x["nom"]))
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",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
"js/etud_info.js",
],
)
sem = formsemestre.to_dict()
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 HTMLBuilder(
header,
render_template(
"assiduites/pages/signal_assiduites_diff.j2",
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,
sem=sem["titre_num"],
defdem=_get_etuds_dem_def(formsemestre),
timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"),
timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"),
timeEvening=ScoDocSiteConfig.get("assi_evening_time", "18:00:00"),
defaultDates=_get_days_between_dates(date_deb, date_fin),
nonworkdays=_non_work_days(),
),
html_sco_header.sco_footer(),
).build()
@bp.route("/SignalEvaluationAbs/<int:evaluation_id>/<int:etudid>")
@scodoc
@permission_required(Permission.ScoView)
def signal_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érieur à 1 jour
Alors 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 = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
delta: datetime.timedelta = evaluation.date_fin - evaluation.date_debut
if delta > datetime.timedelta(days=1):
# rediriger vers page saisie
return redirect(
url_for(
"assiduites.signal_assiduites_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,
)
)
# créer l'assiduité
try:
assiduite_unique: Assiduite = 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 see:
msg: str = see.args[0]
if "Duplication" in msg:
msg = "Une autre assiduité concerne déjà cette période. En cliquant sur continuer vous serez redirigé vers la page de saisie des assiduités de l'étudiant."
dest: str = url_for(
"assiduites.signal_assiduites_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 see
db.session.add(assiduite_unique)
db.session.commit()
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"""
metrique: str = scu.translate_assiduites_metric(
sco_preferences.get_preference("assi_metrique", formsemestre_id=semestre.id),
)
max_nb: int = int(
sco_preferences.get_preference(
"bul_mail_list_abs_nb", formsemestre_id=semestre.id
)
)
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())
stats: dict = scass.get_assiduites_stats(
assiduites, metric=metrique, filtered={"split": True}
)
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 _get_date_str(deb: datetime.datetime, fin: datetime.datetime) -> str:
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):
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, moduleimpl_select, date=None, periode=None, formsemestre_id=None
):
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,
)
etat_def = sco_preferences.get_preference(
"assi_etat_defaut",
formsemestre_id=formsemestre_id,
dept_id=g.scodoc_dept_id,
)
return render_template(
"assiduites/widgets/differee.j2",
etudiants=etudiants,
etat_def=etat_def,
forcer_module=forcer_module,
moduleimpl_select=moduleimpl_select,
date=date,
periode=periode,
)
def _module_selector(
formsemestre: FormSemestre, moduleimpl_id: int = None
) -> HTMLElement:
"""
_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
"""
ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
modimpls_list: list[dict] = []
ues = ntc.get_ues_stat_dict()
for ue in ues:
modimpls_list += ntc.get_modimpls_dict(ue_id=ue["ue_id"])
selected = "" if moduleimpl_id is not None else "selected"
modules = []
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})
try:
moduleimpl_id = int(moduleimpl_id)
except (ValueError, TypeError):
moduleimpl_id = None
return render_template(
"assiduites/widgets/moduleimpl_selector.j2",
selected=selected,
modules=modules,
moduleimpl_id=moduleimpl_id,
)
def _dynamic_module_selector():
return render_template("assiduites/widgets/moduleimpl_dynamic_selector.j2")
def _timeline(formsemestre_id=None) -> HTMLElement:
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
),
)
def _mini_timeline() -> HTMLElement:
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
]
)
+ "}"
)