Merge branch 'scodoc-master' into pe-but-v4

This commit is contained in:
Cléo Baras 2024-02-28 11:02:31 +01:00
commit 9c7576154c
19 changed files with 628 additions and 489 deletions

View File

@ -187,7 +187,7 @@ def dept_etudiants(acronym: str):
] ]
""" """
dept = Departement.query.filter_by(acronym=acronym).first_or_404() 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") @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é. Retourne la liste des étudiants d'un département d'id donné.
""" """
dept = Departement.query.get_or_404(dept_id) 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") @bp.route("/departement/<string:acronym>/formsemestres_ids")

View File

@ -114,10 +114,13 @@ class EtudCursusBUT:
validation_rcue: ApcValidationRCUE validation_rcue: ApcValidationRCUE
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau() niveau = validation_rcue.niveau()
if ( if niveau is None:
niveau is None raise ScoValueError(
or not niveau.competence.id in self.validation_par_competence_et_annee """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] = {} self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get( previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id niveau.competence.id

View File

@ -126,6 +126,7 @@ class AjoutAssiOrJustForm(FlaskForm):
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm): class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'une assiduité pour un étudiant" "Formulaire de saisie d'une assiduité pour un étudiant"
description = TextAreaField( description = TextAreaField(
"Description", "Description",
render_kw={ render_kw={
@ -152,6 +153,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm): class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'un justificatif pour un étudiant" "Formulaire de saisie d'un justificatif pour un étudiant"
raison = TextAreaField( raison = TextAreaField(
"Raison", "Raison",
render_kw={ render_kw={
@ -176,6 +178,12 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
class ChoixDateForm(FlaskForm): 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): def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages" "Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

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

View File

@ -20,8 +20,11 @@ class Trace:
Role des fichiers traces : Role des fichiers traces :
- Sauvegarder la date de dépôt du fichier - 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 la date de suppression du fichier
- 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) (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 : _trace.csv :
nom_fichier_srv,datetime_depot,datetime_suppr,user_id 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 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. 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 : 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 : 2. Ajout d'assiduités :
Exemple d'ajout d'assiduité : Exemple d'ajout d'assiduité :
- calculator.compute_assiduites(etudiant.assiduites) - 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. le nombre total de jours, de demi-journées et d'heures calculées.
Exemple d'accès aux métriques : Exemple d'accès aux métriques :
metrics = calculator.to_dict() 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) (horaires personnalisés)
Exemple de réinitialisation : Exemple de réinitialisation :
calculator.reset() calculator.reset()
@ -61,8 +74,10 @@ class CountCalculator:
- reset() : Réinitialise les compteurs de la classe. - reset() : Réinitialise les compteurs de la classe.
- add_half_day(day: date, is_morning: bool) : Ajoute une demi-journée au comptage. - 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. - 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_long_assiduite(assi: Assiduite) : Traite les assiduités
- compute_assiduites(assiduites: Query | list) : Calcule les métriques pour une collection d'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. - to_dict() : Retourne les métriques sous forme de dictionnaire.
Notes : Notes :
@ -85,17 +100,14 @@ class CountCalculator:
evening: str = None, evening: str = None,
nb_heures_par_jour: int = None, nb_heures_par_jour: int = None,
) -> None: ) -> None:
# Transformation d'une heure "HH:MM" en time(h,m) self.morning: time = str_to_time(
STR_TIME = lambda x: time(*list(map(int, x.split(":"))))
self.morning: time = STR_TIME(
morning if morning else ScoDocSiteConfig.get("assi_morning_time", "08:00") morning if morning else ScoDocSiteConfig.get("assi_morning_time", "08:00")
) )
# Date pivot pour déterminer les demi-journées # 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") 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") 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) 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) # Sera utilisé pour les assiduités longues (> 1 journée)
self.nb_heures_par_jour = ( self.nb_heures_par_jour = (
nb_heures_par_jour nb_heures_par_jour
@ -340,17 +348,27 @@ class CountCalculator:
def setup_data(self): def setup_data(self):
"""Met en forme les données """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: for value in self.data.values():
self.data[key]["journee"] = len(self.data[key]["journee"]) value["journee"] = len(value["journee"])
self.data[key]["demi"] = len(self.data[key]["demi"]) value["demi"] = len(value["demi"])
def to_dict(self, only_total: bool = True) -> dict[str, int | float]: def to_dict(self, only_total: bool = True) -> dict[str, int | float]:
"""Retourne les métriques sous la forme d'un dictionnaire""" """Retourne les métriques sous la forme d'un dictionnaire"""
return self.data["total"] if only_total else self.data 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( def get_assiduites_stats(
assiduites: Query, metric: str = "all", filtered: dict[str, object] = None assiduites: Query, metric: str = "all", filtered: dict[str, object] = None
) -> dict[str, int | float]: ) -> dict[str, int | float]:
@ -756,7 +774,6 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime):
pour cet étudiant et cette date. pour cet étudiant et cette date.
Invalide cache absence et caches semestre Invalide cache absence et caches semestre
""" """
from app.scodoc import sco_compute_moy
# Semestres a cette date: # Semestres a cette date:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True) 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: # Invalide les PDF et les absences:
for sem in sems: for sem in sems:
# Inval cache bulletin et/ou note_table # Inval cache bulletin et/ou note_table
if sco_compute_moy.formsemestre_expressions_use_abscounts( # efface toujours le PDF car il affiche en général les absences
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( sco_cache.invalidate_formsemestre(
formsemestre_id=sem["formsemestre_id"], pdfonly=pdfonly formsemestre_id=sem["formsemestre_id"], pdfonly=True
) )
# Inval cache compteurs absences: # Inval cache compteurs absences:
@ -818,4 +827,4 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None):
pattern=f"tableau-etud-{etudid}*" pattern=f"tableau-etud-{etudid}*"
) )
# Invalide les tableaux "bilan dept" # 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, deb <= Assiduite.date_fin,
) )
abs_etudids = set(assi.etudid for assi in assiduites) abs_etudids = {assi.etudid for assi in assiduites}
abs_nj_etudids = set(assi.etudid for assi in assiduites if assi.est_just is False) abs_nj_etudids = {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) just_etudids = {assi.etudid for assi in assiduites if assi.est_just is True}
# Les notes: # Les notes:
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id) 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) % (pname, pvalue, formsemestre_id)
) )
# 5- Copy formules utilisateur # 5- Copie les parcours
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
formsemestre.parcours = formsemestre_orig.parcours formsemestre.parcours = formsemestre_orig.parcours
db.session.add(formsemestre) db.session.add(formsemestre)
db.session.commit() db.session.commit()
# 7- Copy partitions and groups # 6- Copy partitions and groups
if clone_partitions: if clone_partitions:
sco_groups_copy.clone_partitions_and_groups( sco_groups_copy.clone_partitions_and_groups(
orig_formsemestre_id, formsemestre_id orig_formsemestre_id, formsemestre_id

View File

@ -1216,7 +1216,7 @@ def formsemestre_tableau_modules(
if expr: if expr:
H.append( H.append(
f""" <span class="formula" title="mode de calcul de la moyenne d'UE">{expr}</span> 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 ) url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ue_id=ue.id )
} }
">supprimer</a></span>""" ">supprimer</a></span>"""

