Compare commits

...

14 Commits

20 changed files with 632 additions and 493 deletions

View File

@ -187,7 +187,7 @@ def dept_etudiants(acronym: str):
]
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return [etud.to_dict_short() for etud in dept.etats_civils]
return [etud.to_dict_short() for etud in dept.etudiants]
@bp.route("/departement/id/<int:dept_id>/etudiants")
@ -200,7 +200,7 @@ def dept_etudiants_by_id(dept_id: int):
Retourne la liste des étudiants d'un département d'id donné.
"""
dept = Departement.query.get_or_404(dept_id)
return [etud.to_dict_short() for etud in dept.etats_civils]
return [etud.to_dict_short() for etud in dept.etudiants]
@bp.route("/departement/<string:acronym>/formsemestres_ids")

View File

@ -114,10 +114,13 @@ class EtudCursusBUT:
validation_rcue: ApcValidationRCUE
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if (
niveau is None
or not niveau.competence.id in self.validation_par_competence_et_annee
):
if niveau is None:
raise ScoValueError(
"""UE d'un RCUE non associée à un niveau de compétence.
Vérifiez la formation et les associations de ses UEs.
"""
)
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id

View File

@ -126,6 +126,7 @@ class AjoutAssiOrJustForm(FlaskForm):
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'une assiduité pour un étudiant"
description = TextAreaField(
"Description",
render_kw={
@ -152,6 +153,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'un justificatif pour un étudiant"
raison = TextAreaField(
"Raison",
render_kw={
@ -176,6 +178,12 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
class ChoixDateForm(FlaskForm):
"""
Formulaire de choix de date
(utilisé par la page de choix de date
si la date courante n'est pas dans le semestre)
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)

View File