View File

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

View File

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

View File

@ -3407,6 +3407,17 @@ li.tf-msg {
padding-bottom: 5px; 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 { .warning, .warning-bloquant {
color: red; color: red;
margin-left: 16px; 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 datetime import datetime
from flask import url_for from flask import url_for
@ -8,10 +13,18 @@ from sqlalchemy import desc, literal, union, asc
from app import db, g from app import db, g
from app.auth.models import User from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif 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.tables import table_builder as tb
from app.scodoc.sco_cache import RequeteTableauAssiduiteCache from app.scodoc.sco_cache import RequeteTableauAssiduiteCache
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_preferences import get_preference
class Pagination: 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()` 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. 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 __init__ Instancie un nouvel objet Pagination
Args: 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. 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) # par défaut le total des pages est 1 (même si la collection est vide)
self.total_pages = 1 self.total_pages = 1
@ -195,6 +212,17 @@ class ListeAssiJusti(tb.Table):
r = query_finale.all() r = query_finale.all()
RequeteTableauAssiduiteCache.set(cle_cache, r) 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 # Paginer la requête pour ne pas envoyer trop d'informations au client
pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination) pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination)
self.total_pages = pagination.total_pages self.total_pages = pagination.total_pages
@ -212,15 +240,17 @@ class ListeAssiJusti(tb.Table):
attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`. attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`.
Args: 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. été construite et qui est prête à être exécutée.
Returns: 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: Note:
Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel Cette méthode ne modifie pas la collection originelle;
objet qui contient les résultats paginés. elle renvoie plutôt un nouvel objet qui contient les résultats paginés.
""" """
return Pagination( return Pagination(
collection, collection,
@ -232,29 +262,35 @@ class ListeAssiJusti(tb.Table):
""" """
Combine les requêtes d'assiduités et de justificatifs en une seule requête. 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 Cette fonction prend en entrée deux requêtes optionnelles,
et une pour les justificatifs, et renvoie une requête combinée qui sélectionne une pour les assiduités et une pour les justificatifs,
un ensemble spécifique de colonnes pour chaque type d'objet. 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: 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 - etudid: l'identifiant de l'étudiant
- entry_date: la date de saisie de l'objet - entry_date: la date de saisie de l'objet
- date_debut: la date de début de l'objet - date_debut: la date de début de l'objet
- date_fin: la date de fin de l'objet - date_fin: la date de fin de l'objet
- etat: l'état 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 - 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: Args:
query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
pour les assiduités. 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 query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
pour les justificatifs. 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: Returns:
sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour
@ -599,10 +635,15 @@ class AssiFiltre:
Args: Args:
type_obj (int, optional): type d'objet (0:Tout, 1: Assi, 2:Justi). Defaults to 0. 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. entry_date (tuple[int, datetime], optional):
date_debut (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None. (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. date_debut (tuple[int, datetime], optional):
etats (list[int | EtatJustificatif | EtatAssiduite], optional): liste d'états valides (int | EtatJustificatif | EtatAssiduite). Defaults to None. (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} self.filtres = {"type_obj": type_obj}
@ -637,7 +678,7 @@ class AssiFiltre:
type_filtrage, date = val_filtre type_filtrage, date = val_filtre
match (type_filtrage): match type_filtrage:
# On garde uniquement les dates supérieures au filtre # On garde uniquement les dates supérieures au filtre
case 2: case 2:
query_filtree = query_filtree.filter( query_filtree = query_filtree.filter(
@ -734,6 +775,10 @@ class AssiJustifData:
@staticmethod @staticmethod
def from_etudiants(*etudiants: Identite) -> "AssiJustifData": 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 = AssiJustifData()
data.assiduites_query = Assiduite.query.filter( data.assiduites_query = Assiduite.query.filter(
Assiduite.etudid.in_([e.etudid for e in etudiants]) Assiduite.etudid.in_([e.etudid for e in etudiants])
@ -745,4 +790,5 @@ class AssiJustifData:
return data return data
def get(self) -> tuple[Query, Query]: def get(self) -> tuple[Query, Query]:
"Renvoi les requêtes d'assiduités et justificatifs"
return self.assiduites_query, self.justificatifs_query return self.assiduites_query, self.justificatifs_query

View File

@ -37,7 +37,7 @@ class TableAssi(tb.Table):
convert_values=False, convert_values=False,
**kwargs, **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"] classes = ["gt_table"]
self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"] self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"]
self.formsemestre = formsemestre self.formsemestre = formsemestre

View File

@ -3,9 +3,9 @@
Calendrier de l'assiduité Calendrier de l'assiduité
{% endblock title %} {% endblock title %}
{% block styles %} {% block styles %}
{{ super() }} {{ super() }}
<link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/minitimeline.css"> <link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/minitimeline.css">
<link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/assiduites.css"> <link rel="stylesheet" href="{{scu.STATIC_DIR}}/css/assiduites.css">
{% endblock styles %} {% endblock styles %}
{% block app_content %} {% block app_content %}
@ -15,379 +15,388 @@ Calendrier de l'assiduité
<h2>Assiduité de {{sco.etud.html_link_fiche()|safe}}</h2> <h2>Assiduité de {{sco.etud.html_link_fiche()|safe}}</h2>
<div class="options"> <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" id="show_pres" name="show_pres" class="memo" {{'checked' if show_pres else '' }}><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> for="show_pres">afficher les présences</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" 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>
<div class="calendrier"> <div class="calendrier">
{% for mois,jours in calendrier.items() %} {% for mois,jours in calendrier.items() %}
<div class="month"> <div class="month">
<h3>{{mois}}</h3> <h3>{{mois}}</h3>
<div class="days {{'demi' if mode_demi else ''}}"> <div class="days {{'demi' if mode_demi else ''}}">
{% for jour in jours %} {% for jour in jours %}
{% if jour.is_non_work() %} {% if jour.is_non_work() %}
<div class="day {{jour.get_class()}}"> <div class="day {{jour.get_class()}}">
<span>{{jour.get_nom()}}</span> <span>{{jour.get_nom()}}</span>
{% else %} {% else %}
<div class="day {{jour.get_class(show_pres, show_reta) if not mode_demi else ''}}"> <div class="day {{jour.get_class(show_pres, show_reta) if not mode_demi else ''}}">
{% endif %} {% endif %}
{% if mode_demi %} {% if mode_demi %}
{% if not jour.is_non_work() %} {% if not jour.is_non_work() %}
<span>{{jour.get_nom()}}</span> <span>{{jour.get_nom()}}</span>
<span class="{{jour.get_demi_class(True, show_pres,show_reta)}}"></span> <span class="{{jour.get_demi_class(True, show_pres,show_reta)}}"></span>
<span class="{{jour.get_demi_class(False, show_pres,show_reta)}}"></span> <span class="{{jour.get_demi_class(False, show_pres,show_reta)}}"></span>
{% endif %} {% endif %}
{% else %} {% else %}
{% if not jour.is_non_work() %} {% if not jour.is_non_work() %}
<span>{{jour.get_nom(False)}}</span> <span>{{jour.get_nom(False)}}</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if not jour.is_non_work() and jour.has_assiduites()%} {% if not jour.is_non_work() and jour.has_assiduites()%}
<div class="dayline"> <div class="dayline">
<div class="dayline-title"> <div class="dayline-title">
<span>Assiduité du</span> <span>Assiduité du</span>
<br> <br>
<span>{{jour.get_date()}}</span> <span>{{jour.get_date()}}</span>
{{jour.generate_minitimeline() | safe}} {{jour.generate_minitimeline() | safe}}
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="annee"> <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> année: </span>
<select name="" id="annee"> <select name="" id="annee">
</select> </select>
<span id="label-nom">Assiduité de {{sco.etud.nomprenom}}</span> <span id="label-nom">Assiduité de {{sco.etud.nomprenom}}</span>
</div> </div>
<div class="help"> <div class="help">
<h3>Calendrier</h3> <h3>Calendrier</h3>
<p>Code couleur</p> <p>Code couleur</p>
<ul class="couleurs"> <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
</li> période
<li><span title="Bleu clair" class="nonwork demo"></span> &rightarrow; la période n'est pas travaillée </li>
</li> <li><span title="Bleu clair" class="nonwork demo"></span> &rightarrow; la période n'est pas travaillée
<li><span title="Rouge" class="absent demo"></span> &rightarrow; absence de l'étudiant lors de la période </li>
</li> <li><span title="Rouge" class="absent demo"></span> &rightarrow; absence de l'étudiant lors de la
<li><span title="Rose" class="demo color absent est_just"></span> &rightarrow; absence justifiée période
</li> </li>
<li><span title="Orange" class="retard demo"></span> &rightarrow; retard de l'étudiant lors de la période <li><span title="Rose" class="demo color absent est_just"></span> &rightarrow; absence justifiée
</li> </li>
<li><span title="Jaune clair" class="demo color retard est_just"></span> &rightarrow; retard justifié <li><span title="Orange" class="retard demo"></span> &rightarrow; retard de l'étudiant lors de la
</li> période
</li>
<li><span title="Jaune clair" class="demo color retard est_just"></span> &rightarrow; retard justifié
</li>
<li><span title="Quart Bleu" class="est_just demo"></span> &rightarrow; la période est couverte par un <li><span title="Quart Bleu" class="est_just demo"></span> &rightarrow; la période est couverte par un
justificatif valide</li>
<li><span title="Justif. non valide" class="invalide demo"></span> &rightarrow; la période est
couverte par un justificatif non valide
</li>
<li><span title="Justif. en attente" class="attente demo"></span> &rightarrow; la période
a un justificatif en attente de validation
</li>
</ul>
<p>Vous pouvez passer le curseur sur les jours colorés afin de voir les informations supplémentaires</p>
</div>
<ul class="couleurs print">
<li><span title="Vert" class="present demo"></span> présence
</li>
<li><span title="Bleu clair" class="nonwork demo"></span> non travaillé
</li>
<li><span title="Rouge" class="absent demo"></span> absence
</li>
<li><span title="Rose" class="demo color absent est_just"></span> absence justifiée
</li>
<li><span title="Orange" class="retard demo"></span> retard
</li>
<li><span title="Jaune clair" class="demo color retard est_just"></span>retard justifié
</li>
<li><span title="Quart Bleu" class="est_just demo"></span>
justificatif valide</li> justificatif valide</li>
<li><span title="Justif. non valide" class="invalide demo"></span> &rightarrow; la période est <li><span title="Quart Violet" class="invalide demo"></span> justificatif non valide
couverte par un justificatif non valide
</li>
<li><span title="Justif. en attente" class="attente demo"></span> &rightarrow; la période
a un justificatif en attente de validation
</li> </li>
</ul> </ul>
<p>Vous pouvez passer le curseur sur les jours colorés afin de voir les informations supplémentaires</p>
</div> </div>
<ul class="couleurs print">
<li><span title="Vert" class="present demo"></span> présence
</li>
<li><span title="Bleu clair" class="nonwork demo"></span> non travaillé
</li>
<li><span title="Rouge" class="absent demo"></span> absence
</li>
<li><span title="Rose" class="demo color absent est_just"></span> absence justifiée
</li>
<li><span title="Orange" class="retard demo"></span> retard
</li>
<li><span title="Jaune clair" class="demo color retard est_just"></span>retard justifié
</li>
<li><span title="Quart Bleu" class="est_just demo"></span>
justificatif valide</li>
<li><span title="Quart Violet" class="invalide demo"></span> justificatif non valide
</li>
</ul>
</div>
<style> <style>
.help .couleurs { .help .couleurs {
grid-template-columns: 2; grid-template-columns: 2;
grid-template-rows: auto; grid-template-rows: auto;
display: grid; display: grid;
} }
.couleurs.print {
display: none;
}
.help .couleurs li:nth-child(odd) {
grid-column: 1;
list-style-type: none;
}
.help .couleurs li:nth-child(even) {
grid-column: 2;
list-style-type: none;
}
.color.present {
background-color: var(--color-present) !important;
}
.color.absent {
background-color: var(--color-absent) !important;
}
.color.absent.est_just {
background-color: var(--color-absent-justi) !important;
}
.color.retard {
background-color: var(--color-retard) !important;
}
.color.retard.est_just {
background-color: var(--color-retard-justi) !important;
}
.color.nonwork {
background-color: var(--color-nonwork) !important;
}
.color {
background-color: var(--color-defaut) !important;
}
.pageContent {
margin-top: 1vh;
max-width: var(--sco-content-max-width);
}
.calendrier {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
border: 1px solid #444;
border-radius: 12px;
margin-bottom: 12px;
}
.month h3 {
text-align: center;
}
.day,
.demi .day.color.nonwork {
text-align: left;
margin: 2px;
cursor: default;
font-size: 13px;
position: relative;
font-weight: normal;
min-width: 6em;
display: flex;
justify-content: start;
}
.color.est_just.sans_etat::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
background-color: var(--color-justi) !important;
right: 0;
}
.color.invalide::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
right: 0;
background-color: var(--color-justi-invalide) !important;
}
.color.attente::before, .color.modifie::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
right: 0;
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;
}
.demo.invalide {
background-color: var(--color-justi-invalide) !important;
}
.demo.attente {
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;
}
.demo.est_just {
background-color: var(--color-justi) !important;
}
.demi .day.nonwork>span {
flex: none;
border: none;
}
.demi .day {
border-radius: 0;
}
@media print {
.couleurs.print { .couleurs.print {
display: flex;
justify-content: space-evenly;
align-items: center;
}
.couleurs.print li {
list-style-type: none !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.day,
.demi .day.color.color.nonwork {
min-width: 5em;
font-size: 11px;
}
.demi .day>span:first-of-type {
width: 2.5em;
min-width: 2.5em;
}
.color {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.day.est_just,
.demi .day span.est_just {
background-image: none;
}
.day.invalide,
.demi .day span.invalide {
background-image: none;
}
.demi .day span.est_just::before {
content: "J";
}
.demi .day span.invalide::before {
content: "JI";
}
#sidebar,
.help,
h2,
#annee,
#label-changer,
.options {
display: none; display: none;
} }
#label-nom, .help .couleurs li:nth-child(odd) {
#label-justi { grid-column: 1;
display: inline; list-style-type: none;
} }
#gtrcontent { .help .couleurs li:nth-child(even) {
margin: 5px; grid-column: 2;
list-style-type: none;
} }
.annee { .color.present {
background-color: var(--color-present) !important;
}
.color.absent {
background-color: var(--color-absent) !important;
}
.color.absent.est_just {
background-color: var(--color-absent-justi) !important;
}
.color.retard {
background-color: var(--color-retard) !important;
}
.color.retard.est_just {
background-color: var(--color-retard-justi) !important;
}
.color.nonwork {
background-color: var(--color-nonwork) !important;
}
.color {
background-color: var(--color-defaut) !important;
}
.pageContent {
margin-top: 1vh;
max-width: var(--sco-content-max-width);
}
.calendrier {
display: flex; display: flex;
justify-content: space-evenly; justify-content: space-evenly;
align-items: center; flex-wrap: wrap;
border: 1px solid #444;
border-radius: 12px;
margin-bottom: 12px;
} }
}
</style>
<script> .month h3 {
function getOptions() { text-align: center;
return {
"show_pres": document.getElementById("show_pres").checked,
"show_reta": document.getElementById("show_reta").checked,
"mode_demi": document.getElementById("mode_demi").checked,
} }
}
.day,
function updatePage(){ .demi .day.color.nonwork {
const url = new URL(location.href); text-align: left;
const options = getOptions(); margin: 2px;
url.searchParams.set("annee", document.getElementById('annee').value); cursor: default;
url.searchParams.set("mode_demi", options.mode_demi); font-size: 13px;
url.searchParams.set("show_pres", options.show_pres); position: relative;
url.searchParams.set("show_reta", options.show_reta); font-weight: normal;
min-width: 6em;
if (location.href != url.href){ display: flex;
location.href = url.href justify-content: start;
} }
}
const defAnnee = {{ annee }}
let annees = {{ annees | safe }}
annees = annees.filter((x, i) => annees.indexOf(x) === i)
const etudid = {{ sco.etud.id }};
const select = document.querySelector('#annee');
annees.forEach((a) => {
const opt = document.createElement("option");
opt.value = a + "",
opt.textContent = `${a} - ${a + 1}`;
if (a === defAnnee) {
opt.selected = true;
document.querySelector('.annee #label-annee').textContent = `Année scolaire ${a}-${a + 1}`
.color.est_just.sans_etat::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
background-color: var(--color-justi) !important;
right: 0;
} }
select.appendChild(opt)
}) .color.invalide::before {
content: "";
document.querySelectorAll('input[type="checkbox"].memo, #annee').forEach(el => { position: absolute;
el.addEventListener('change', function() { width: 25%;
updatePage(); height: 100%;
})}); right: 0;
background-color: var(--color-justi-invalide) !important;
document.querySelectorAll('[assi_id]').forEach((el,i) => { }
el.addEventListener('click', ()=>{
const assi_id = el.getAttribute('assi_id'); .color.attente::before,
window.open(`${SCO_URL}/Assiduites/tableau_assiduite_actions?type=assiduite&action=details&obj_id=${assi_id}`); .color.modifie::before {
content: "";
position: absolute;
width: 25%;
height: 100%;
right: 0;
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;
}
.demo.invalide {
background-color: var(--color-justi-invalide) !important;
}
.demo.attente {
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;
}
.demo.est_just {
background-color: var(--color-justi) !important;
}
.demi .day.nonwork>span {
flex: none;
border: none;
}
.demi .day {
border-radius: 0;
}
@media print {
.couleurs.print {
display: flex;
justify-content: space-evenly;
align-items: center;
}
.couleurs.print li {
list-style-type: none !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.day,
.demi .day.color.color.nonwork {
min-width: 5em;
font-size: 11px;
}
.demi .day>span:first-of-type {
width: 2.5em;
min-width: 2.5em;
}
.color {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.day.est_just,
.demi .day span.est_just {
background-image: none;
}
.day.invalide,
.demi .day span.invalide {
background-image: none;
}
.demi .day span.est_just::before {
content: "J";
}
.demi .day span.invalide::before {
content: "JI";
}
#sidebar,
.help,
h2,
#annee,
#label-changer,
.options {
display: none;
}
#label-nom,
#label-justi {
display: inline;
}
#gtrcontent {
margin: 5px;
}
.annee {
display: flex;
justify-content: space-evenly;
align-items: center;
}
}
</style>
<script>
function getOptions() {
return {
"show_pres": document.getElementById("show_pres").checked,
"show_reta": document.getElementById("show_reta").checked,
"mode_demi": document.getElementById("mode_demi").checked,
}
}
function updatePage() {
const url = new URL(location.href);
const options = getOptions();
url.searchParams.set("annee", document.getElementById('annee').value);
url.searchParams.set("mode_demi", options.mode_demi);
url.searchParams.set("show_pres", options.show_pres);
url.searchParams.set("show_reta", options.show_reta);
if (location.href != url.href) {
location.href = url.href
}
}
const defAnnee = "{{ annee | safe}}"
let annees = {{ annees | safe }}
annees = annees.filter((x, i) => annees.indexOf(x) === i)
const etudid = {{ sco.etud.id }};
const select = document.querySelector('#annee');
annees.forEach((a) => {
const opt = document.createElement("option");
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}`
}
select.appendChild(opt)
}) })
});
</script> document.querySelectorAll('input[type="checkbox"].memo, #annee').forEach(el => {
{% endblock app_content %} el.addEventListener('change', function () {
updatePage();
})
});
document.querySelectorAll('[assi_id]').forEach((el, i) => {
el.addEventListener('click', () => {
const assi_id = el.getAttribute('assi_id');
window.open(`${SCO_URL}/Assiduites/tableau_assiduite_actions?type=assiduite&action=details&obj_id=${assi_id}`);
})
});
</script>
{% endblock app_content %}

View File

@ -25,8 +25,10 @@
############################################################################## ##############################################################################
import datetime import datetime
import json
import re import re
from typing import Any
from collections import OrderedDict
from flask import g, request, render_template, flash from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response from flask import abort, url_for, redirect, Response
@ -121,7 +123,6 @@ def bilan_dept():
if formsemestre_id: if formsemestre_id:
try: try:
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
annee = formsemestre.annee_scolaire()
except AttributeError: except AttributeError:
formsemestre_id = "" 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 # On dresse la liste des modules de l'année scolaire en cours
# auxquels est inscrit l'étudiant pour peupler le menu "module" # auxquels est inscrit l'étudiant pour peupler le menu "module"
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
choices = { choices: OrderedDict = OrderedDict()
"": [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")] choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
}
for formsemestre_id in modimpls_by_formsemestre: for formsemestre_id in modimpls_by_formsemestre:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
# indique le nom du semestre dans le menu (optgroup) # 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 ''}") (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
for m in modimpls_by_formsemestre[formsemestre_id] for m in modimpls_by_formsemestre[formsemestre_id]
if m.module.ue.type == UE_STANDARD 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 form.modimpl.choices = choices
if form.validate_on_submit(): if form.validate_on_submit():
@ -825,17 +831,19 @@ def calendrier_assi_etud():
# Récupération des années d'étude de l'étudiant # Récupération des années d'étude de l'étudiant
annees: list[int] = [] annees: list[int] = []
for ins in etud.formsemestre_inscriptions: for ins in etud.formsemestre_inscriptions:
date_deb = ins.formsemestre.date_debut
date_fin = ins.formsemestre.date_fin
annees.extend( 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) annees = sorted(annees, reverse=True)
# Transformation en une liste "json" # Transformation en une liste "json"
# (sera utilisé pour générer le selecteur d'année) # (sera utilisé pour générer le selecteur d'année)
annees_str: str = "[" annees_str: str = json.dumps(annees)
for ann in annees:
annees_str += f"{ann},"
annees_str += "]"
calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee) calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee)
@ -857,6 +865,15 @@ def calendrier_assi_etud():
@scodoc @scodoc
@permission_required(Permission.AbsChange) @permission_required(Permission.AbsChange)
def choix_date() -> str: 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_id = request.args.get("formsemestre_id")
formsemestre: FormSemestre = FormSemestre.query.get_or_404(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: if formsemestre.dept_id != g.scodoc_dept_id:
abort(404, "groupes inexistants dans ce département") 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 # Récupération des étudiants des groupes
etuds = [ etuds = [
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] 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: if formsemestre.dept_id != g.scodoc_dept_id:
abort(404, "groupes inexistants dans ce département") 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) # Récupération des étudiants du/des groupe(s)
etuds = [ etuds = [
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] 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)) 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: if groups_infos.tous_les_etuds_du_sem:
gr_tit = "en" gr_tit = "en"
else: else:
@ -2075,8 +2070,8 @@ def _differee(
etudiants (list[dict]): la liste des étudiants (représentés par des dictionnaires) etudiants (list[dict]): la liste des étudiants (représentés par des dictionnaires)
moduleimpl_select (str): l'html représentant le selecteur de module moduleimpl_select (str): l'html représentant le selecteur de module
date (str, optional): la première date à afficher. Defaults to None. 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. 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. Defaults to None. formsemestre_id (int, optional): l'id du semestre pour le selecteur de module.
Returns: Returns:
str: le widget (html/css/js) 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é. Prend les semestres de l'année, sauf si only_form est indiqué.
""" """
modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire()) modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
choices = {} choices = OrderedDict()
for formsemestre_id in modimpls_by_formsemestre: for formsemestre_id in modimpls_by_formsemestre:
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if only_form is not None and formsemestre != only_form: 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 m.module.ue.type == UE_STANDARD
] ]
if formsemestre.est_courant():
choices.move_to_end(formsemestre.titre_annee(), last=False)
return render_template( return render_template(
"assiduites/widgets/moduleimpl_selector_multiple.j2", "assiduites/widgets/moduleimpl_selector_multiple.j2",
choices=choices, choices=choices,
@ -2262,6 +2260,9 @@ def generate_calendar(
etudiant: Identite, etudiant: Identite,
annee: int = None, annee: int = None,
) -> dict[str, list["Jour"]]: ) -> 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 # Si pas d'année alors on prend l'année scolaire en cours
if annee is None: if annee is None:
annee = scu.annee_scolaire() annee = scu.annee_scolaire()
@ -2309,13 +2310,26 @@ class Jour:
self.justificatifs = justificatifs self.justificatifs = justificatifs
def get_nom(self, mode_demi: bool = True) -> str: 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() 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: def get_date(self) -> str:
"""
Renvoie la date du jour au format "dd/mm/yyyy"
"""
return self.date.strftime("%d/%m/%Y") return self.date.strftime("%d/%m/%Y")
def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str: def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str:
"""
Retourne la classe css du jour (mode normal)
"""
etat = "" etat = ""
est_just = "" est_just = ""
@ -2337,13 +2351,19 @@ class Jour:
def get_demi_class( def get_demi_class(
self, matin: bool, show_pres: bool = False, show_reta: bool = False self, matin: bool, show_pres: bool = False, show_reta: bool = False
) -> str: ) -> 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: 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 = ( matin = (
# date debut # date debut
scu.localize_datetime( scu.localize_datetime(
@ -2355,12 +2375,16 @@ class Jour:
assiduites_matin = [ assiduites_matin = [
assi assi
for assi in self.assiduites 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 = [ justificatifs_matin = [
justi justi
for justi in self.justificatifs 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( etat = self._get_color_assiduites_cascade(
@ -2375,7 +2399,9 @@ class Jour:
return f"color {etat} {est_just}" 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 # séparation en demi journées
aprem = ( aprem = (
@ -2388,13 +2414,17 @@ class Jour:
assiduites_aprem = [ assiduites_aprem = [
assi assi
for assi in self.assiduites 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 = [ justificatifs_aprem = [
justi justi
for justi in self.justificatifs 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( etat = self._get_color_assiduites_cascade(
@ -2410,22 +2440,21 @@ class Jour:
return f"color {etat} {est_just}" return f"color {etat} {est_just}"
def has_assiduites(self) -> bool: def has_assiduites(self) -> bool:
"""
Renverra True si le jour a des assiduités
"""
return self.assiduites.count() > 0 return self.assiduites.count() > 0
def generate_minitimeline(self) -> str: def generate_minitimeline(self) -> str:
"""
Génère la minitimeline du jour
"""
# Récupérer le référenciel de la timeline # Récupérer le référenciel de la timeline
str2time = lambda x: _time_to_timedelta( heure_matin: datetime.timedelta = _time_to_timedelta(
datetime.time(*list(map(int, x.split(":")))) scass.str_to_time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
) )
heure_soir: datetime.timedelta = _time_to_timedelta(
heure_matin: datetime.timedelta = str2time( scass.str_to_time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))
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 = heure_soir - heure_matin
longueur_timeline: datetime.timedelta = 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: # chaque block d'assiduité est défini par:
# longueur = ( (fin-deb) / longueur_timeline ) * 100 # longueur = ( (fin-deb) / longueur_timeline ) * 100
# emplacement = ( (deb - heure_matin) / 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]] = [] assiduite_blocks: list[dict[str, float | str]] = []
@ -2448,8 +2478,10 @@ class Jour:
else heure_soir else heure_soir
) )
longueur: float = ((fin - deb) / longueur_timeline) * 100
emplacement: float = ((deb - heure_matin) / 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() etat: str = scu.EtatAssiduite(assi.etat).name.lower()
est_just: str = "est_just" if assi.est_just else "" est_just: str = "est_just" if assi.est_just else ""
@ -2470,17 +2502,21 @@ class Jour:
) )
def is_non_work(self): 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( return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days(
dept_id=g.scodoc_dept_id dept_id=g.scodoc_dept_id
) )
def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]: 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( def _get_etats_from_justificatifs(
self, justificatifs: Query self, justificatifs: Query
) -> list[scu.EtatJustificatif]: ) -> 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( def _get_color_assiduites_cascade(
self, self,

View File

@ -61,11 +61,11 @@ class DevConfig(Config):
DEBUG = True DEBUG = True
TESTING = False TESTING = False
SQLALCHEMY_DATABASE_URI = ( 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" SECRET_KEY = os.environ.get("DEV_SECRET_KEY") or "bb3faec7d9a34eb68a8e3e710087d87a"
# pour le avoir url_for dans le shell: # 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): class TestConfig(DevConfig):

View File

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

View File

@ -4,8 +4,8 @@
# Ce script lance un serveur scodoc sur le port 5555 # Ce script lance un serveur scodoc sur le port 5555
# attend qu'il soit initialisé puis lance les tests client API. # attend qu'il soit initialisé puis lance les tests client API.
# #
# On peut aussi le lancer avec l'option --dont-start-server # On peut aussi le lancer avec l'option --dont-start-server
# auquel cas il utilise un serveur existant, qui doit avoir été lancé # auquel cas il utilise un serveur existant, qui doit avoir été lancé
# par ailleurs, par exemple via le script: # par ailleurs, par exemple via le script:
# tests/api/start_api_server.sh -p 5555 # tests/api/start_api_server.sh -p 5555
# #
@ -21,7 +21,7 @@
# #
# E. Viennet, Fev 2023 # E. Viennet, Fev 2023
cd /opt/scodoc cd /opt/scodoc || exit 1
# suppose que le virtual env est bien configuré # suppose que le virtual env est bien configuré
# Utilise un port spécifique pour pouvoir lancer ce test sans couper # Utilise un port spécifique pour pouvoir lancer ce test sans couper
@ -57,7 +57,7 @@ then
echo "ScoDoc test server logs are in $SERVER_LOG" echo "ScoDoc test server logs are in $SERVER_LOG"
# Wait for server setup # Wait for server setup
echo -n "Waiting for server" echo -n "Waiting for server"
while ! nc -z localhost "$PORT"; do while ! nc -z localhost "$PORT"; do
echo -n . echo -n .
sleep 1 sleep 1
done done