@ -5,7 +5,6 @@ from datetime import datetime
from flask_login import current_user
from flask_sqlalchemy.query import Query
from sqlalchemy.exc import DataError
from app import db, log, g, set_sco_dept
from app.models import (
@ -89,6 +88,8 @@ class Assiduite(ScoDocModel):
lazy="select",
)
# Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel
# pylint: disable-next=unused-argument
def to_dict(self, format_api=True, restrict: bool | None = None) -> dict:
"""Retourne la représentation json de l'assiduité
restrict n'est pas utilisé ici.
@ -307,6 +308,9 @@ class Assiduite(ScoDocModel):
def supprime(self):
"Supprime l'assiduité. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
@ -356,7 +360,7 @@ class Assiduite(ScoDocModel):
date: str = self.entry_date.strftime("%d/%m/%Y à %H:%M")
utilisateur: str = ""
if self.user != None:
if self.user is not None:
self.user: User
utilisateur = f"par {self.user.get_prenomnom()}"
@ -515,6 +519,8 @@ class Justificatif(ScoDocModel):
def create_justificatif(
cls,
etudiant: Identite,
# On a besoin des arguments mais on utilise "locals" pour les récupérer
# pylint: disable=unused-argument
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
@ -538,8 +544,10 @@ class Justificatif(ScoDocModel):
def supprime(self):
"Supprime le justificatif. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
# Récupération de l'archive du justificatif
archive_name: str = self.fichier

View File

@ -241,7 +241,7 @@ class JuryPE(object):
onglet = res_sem_tag.get_repr(verbose=True)
onglet = onglet.replace("Semestre ", "S")
onglets += ["📊" + onglet]
df = res_sem_tag.to_df()
df = res_sem_tag.to_df(options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
# écriture dans l'onglet
@ -332,7 +332,7 @@ class JuryPE(object):
if sxtag.is_significatif():
onglet = sxtag.get_repr(verbose=False)
onglets += ["📊" + onglet]
df = sxtag.to_df()
df = sxtag.to_df(options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
@ -432,7 +432,7 @@ class JuryPE(object):
onglet = rcs_tag.get_repr(verbose=False)
onglets += ["📊" + onglet]
df = rcs_tag.to_df()
df = rcs_tag.to_df(options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
onglets += ["📊" + onglet]
@ -524,7 +524,7 @@ class JuryPE(object):
if interclass.is_significatif():
onglet = interclass.get_repr()
onglets += ["📊" + onglet]
df = interclass.to_df(cohorte="Promo")
df = interclass.to_df(cohorte="Promo", options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
onglets += [onglet]

View File

@ -20,8 +20,11 @@ class Trace:
Role des fichiers traces :
- Sauvegarder la date de dépôt du fichier
- Sauvegarder la date de suppression du fichier (dans le cas de plusieurs fichiers pour un même justif)
- Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier (=> permet de montrer les fichiers qu'aux personnes qui l'on déposé / qui ont le rôle AssiJustifView)
- Sauvegarder la date de suppression du fichier
(dans le cas de plusieurs fichiers pour un même justif)
- Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier
(=> permet de montrer les fichiers qu'aux personnes
qui l'on déposé / qui ont le rôle AssiJustifView)
_trace.csv :
nom_fichier_srv,datetime_depot,datetime_suppr,user_id

View File

@ -37,21 +37,34 @@ class CountCalculator:
------------
1. Initialisation : La classe peut être initialisée avec des horaires personnalisés
pour le matin, le midi et le soir, ainsi qu'une durée de pause déjeuner.
Si non spécifiés, les valeurs par défaut seront chargées depuis la configuration `ScoDocSiteConfig`.
Si non spécifiés, les valeurs par défaut seront
chargées depuis la configuration `ScoDocSiteConfig`.
Exemple d'initialisation :
calculator = CountCalculator(morning="08:00", noon="13:00", evening="18:00", nb_heures_par_jour=8)
calculator = CountCalculator(
morning="08:00",
noon="13:00",
evening="18:00",
nb_heures_par_jour=8
)
2. Ajout d'assiduités :
Exemple d'ajout d'assiduité :
- calculator.compute_assiduites(etudiant.assiduites)
- calculator.compute_assiduites([<Assiduite>, <Assiduite>, <Assiduite>, <Assiduite>])
- calculator.compute_assiduites([
<Assiduite>,
<Assiduite>,
<Assiduite>,
<Assiduite>
])
3. Accès aux métriques : Après l'ajout des assiduités, on peut accéder aux métriques telles que :
3. Accès aux métriques : Après l'ajout des assiduités,
on peut accéder aux métriques telles que :
le nombre total de jours, de demi-journées et d'heures calculées.
Exemple d'accès aux métriques :
metrics = calculator.to_dict()
4.Réinitialisation du comptage: Si besoin on peut réinitialisé le compteur sans perdre la configuration
4.Réinitialisation du comptage: Si besoin on peut réinitialiser
le compteur sans perdre la configuration
(horaires personnalisés)
Exemple de réinitialisation :
calculator.reset()
@ -61,8 +74,10 @@ class CountCalculator:
- reset() : Réinitialise les compteurs de la classe.
- add_half_day(day: date, is_morning: bool) : Ajoute une demi-journée au comptage.
- add_day(day: date) : Ajoute un jour complet au comptage.
- compute_long_assiduite(assi: Assiduite) : Traite les assiduités s'étendant sur plus d'un jour.
- compute_assiduites(assiduites: Query | list) : Calcule les métriques pour une collection d'assiduités.
- compute_long_assiduite(assi: Assiduite) : Traite les assiduités
s'étendant sur plus d'un jour.
- compute_assiduites(assiduites: Query | list) : Calcule les métriques pour
une collection d'assiduités.
- to_dict() : Retourne les métriques sous forme de dictionnaire.
Notes :
@ -85,17 +100,14 @@ class CountCalculator:
evening: str = None,
nb_heures_par_jour: int = None,
) -> None:
# Transformation d'une heure "HH:MM" en time(h,m)
STR_TIME = lambda x: time(*list(map(int, x.split(":"))))
self.morning: time = STR_TIME(
self.morning: time = str_to_time(
morning if morning else ScoDocSiteConfig.get("assi_morning_time", "08:00")
)
# Date pivot pour déterminer les demi-journées
self.noon: time = STR_TIME(
self.noon: time = str_to_time(
noon if noon else ScoDocSiteConfig.get("assi_lunch_time", "13:00")
)
self.evening: time = STR_TIME(
self.evening: time = str_to_time(
evening if evening else ScoDocSiteConfig.get("assi_afternoon_time", "18:00")
)
@ -103,10 +115,6 @@ class CountCalculator:
scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
)
delta_total: timedelta = datetime.combine(
date.min, self.evening
) - datetime.combine(date.min, self.morning)
# Sera utilisé pour les assiduités longues (> 1 journée)
self.nb_heures_par_jour = (
nb_heures_par_jour
@ -340,17 +348,27 @@ class CountCalculator:
def setup_data(self):
"""Met en forme les données
pour les journées et les demi-journées : au lieu d'avoir list[str] on a le nombre (len(list[str]))
pour les journées et les demi-journées :
au lieu d'avoir list[str] on a le nombre (len(list[str]))
"""
for key in self.data:
self.data[key]["journee"] = len(self.data[key]["journee"])
self.data[key]["demi"] = len(self.data[key]["demi"])
for value in self.data.values():
value["journee"] = len(value["journee"])
value["demi"] = len(value["demi"])
def to_dict(self, only_total: bool = True) -> dict[str, int | float]:
"""Retourne les métriques sous la forme d'un dictionnaire"""
return self.data["total"] if only_total else self.data
def str_to_time(time_str: str) -> time:
"""Convertit une chaîne de caractères représentant une heure en objet time
exemples :
- "08:00" -> time(8, 0)
- "18:00:00" -> time(18, 0, 0)
"""
return time(*list(map(int, time_str.split(":"))))
def get_assiduites_stats(
assiduites: Query, metric: str = "all", filtered: dict[str, object] = None
) -> dict[str, int | float]:
@ -756,7 +774,6 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime):
pour cet étudiant et cette date.
Invalide cache absence et caches semestre
"""
from app.scodoc import sco_compute_moy
# Semestres a cette date:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)
@ -776,17 +793,9 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime):
# Invalide les PDF et les absences:
for sem in sems:
# Inval cache bulletin et/ou note_table
if sco_compute_moy.formsemestre_expressions_use_abscounts(
sem["formsemestre_id"]
):
# certaines formules utilisent les absences
pdfonly = False
else:
# efface toujours le PDF car il affiche en général les absences
pdfonly = True
sco_cache.invalidate_formsemestre(
formsemestre_id=sem["formsemestre_id"], pdfonly=pdfonly
formsemestre_id=sem["formsemestre_id"], pdfonly=True
)
# Inval cache compteurs absences:
@ -818,4 +827,4 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None):
pattern=f"tableau-etud-{etudid}*"
)
# Invalide les tableaux "bilan dept"
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern=f"tableau-dept*")
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern="tableau-dept*")

View File

@ -70,9 +70,9 @@ def evaluation_check_absences(evaluation: Evaluation):
deb <= Assiduite.date_fin,
)
abs_etudids = set(assi.etudid for assi in assiduites)
abs_nj_etudids = set(assi.etudid for assi in assiduites if assi.est_just is False)
just_etudids = set(assi.etudid for assi in assiduites if assi.est_just is True)
abs_etudids = {assi.etudid for assi in assiduites}
abs_nj_etudids = {assi.etudid for assi in assiduites if assi.est_just is False}
just_etudids = {assi.etudid for assi in assiduites if assi.est_just is True}
# Les notes:
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)

View File

@ -1337,21 +1337,12 @@ def do_formsemestre_clone(
% (pname, pvalue, formsemestre_id)
)
# 5- Copy formules utilisateur
objs = sco_compute_moy.formsemestre_ue_computation_expr_list(
cnx, args={"formsemestre_id": orig_formsemestre_id}
)
for obj in objs:
args = obj.copy()
args["formsemestre_id"] = formsemestre_id
_ = sco_compute_moy.formsemestre_ue_computation_expr_create(cnx, args)
# 6- Copie les parcours
# 5- Copie les parcours
formsemestre.parcours = formsemestre_orig.parcours
db.session.add(formsemestre)
db.session.commit()
# 7- Copy partitions and groups
# 6- Copy partitions and groups
if clone_partitions:
sco_groups_copy.clone_partitions_and_groups(
orig_formsemestre_id, formsemestre_id

View File

@ -1216,7 +1216,7 @@ def formsemestre_tableau_modules(
if expr:
H.append(
f""" <span class="formula" title="mode de calcul de la moyenne d'UE">{expr}</span>
<span class="warning">formule inutilisée en 9.2: <a href="{
<span class="warning">formule inutilisée en ScoDoc 9: <a href="{
url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ue_id=ue.id )
}
">supprimer</a></span>"""

View File

@ -358,11 +358,11 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
if formsemestre_has_decisions(formsemestre_id):
H.append(
"""<ul class="tf-msg">
<li class="tf-msg warning">Décisions de jury saisies: seul le ou la responsable du
"""<div class="formsemestre-warning-box">
<div class="warning">Décisions de jury saisies: seul le ou la responsable du
semestre peut saisir des notes (elle devra modifier les décisions de jury).
</li>
</ul>"""
</div>
</div>"""
)
#
H.append(

View File

@ -130,7 +130,8 @@ def print_progress_bar(
decimals - Optional : nombres de chiffres après la virgule (Int)
length - Optional : taille de la barre en nombre de caractères (Int)
fill - Optional : charactère de remplissange de la barre (Str)
autosize - Optional : Choisir automatiquement la taille de la barre en fonction du terminal (Bool)
autosize - Optional : Choisir automatiquement la taille de la barre
en fonction du terminal (Bool)
"""
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
color = TerminalColor.RED
@ -174,11 +175,15 @@ class BiDirectionalEnum(Enum):
@classmethod
def contains(cls, attr: str):
"""Vérifie sur un attribut existe dans l'enum"""
# Existe dans la classe parent de Enum (EnumType)
# pylint: disable-next=no-member
return attr.upper() in cls._member_names_
@classmethod
def all(cls, keys=True):
"""Retourne toutes les clés de l'enum"""
# pylint: disable-next=no-member
return cls._member_names_ if keys else list(cls._value2member_map_.keys())
@classmethod
@ -207,6 +212,9 @@ class EtatAssiduite(int, BiDirectionalEnum):
ABSENT = 2
def version_lisible(self) -> str:
"""Retourne une version lisible des états d'assiduités
Est utilisé pour les vues.
"""
return {
EtatAssiduite.PRESENT: "Présence",
EtatAssiduite.ABSENT: "Absence",
@ -225,6 +233,9 @@ class EtatJustificatif(int, BiDirectionalEnum):
MODIFIE = 3
def version_lisible(self) -> str:
"""Retourne une version lisible des états de justificatifs
Est utilisé pour les vues.
"""
return {
EtatJustificatif.VALIDE: "valide",
EtatJustificatif.ATTENTE: "soumis",
@ -254,11 +265,13 @@ class NonWorkDays(int, BiDirectionalEnum):
cls, formsemestre_id: int = None, dept_id: int = None
) -> list["NonWorkDays"]:
"""
get_all_non_work_days Récupère la liste des non workdays (str) depuis les préférences
get_all_non_work_days Récupère la liste des non workdays
(str) depuis les préférences
puis renvoie une liste BiDirectionnalEnum<int> NonWorkDays
Example:
non_work_days : list[NonWorkDays] = NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
non_work_days : list[NonWorkDays] =
NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
if datetime.datetime.now().weekday() in non_work_days:
print("Aujourd'hui est un jour non travaillé")
@ -269,6 +282,8 @@ class NonWorkDays(int, BiDirectionalEnum):
Returns:
list[NonWorkDays]: La liste des NonWorkDays en version BiDirectionnalEnum<int>
"""
# Import circulaire
# pylint: disable=import-outside-toplevel
from app.scodoc import sco_preferences
return [

View File

@ -3407,6 +3407,17 @@ li.tf-msg {
padding-bottom: 5px;
}
div.formsemestre-warning-box {
background-color: yellow;
border-radius: 4px;
margin-top: 12px;
margin-left: 0px;
padding-left: 0px;
padding-right: 4px;
padding-top: 2px;
/* padding-bottom: 1px; */
}
.warning, .warning-bloquant {
color: red;
margin-left: 16px;

View File

@ -1,3 +1,8 @@
"""
Gestion des listes d'assiduités et justificatifs
(affichage, pagination, filtrage, options d'affichage, tableaux)
"""
from datetime import datetime
from flask import url_for
@ -8,10 +13,18 @@ from sqlalchemy import desc, literal, union, asc
from app import db, g
from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif
from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
to_bool,
date_debut_annee_scolaire,
date_fin_annee_scolaire,
localize_datetime,
)
from app.tables import table_builder as tb
from app.scodoc.sco_cache import RequeteTableauAssiduiteCache
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_preferences import get_preference
class Pagination:
@ -26,9 +39,11 @@ class Pagination:
On peut ensuite récupérer les éléments de la page courante avec la méthode `items()`
Cette classe ne permet pas de changer de page.
(Pour cela, il faut créer une nouvelle instance, avec la collection originelle et la nouvelle page)
(Pour cela, il faut créer une nouvelle instance,
avec la collection originelle et la nouvelle page)
l'intéret est de ne pas garder en mémoire toute la collection, mais seulement la page courante
l'intéret est de ne pas garder en mémoire toute la collection,
mais seulement la page courante
"""
@ -37,9 +52,11 @@ class Pagination:
__init__ Instancie un nouvel objet Pagination
Args:
collection (list): La collection à paginer. Il s'agit par exemple d'une requête
collection (list): La collection à paginer.
Il s'agit par exemple d'une requête
page (int, optional): le numéro de la page à voir. Defaults to 1.
per_page (int, optional): le nombre d'éléments par page. Defaults to -1. (-1 = pas de pagination/tout afficher)
per_page (int, optional): le nombre d'éléments par page.
Defaults to -1. (-1 = pas de pagination/tout afficher)
"""
# par défaut le total des pages est 1 (même si la collection est vide)
self.total_pages = 1
@ -195,6 +212,17 @@ class ListeAssiJusti(tb.Table):
r = query_finale.all()
RequeteTableauAssiduiteCache.set(cle_cache, r)
# Filtrer Si préférence "Limiter les assiduités à l'année courante"
if get_preference("assi_limit_annee"):
annee_debut = localize_datetime(date_debut_annee_scolaire())
annee_fin = localize_datetime(date_fin_annee_scolaire())
r = [
obj
for obj in r
if obj._asdict()["date_debut"] >= annee_debut
and obj._asdict()["date_fin"] <= annee_fin
]
# Paginer la requête pour ne pas envoyer trop d'informations au client
pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination)
self.total_pages = pagination.total_pages
@ -212,15 +240,17 @@ class ListeAssiJusti(tb.Table):
attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`.
Args:
collection (list): La collection à paginer. Il s'agit par exemple d'une requête qui a déjà
collection (list): La collection à paginer.
Il s'agit par exemple d'une requête qui a déjà
été construite et qui est prête à être exécutée.
Returns:
Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée.
Pagination: Un objet Pagination qui encapsule les résultats de
la requête paginée.
Note:
Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel
objet qui contient les résultats paginés.
Cette méthode ne modifie pas la collection originelle;
elle renvoie plutôt un nouvel objet qui contient les résultats paginés.
"""
return Pagination(
collection,
@ -232,29 +262,35 @@ class ListeAssiJusti(tb.Table):
"""
Combine les requêtes d'assiduités et de justificatifs en une seule requête.
Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités
et une pour les justificatifs, et renvoie une requête combinée qui sélectionne
un ensemble spécifique de colonnes pour chaque type d'objet.
Cette fonction prend en entrée deux requêtes optionnelles,
une pour les assiduités et une pour les justificatifs,
et renvoie une requête combinée qui sélectionne un ensemble
spécifique de colonnes pour chaque type d'objet.
Les colonnes sélectionnées sont:
- obj_id: l'identifiant de l'objet (assiduite_id pour les assiduités, justif_id pour les justificatifs)
- obj_id: l'identifiant de l'objet
(assiduite_id pour les assiduités, justif_id pour les justificatifs)
- etudid: l'identifiant de l'étudiant
- entry_date: la date de saisie de l'objet
- date_debut: la date de début de l'objet
- date_fin: la date de fin de l'objet
- etat: l'état de l'objet
- type: le type de l'objet ("assiduite" pour les assiduités, "justificatif" pour les justificatifs)
- type: le type de l'objet
("assiduite" pour les assiduités, "justificatif" pour les justificatifs)
- est_just : si l'assiduité est justifié (booléen) None pour les justificatifs
- user_id : l'identifiant de l'utilisateur qui a signalé l'assiduité ou le justificatif
- user_id : l'identifiant de l'utilisateur qui a
signalé l'assiduité ou le justificatif
Args:
query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
pour les assiduités.
Si None (default), aucune assiduité ne sera incluse dans la requête combinée.
Si None (default), aucune assiduité ne sera incluse
dans la requête combinée.
query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
pour les justificatifs.
Si None (default), aucun justificatif ne sera inclus dans la requête combinée.
Si None (default), aucun justificatif ne sera
inclus dans la requête combinée.
Returns:
sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour
@ -599,10 +635,15 @@ class AssiFiltre:
Args:
type_obj (int, optional): type d'objet (0:Tout, 1: Assi, 2:Justi). Defaults to 0.
entry_date (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_debut (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_fin (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
etats (list[int | EtatJustificatif | EtatAssiduite], optional): liste d'états valides (int | EtatJustificatif | EtatAssiduite). Defaults to None.
entry_date (tuple[int, datetime], optional):
(0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_debut (tuple[int, datetime], optional):
(0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_fin (tuple[int, datetime], optional):
(0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
etats (list[int | EtatJustificatif | EtatAssiduite], optional):
liste d'états valides (int | EtatJustificatif | EtatAssiduite).
Defaults to None.
"""
self.filtres = {"type_obj": type_obj}
@ -637,7 +678,7 @@ class AssiFiltre:
type_filtrage, date = val_filtre
match (type_filtrage):
match type_filtrage:
# On garde uniquement les dates supérieures au filtre
case 2:
query_filtree = query_filtree.filter(
@ -734,6 +775,10 @@ class AssiJustifData:
@staticmethod
def from_etudiants(*etudiants: Identite) -> "AssiJustifData":
"""
Génère un object AssiJustifData à partir d'une liste d'étudiants
(Récupère les assiduités et justificatifs des étudiants)
"""
data = AssiJustifData()
data.assiduites_query = Assiduite.query.filter(
Assiduite.etudid.in_([e.etudid for e in etudiants])
@ -745,4 +790,5 @@ class AssiJustifData:
return data
def get(self) -> tuple[Query, Query]:
"Renvoi les requêtes d'assiduités et justificatifs"
return self.assiduites_query, self.justificatifs_query

View File

@ -37,7 +37,7 @@ class TableAssi(tb.Table):
convert_values=False,
**kwargs,
):
self.rows: list["RowEtud"] = [] # juste pour que VSCode nous aide sur .rows
self.rows: list["RowAssi"] = [] # juste pour que VSCode nous aide sur .rows
classes = ["gt_table"]
self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"]
self.formsemestre = formsemestre

View File

@ -15,9 +15,12 @@ Calendrier de l'assiduité
<h2>Assiduité de {{sco.etud.html_link_fiche()|safe}}</h2>
<div class="options">
<input type="checkbox" id="show_pres" name="show_pres" class="memo" {{'checked' if show_pres else ''}}><label for="show_pres">afficher les présences</label>
<input type="checkbox" name="show_reta" id="show_reta" class="memo" {{'checked' if show_reta else ''}}><label for="show_reta">afficher les retards</label>
<input type="checkbox" name="mode_demi" id="mode_demi" class="memo" {{'checked' if mode_demi else ''}}><label for="mode_demi">mode demi journée</label>
<input type="checkbox" id="show_pres" name="show_pres" class="memo" {{'checked' if show_pres else '' }}><label
for="show_pres">afficher les présences</label>
<input type="checkbox" name="show_reta" id="show_reta" class="memo" {{'checked' if show_reta else '' }}><label
for="show_reta">afficher les retards</label>
<input type="checkbox" name="mode_demi" id="mode_demi" class="memo" {{'checked' if mode_demi else '' }}><label
for="mode_demi">mode demi journée</label>
</div>
<div class="calendrier">
@ -64,7 +67,7 @@ Calendrier de l'assiduité
{% endfor %}
</div>
<div class="annee">
<span id="label-annee">Année scolaire 2022-2023</span><span id="label-changer" style="margin-left: 5px;">Changer
<span id="label-annee">Année scolaire</span><span id="label-changer" style="margin-left: 5px;">Changer
année: </span>
<select name="" id="annee">
</select>
@ -76,15 +79,18 @@ Calendrier de l'assiduité
<h3>Calendrier</h3>
<p>Code couleur</p>
<ul class="couleurs">
<li><span title="Vert" class="present demo"></span> &rightarrow; présence de l'étudiant lors de la période
<li><span title="Vert" class="present demo"></span> &rightarrow; présence de l'étudiant lors de la
période
</li>
<li><span title="Bleu clair" class="nonwork demo"></span> &rightarrow; la période n'est pas travaillée
</li>
<li><span title="Rouge" class="absent demo"></span> &rightarrow; absence de l'étudiant lors de la période
<li><span title="Rouge" class="absent demo"></span> &rightarrow; absence de l'étudiant lors de la
période
</li>
<li><span title="Rose" class="demo color absent est_just"></span> &rightarrow; absence justifiée
</li>
<li><span title="Orange" class="retard demo"></span> &rightarrow; retard de l'étudiant lors de la période
<li><span title="Orange" class="retard demo"></span> &rightarrow; retard de l'étudiant lors de la
période
</li>
<li><span title="Jaune clair" class="demo color retard est_just"></span> &rightarrow; retard justifié
</li>
@ -154,6 +160,7 @@ Calendrier de l'assiduité
.color.absent.est_just {
background-color: var(--color-absent-justi) !important;
}
.color.retard {
background-color: var(--color-retard) !important;
}
@ -218,31 +225,31 @@ Calendrier de l'assiduité
right: 0;
background-color: var(--color-justi-invalide) !important;
}
.color.attente::before, .color.modifie::before {
.color.attente::before,
.color.modifie::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
right: 0;
background: repeating-linear-gradient(
to bottom,
background: repeating-linear-gradient(to bottom,
var(--color-justi-attente-stripe) 0px,
var(--color-justi-attente-stripe) 4px,
var(--color-justi-attente) 4px,
var(--color-justi-attente) 7px
)!important;
var(--color-justi-attente) 7px) !important;
}
.demo.invalide {
background-color: var(--color-justi-invalide) !important;
}
.demo.attente {
background: repeating-linear-gradient(
to bottom,
background: repeating-linear-gradient(to bottom,
var(--color-justi-attente-stripe) 0px,
var(--color-justi-attente-stripe) 4px,
var(--color-justi-attente) 4px,
var(--color-justi-attente) 7px
)!important;
var(--color-justi-attente) 7px) !important;
}
.demo.est_just {
@ -358,7 +365,7 @@ Calendrier de l'assiduité
}
}
const defAnnee = {{ annee }}
const defAnnee = "{{ annee | safe}}"
let annees = {{ annees | safe }}
annees = annees.filter((x, i) => annees.indexOf(x) === i)
const etudid = {{ sco.etud.id }};
@ -366,11 +373,12 @@ Calendrier de l'assiduité
const select = document.querySelector('#annee');
annees.forEach((a) => {
const opt = document.createElement("option");
opt.value = a + "",
opt.textContent = `${a} - ${a + 1}`;
if (a === defAnnee) {
let a_1 = a.substring(0, 4)
opt.value = a_1 + "",
opt.textContent = a
if (a_1 === defAnnee) {
opt.selected = true;
document.querySelector('.annee #label-annee').textContent = `Année scolaire ${a}-${a + 1}`
document.querySelector('.annee #label-annee').textContent = `Année scolaire ${a}`
}
select.appendChild(opt)
@ -379,7 +387,8 @@ Calendrier de l'assiduité
document.querySelectorAll('input[type="checkbox"].memo, #annee').forEach(el => {
el.addEventListener('change', function () {
updatePage();
})});
})
});
document.querySelectorAll('[assi_id]').forEach((el, i) => {
el.addEventListener('click', () => {

View File

@ -25,8 +25,10 @@
##############################################################################
import datetime
import json
import re
from typing import Any
from collections import OrderedDict
from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response
@ -121,7 +123,6 @@ def bilan_dept():
if formsemestre_id:
try:
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
annee = formsemestre.annee_scolaire()
except AttributeError:
formsemestre_id = ""
@ -230,17 +231,22 @@ def ajout_assiduite_etud() -> str | Response:
# 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)")]
}
choices: OrderedDict = OrderedDict()
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()] = [
group_name: str = formsemestre.titre_annee()
choices[group_name] = [
(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
]
if formsemestre.est_courant():
choices.move_to_end(group_name, last=False)
choices.move_to_end("", last=False)
form.modimpl.choices = choices
if form.validate_on_submit():
@ -825,17 +831,19 @@ def calendrier_assi_etud():
# Récupération des années d'étude de l'étudiant
annees: list[int] = []
for ins in etud.formsemestre_inscriptions:
date_deb = ins.formsemestre.date_debut
date_fin = ins.formsemestre.date_fin
annees.extend(
(ins.formsemestre.date_debut.year, ins.formsemestre.date_fin.year)
[
scu.annee_scolaire_repr(date_deb.year, date_deb.month),
scu.annee_scolaire_repr(date_fin.year, date_fin.month),
]
)
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 += "]"
annees_str: str = json.dumps(annees)
calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee)
@ -857,6 +865,15 @@ def calendrier_assi_etud():
@scodoc
@permission_required(Permission.AbsChange)
def choix_date() -> str:
"""
choix_date Choix de la date pour la saisie des assiduités
Route utilisée uniquement si la date courante n'est pas dans le semestre
concerné par la requête vers une des pages suivantes :
- saisie_assiduites_group
- visu_assiduites_group
"""
formsemestre_id = request.args.get("formsemestre_id")
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
@ -973,9 +990,6 @@ def signal_assiduites_group():
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]
@ -1107,9 +1121,6 @@ def visu_assiduites_group():
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]
@ -1781,22 +1792,6 @@ def signal_assiduites_diff():
)
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:
@ -2075,8 +2070,8 @@ def _differee(
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.
periode (dict[str, str], optional):La période par défaut de la première colonne.
formsemestre_id (int, optional): l'id du semestre pour le selecteur de module.
Returns:
str: le widget (html/css/js)
@ -2162,7 +2157,7 @@ def _module_selector_multiple(
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 = {}
choices = OrderedDict()
for formsemestre_id in modimpls_by_formsemestre:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if only_form is not None and formsemestre != only_form:
@ -2177,6 +2172,9 @@ def _module_selector_multiple(
if m.module.ue.type == UE_STANDARD
]
if formsemestre.est_courant():
choices.move_to_end(formsemestre.titre_annee(), last=False)
return render_template(
"assiduites/widgets/moduleimpl_selector_multiple.j2",
choices=choices,
@ -2262,6 +2260,9 @@ def generate_calendar(
etudiant: Identite,
annee: int = None,
) -> dict[str, list["Jour"]]:
"""
Génère le calendrier d'assiduité de l'étudiant pour une année scolaire donnée
"""
# Si pas d'année alors on prend l'année scolaire en cours
if annee is None:
annee = scu.annee_scolaire()
@ -2309,13 +2310,26 @@ class Jour:
self.justificatifs = justificatifs
def get_nom(self, mode_demi: bool = True) -> str:
"""
Renvoie le nom du jour
"M19" ou "Mer 19"
"""
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}"
return (
f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour[:3]+' '}"
+ f"{self.date.day}"
)
def get_date(self) -> str:
"""
Renvoie la date du jour au format "dd/mm/yyyy"
"""
return self.date.strftime("%d/%m/%Y")
def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str:
"""
Retourne la classe css du jour (mode normal)
"""
etat = ""
est_just = ""
@ -2337,13 +2351,19 @@ class Jour:
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(":"))))
"""
Renvoie la class css de la demi journée
"""
heure_midi = str2time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))
heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))
if matin:
heure_matin = str2time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
heure_matin = scass.str_to_time(
ScoDocSiteConfig.get("assi_morning_time", "08:00")
)
log(
f'{ScoDocSiteConfig.get("assi_morning_time", "08:00")=}{heure_matin=} {type(heure_matin)=}'
)
matin = (
# date debut
scu.localize_datetime(
@ -2355,12 +2375,16 @@ class Jour:
assiduites_matin = [
assi
for assi in self.assiduites
if scu.is_period_overlapping((assi.date_debut, assi.date_fin), matin)
if scu.is_period_overlapping(
(assi.date_debut, assi.date_fin), matin, bornes=False
)
]
justificatifs_matin = [
justi
for justi in self.justificatifs
if scu.is_period_overlapping((justi.date_debut, justi.date_fin), matin)
if scu.is_period_overlapping(
(justi.date_debut, justi.date_fin), matin, bornes=False
)
]
etat = self._get_color_assiduites_cascade(
@ -2375,7 +2399,9 @@ class Jour:
return f"color {etat} {est_just}"
heure_soir = str2time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))
heure_soir = scass.str_to_time(
ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
)
# séparation en demi journées
aprem = (
@ -2388,13 +2414,17 @@ class Jour:
assiduites_aprem = [
assi
for assi in self.assiduites
if scu.is_period_overlapping((assi.date_debut, assi.date_fin), aprem)
if scu.is_period_overlapping(
(assi.date_debut, assi.date_fin), aprem, bornes=False
)
]
justificatifs_aprem = [
justi
for justi in self.justificatifs
if scu.is_period_overlapping((justi.date_debut, justi.date_fin), aprem)
if scu.is_period_overlapping(
(justi.date_debut, justi.date_fin), aprem, bornes=False
)
]
etat = self._get_color_assiduites_cascade(
@ -2410,22 +2440,21 @@ class Jour:
return f"color {etat} {est_just}"
def has_assiduites(self) -> bool:
"""
Renverra True si le jour a des assiduités
"""
return self.assiduites.count() > 0
def generate_minitimeline(self) -> str:
"""
Génère la minitimeline du jour
"""
# 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 = _time_to_timedelta(
scass.str_to_time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
)
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")
heure_soir: datetime.timedelta = _time_to_timedelta(
scass.str_to_time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))
)
# longueur_timeline = heure_soir - heure_matin
longueur_timeline: datetime.timedelta = heure_soir - heure_matin
@ -2433,6 +2462,7 @@ class Jour:
# chaque block d'assiduité est défini par:
# longueur = ( (fin-deb) / longueur_timeline ) * 100
# emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100
# longueur + emplacement = 100% sinon on réduit longueur
assiduite_blocks: list[dict[str, float | str]] = []
@ -2448,8 +2478,10 @@ class Jour:
else heure_soir
)
longueur: float = ((fin - deb) / longueur_timeline) * 100
emplacement: float = ((deb - heure_matin) / longueur_timeline) * 100
longueur: float = ((fin - deb) / longueur_timeline) * 100
if longueur + emplacement > 100:
longueur = 100 - emplacement
etat: str = scu.EtatAssiduite(assi.etat).name.lower()
est_just: str = "est_just" if assi.est_just else ""
@ -2470,17 +2502,21 @@ class Jour:
)
def is_non_work(self):
"""
Renvoie True si le jour est un jour non travaillé
(en fonction de la préférence du département)
"""
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]))
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]))
return list(set(scu.EtatJustificatif(justi.etat) for justi in justificatifs))
def _get_color_assiduites_cascade(
self,

View File

@ -61,11 +61,11 @@ class DevConfig(Config):
DEBUG = True
TESTING = False
SQLALCHEMY_DATABASE_URI = (
os.environ.get("SCODOC_DATABASE_URI") or "postgresql:///SCODOC"
os.environ.get("SCODOC_DATABASE_URI") or "postgresql:///SCODOC_DEV"
)
SECRET_KEY = os.environ.get("DEV_SECRET_KEY") or "bb3faec7d9a34eb68a8e3e710087d87a"
# pour le avoir url_for dans le shell:
SERVER_NAME = "http://localhost:8080"
# SERVER_NAME = os.environ.get("SCODOC_TEST_SERVER_NAME") or "localhost"
class TestConfig(DevConfig):

View File

@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.6.946"
SCOVERSION = "9.6.948"
SCONAME = "ScoDoc"

View File

@ -21,7 +21,7 @@
#
# E. Viennet, Fev 2023
cd /opt/scodoc
cd /opt/scodoc || exit 1
# suppose que le virtual env est bien configuré
# Utilise un port spécifique pour pouvoir lancer ce test sans couper