Compare commits

...

15 Commits

Author SHA1 Message Date
iziram
b7b91fa415 bugfix : duplication rouge get_photo_image 2023-04-21 07:22:15 +02:00
iziram
e00fc8dd09 Assiduités : Page Liste Assiduites / Justifs (WIP) 2023-04-21 07:08:57 +02:00
iziram
f26ecb1f8a Assiduites : Gestion des justificatifs (rapide) WIP
Assiduites : ajout style justifié (minitimeline)
2023-04-21 07:08:57 +02:00
iziram
f3b540b4c1 Assiduites : modification automatique du moduleimpl_id 2023-04-21 07:08:57 +02:00
iziram
5a997dddf4 Assiduites : modification styles (proposition Sébastien Lehmann) 2023-04-21 07:08:57 +02:00
iziram
dae7486d3f Assiduites : Fonctionnement BackEnd + API 2023-04-21 07:07:22 +02:00
iziram
c7300ccf0d bugfix : placement modaux + affichage conflit 2023-04-18 14:08:46 +02:00
iziram
71aa49b1b1 bugfix : disparition liste dept page accueil 2023-04-18 14:02:07 +02:00
iziram
70a52e7ce1 bac à sable : table + assiduites 2023-04-17 16:11:25 +02:00
iziram
5a9d65788f Assiduites : Front End 2023-04-17 15:53:30 +02:00
iziram
650deff2c6 Assiduites : script de migration et de suppression 2023-04-17 15:52:05 +02:00
iziram
d57a3ba1db Assiduités : Ajout des tests (Unit/API) 2023-04-17 15:52:05 +02:00
iziram
68a35864d1 Assiduites : Fonctionnement BackEnd + API 2023-04-17 15:52:05 +02:00
iziram
33855cd38d Assiduités : Ajout des migrations 2023-04-17 15:52:05 +02:00
iziram
4691ed8f36 Assiduites :Création des models 2023-04-17 15:52:05 +02:00
56 changed files with 14425 additions and 26 deletions

4
app/__init__.py Normal file → Executable file
View File

@ -322,6 +322,7 @@ def create_app(config_class=DevConfig):
from app.views import notes_bp
from app.views import users_bp
from app.views import absences_bp
from app.views import assiduites_bp
from app.api import api_bp
from app.api import api_web_bp
@ -340,6 +341,9 @@ def create_app(config_class=DevConfig):
app.register_blueprint(
absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences"
)
app.register_blueprint(
assiduites_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Assiduites"
)
app.register_blueprint(api_bp, url_prefix="/ScoDoc/api")
app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api")

View File

@ -2,7 +2,8 @@
"""
from flask import Blueprint
from flask import request
from flask import request, g, jsonify
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
@ -34,9 +35,26 @@ def requested_format(default_format="json", allowed_formats=None):
return None
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemstre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
"""
query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None:
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
unique: model_cls = query.first_or_404()
return jsonify(unique.to_dict(format_api=True))
from app.api import tokens
from app.api import (
absences,
assiduites,
billets_absences,
departements,
etudiants,
@ -44,6 +62,7 @@ from app.api import (
formations,
formsemestres,
jury,
justificatifs,
logos,
partitions,
semset,

868
app/api/assiduites.py Normal file
View File

@ -0,0 +1,868 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Assiduite, FormSemestre, Identite, ModuleImpl
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@bp.route("/assiduite/<int:assiduite_id>")
@api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc
@permission_required(Permission.ScoView)
def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id
Exemple de résultat:
{
"assiduite_id": 1,
"etudid": 2,
"moduleimpl_id": 3,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "retard",
"desc": "une description",
"user_id: 1 or null,
"est_just": False or True,
}
"""
return get_model_api_object(Assiduite, assiduite_id, Identite)
@bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def count_assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne le nombre d'assiduités d'un étudiant
chemin : /assiduites/<int:etudid>/count
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/count/query?
Les différents filtres :
Type (type de comptage -> journee, demi, heure, nombre d'assiduite):
query?type=(journee, demi, heure) -> une seule valeur parmis les trois
ex: .../query?type=heure
Comportement par défaut : compte le nombre d'assiduité enregistrée
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemestre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
filtered: dict[str, object] = {}
metric: str = "all"
if with_query:
metric, filtered = _count_manager(request)
return jsonify(
scass.get_assiduites_stats(
assiduites=etud.assiduites, metric=metric, filtered=filtered
)
)
@bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /assiduites/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/query?
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
assiduites_query = etud.assiduites
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites_group(with_query: bool = False):
"""
Retourne toutes les assiduités d'un groupe d'étudiants
chemin : /assiduites/group/query?etudids=1,2,3
Un filtrage peut être donné avec une query
chemin : /assiduites/group/query?etudids=1,2,3
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
etuds = request.args.get("etudids", "")
etuds = etuds.split(",")
try:
etuds = [int(etu) for etu in etuds]
except ValueError:
return json_error(404, "Le champs etudids n'est pas correctement formé")
query = Identite.query.filter(Identite.id.in_(etuds))
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
if len(etuds) != query.count() or len(etuds) == 0:
return json_error(
404,
"Tous les étudiants ne sont pas dans le même département et/ou n'existe pas.",
)
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds))
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: dict[list[dict]] = {key: [] for key in etuds}
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.get(data["etudid"]).append(data)
return jsonify(data_set)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
assiduites_query = scass.filter_by_formsemestre(Assiduite.query, formsemestre)
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
"""Comptage des assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
etuds = formsemestre.etuds.all()
etuds_id = [etud.id for etud in etuds]
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id))
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
metric: str = "all"
filtered: dict = {}
if with_query:
metric, filtered = _count_manager(request)
return jsonify(scass.get_assiduites_stats(assiduites_query, metric, filtered))
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_create(etudid: int = None):
"""
Création d'une assiduité pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"moduleimpl_id": int,
"desc":str,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
db.session.commit()
return jsonify({"errors": errors, "success": success})
@bp.route("/assiduites/create", methods=["POST"])
@api_web_bp.route("/assiduites/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduites_create():
"""
Création d'une assiduité ou plusieurs assiduites
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
"etudid":int,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"etudid":int,
"moduleimpl_id": int,
"desc":str,
}
...
]
"""
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
etud: Identite = Identite.query.filter_by(id=data["etudid"]).first()
if etud is None:
errors[i] = "Cet étudiant n'existe pas."
continue
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
return jsonify({"errors": errors, "success": success})
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatAssiduite.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatAssiduite.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
# cas 5 : desc
desc: str = data.get("desc", None)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_assiduite: Assiduite = Assiduite.create_assiduite(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
moduleimpl=moduleimpl,
description=desc,
user_id=current_user.id,
)
db.session.add(nouv_assiduite)
db.session.commit()
return (200, {"assiduite_id": nouv_assiduite.id})
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/assiduite/delete", methods=["POST"])
@api_web_bp.route("/assiduite/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_delete():
"""
Suppression d'une assiduité à partir de son id
Forme des données envoyées :
[
<assiduite_id:int>,
...
]
"""
assiduites_list: list[int] = request.get_json(force=True)
if not isinstance(assiduites_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
for i, ass in enumerate(assiduites_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return jsonify(output)
def _delete_singular(assiduite_id: int, database):
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
return (404, "Assiduite non existante")
database.session.delete(assiduite_unique)
return (200, "OK")
@bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@api_web_bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_edit(assiduite_id: int):
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
"""
assiduite_unique: Assiduite = Assiduite.query.filter_by(
id=assiduite_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.desc = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(assiduite_unique)
db.session.commit()
return jsonify({"OK": True})
@bp.route("/assiduites/edit", methods=["POST"])
@api_web_bp.route("/assiduites/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduites_edit():
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
"""
edit_list: list[object] = request.get_json(force=True)
if not isinstance(edit_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(edit_list):
assi: Identite = Assiduite.query.filter_by(id=data["assiduite_id"]).first()
if assi is None:
errors[i] = "Cet assiduité n'existe pas."
continue
code, obj = _edit_singular(assi, data)
if code == 404:
errors[i] = obj
else:
success[i] = obj
db.session.commit()
return jsonify({"errors": errors, "success": success})
def _edit_singular(assiduite_unique, data):
errors: list[str] = []
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.desc = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
err: str = ", ".join(errors)
return (404, err)
db.session.add(assiduite_unique)
return (200, "OK")
# -- Utils --
def _count_manager(requested) -> tuple[str, dict]:
"""
Retourne la/les métriques à utiliser ainsi que le filtre donnés en query de la requête
"""
filtered: dict = {}
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
filtered["etat"] = etat
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
if deb is not None:
filtered["date_debut"] = deb
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if fin is not None:
filtered["date_fin"] = fin
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
filtered["moduleimpl_id"] = module
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
filtered["formsemestre"] = formsemestre
# cas 6 : type
metric = requested.args.get("metric", "all")
# cas 7 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
filtered["est_just"] = True
elif est_just.lower() in falses:
filtered["est_just"] = False
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
filtered["user_id"] = user_id
return (metric, filtered)
def _filter_manager(requested, assiduites_query: Assiduite):
"""
Retourne les assiduites entrées filtrées en fonction de la request
"""
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
assiduites_query: Assiduite = scass.filter_by_date(
assiduites_query, Assiduite, deb, fin
)
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
assiduites_query = scass.filter_by_module_impl(assiduites_query, module)
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
# cas 6 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, True
)
elif est_just.lower() in falses:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, False
)
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
assiduites_query: Assiduite = scass.filter_by_user_id(assiduites_query, user_id)
return assiduites_query

37
app/api/etudiants.py Normal file → Executable file
View File

@ -31,6 +31,7 @@ from app.scodoc import sco_bulletins
from app.scodoc import sco_groups
from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_photos as sco_photos
# Un exemple:
# @bp.route("/api_function/<int:arg>")
@ -133,6 +134,42 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
return etud.to_dict_api()
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo")
@api_web_bp.route("/etudiant/nip/<string:nip>/photo")
@api_web_bp.route("/etudiant/ine/<string:ine>/photo")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne la photo de l'étudiant
correspondant ou un placeholder si non existant.
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
Attention : Ne peut être qu'utilisée en tant que route de département
"""
etud = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
if not etudid:
filename = sco_photos.UNKNOWN_IMAGE_PATH
size = request.args.get("size", "orig")
filename = sco_photos.photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = sco_photos.UNKNOWN_IMAGE_PATH
res = sco_photos.build_image_response(filename)
return res
@bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
@bp.route("/etudiants/nip/<string:nip>", methods=["GET"])
@bp.route("/etudiants/ine/<string:ine>", methods=["GET"])

596
app/api/justificatifs.py Normal file
View File

@ -0,0 +1,596 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif
from app.models.assiduites import compute_assiduites_justified
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
# Partie Modèle
@bp.route("/justificatif/<int:justif_id>")
@api_web_bp.route("/justificatif/<int:justif_id>")
@scodoc
@permission_required(Permission.ScoView)
def justificatif(justif_id: int = None):
"""Retourne un objet justificatif à partir de son id
Exemple de résultat:
{
"justif_id": 1,
"etudid": 2,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison",
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(Justificatif, justif_id, Identite)
@bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def justificatifs(etudid: int = None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /justificatifs/<int:etudid>/query?
Les différents filtres :
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=validé,modifié
Date debut
(date de début du justificatif, sont affichés les justificatifs
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin du justificatif, sont affichés les justificatifs
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
justificatifs_query = etud.justificatifs
if with_query:
justificatifs_query = _filter_manager(request, justificatifs_query)
data_set: list[dict] = []
for just in justificatifs_query.all():
data = just.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_create(etudid: int = None):
"""
Création d'un justificatif pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"raison":str,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
return jsonify({"errors": errors, "success": success})
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatJustificatif.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatJustificatif.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : raison
raison: str = data.get("raison", None)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_justificatif: Justificatif = Justificatif.create_justificatif(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
raison=raison,
user_id=current_user.id,
)
db.session.add(nouv_justificatif)
db.session.commit()
return (
200,
{
"justif_id": nouv_justificatif.id,
"couverture": scass.justifies(nouv_justificatif),
},
)
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_edit(justif_id: int):
"""
Edition d'un justificatif à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"raison"?: str
"date_debut"?: str
"date_fin"?: str
}
"""
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
avant_ids: list[int] = scass.justifies(justificatif_unique)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatJustificatif.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
# Cas 2 : raison
raison = data.get("raison", False)
if raison is not False:
justificatif_unique.raison = raison
deb, fin = None, None
# cas 3 : date_debut
date_debut = data.get("date_debut", False)
if date_debut is not False:
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
if justificatif_unique.date_fin >= deb:
errors.append("param 'date_debut': date de début située après date de fin ")
# cas 4 : date_fin
date_fin = data.get("date_fin", False)
if date_fin is not False:
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
if justificatif_unique.date_debut <= fin:
errors.append("param 'date_fin': date de fin située avant date de début ")
# Mise à jour des dates
deb = deb if deb is not None else justificatif_unique.date_debut
fin = fin if fin is not None else justificatif_unique.date_fin
justificatif_unique.date_debut = deb
justificatif_unique.date_fin = fin
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(justificatif_unique)
db.session.commit()
return jsonify(
{
"couverture": {
"avant": avant_ids,
"après": compute_assiduites_justified(
Justificatif.query.filter_by(etudid=justificatif_unique.etudid),
True,
),
}
}
)
@bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_delete():
"""
Suppression d'un justificatif à partir de son id
Forme des données envoyées :
[
<justif_id:int>,
...
]
"""
justificatifs_list: list[int] = request.get_json(force=True)
if not isinstance(justificatifs_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
for i, ass in enumerate(justificatifs_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return jsonify(output)
def _delete_singular(justif_id: int, database):
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first()
if justificatif_unique is None:
return (404, "Justificatif non existant")
archive_name: str = justificatif_unique.fichier
if archive_name is not None:
archiver: JustificatifArchiver = JustificatifArchiver()
archiver.delete_justificatif(justificatif_unique.etudid, archive_name)
database.session.delete(justificatif_unique)
compute_assiduites_justified(
Justificatif.query.filter_by(etudid=justificatif_unique.etudid), True
)
return (200, "OK")
# Partie archivage
@bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_import(justif_id: int = None):
"""
Importation d'un fichier (création d'archive)
"""
if len(request.files) == 0:
return json_error(404, "Il n'y a pas de fichier joint")
file = list(request.files.values())[0]
if file.filename == "":
return json_error(404, "Il n'y a pas de fichier joint")
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
archiver: JustificatifArchiver = JustificatifArchiver()
try:
fname: str
archive_name, fname = archiver.save_justificatif(
etudid=justificatif_unique.etudid,
filename=file.filename,
data=file.stream.read(),
archive_name=archive_name,
)
justificatif_unique.fichier = archive_name
db.session.add(justificatif_unique)
db.session.commit()
return jsonify({"filename": fname})
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_export(justif_id: int = None, filename: str = None):
"""
Retourne un fichier d'une archive d'un justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
archiver: JustificatifArchiver = JustificatifArchiver()
try:
return archiver.get_justificatif_file(
archive_name, justificatif_unique.etudid, filename
)
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_remove(justif_id: int = None):
"""
Supression d'un fichier ou d'une archive
# TOTALK: Doc, expliquer les noms coté server
{
"remove": <"all"/"list">
"filenames"?: [
<filename:str>,
...
]
}
"""
data: dict = request.get_json(force=True)
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
remove: str = data.get("remove")
if remove is None or remove not in ("all", "list"):
return json_error(404, "param 'remove': Valeur invalide")
archiver: JustificatifArchiver = JustificatifArchiver()
etudid: int = justificatif_unique.etudid
try:
if remove == "all":
archiver.delete_justificatif(etudid=etudid, archive_name=archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
else:
for fname in data.get("filenames", []):
archiver.delete_justificatif(
etudid=etudid,
archive_name=archive_name,
filename=fname,
)
if len(archiver.list_justificatifs(archive_name, etudid)) == 0:
archiver.delete_justificatif(etudid, archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
except ScoValueError as err:
return json_error(404, err.args[0])
return jsonify({"response": "removed"})
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_list(justif_id: int = None):
"""
Liste les fichiers du justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
filenames: list[str] = []
archiver: JustificatifArchiver = JustificatifArchiver()
if archive_name is not None:
filenames = archiver.list_justificatifs(
archive_name, justificatif_unique.etudid
)
return jsonify(filenames)
# Partie justification
@bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_justifies(justif_id: int = None):
"""
Liste assiduite_id justifiées par le justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
assiduites_list: list[int] = scass.justifies(justificatif_unique)
return jsonify(assiduites_list)
# -- Utils --
def _filter_manager(requested, justificatifs_query):
"""
Retourne les justificatifs entrés filtrés en fonction de la request
"""
# cas 1 : etat justificatif
etat = requested.args.get("etat")
if etat is not None:
justificatifs_query = scass.filter_justificatifs_by_etat(
justificatifs_query, etat
)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
justificatifs_query: Justificatif = scass.filter_by_date(
justificatifs_query, Justificatif, deb, fin
)
user_id = requested.args.get("user_id", False)
if user_id is not False:
justificatif_query: Justificatif = scass.filter_by_user_id(
justificatif_query, user_id
)
return justificatifs_query

View File

@ -81,3 +81,5 @@ from app.models.but_refcomp import (
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif

341
app/models/assiduites.py Normal file
View File

@ -0,0 +1,341 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
from datetime import datetime
from app import db
from app.models import ModuleImpl
from app.models.etudiants import Identite
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
is_period_overlapping,
)
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
)
class Assiduite(db.Model):
"""
Représente une assiduité:
- une plage horaire lié à un état et un étudiant
- un module si spécifiée
- une description si spécifiée
"""
__tablename__ = "assiduites"
id = db.Column(db.Integer, primary_key=True, nullable=False)
assiduite_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
moduleimpl_id = db.Column(
db.Integer,
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(db.Integer, nullable=False)
desc = db.Column(db.Text)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
)
est_just = db.Column(db.Boolean, server_default="false", nullable=False)
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
etat = self.etat
if format_api:
etat = EtatAssiduite.inverse().get(self.etat).name
data = {
"assiduite_id": self.id,
"etudid": self.etudid,
"moduleimpl_id": self.moduleimpl_id,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"desc": self.desc,
"entry_date": self.entry_date,
"user_id": self.user_id,
"est_just": self.est_just,
}
return data
@classmethod
def create_assiduite(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl: ModuleImpl = None,
description: str = None,
entry_date: datetime = None,
user_id: int = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
assiduites: list[Assiduite] = etud.assiduites
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite):
raise ScoValueError(
"Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
if moduleimpl is not None:
# Vérification de l'existence du module pour l'étudiant
if moduleimpl.est_inscrit(etud):
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
moduleimpl_id=moduleimpl.id,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
else:
raise ScoValueError("L'étudiant n'est pas inscrit au moduleimpl")
else:
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
return nouv_assiduite
@classmethod
def fast_create_assiduite(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl_id: int = None,
description: str = None,
entry_date: datetime = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
moduleimpl_id=moduleimpl_id,
desc=description,
entry_date=entry_date,
est_just=est_just,
)
return nouv_assiduite
class Justificatif(db.Model):
"""
Représente un justificatif:
- une plage horaire lié à un état et un étudiant
- une raison si spécifiée
- un fichier si spécifié
"""
__tablename__ = "justificatifs"
id = db.Column(db.Integer, primary_key=True)
justif_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(
db.Integer,
nullable=False,
)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
raison = db.Column(db.Text())
# Archive_id -> sco_archives_justificatifs.py
fichier = db.Column(db.Text())
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
etat = self.etat
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
data = {
"justif_id": self.justif_id,
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": self.raison,
"fichier": self.fichier,
"entry_date": self.entry_date,
"user_id": self.user_id,
}
return data
@classmethod
def create_justificatif(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
user_id: int = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
raison=raison,
entry_date=entry_date,
user_id=user_id,
)
return nouv_justificatif
@classmethod
def fast_create_justificatif(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
raison=raison,
entry_date=entry_date,
)
return nouv_justificatif
def is_period_conflicting(
date_debut: datetime,
date_fin: datetime,
collection: list[Assiduite or Justificatif],
collection_cls: Assiduite or Justificatif,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
"""
date_debut = localize_datetime(date_debut)
date_fin = localize_datetime(date_fin)
if (
collection.filter_by(date_debut=date_debut, date_fin=date_fin).first()
is not None
):
return True
count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
).count()
return count > 0
def compute_assiduites_justified(
justificatifs: Justificatif = Justificatif, reset: bool = False
) -> list[int]:
"""Calcule et modifie les champs "est_just" de chaque assiduité lié à l'étud
retourne la liste des assiduite_id justifiées
Si reset alors : met à false toutes les assiduités non justifiées par les justificatifs donnés
"""
list_assiduites_id: set[int] = set()
for justi in justificatifs:
assiduites: Assiduite = (
Assiduite.query.join(Justificatif, Justificatif.etudid == Assiduite.etudid)
.filter(justi.etat == EtatJustificatif.VALIDE)
.filter(
Assiduite.date_debut < justi.date_fin,
Assiduite.date_fin > justi.date_debut,
)
)
for assi in assiduites:
assi.est_just = True
list_assiduites_id.add(assi.id)
db.session.add(assi)
if reset:
un_justified: Assiduite = Assiduite.query.filter(
Assiduite.id.not_in(list_assiduites_id)
).join(Justificatif, Justificatif.etudid == Assiduite.etudid)
for assi in un_justified:
assi.est_just = False
db.session.add(assi)
db.session.commit()
return list(list_assiduites_id)

View File

@ -66,6 +66,10 @@ class Identite(db.Model):
passive_deletes=True,
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic")
justificatifs = db.relationship("Justificatif", backref="etudiant", lazy="dynamic")
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"

View File

@ -122,6 +122,22 @@ class ModuleImpl(db.Model):
raise AccessDenied(f"Modification impossible pour {user}")
return False
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl
Retourne Vrai si c'est le cas, faux sinon
"""
is_module: int = (
ModuleImplInscription.query.filter_by(
etudid=etud.id, moduleimpl_id=self.id
).count()
> 0
)
return is_module
# Enseignants (chargés de TD ou TP) d'un moduleimpl
notes_modules_enseignants = db.Table(

43
app/profiler.py Normal file
View File

@ -0,0 +1,43 @@
from time import time
from datetime import datetime
class Profiler:
OUTPUT: str = "/tmp/scodoc.profiler.csv"
def __init__(self, tag: str) -> None:
self.tag: str = tag
self.start_time: time = None
self.stop_time: time = None
def start(self):
self.start_time = time()
return self
def stop(self):
self.stop_time = time()
return self
def elapsed(self) -> float:
return self.stop_time - self.start_time
def dates(self) -> tuple[datetime, datetime]:
return datetime.fromtimestamp(self.start_time), datetime.fromtimestamp(
self.stop_time
)
def write(self):
with open(Profiler.OUTPUT, "a") as file:
dates: tuple = self.dates()
date_str = (dates[0].isoformat(), dates[1].isoformat())
file.write(f"\n{self.tag},{self.elapsed() : .2}")
@classmethod
def write_in(cls, msg: str):
with open(cls.OUTPUT, "a") as file:
file.write(f"\n# {msg}")
@classmethod
def clear(cls):
with open(cls.OUTPUT, "w") as file:
file.write("")

4
app/scodoc/html_sidebar.py Normal file → Executable file
View File

@ -126,7 +126,7 @@ def sidebar(etudid: int = None):
if current_user.has_permission(Permission.ScoAbsChange):
H.append(
f"""
<li><a href="{ url_for('absences.SignaleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
<li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li>
"""
@ -138,7 +138,7 @@ def sidebar(etudid: int = None):
H.append(
f"""
<li><a href="{ url_for('absences.CalAbs', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li>
<li><a href="{ url_for('absences.ListeAbsEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
<li><a href="{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
</ul>
"""
)

41
app/scodoc/sco_abs.py Normal file → Executable file
View File

@ -42,6 +42,8 @@ from app.scodoc import sco_cache
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences
from app.models import Assiduite, Justificatif
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
# --- Misc tools.... ------------------
@ -1052,6 +1054,45 @@ def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
return r
def get_assiduites_count_in_interval(etudid, date_debut_iso, date_fin_iso):
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso + "_assiduites"
r = sco_cache.AbsSemEtudCache.get(key)
if not r:
date_debut: datetime.datetime = scu.is_iso_formated(date_debut_iso, True)
date_fin: datetime.datetime = scu.is_iso_formated(date_fin_iso, True)
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
justificatifs: Justificatif = Justificatif.query.filter_by(etudid=etudid)
assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)
justificatifs = scass.filter_by_date(
justificatifs, Justificatif, date_debut, date_fin
)
calculator: scass.CountCalculator = scass.CountCalculator()
calculator.compute_assiduites(assiduites)
nb_abs: dict = calculator.to_dict()["demi"]
abs_just: list[Assiduite] = scass.get_all_justified(
etudid, date_debut, date_fin
)
calculator.reset()
calculator.compute_assiduites(abs_just)
nb_abs_just: dict = calculator.to_dict()["demi"]
r = (nb_abs, nb_abs_just)
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_assiduites_count failed to cache")
return r
def invalidate_abs_count(etudid, sem):
"""Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"]

View File

@ -68,7 +68,7 @@ from app import log, ScoDocJSONEncoder
from app.but import jury_but_pv
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Departement, FormSemestre
from app.models import FormSemestre
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import ScoPermissionDenied
from app.scodoc import html_sco_header
@ -86,6 +86,11 @@ class BaseArchiver(object):
self.archive_type = archive_type
self.initialized = False
self.root = None
self.dept_id = None
def set_dept_id(self, dept_id: int):
"set dept"
self.dept_id = dept_id
def initialize(self):
if self.initialized:
@ -107,6 +112,8 @@ class BaseArchiver(object):
finally:
scu.GSL.release()
self.initialized = True
if self.dept_id is None:
self.dept_id = getattr(g, "scodoc_dept_id")
def get_obj_dir(self, oid: int):
"""
@ -114,8 +121,7 @@ class BaseArchiver(object):
If directory does not yet exist, create it.
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
dept_dir = os.path.join(self.root, str(dept.id))
dept_dir = os.path.join(self.root, str(self.dept_id))
try:
scu.GSL.acquire()
if not os.path.isdir(dept_dir):
@ -134,8 +140,7 @@ class BaseArchiver(object):
:return: list of archive oids
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
base = os.path.join(self.root, str(dept.id)) + os.path.sep
base = os.path.join(self.root, str(self.dept_id)) + os.path.sep
dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs]

View File

@ -0,0 +1,215 @@
"""
Gestion de l'archivage des justificatifs
Ecrit par Matthias HARTMANN
"""
import os
from datetime import datetime
from shutil import rmtree
from app.models import Identite
from app.scodoc.sco_archives import BaseArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import is_iso_formated
class Trace:
"""gestionnaire de la trace des fichiers justificatifs"""
def __init__(self, path: str) -> None:
self.path: str = path + "/_trace.csv"
self.content: dict[str, list[datetime, datetime]] = {}
self.import_from_file()
def import_from_file(self):
"""import trace from file"""
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as file:
for line in file.readlines():
csv = line.split(",")
fname: str = csv[0]
entry_date: datetime = is_iso_formated(csv[1], True)
delete_date: datetime = is_iso_formated(csv[2], True)
self.content[fname] = [entry_date, delete_date]
def set_trace(self, *fnames: str, mode: str = "entry"):
"""Ajoute une trace du fichier donné
mode : entry / delete
"""
modes: list[str] = ["entry", "delete"]
for fname in fnames:
if fname in modes:
continue
traced: list[datetime, datetime] = self.content.get(fname, False)
if not traced:
self.content[fname] = [None, None]
traced = self.content[fname]
traced[modes.index(mode)] = datetime.now()
self.save_trace()
def save_trace(self):
"""Enregistre la trace dans le fichier _trace.csv"""
lines: list[str] = []
for fname, traced in self.content.items():
date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None"
lines.append(f"{fname},{traced[0].isoformat()},{date_fin}")
with open(self.path, "w", encoding="utf-8") as file:
file.write("\n".join(lines))
def get_trace(self, fnames: list[str] = ()) -> dict[str, list[datetime, datetime]]:
"""Récupère la trace pour les noms de fichiers.
si aucun nom n'est donné, récupère tous les fichiers"""
if fnames is None or len(fnames) == 0:
return self.content
traced: dict = {}
for fname in fnames:
traced[fname] = self.content.get(fname, None)
return traced
class JustificatifArchiver(BaseArchiver):
"""
TOTALK:
- oid -> etudid
- archive_id -> date de création de l'archive (une archive par dépot de document)
justificatif
<dept_id>
<etudid/oid>
[_trace.csv]
<archive_id>
[_description.txt]
[<filename.ext>]
"""
def __init__(self):
BaseArchiver.__init__(self, archive_type="justificatifs")
def save_justificatif(
self,
etudid: int,
filename: str,
data: bytes or str,
archive_name: str = None,
description: str = "",
) -> str:
"""
Ajoute un fichier dans une archive "justificatif" pour l'etudid donné
Retourne l'archive_name utilisé
"""
self._set_dept(etudid)
if archive_name is None:
archive_id: str = self.create_obj_archive(
oid=etudid, description=description
)
else:
archive_id: str = self.get_id_from_name(etudid, archive_name)
fname: str = self.store(archive_id, filename, data)
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(fname, "entry")
return self.get_archive_name(archive_id), fname
def delete_justificatif(
self,
etudid: int,
archive_name: str,
filename: str = None,
has_trace: bool = True,
):
"""
Supprime une archive ou un fichier particulier de l'archivage de l'étudiant donné
Si trace == True : sauvegarde le nom du/des fichier(s) supprimé(s) dans la trace de l'étudiant
"""
self._set_dept(etudid)
if str(etudid) not in self.list_oids():
raise ValueError(f"Aucune archive pour etudid[{etudid}]")
archive_id = self.get_id_from_name(etudid, archive_name)
if filename is not None:
if filename not in self.list_archive(archive_id):
raise ValueError(
f"filename {filename} inconnu dans l'archive archive_id[{archive_id}] -> etudid[{etudid}]"
)
path: str = os.path.join(self.get_obj_dir(etudid), archive_id, filename)
if os.path.isfile(path):
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(filename, "delete")
os.remove(path)
else:
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(*self.list_archive(archive_id), mode="delete")
self.delete_archive(
os.path.join(
self.get_obj_dir(etudid),
archive_id,
)
)
def list_justificatifs(self, archive_name: str, etudid: int) -> list[str]:
"""
Retourne la liste des noms de fichiers dans l'archive donnée
"""
self._set_dept(etudid)
filenames: list[str] = []
archive_id = self.get_id_from_name(etudid, archive_name)
filenames = self.list_archive(archive_id)
return filenames
def get_justificatif_file(self, archive_name: str, etudid: int, filename: str):
"""
Retourne une réponse de téléchargement de fichier si le fichier existe
"""
self._set_dept(etudid)
archive_id: str = self.get_id_from_name(etudid, archive_name)
if filename in self.list_archive(archive_id):
return self.get_archived_file(etudid, archive_name, filename)
raise ScoValueError(
f"Fichier {filename} introuvable dans l'archive {archive_name}"
)
def _set_dept(self, etudid: int):
"""
Mets à jour le dept_id de l'archiver en fonction du département de l'étudiant
"""
etud: Identite = Identite.query.filter_by(id=etudid).first()
self.set_dept_id(etud.dept_id)
def remove_dept_archive(self, dept_id: int = None):
"""
Supprime toutes les archives d'un département (ou de tous les départements)
Supprime aussi les fichiers de trace
"""
self.set_dept_id(1)
self.initialize()
if dept_id is None:
rmtree(self.root, ignore_errors=True)
else:
rmtree(os.path.join(self.root, str(dept_id)), ignore_errors=True)
def get_trace(
self, etudid: int, *fnames: str
) -> dict[str, list[datetime, datetime]]:
"""Récupère la trace des justificatifs de l'étudiant"""
trace = Trace(self.get_obj_dir(etudid))
return trace.get_trace(fnames)

View File

@ -0,0 +1,352 @@
"""
Ecrit par Matthias Hartmann.
"""
from datetime import date, datetime, time, timedelta
import app.scodoc.sco_utils as scu
from app.models.assiduites import Assiduite, Justificatif
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre, FormSemestreInscription
class CountCalculator:
"""Classe qui gére le comptage des assiduités"""
def __init__(
self,
morning: time = time(8, 0),
noon: time = time(12, 0),
after_noon: time = time(14, 00),
evening: time = time(18, 0),
skip_saturday: bool = True,
) -> None:
self.morning: time = morning
self.noon: time = noon
self.after_noon: time = after_noon
self.evening: time = evening
self.skip_saturday: bool = skip_saturday
delta_total: timedelta = datetime.combine(date.min, evening) - datetime.combine(
date.min, morning
)
delta_lunch: timedelta = datetime.combine(
date.min, after_noon
) - datetime.combine(date.min, noon)
self.hour_per_day: float = (delta_total - delta_lunch).total_seconds() / 3600
self.days: list[date] = []
self.half_days: list[tuple[date, bool]] = [] # tuple -> (date, morning:bool)
self.hours: float = 0.0
self.count: int = 0
def reset(self):
"""Remet à zero le compteur"""
self.days = []
self.half_days = []
self.hours = 0.0
self.count = 0
def add_half_day(self, day: date, is_morning: bool = True):
"""Ajoute une demi journée dans le comptage"""
key: tuple[date, bool] = (day, is_morning)
if key not in self.half_days:
self.half_days.append(key)
def add_day(self, day: date):
"""Ajoute un jour dans le comptage"""
if day not in self.days:
self.days.append(day)
def check_in_morning(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifiée si la période donnée fait partie du matin
(Test sur la date de début)
"""
interval_morning: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.morning)),
scu.localize_datetime(datetime.combine(period[0].date(), self.noon)),
)
in_morning: bool = scu.is_period_overlapping(
period, interval_morning, bornes=False
)
return in_morning
def check_in_evening(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifie si la période fait partie de l'aprèm
(test sur la date de début)
"""
interval_evening: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.after_noon)),
scu.localize_datetime(datetime.combine(period[0].date(), self.evening)),
)
in_evening: bool = scu.is_period_overlapping(period, interval_evening)
return in_evening
def compute_long_assiduite(self, assi: Assiduite):
"""Calcule les métriques sur une assiduité longue (plus d'un jour)"""
pointer_date: date = assi.date_debut.date() + timedelta(days=1)
start_hours: timedelta = assi.date_debut - scu.localize_datetime(
datetime.combine(assi.date_debut, self.morning)
)
finish_hours: timedelta = assi.date_fin - scu.localize_datetime(
datetime.combine(assi.date_fin, self.morning)
)
self.add_day(assi.date_debut.date())
self.add_day(assi.date_fin.date())
start_period: tuple[datetime, datetime] = (
assi.date_debut,
scu.localize_datetime(
datetime.combine(assi.date_debut.date(), self.evening)
),
)
finish_period: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(assi.date_fin.date(), self.morning)),
assi.date_fin,
)
hours = 0.0
for period in (start_period, finish_period):
if self.check_in_evening(period):
self.add_half_day(period[0].date(), False)
if self.check_in_morning(period):
self.add_half_day(period[0].date())
while pointer_date < assi.date_fin.date():
if pointer_date.weekday() < (6 - self.skip_saturday):
self.add_day(pointer_date)
self.add_half_day(pointer_date)
self.add_half_day(pointer_date, False)
self.hours += self.hour_per_day
hours += self.hour_per_day
pointer_date += timedelta(days=1)
self.hours += finish_hours.total_seconds() / 3600
self.hours += self.hour_per_day - (start_hours.total_seconds() / 3600)
def compute_assiduites(self, assiduites: Assiduite):
"""Calcule les métriques pour la collection d'assiduité donnée"""
assi: Assiduite
assiduites: list[Assiduite] = (
assiduites.all() if isinstance(assiduites, Assiduite) else assiduites
)
for assi in assiduites:
self.count += 1
delta: timedelta = assi.date_fin - assi.date_debut
if delta.days > 0:
# raise Exception(self.hours)
self.compute_long_assiduite(assi)
continue
period: tuple[datetime, datetime] = (assi.date_debut, assi.date_fin)
deb_date: date = assi.date_debut.date()
if self.check_in_morning(period):
self.add_half_day(deb_date)
if self.check_in_evening(period):
self.add_half_day(deb_date, False)
self.add_day(deb_date)
self.hours += delta.total_seconds() / 3600
def to_dict(self) -> dict[str, object]:
"""Retourne les métriques sous la forme d'un dictionnaire"""
return {
"compte": self.count,
"journee": len(self.days),
"demi": len(self.half_days),
"heure": round(self.hours, 2),
}
def get_assiduites_stats(
assiduites: Assiduite, metric: str = "all", filtered: dict[str, object] = None
) -> Assiduite:
"""Compte les assiduités en fonction des filtres"""
if filtered is not None:
deb, fin = None, None
for key in filtered:
if key == "etat":
assiduites = filter_assiduites_by_etat(assiduites, filtered[key])
elif key == "date_fin":
fin = filtered[key]
elif key == "date_debut":
deb = filtered[key]
elif key == "moduleimpl_id":
assiduites = filter_by_module_impl(assiduites, filtered[key])
elif key == "formsemestre":
assiduites = filter_by_formsemestre(assiduites, filtered[key])
elif key == "est_just":
assiduites = filter_assiduites_by_est_just(assiduites, filtered[key])
elif key == "user_id":
assiduites = filter_by_user_id(assiduites, filtered[key])
if (deb, fin) != (None, None):
assiduites = filter_by_date(assiduites, Assiduite, deb, fin)
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
count: dict = calculator.to_dict()
metrics: list[str] = metric.split(",")
output: dict = {}
for key, val in count.items():
if key in metrics:
output[key] = val
return output if output else count
def filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatAssiduite.get(e, -1) for e in etats]
return assiduites.filter(Assiduite.etat.in_(etats))
def filter_assiduites_by_est_just(
assiduites: Assiduite, est_just: bool
) -> Justificatif:
"""
Filtrage d'une collection d'assiduites en fonction de s'ils sont justifiés
"""
return assiduites.filter_by(est_just=est_just)
def filter_by_user_id(
collection: Assiduite or Justificatif,
user_id: int,
) -> Justificatif:
"""
Filtrage d'une collection en fonction de l'user_id
"""
return collection.filter_by(user_id=user_id)
def filter_by_date(
collection: Assiduite or Justificatif,
collection_cls: Assiduite or Justificatif,
date_deb: datetime = None,
date_fin: datetime = None,
strict: bool = False,
):
"""
Filtrage d'une collection d'assiduites en fonction d'une date
"""
if date_deb is None:
date_deb = datetime.min
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb)
date_fin = scu.localize_datetime(date_fin)
if not strict:
return collection.filter(
collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb
)
return collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_deb
)
def filter_justificatifs_by_etat(
justificatifs: Justificatif, etat: str
) -> Justificatif:
"""
Filtrage d'une collection de justificatifs en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatJustificatif.get(e, -1) for e in etats]
return justificatifs.filter(Justificatif.etat.in_(etats))
def filter_by_module_impl(
assiduites: Assiduite, module_impl_id: int or None
) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de l'ID du module_impl
"""
return assiduites.filter(Assiduite.moduleimpl_id == module_impl_id)
def filter_by_formsemestre(assiduites_query: Assiduite, formsemestre: FormSemestre):
"""
Filtrage d'une collection d'assiduites en fonction d'un formsemestre
"""
if formsemestre is None:
return assiduites_query.filter(False)
assiduites_query = (
assiduites_query.join(Identite, Assiduite.etudid == Identite.id)
.join(
FormSemestreInscription,
Identite.id == FormSemestreInscription.etudid,
)
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
)
assiduites_query = assiduites_query.filter(
Assiduite.date_debut >= formsemestre.date_debut
)
return assiduites_query.filter(Assiduite.date_fin <= formsemestre.date_fin)
def justifies(justi: Justificatif, obj: bool = False) -> list[int]:
"""
Retourne la liste des assiduite_id qui sont justifié par la justification
Une assiduité est justifiée si elle est COMPLETEMENT ou PARTIELLEMENT comprise dans la plage du justificatif
et que l'état du justificatif est "valide"
renvoie des id si obj == False, sinon les Assiduités
"""
if justi.etat != scu.EtatJustificatif.VALIDE:
return []
assiduites_query: Assiduite = Assiduite.query.join(
Justificatif, Assiduite.etudid == Justificatif.etudid
).filter(
Assiduite.date_debut <= justi.date_fin,
Assiduite.date_fin >= justi.date_debut,
)
if not obj:
return [assi.id for assi in assiduites_query.all()]
return assiduites_query
def get_all_justified(
etudid: int, date_deb: datetime = None, date_fin: datetime = None
) -> list[Assiduite]:
"""Retourne toutes les assiduités justifiées sur une période"""
if date_deb is None:
date_deb = datetime.min
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb)
date_fin = scu.localize_datetime(date_fin)
justified = Assiduite.query.filter_by(est_just=True, etudid=etudid)
after = filter_by_date(
justified,
Assiduite,
date_deb,
date_fin,
)
return after

8
app/scodoc/sco_formsemestre_status.py Normal file → Executable file
View File

@ -815,9 +815,9 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</td>
<td>
<form action="{url_for(
"absences.SignaleAbsenceGrSemestre", scodoc_dept=g.scodoc_dept
"assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept
)}" method="get">
<input type="hidden" name="datefin" value="{
<input type="hidden" name="date" value="{
formsemestre.date_fin.strftime("%d/%m/%Y")}"/>
<input type="hidden" name="group_ids" value="%(group_id)s"/>
<input type="hidden" name="destination" value="{destination}"/>
@ -834,8 +834,8 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
</select>
<a href="{
url_for("absences.choix_semaine", scodoc_dept=g.scodoc_dept)
}?group_id=%(group_id)s">saisie par semaine</a>
url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_ids=%(group_id)s&jour={datetime.date.today().isoformat()}&formsemestre_id={formsemestre.id}">saisie par semaine</a>
</form></td>
"""
else:

4
app/scodoc/sco_photos.py Normal file → Executable file
View File

@ -148,11 +148,11 @@ def get_photo_image(etudid=None, size="small"):
filename = photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = UNKNOWN_IMAGE_PATH
r = _http_jpeg_file(filename)
r = build_image_response(filename)
return r
def _http_jpeg_file(filename):
def build_image_response(filename):
"""returns an image as a Flask response"""
st = os.stat(filename)
last_modified = st.st_mtime # float timestamp

View File

@ -111,7 +111,7 @@ get_base_preferences(formsemestre_id)
"""
import flask
from flask import flash, g, request
from flask import flash, g, request, url_for
# from flask_login import current_user

View File

@ -32,13 +32,14 @@ import base64
import bisect
import collections
import datetime
from enum import IntEnum
from enum import IntEnum, Enum
import io
import json
from hashlib import md5
import numbers
import os
import re
from shutil import get_terminal_size
import _thread
import time
import unicodedata
@ -50,6 +51,10 @@ from PIL import Image as PILImage
import pydot
import requests
from pytz import timezone
import dateutil.parser as dtparser
import flask
from flask import g, request, Response
from flask import flash, url_for, make_response
@ -91,6 +96,161 @@ ETATS_INSCRIPTION = {
}
def print_progress_bar(
iteration,
total,
prefix="",
suffix="",
finish_msg="",
decimals=1,
length=100,
fill="",
autosize=False,
):
"""
Affiche une progress bar à un point donné (mettre dans une boucle pour rendre dynamique)
@params:
iteration - Required : index du point donné (Int)
total - Required : nombre total avant complétion (eg: len(List))
prefix - Optional : Préfix -> écrit à gauche de la barre (Str)
suffix - Optional : Suffix -> écrit à droite de la barre (Str)
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)
"""
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
color = TerminalColor.RED
if 50 >= float(percent) > 25:
color = TerminalColor.MAGENTA
if 75 >= float(percent) > 50:
color = TerminalColor.BLUE
if 90 >= float(percent) > 75:
color = TerminalColor.CYAN
if 100 >= float(percent) > 90:
color = TerminalColor.GREEN
styling = f"{prefix} |{fill}| {percent}% {suffix}"
if autosize:
cols, _ = get_terminal_size(fallback=(length, 1))
length = cols - len(styling)
filled_length = int(length * iteration // total)
pg_bar = fill * filled_length + "-" * (length - filled_length)
print(f"\r{color}{styling.replace(fill, pg_bar)}{TerminalColor.RESET}", end="\r")
# Affiche une nouvelle ligne vide
if iteration == total:
print(f"\n{finish_msg}")
class TerminalColor:
"""Ensemble de couleur pour terminaux"""
BLUE = "\033[94m"
CYAN = "\033[96m"
GREEN = "\033[92m"
MAGENTA = "\033[95m"
RED = "\033[91m"
RESET = "\033[0m"
class BiDirectionalEnum(Enum):
"""Permet la recherche inverse d'un enum
Condition : les clés et les valeurs doivent être uniques
les clés doivent être en MAJUSCULES
"""
@classmethod
def contains(cls, attr: str):
"""Vérifie sur un attribut existe dans l'enum"""
return attr.upper() in cls._member_names_
@classmethod
def get(cls, attr: str, default: any = None):
"""Récupère une valeur à partir de son attribut"""
val = None
try:
val = cls[attr.upper()]
except (KeyError, AttributeError):
val = default
return val
@classmethod
def inverse(cls):
"""Retourne un dictionnaire représentant la map inverse de l'Enum"""
return cls._value2member_map_
class EtatAssiduite(int, BiDirectionalEnum):
"""Code des états d'assiduité"""
# Stockés en BD ne pas modifier
PRESENT = 0
RETARD = 1
ABSENT = 2
class EtatJustificatif(int, BiDirectionalEnum):
"""Code des états des justificatifs"""
# Stockés en BD ne pas modifier
VALIDE = 0
NON_VALIDE = 1
ATTENTE = 2
MODIFIE = 3
def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None:
"""
Vérifie si une date est au format iso
Retourne un booléen Vrai (ou un objet Datetime si convert = True)
si l'objet est au format iso
Retourne Faux si l'objet n'est pas au format et convert = False
Retourne None sinon
"""
try:
date: datetime.datetime = dtparser.isoparse(date)
return date if convert else True
except (dtparser.ParserError, ValueError, TypeError):
return None if convert else False
def localize_datetime(date: datetime.datetime or str) -> datetime.datetime:
"""Ajoute un timecode UTC à la date donnée."""
if isinstance(date, str):
date = is_iso_formated(date, convert=True)
new_date: datetime.datetime = date
if new_date.tzinfo is None:
try:
new_date = timezone("Europe/Paris").localize(date)
except OverflowError:
new_date = timezone("UTC").localize(date)
return new_date
def is_period_overlapping(
periode: tuple[datetime.datetime, datetime.datetime],
interval: tuple[datetime.datetime, datetime.datetime],
bornes: bool = True,
) -> bool:
"""
Vérifie si la période et l'interval s'intersectent
si strict == True : les extrémitées ne comptes pas
Retourne Vrai si c'est le cas, faux sinon
"""
p_deb, p_fin = periode
i_deb, i_fin = interval
if bornes:
return p_deb <= i_fin and p_fin >= i_deb
return p_deb < i_fin and p_fin > i_deb
# Types de modules
class ModuleType(IntEnum):
"""Code des types de module."""

View File

@ -0,0 +1,489 @@
* {
box-sizing: border-box;
}
.selectors>* {
margin: 10px 0;
}
.selectors:disabled {
opacity: 0.5;
}
.no-display {
display: none !important;
}
/* === Gestion de la timeline === */
#tl_date {
visibility: hidden;
width: 0px;
height: 0px;
position: absolute;
left: 15%;
}
.infos {
position: relative;
width: fit-content;
}
#datestr {
cursor: pointer;
background-color: white;
border: 1px #444 solid;
border-radius: 5px;
padding: 5px;
}
#tl_slider {
width: 90%;
cursor: grab;
/* visibility: hidden; */
}
#datestr,
#time {
width: fit-content;
}
.ui-slider-handle.tl_handle {
background: none;
width: 25px;
height: 25px;
visibility: visible;
background-position: top;
background-size: cover;
border: none;
top: -180%;
cursor: grab;
}
#l_handle {
background-image: url(../icons/l_handle.svg);
}
#r_handle {
background-image: url(../icons/r_handle.svg);
}
.ui-slider-range.ui-widget-header.ui-corner-all {
background-color: #F9C768;
background-image: none;
opacity: 0.50;
visibility: visible;
}
/* === Gestion des etuds row === */
.etud_holder {
margin-top: 5vh;
}
.etud_row {
display: grid;
grid-template-columns: auto auto 1fr auto;
gap: 16px;
background-color: white;
border-radius: 15px;
padding: 4px 16px;
margin: 0.5% 0;
box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-webkit-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-moz-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
max-width: 800px;
}
.etud_row * {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
}
/* --- Index --- */
.etud_row .index_field {
grid-column: 1;
}
/* --- Nom étud --- */
.etud_row .name_field {
grid-column: 2;
height: 100%;
}
.etud_row .name_field .name_set {
flex-direction: column;
align-items: flex-start;
margin: 0 5%;
}
.etud_row .name_field .name_set * {
padding: 0;
margin: 0;
}
.etud_row .name_field .name_set h4 {
font-size: small;
font-weight: 600;
}
.etud_row .name_field .name_set h5 {
font-size: x-small;
}
.etud_row .pdp {
border-radius: 15px;
}
/* --- Barre assiduités --- */
.etud_row .assiduites_bar {
display: grid;
grid-template-columns: 7px 1fr;
gap: 13px;
grid-column: 3;
position: relative;
}
.etud_row .assiduites_bar .filler {
height: 5px;
width: 90%;
background-color: white;
border: 1px solid #444;
}
.etud_row .assiduites_bar #prevDateAssi {
height: 7px;
width: 7px;
background-color: white;
border: 1px solid #444;
margin: 0px 8px;
}
.etud_row .assiduites_bar #prevDateAssi.single {
height: 9px;
width: 9px;
}
.etud_row.conflit {
background-color: #ff000061;
}
.etud_row .assiduites_bar .absent {
background-color: #F1A69C !important;
}
.etud_row .assiduites_bar .present {
background-color: #9CF1AF !important;
}
.etud_row .assiduites_bar .retard {
background-color: #F1D99C !important;
}
.etud_row .assiduites_bar .justified {
background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #7059FF 4px, #7059FF 8px);
}
.etud_row .assiduites_bar .invalid_justified {
background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #d61616 4px, #d61616 8px);
}
/* --- Boutons assiduités --- */
.etud_row .btns_field {
grid-column: 4;
}
.btns_field:disabled {
opacity: 0.7;
}
.etud_row .btns_field * {
margin: 0 5%;
cursor: pointer;
width: 35px;
height: 35px;
}
.rbtn {
-webkit-appearance: none;
appearance: none;
}
.rbtn::before {
content: "";
display: inline-block;
width: 35px;
height: 35px;
background-position: center;
background-size: cover;
}
.rbtn.present::before {
background-image: url(../icons/present.svg);
}
.rbtn.absent::before {
background-image: url(../icons/absent.svg);
}
.rbtn.retard::before {
background-image: url(../icons/retard.svg);
}
.rbtn:checked:before {
outline: 3px solid #7059FF;
border-radius: 5px;
}
.rbtn:focus {
outline: none !important;
}
/*<== Modal conflit ==>*/
.modal {
display: none;
position: fixed;
z-index: 500;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
height: 30%;
position: relative;
border-radius: 10px;
}
.close {
color: #111;
position: absolute;
right: 5px;
top: 0px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
/* Ajout de styles pour la frise chronologique */
.modal-timeline {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 20px;
}
.time-labels,
.assiduites-container {
display: flex;
justify-content: space-between;
position: relative;
}
.time-label {
font-size: 14px;
margin-bottom: 4px;
}
.assiduite {
position: absolute;
top: 20px;
cursor: pointer;
border-radius: 4px;
z-index: 10;
height: 100px;
padding: 4px;
}
.assiduite-info {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.assiduites-container {
min-height: 20px;
height: calc(50% - 60px);
/* Augmentation de la hauteur du conteneur des assiduités */
position: relative;
margin-bottom: 10px;
}
.action-buttons {
position: absolute;
text-align: center;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 60px;
width: 100%;
bottom: 5%;
}
/* Ajout de la classe CSS pour la bordure en pointillés */
.assiduite.selected {
border: 2px dashed black;
}
.assiduite-special {
height: 120px;
position: absolute;
z-index: 5;
border: 2px solid #000;
background-color: rgba(36, 36, 36, 0.25);
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.61) 5px, rgba(81, 81, 81, 0.61) 10px);
border-radius: 5px;
}
/*<== Info sur l'assiduité sélectionnée ==>*/
.modal-assiduite-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: max-content;
height: 30%;
position: relative;
border-radius: 10px;
display: none;
}
.modal-assiduite-content.show {
display: block;
}
.modal-assiduite-content .infos {
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: flex-start;
}
/*<=== Mass Action ==>*/
.mass-selection {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
margin: 2% 0;
}
.mass-selection span {
margin: 0 1%;
}
.mass-selection .rbtn {
background-color: transparent;
cursor: pointer;
}
/*<== Loader ==> */
.loader-container {
display: none;
/* Cacher le loader par défaut */
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
/* Fond semi-transparent pour bloquer les clics */
z-index: 9999;
/* Placer le loader au-dessus de tout le contenu */
}
.loader {
border: 6px solid #f3f3f3;
border-radius: 50%;
border-top: 6px solid #3498db;
width: 60px;
height: 60px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.fieldsplit {
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
}
.fieldsplit legend {
margin: 0;
}
#page-assiduite-content {
display: flex;
flex-wrap: wrap;
gap: 5%;
flex-direction: column;
}
#page-assiduite-content>* {
margin: 1.5% 0;
}
.rouge {
color: crimson;
}

11
app/static/icons/absent.svg Executable file
View File

@ -0,0 +1,11 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1A69C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4425)">
<path d="M67.2116 70L43 45.707L18.7885 70L15.0809 66.3043L39.305 41.9995L15.0809 17.6939L18.7885 14L43 38.2922L67.2116 14L70.9191 17.6939L46.695 41.9995L70.9191 66.3043L67.2116 70Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4425">
<rect width="56" height="56" fill="white" transform="matrix(1 0 0 -1 15 70)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 547 B

13
app/static/icons/present.svg Executable file
View File

@ -0,0 +1,13 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#9CF1AF"/>
<g clip-path="url(#clip0_120_4405)">
<g opacity="0.5">
<path d="M70.7713 27.5875L36.0497 62.3091C35.7438 62.6149 35.2487 62.6149 34.9435 62.3091L15.2286 42.5935C14.9235 42.2891 14.9235 41.7939 15.2286 41.488L20.0191 36.6976C20.3249 36.3924 20.8201 36.3924 21.1252 36.6976L35.4973 51.069L64.8754 21.6909C65.1819 21.3858 65.6757 21.3858 65.9815 21.6909L70.7713 26.4814C71.0771 26.7865 71.0771 27.281 70.7713 27.5875Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_120_4405">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 729 B

12
app/static/icons/retard.svg Executable file
View File

@ -0,0 +1,12 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1D99C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4407)">
<path d="M55.2901 49.1836L44.1475 41.3918V28C44.1475 27.3688 43.6311 26.8524 43 26.8524C42.3688 26.8524 41.8524 27.3688 41.8524 28V42C41.8524 42.3787 42.036 42.7229 42.3459 42.941L53.9819 51.077C54.177 51.2147 54.4065 51.2836 54.636 51.2836C54.9918 51.2836 55.3475 51.1115 55.577 50.7787C55.9327 50.2623 55.8065 49.5508 55.2901 49.1836Z" fill="black"/>
<path d="M62.7836 22.2164C57.482 16.9148 50.459 14 43 14C35.541 14 28.518 16.9148 23.2164 22.2164C17.9148 27.518 15 34.541 15 42C15 49.459 17.9148 56.482 23.2164 61.7836C28.518 67.0852 35.541 70 43 70C50.459 70 57.482 67.0852 62.7836 61.7836C68.0852 56.482 71 49.459 71 42C71 34.541 68.0852 27.518 62.7836 22.2164ZM44.1475 67.682V63C44.1475 62.3689 43.6311 61.8525 43 61.8525C42.3689 61.8525 41.8525 62.3689 41.8525 63V67.682C28.5869 67.0967 17.9033 56.4131 17.318 43.1475H22C22.6311 43.1475 23.1475 42.6311 23.1475 42C23.1475 41.3689 22.6311 40.8525 22 40.8525H17.318C17.9033 27.5869 28.5869 16.9033 41.8525 16.318V21C41.8525 21.6311 42.3689 22.1475 43 22.1475C43.6311 22.1475 44.1475 21.6311 44.1475 21V16.318C57.4131 16.9033 68.0967 27.5869 68.682 40.8525H64C63.3689 40.8525 62.8525 41.3689 62.8525 42C62.8525 42.6311 63.3689 43.1475 64 43.1475H68.682C68.0967 56.4131 57.4131 67.0967 44.1475 67.682Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4407">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

1981
app/static/js/assiduites.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3309
app/static/libjs/moment.new.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,156 @@
{% block alertmodal %}
<div id="alertModal" class="alertmodal">
<!-- alertModal content -->
<div class="alertmodal-content">
<div class="alertmodal-header">
<span class="alertmodal-close">&times;</span>
<h2 class="alertmodal-title">alertModal Header</h2>
</div>
<div class="alertmodal-body">
<p>Some text in the alertModal Body</p>
<p>Some other text...</p>
</div>
<div class="alertmodal-footer">
<h3>alertModal Footer</h3>
</div>
</div>
</div>
<style>
/* The alertModal (background) */
.alertmodal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 750;
/* Sit on top */
padding-top: 100px;
/* Location of the box */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: rgb(0, 0, 0);
/* Fallback color */
background-color: rgba(0, 0, 0, 0.4);
/* Black w/ opacity */
}
/* alertModal Content */
.alertmodal-content {
border-radius: 8px;
overflow: hidden;
position: relative;
background-color: #fefefe;
margin: auto;
padding: 0;
border: 1px solid #888;
width: 45%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
-webkit-animation-name: animatetop;
-webkit-animation-duration: 0.4s;
animation-name: animatetop;
animation-duration: 0.4s
}
/* Add Animation */
@-webkit-keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
/* The Close Button */
.alertmodal-close {
color: white;
position: absolute;
right: 10px;
font-size: 28px;
font-weight: bold;
}
.alertmodal-close:hover,
.alertmodal-close:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
.alertmodal-header {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.alertmodal-body {
padding: 2px 16px;
}
.alertmodal-footer {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.alertmodal.is-active {
display: block;
}
</style>
<script>
const alertmodal = document.getElementById("alertModal");
function openAlertModal(titre, contenu, footer, color = "crimson") {
alertmodal.classList.add('is-active');
alertmodal.querySelector('.alertmodal-title').textContent = titre;
alertmodal.querySelector('.alertmodal-body').innerHTML = ""
alertmodal.querySelector('.alertmodal-body').appendChild(contenu);
alertmodal.querySelector('.alertmodal-footer').textContent = footer;
const banners = Array.from(alertmodal.querySelectorAll('.alertmodal-footer,.alertmodal-header'))
banners.forEach((ban) => {
ban.style.backgroundColor = color;
})
}
function closeAlertModal() {
alertmodal.classList.remove("is-active")
}
const alertClose = document.querySelector(".alertmodal-close");
alertClose.onclick = function () {
closeAlertModal()
}
window.onclick = function (event) {
if (event.target == alertmodal) {
alertmodal.classList.remove('is-active');
}
}
</script>
{% endblock alertmodal %}

View File

@ -0,0 +1,330 @@
{% include "assiduites/alert.j2" %}
{% include "assiduites/prompt.j2" %}
{% block app_content %}
<div class="pageContent">
<h2>Liste de l'assiduité et des justificatifs de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
<h3>Assiduités :</h3>
<table id="assiduiteTable">
<thead>
<tr>
<th>Début</th>
<th>Fin</th>
<th>État</th>
<th>Module</th>
<th>Justifiée</th>
</tr>
</thead>
<tbody id="tableBodyAssiduites">
</tbody>
</table>
<div id="paginationContainerAssiduites" class="pagination-container">
</div>
<h3>Justificatifs :</h3>
<table id="justificatifTable">
<thead>
<tr>
<th>Début</th>
<th>Fin</th>
<th>État</th>
<th>Raison</th>
</tr>
</thead>
<tbody id="tableBodyJustificatifs">
</tbody>
</table>
<div id="paginationContainerJustificatifs" class="pagination-container">
</div>
<ul id="contextMenu" class="context-menu">
<li id="detailOption">Detail</li>
<li id="editOption">Editer</li>
<li id="deleteOption">Supprimer</li>
</ul>
</div>
{% endblock app_content %}
<style>
.pageContent {
width: 100%;
max-width: 800px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
}
table {
border-collapse: collapse;
text-align: left;
margin: 20px 0;
}
th,
td {
border: 1px solid #dddddd;
padding: 8px;
}
th {
background-color: #f2f2f2;
}
tr:hover {
filter: brightness(1.2)
}
.context-menu {
display: none;
position: absolute;
list-style-type: none;
padding: 10px 0;
background-color: #f9f9f9;
border: 1px solid #ccc;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
}
.context-menu li {
padding: 8px 16px;
}
.context-menu li:hover {
background-color: #ddd;
}
.present {
background-color: #9CF1AF;
}
.absent,
.invalid {
background-color: #F1A69C;
}
.valid {
background-color: #8f7eff;
}
.retard {
background-color: #F1D99C;
}
/* Ajoutez des styles pour le conteneur de pagination et les boutons */
.pagination-container {
display: flex;
justify-content: center;
margin: 20px 0;
}
.pagination-button {
padding: 10px;
border: 1px solid #ccc;
cursor: pointer;
background-color: #f9f9f9;
margin: 0 5px;
text-decoration: none;
color: #000;
}
.pagination-button:hover {
background-color: #ddd;
}
.pagination-button.active {
background-color: #007bff;
color: #fff;
border-color: #007bff;
}
</style>
<script>
const etudid = {{ sco.etud.id }};
const paginationContainerAssiduites = document.getElementById("paginationContainerAssiduites");
const paginationContainerJustificatifs = document.getElementById("paginationContainerJustificatifs");
const itemsPerPage = 10;
let currentPageAssiduites = 1;
let currentPageJustificatifs = 1;
const tableBodyAssiduites = document.getElementById("tableBodyAssiduites");
const tableBodyJustificatifs = document.getElementById("tableBodyJustificatifs");
const contextMenu = document.getElementById("contextMenu");
const editOption = document.getElementById("editOption");
const deleteOption = document.getElementById("deleteOption");
let selectedRow;
document.addEventListener("click", () => {
contextMenu.style.display = "none";
});
editOption.addEventListener("click", () => {
if (selectedRow) {
// Code pour éditer la ligne sélectionnée
console.debug("Éditer :", selectedRow);
}
});
deleteOption.addEventListener("click", () => {
if (selectedRow) {
// Code pour supprimer la ligne sélectionnée
const type = selectedRow.getAttribute('type');
const obj_id = selectedRow.getAttribute('obj_id');
if (type == "assiduite") {
deleteAssiduite(obj_id);
} else {
deleteJustificatif(obj_id);
}
loadAll();
}
});
function renderTableAssiduites(page, assiduités) {
tableBodyAssiduites.innerHTML = "";
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
assiduités.slice(start, end).forEach((assiduite) => {
const row = document.createElement("tr");
row.setAttribute('type', "assiduite");
row.setAttribute('obj_id', assiduite.assiduite_id);
const etat = assiduite.etat.toLowerCase();
row.classList.add(etat);
row.innerHTML = `
<td>${new Date(assiduite.date_debut).toLocaleString()}</td>
<td>${new Date(assiduite.date_fin).toLocaleString()}</td>
<td>${etat}</td>
<td>${assiduite.moduleimpl_id}</td> <td>${assiduite.est_just ? "Oui" : "Non"
}</td>
`;
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
selectedRow = e.target.parentElement;
contextMenu.style.top = `${e.clientY}px`;
contextMenu.style.left = `${e.clientX}px`;
contextMenu.style.display = "block";
console.log(selectedRow);
});
tableBodyAssiduites.appendChild(row);
});
updateActivePaginationButton();
}
function renderTableJustificatifs(page, justificatifs) {
tableBodyJustificatifs.innerHTML = "";
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
justificatifs.slice(start, end).forEach((justificatif) => {
const row = document.createElement("tr");
row.setAttribute('type', "justificatif");
row.setAttribute('obj_id', justificatif.justif_id);
const etat = justificatif.etat.toLowerCase();
if (etat == "valide") {
row.classList.add('valid');
} else {
row.classList.add('invalid')
}
row.innerHTML = `
<td>${new Date(justificatif.date_debut).toLocaleString()}</td>
<td>${new Date(justificatif.date_fin).toLocaleString()}</td>
<td>${etat}</td>
<td>${justificatif.raison}</td>
`;
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
selectedRow = e.target.parentElement;
contextMenu.style.top = `${e.clientY}px`;
contextMenu.style.left = `${e.clientX}px`;
contextMenu.style.display = "block";
console.log(selectedRow);
});
tableBodyJustificatifs.appendChild(row);
});
updateActivePaginationButton(false);
}
function renderPaginationButtons(array, assi = true) {
const totalPages = Math.ceil(array.length / itemsPerPage);
if (assi) {
paginationContainerAssiduites.innerHTML = ""
} else {
paginationContainerJustificatifs.innerHTML = ""
}
for (let i = 1; i <= totalPages; i++) {
const paginationButton = document.createElement("a");
paginationButton.textContent = i;
paginationButton.classList.add("pagination-button");
if (assi) {
paginationButton.addEventListener("click", () => {
currentPageAssiduites = i;
renderTableAssiduites(currentPageAssiduites, array);
});
paginationContainerAssiduites.appendChild(paginationButton);
} else {
paginationButton.addEventListener("click", () => {
currentPageJustificatifs = i;
renderTableAssiduites(currentPageJustificatifs, array);
});
paginationContainerJustificatifs.appendChild(paginationButton);
}
}
updateActivePaginationButton(assi);
}
function updateActivePaginationButton(assi = true) {
if (assi) {
const paginationButtons =
paginationContainerAssiduites.querySelectorAll("#paginationContainerAssiduites .pagination-button");
paginationButtons.forEach((button) => {
if (parseInt(button.textContent) === currentPageAssiduites) {
button.classList.add("active");
} else {
button.classList.remove("active");
}
});
} else {
const paginationButtons =
paginationContainerJustificatifs.querySelectorAll("#paginationContainerJustificatifs .pagination-button");
paginationButtons.forEach((button) => {
if (parseInt(button.textContent) === currentPageJustificatifs) {
button.classList.add("active");
} else {
button.classList.remove("active");
}
});
}
}
function loadAll() {
getAllAssiduitesFromEtud(etudid, (assi) => {
renderTableAssiduites(currentPageAssiduites, assi);
renderPaginationButtons(assi), true;
})
getAllJustificatifsFromEtud(etudid, (assi) => {
renderTableJustificatifs(currentPageJustificatifs, assi);
renderPaginationButtons(assi, false);
})
}
window.onload = () => {
loadAll();
}
</script>

View File

@ -0,0 +1,108 @@
<div class="assiduite-bubble">
</div>
<script>
</script>
<style>
.assiduite-bubble {
position: fixed;
display: none;
background-color: #f9f9f9;
border-radius: 5px;
padding: 8px;
border: 3px solid #ccc;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-size: 12px;
line-height: 1.4;
z-index: 500;
}
.assiduite-bubble.is-active {
display: block;
}
.assiduite-bubble::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent transparent #f9f9f9 transparent;
}
.assiduite-bubble::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent #ccc transparent;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
margin-bottom: 4px;
}
.assiduite-bubble.absent {
border-color: #F1A69C !important;
}
.assiduite-bubble.present {
border-color: #9CF1AF !important;
}
.assiduite-bubble.retard {
border-color: #F1D99C !important;
}
.mini-timeline {
height: 7px;
border: 1px solid black;
position: relative;
background-color: white;
}
.mini-timeline.single {
height: 9px;
}
.mini-timeline-block {
position: absolute;
height: 100%;
z-index: 1;
}
#page-assiduite-content .mini-timeline-block {
cursor: pointer;
}
.mini_tick {
position: absolute;
text-align: center;
top: -16px;
left: 50%;
}
.mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -2px;
z-index: 2;
}
.mini-timeline-block.creneau {
outline: 3px solid #7059FF;
pointer-events: none;
}
</style>

View File

@ -0,0 +1,132 @@
<label for="moduleimpl_select">
Module
<select id="moduleimpl_select">
<option value="" selected> Non spécifié </option>
</select>
</label>
<script>
function getEtudFormSemestres() {
let semestre = {};
sync_get(getUrl() + `/api/etudiant/etudid/${etudid}/formsemestres`, (data) => {
semestre = data;
});
return semestre;
}
function filterFormSemestres(semestres) {
const date = new moment.tz(
document.querySelector("#tl_date").value,
TIMEZONE
);
semestres = semestres.filter((fm) => {
return date.isBetween(fm.date_debut_iso, fm.date_fin_iso)
})
return semestres;
}
function getFormSemestreProgramme(fm_id) {
let semestre = {};
sync_get(getUrl() + `/api/formsemestre/${fm_id}/programme`, (data) => {
semestre = data;
});
return semestre;
}
function getModulesImplByFormsemestre(semestres) {
const map = new Map();
semestres.forEach((fm) => {
const array = [];
const fm_p = getFormSemestreProgramme(fm.formsemestre_id);
["ressources", "saes", "modules"].forEach((r) => {
if (r in fm_p) {
fm_p[r].forEach((o) => {
array.push(getModuleInfos(o))
})
}
})
map.set(fm.titre_num, array)
})
return map;
}
function getModuleInfos(obj) {
return {
moduleimpl_id: obj.moduleimpl_id,
titre: obj.module.titre,
code: obj.module.code,
}
}
function populateSelect(sems, selected) {
const select = document.getElementById('moduleimpl_select');
select.innerHTML = `<option value="" selected> Non spécifié </option>`
sems.forEach((mods, label) => {
const optGrp = document.createElement('optgroup');
optGrp.label = label
mods.forEach((obj) => {
const opt = document.createElement('option');
opt.value = obj.moduleimpl_id;
opt.textContent = `${obj.code} ${obj.titre}`
if (obj.moduleimpl_id == selected) {
opt.setAttribute('selected', 'true');
}
optGrp.appendChild(opt);
})
select.appendChild(optGrp);
})
}
function updateSelect(moduleimpl_id) {
let sem = getEtudFormSemestres()
sem = filterFormSemestres(sem)
const mod = getModulesImplByFormsemestre(sem)
populateSelect(mod, moduleimpl_id);
}
function updateSelectedSelect(moduleimpl_id) {
const mod_id = moduleimpl_id != null ? moduleimpl_id : ""
document.getElementById('moduleimpl_select').value = mod_id;
}
window.onload = () => {
document.getElementById('moduleimpl_select').addEventListener('change', () => {
const mod_id = document.getElementById('moduleimpl_select').value;
const assi = getCurrentAssiduite(etudid);
if (assi) {
editAssiduite(assi.assiduite_id, assi.etat);
}
})
const conflicts = getAssiduitesConflict(etudid);
if (conflicts.length > 0) {
updateSelectedSelect(conflicts[0].moduleimpl_id);
}
}
</script>
<style>
#moduleimpl_select {
width: 125px;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,14 @@
<select name="moduleimpl_select" id="moduleimpl_select">
<option value="" {{selected}}> Non spécifié </option>
{% for mod in modules %}
{% if mod.moduleimpl_id == moduleimpl_id %}
<option value="{{mod.moduleimpl_id}}" selected> {{mod.name}} </option>
{% else %}
<option value="{{mod.moduleimpl_id}}"> {{mod.name}} </option>
{% endif %}
{% endfor %}
</select>

View File

@ -0,0 +1,211 @@
{% block promptModal %}
<div id="promptModal" class="promptModal">
<!-- promptModal content -->
<div class="promptModal-content">
<div class="promptModal-header">
<span class="promptModal-close">&times;</span>
<h2 class="promptModal-title">promptModal Header</h2>
</div>
<div class="promptModal-body">
<p>Some text in the promptModal Body</p>
<p>Some other text...</p>
</div>
<div class="promptModal-footer">
<h3>promptModal Footer</h3>
</div>
</div>
</div>
<style>
/* The promptModal (background) */
.promptModal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 750;
/* Sit on top */
padding-top: 100px;
/* Location of the box */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: rgb(0, 0, 0);
/* Fallback color */
background-color: rgba(0, 0, 0, 0.4);
/* Black w/ opacity */
}
/* promptModal Content */
.promptModal-content {
border-radius: 8px;
overflow: hidden;
position: relative;
background-color: #fefefe;
margin: auto;
padding: 0;
border: 1px solid #888;
width: 45%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
-webkit-animation-name: animatetop;
-webkit-animation-duration: 0.4s;
animation-name: animatetop;
animation-duration: 0.4s
}
/* Add Animation */
@-webkit-keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
/* The Close Button */
.promptModal-close {
color: white;
position: absolute;
right: 10px;
font-size: 28px;
font-weight: bold;
}
.promptModal-close:hover,
.promptModal-close:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
.promptModal-header {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.promptModal-body {
padding: 2px 16px;
}
.promptModal-footer {
padding: 2px 16px;
color: white;
display: flex;
justify-content: space-evenly;
align-items: center;
}
.promptModal.is-active {
display: block;
}
.btnPrompt {
display: inline-block;
padding: 6px 12px;
font-size: 14px;
font-weight: 500;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #6c757d;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.2);
}
.btnPrompt:hover {
background-color: #5a6268;
}
.btnPrompt:active {
transform: translateY(1px);
}
.btnPrompt:disabled {
opacity: 0.7;
background-color: whitesmoke;
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.61) 5px, rgba(81, 81, 81, 0.61) 10px)
}
</style>
<script>
const promptModal = document.getElementById("promptModal");
function openPromptModal(titre, contenu, success, cancel = () => { }, color = "crimson") {
promptModal.classList.add('is-active');
promptModal.querySelector('.promptModal-title').textContent = titre;
promptModal.querySelector('.promptModal-body').innerHTML = ""
promptModal.querySelector('.promptModal-body').appendChild(contenu);
promptModal.querySelector('.promptModal-footer').innerHTML = ""
promptModalButtonAction(success, cancel).forEach((btnPrompt) => {
promptModal.querySelector('.promptModal-footer').appendChild(btnPrompt)
})
const banners = Array.from(promptModal.querySelectorAll('.promptModal-footer,.promptModal-header'))
banners.forEach((ban) => {
ban.style.backgroundColor = color;
})
}
function promptModalButtonAction(success, cancel) {
const succBtn = document.createElement('button')
succBtn.classList.add("btnPrompt")
succBtn.textContent = "Valider"
succBtn.addEventListener('click', () => {
success();
closePromptModal();
})
const cancelBtn = document.createElement('button')
cancelBtn.classList.add("btnPrompt")
cancelBtn.textContent = "Annuler"
cancelBtn.addEventListener('click', () => {
cancel();
closePromptModal();
})
return [succBtn, cancelBtn]
}
function closePromptModal() {
promptModal.classList.remove("is-active")
}
const promptClose = document.querySelector(".promptModal-close");
promptClose.onclick = function () {
closePromptModal()
}
window.onclick = function (event) {
if (event.target == promptModal) {
promptModal.classList.remove('is-active');
}
}
</script>
{% endblock promptModal %}

View File

@ -0,0 +1,109 @@
{# -*- mode: jinja-html -*- #}
<div id="myModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Veuillez régler le conflit pour poursuivre</h2>
<!-- Ajout de la frise chronologique -->
<div class="modal-timeline">
<div class="time-labels"></div>
<div class="assiduites-container"></div>
</div>
<div class="action-buttons">
<button id="delete" class="btnPrompt" disabled>Supprimer</button>
<button id="split" class="btnPrompt" disabled>Séparer</button>
<button id="edit" class="btnPrompt" disabled>Modifier</button>
</div>
</div>
<div class="modal-assiduite-content">
<h2>Information de l'assiduité sélectionnée</h2>
<div class="infos">
<p>Assiduite id : <span id="modal-assiduite-id">A</span></p>
<p>Etat : <span id="modal-assiduite-etat">B</span></p>
<p>Date de début : <span id="modal-assiduite-deb">C</span></p>
<p>Date de fin: <span id="modal-assiduite-fin">D</span></p>
<p>Module : <span id="modal-assiduite-module">E</span></p>
<p><span id="modal-assiduite-user">F</span></p>
</div>
</div>
</div>
{% include "assiduites/toast.j2" %}
{% include "assiduites/alert.j2" %}
{% include "assiduites/prompt.j2" %}
<div id="page-assiduite-content">
{% block content %}
<h2>Signalement de l'assiduité de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
<div class="infos">
Date: <span id="datestr"></span>
<input type="date" name="tl_date" id="tl_date" value="{{ date }}" onchange="updateDate()">
</div>
{% include "assiduites/timeline.j2" %}
<div>
{% include "assiduites/moduleimpl_dynamic_selector.j2" %}
<button class="btn" onclick="fastJustify(getCurrentAssiduite(etudid))" id="justif-rapide">Justifier</button>
</div>
<div class="btn_group">
<button class="btn" onclick="setTimeLineTimes(8,18)">Journée</button>
<button class="btn" onclick="setTimeLineTimes(8,13)">Matin</button>
<button class="btn" onclick="setTimeLineTimes(13,18)">Après-midi</button>
</div>
<div class="etud_holder">
<div id="etud_row_{{sco.etud.id}}">
<div class="index"></div>
</div>
</div>
<!-- Ajout d'un conteneur pour le loader -->
<div class="loader-container" id="loaderContainer">
<div class="loader"></div>
</div>
<script>
const etudid = {{ sco.etud.id }};
setupDate(() => {
actualizeEtud(etudid);
updateSelect()
});
setupTimeLine(() => {
updateJustifyBtn();
});
updateDate();
getSingleEtud({{ sco.etud.id }});
actualizeEtud({{ sco.etud.id }});
updateSelect()
updateJustifyBtn();
function setTimeLineTimes(a, b) {
setPeriodValues(a, b);
updateJustifyBtn();
}
</script>
<style>
.justifie {
background-color: rgb(104, 104, 252);
color: whitesmoke;
}
</style>
{% endblock %}
</div>

View File

@ -0,0 +1,77 @@
{% include "assiduites/toast.j2" %}
<section id="content">
<div class="no-display">
<span class="formsemestre_id">{{formsemestre_id}}</span>
<span id="formsemestre_date_debut">{{formsemestre_date_debut}}</span>
<span id="formsemestre_date_fin">{{formsemestre_date_fin}}</span>
</div>
<h2>
Saisie des assiduités {{gr_tit|safe}} {{sem}}
</h2>
<fieldset class="selectors">
<div>Groupes : {{grp|safe}}</div>
<div>Modules :{{moduleimpl_select|safe}}</div>
<div class="infos">
Date: <span id="datestr"></span>
<input type="date" name="tl_date" id="tl_date" value="{{ date }}" onchange="updateDate()">
</div>
<button id="validate_selectors" onclick="validateSelectors()">
Valider
</button>
</fieldset>
{{timeline|safe}}
<div class="etud_holder">
</div>
<div id="myModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Veuillez régler le conflit pour poursuivre</h2>
<!-- Ajout de la frise chronologique -->
<div class="modal-timeline">
<div class="time-labels"></div>
<div class="assiduites-container"></div>
</div>
<div class="action-buttons">
<button id="delete" class="btnPrompt" disabled>Supprimer</button>
<button id="split" class="btnPrompt" disabled>Séparer</button>
<button id="edit" class="btnPrompt" disabled>Modifier</button>
</div>
</div>
<div class="modal-assiduite-content">
<h2>Information de l'assiduité sélectionnée</h2>
<div class="infos">
<p>Assiduite id : <span id="modal-assiduite-id">A</span></p>
<p>Etat : <span id="modal-assiduite-etat">B</span></p>
<p>Date de début : <span id="modal-assiduite-deb">C</span></p>
<p>Date de fin: <span id="modal-assiduite-fin">D</span></p>
<p>Module : <span id="modal-assiduite-module">E</span></p>
<p><span id="modal-assiduite-user">F</span></p>
</div>
</div>
</div>
<!-- Ajout d'un conteneur pour le loader -->
<div class="loader-container" id="loaderContainer">
<div class="loader"></div>
</div>
{% include "assiduites/alert.j2" %}
{% include "assiduites/prompt.j2" %}
<script>
updateDate();
setupDate();
setupTimeLine();
</script>
</section>

View File

@ -0,0 +1,219 @@
<div class="timeline-container">
<div class="period" style="left: 0%; width: 20%">
<div class="period-handle left"></div>
<div class="period-handle right"></div>
</div>
</div>
<script>
const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period");
function createTicks() {
for (let i = 8; i <= 18; i++) {
const hourTick = document.createElement("div");
hourTick.classList.add("tick", "hour");
hourTick.style.left = `${((i - 8) / 10) * 100}%`;
timelineContainer.appendChild(hourTick);
const tickLabel = document.createElement("div");
tickLabel.classList.add("tick-label");
tickLabel.style.left = `${((i - 8) / 10) * 100}%`;
tickLabel.textContent = i < 10 ? `0${i}:00` : `${i}:00`;
timelineContainer.appendChild(tickLabel);
if (i < 18) {
for (let j = 1; j < 4; j++) {
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter");
quarterTick.style.left = `${((i - 8 + j / 4) / 10) * 100}%`;
timelineContainer.appendChild(quarterTick);
}
}
}
}
function snapToQuarter(value) {
return Math.round(value * 4) / 4;
}
function setupTimeLine(callback) {
const func_call = callback ? callback : () => { }
timelineContainer.addEventListener("mousedown", (event) => {
const startX = event.clientX;
if (event.target === periodTimeLine) {
const startLeft = parseFloat(periodTimeLine.style.left);
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const containerWidth = timelineContainer.clientWidth;
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, parseFloat(periodTimeLine.style.width));
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener(
"mouseup",
() => {
generateAllEtudRow();
snapHandlesToQuarters()
document.removeEventListener("mousemove", onMouseMove);
func_call()
},
{ once: true }
);
} else if (event.target.classList.contains("period-handle")) {
const startWidth = parseFloat(periodTimeLine.style.width);
const startLeft = parseFloat(periodTimeLine.style.left);
const isLeftHandle = event.target.classList.contains("left");
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const containerWidth = timelineContainer.clientWidth;
const newWidth =
startWidth + ((isLeftHandle ? -deltaX : deltaX) / containerWidth) * 100;
if (isLeftHandle) {
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, newWidth);
} else {
adjustPeriodPosition(parseFloat(periodTimeLine.style.left), newWidth);
}
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener(
"mouseup",
() => {
snapHandlesToQuarters()
generateAllEtudRow();
document.removeEventListener("mousemove", onMouseMove);
func_call()
},
{ once: true }
);
}
});
}
function adjustPeriodPosition(newLeft, newWidth) {
const snappedLeft = snapToQuarter(newLeft);
const snappedWidth = snapToQuarter(newWidth);
const minLeft = 0;
const maxLeft = 100 - snappedWidth;
const clampedLeft = Math.min(Math.max(snappedLeft, minLeft), maxLeft);
periodTimeLine.style.left = `${clampedLeft}%`;
periodTimeLine.style.width = `${snappedWidth}%`;
}
function getPeriodValues() {
const leftPercentage = parseFloat(periodTimeLine.style.left);
const widthPercentage = parseFloat(periodTimeLine.style.width);
const startHour = (leftPercentage / 100) * 10 + 8;
const endHour = ((leftPercentage + widthPercentage) / 100) * 10 + 8;
const startValue = Math.round(startHour * 4) / 4;
const endValue = Math.round(endHour * 4) / 4;
return [startValue, endValue]
}
function setPeriodValues(deb, fin) {
let leftPercentage = (deb - 8) / 10 * 100
let widthPercentage = (fin - deb) / 10 * 100
periodTimeLine.style.left = `${leftPercentage}%`
periodTimeLine.style.width = `${widthPercentage}%`
snapHandlesToQuarters()
generateAllEtudRow();
}
function snapHandlesToQuarters() {
const periodValues = getPeriodValues();
let lef = Math.min((periodValues[0] - 8) * 10, 97.5)
if (lef < 0) {
lef = 0;
}
const left = `${lef}%`
let wid = Math.max((periodValues[1] - periodValues[0]) * 10, 2.5)
if (wid > 100) {
wid = 100;
}
const width = `${wid}%`
periodTimeLine.style.left = left;
periodTimeLine.style.width = width;
}
createTicks();
</script>
<style>
.timeline-container {
width: 75%;
margin-left: 5%;
background-color: white;
border-radius: 15px;
position: relative;
height: 40px;
}
/* ... */
.tick {
position: absolute;
bottom: 0;
width: 1px;
background-color: rgba(0, 0, 0, 0.5);
}
.tick.hour {
height: 100%;
}
.tick.quarter {
height: 50%;
}
.tick-label {
position: absolute;
bottom: 0;
font-size: 12px;
text-align: center;
transform: translateY(100%) translateX(-50%);
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.period {
position: absolute;
height: 100%;
background-color: rgba(0, 183, 255, 0.5);
border-radius: 15px;
}
.period-handle {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
border-radius: 0 4px 4px 0;
cursor: col-resize;
}
.period-handle.right {
right: 0;
border-radius: 4px 0 0 4px;
}
</style>

View File

@ -0,0 +1,88 @@
<div class="toast-holder">
</div>
<style>
.toast-holder {
position: fixed;
right: 1vw;
top: 5vh;
height: 80vh;
width: 20vw;
display: flex;
flex-direction: column;
flex-wrap: wrap;
transition: all 0.3s ease-in-out;
}
.toast {
margin: 0.5vh 0;
display: flex;
width: 100%;
height: fit-content;
justify-content: flex-start;
align-items: center;
border-radius: 10px;
padding: 7px;
z-index: 250;
transition: all 0.3s ease-in-out;
}
.fadeIn {
animation: fadeIn 0.5s ease-in;
}
.fadeOut {
animation: fadeOut 0.5s ease-in;
}
.toast-content {
color: whitesmoke;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>
<script>
function generateToast(content, color = "#12d3a5", ttl = 5) {
const toast = document.createElement('div')
toast.classList.add('toast', 'fadeIn')
const toastContent = document.createElement('div')
toastContent.classList.add('toast-content')
toastContent.appendChild(content)
toast.style.backgroundColor = color;
setTimeout(() => { toast.classList.replace('fadeIn', 'fadeOut') }, Math.max(0, ttl * 1000 - 500))
setTimeout(() => { toast.remove() }, Math.max(0, ttl * 1000))
toast.appendChild(toastContent)
return toast
}
</script>

View File

@ -64,6 +64,9 @@
{% block content %}
<div class="container flashes">
{% include "flashed_messages.j2" %}
</div>
<div class="container">
{# application content needs to be provided in the app_content block #}
{% block app_content %}{% endblock %}
</div>

4
app/templates/sidebar.j2 Normal file → Executable file
View File

@ -60,7 +60,7 @@
{% endif %}
<ul>
{% if current_user.has_permission(sco.Permission.ScoAbsChange) %}
<li><a href="{{ url_for('absences.SignaleAbsenceEtud', scodoc_dept=g.scodoc_dept,
<li><a href="{{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Ajouter</a></li>
<li><a href="{{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Justifier</a></li>
@ -73,7 +73,7 @@
{% endif %}
<li><a href="{{ url_for('absences.CalAbs', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Calendrier</a></li>
<li><a href="{{ url_for('absences.ListeAbsEtud', scodoc_dept=g.scodoc_dept,
<li><a href="{{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept,
etudid=sco.etud.id) }}">Liste</a></li>
</ul>
{% endif %}

View File

@ -23,6 +23,7 @@ scolar_bp = Blueprint("scolar", __name__)
notes_bp = Blueprint("notes", __name__)
users_bp = Blueprint("users", __name__)
absences_bp = Blueprint("absences", __name__)
assiduites_bp = Blueprint("assiduites", __name__)
# Cette fonction est bien appelée avant toutes les requêtes
@ -108,6 +109,7 @@ class ScoData:
from app.views import (
absences,
but_formation,
assiduites,
notes_formsemestre,
notes,
pn_modules,

419
app/views/assiduites.py Normal file
View File

@ -0,0 +1,419 @@
import datetime
from flask import g, request, render_template
from flask import abort, url_for
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
scodoc,
permission_required,
)
from app.models import FormSemestre, Identite
from app.views import assiduites_bp as bp
from app.views import ScoData
# ---------------
from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_groups_view
from app.scodoc import sco_etud
from app.scodoc import sco_find_etud
from flask_login import current_user
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
# --- UTILS ---
class HTMLElement:
""""""
class HTMLElement:
"""Représentation d'un HTMLElement version Python"""
def __init__(self, tag: str, *attr, **kattr) -> None:
self.tag: str = tag
self.children: list[HTMLElement] = []
self.self_close: bool = kattr.get("self_close", False)
self.text_content: str = kattr.get("text_content", "")
self.key_attributes: dict[str, any] = kattr
self.attributes: list[str] = list(attr)
def add(self, *child: HTMLElement) -> None:
"""add child element to self"""
for kid in child:
self.children.append(kid)
def remove(self, child: HTMLElement) -> None:
"""Remove child element from self"""
if child in self.children:
self.children.remove(child)
def __str__(self) -> str:
attr: list[str] = self.attributes
for att, val in self.key_attributes.items():
if att in ("self_close", "text_content"):
continue
if att != "cls":
attr.append(f'{att}="{val}"')
else:
attr.append(f'class="{val}"')
if not self.self_close:
head: str = f"<{self.tag} {' '.join(attr)}>{self.text_content}"
body: str = "\n".join(map(str, self.children))
foot: str = f"</{self.tag}>"
return head + body + foot
return f"<{self.tag} {' '.join(attr)}/>"
def __add__(self, other: str):
return str(self) + other
def __radd__(self, other: str):
return other + str(self)
class HTMLStringElement(HTMLElement):
"""Utilisation d'une chaine de caracètres pour représenter un element"""
def __init__(self, text: str) -> None:
self.text: str = text
HTMLElement.__init__(self, "textnode")
def __str__(self) -> str:
return self.text
class HTMLBuilder:
def __init__(self, *content: HTMLElement or str) -> None:
self.content: list[HTMLElement or str] = list(content)
def add(self, *element: HTMLElement or str):
self.content.extend(element)
def remove(self, element: HTMLElement or str):
if element in self.content:
self.content.remove(element)
def __str__(self) -> str:
return "\n".join(map(str, self.content))
def build(self) -> str:
return self.__str__()
# --------------------------------------------------------------------
#
# Assiduités (/ScoDoc/<dept>/Scolarite/Assiduites/...)
#
# --------------------------------------------------------------------
@bp.route("/")
@bp.route("/index_html")
@scodoc
@permission_required(Permission.ScoView)
def index_html():
"""Gestionnaire assiduités, page principale"""
H = [
html_sco_header.sco_header(
page_title="Saisie des assiduités",
cssstyles=["css/calabs.css"],
javascripts=["js/calabs.js"],
),
"""<h2>Traitement des assiduités</h2>
<p class="help">
Pour saisir des assiduités ou consulter les états, il est recommandé par passer par
le semestre concerné (saisie par jours nommés ou par semaines).
</p>
""",
]
H.append(
"""<p class="help">Pour signaler, annuler ou justifier une assiduité pour un seul étudiant,
choisissez d'abord concerné:</p>"""
)
H.append(sco_find_etud.form_search_etud())
if current_user.has_permission(
Permission.ScoAbsChange
) and sco_preferences.get_preference("handle_billets_abs"):
H.append(
f"""
<h2 style="margin-top: 30px;">Billets d'absence</h2>
<ul><li><a href="{url_for("absences.list_billets", scodoc_dept=g.scodoc_dept)
}">Traitement des billets d'absence en attente</a>
</li></ul>
"""
)
H.append(html_sco_header.sco_footer())
return "\n".join(H)
@bp.route("/SignaleAssiduiteEtud")
@scodoc
@permission_required(Permission.ScoAbsChange)
def signal_assiduites_etud():
"""
signal_assiduites_etud Saisie de l'assiduité d'un étudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
etudid = request.args.get("etudid", -1)
etud: Identite = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
header: str = html_sco_header.sco_header(
page_title="Saisie Assiduités",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
return HTMLBuilder(
header,
render_template("assiduites/minitimeline.j2"),
render_template(
"assiduites/signal_assiduites_etud.j2",
sco=ScoData(etud),
date=datetime.date.today().isoformat(),
),
).build()
@bp.route("/ListeAssiduitesEtud")
@scodoc
@permission_required(Permission.ScoAbsChange)
def liste_assiduites_etud():
"""
liste_assiduites_etud Affichage de toutes les assiduites et justificatifs d'un etudiant
Args:
etudid (int): l'identifiant de l'étudiant
Returns:
str: l'html généré
"""
etudid = request.args.get("etudid", -1)
etud: Identite = Identite.query.get_or_404(etudid)
if etud.dept_id != g.scodoc_dept_id:
abort(404, "étudiant inexistant dans ce département")
header: str = html_sco_header.sco_header(
page_title="Liste des assiduités",
init_qtip=True,
javascripts=[
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
)
return HTMLBuilder(
header,
render_template(
"assiduites/liste_assiduites.j2",
sco=ScoData(etud),
date=datetime.date.today().isoformat(),
),
).build()
@bp.route("/SignalAssiduiteGr")
@scodoc
@permission_required(Permission.ScoAbsChange)
def signal_assiduites_group():
"""
signal_assiduites_group Saisie des assiduités des groupes pour le jour donnée
Returns:
str: l'html généré
"""
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
group_ids: list[int] = request.args.get("group_ids", None)
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
date: str = request.args.get("jour", datetime.date.today().isoformat())
group_ids: list[int] = request.args.get("group_ids", None)
if group_ids is None:
group_ids = []
else:
group_ids = group_ids.split(",")
map(str, group_ids)
# Vérification du moduleimpl_id
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
moduleimpl_id = None
# Vérification du formsemestre_id
try:
formsemestre_id = int(formsemestre_id)
except (TypeError, ValueError):
formsemestre_id = None
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id
)
# Aucun étudiant WIP
if not groups_infos.members:
return (
html_sco_header.sco_header(page_title="Saisie journalière des Assiduités")
+ "<h3>Aucun étudiant ! </h3>"
+ html_sco_header.sco_footer()
)
# --- URL DEFAULT ---
base_url: str = f"SignalAssiduiteGr?date={date}&{groups_infos.groups_query_args}"
# --- Filtrage par formsemestre ---
formsemestre_id = groups_infos.formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if formsemestre.dept_id != g.scodoc_dept_id:
abort(404, "groupes inexistants dans ce département")
require_module = sco_preferences.get_preference(
"abs_require_module", formsemestre_id
)
etuds = [
sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
for m in groups_infos.members
]
# --- Restriction en fonction du moduleimpl_id ---
if moduleimpl_id:
mod_inscrits = {
x["etudid"]
for x in sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=moduleimpl_id
)
}
etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
if etuds_inscrits_module:
etuds = etuds_inscrits_module
else:
# Si aucun etudiant n'est inscrit au module choisi...
moduleimpl_id = None
# --- Génération de l'HTML ---
sem = formsemestre.to_dict()
if groups_infos.tous_les_etuds_du_sem:
gr_tit = "en"
else:
if len(groups_infos.group_ids) > 1:
grp = "des groupes"
else:
grp = "du groupe"
gr_tit = (
grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
)
header: str = html_sco_header.sco_header(
page_title="Saisie journalière des assiduités",
init_qtip=True,
javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
+ [
# Voir fonctionnement JS
"js/etud_info.js",
"js/abs_ajax.js",
"js/groups_view.js",
"js/assiduites.js",
"libjs/moment.new.min.js",
"libjs/moment-timezone.js",
],
cssstyles=CSSSTYLES
+ [
"css/assiduites.css",
],
no_side_bar=1,
)
return HTMLBuilder(
header,
render_template("assiduites/minitimeline.j2"),
render_template(
"assiduites/signal_assiduites_group.j2",
gr_tit=gr_tit,
sem=sem["titre_num"],
date=date,
formsemestre_id=formsemestre_id,
grp=sco_groups_view.menu_groups_choice(groups_infos),
moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
timeline=_timeline(),
formsemestre_date_debut=str(formsemestre.date_debut),
formsemestre_date_fin=str(formsemestre.date_fin),
),
html_sco_header.sco_footer(),
).build()
def _module_selector(
formsemestre: FormSemestre, moduleimpl_id: int = None
) -> HTMLElement:
"""
_module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre
Args:
formsemestre (FormSemestre): Le formsemestre d'où les moduleimpls seront pris.
Returns:
str: La représentation str d'un HTMLSelectElement
"""
ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
modimpls_list: list[dict] = []
ues = ntc.get_ues_stat_dict()
for ue in ues:
modimpls_list += ntc.get_modimpls_dict(ue_id=ue["ue_id"])
selected = moduleimpl_id is not None
modules = []
for modimpl in modimpls_list:
modname: str = (
(modimpl["module"]["code"] or "")
+ " "
+ (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "")
)
modules.append({"moduleimpl_id": modimpl["moduleimpl_id"], "name": modname})
return render_template(
"assiduites/moduleimpl_selector.j2", selected=selected, modules=modules
)
def _timeline() -> HTMLElement:
return render_template("assiduites/timeline.j2")

View File

@ -0,0 +1,71 @@
"""assiduites ajout user_id,est_just
Revision ID: b555390780b2
Revises: dbcf2175e87f
Create Date: 2023-02-22 18:44:22.643275
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b555390780b2"
down_revision = "dbcf2175e87f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"assiduites",
sa.Column(
"user_id",
sa.Integer(),
nullable=True,
),
)
op.add_column(
"assiduites",
sa.Column("est_just", sa.Boolean(), server_default="false", nullable=False),
)
op.create_index(
op.f("ix_assiduites_user_id"), "assiduites", ["user_id"], unique=False
)
op.create_foreign_key(
"fk_assiduites_user_id",
"assiduites",
"user",
["user_id"],
["id"],
ondelete="SET NULL",
)
op.add_column(
"justificatifs",
sa.Column("user_id", sa.Integer(), nullable=True),
)
op.create_index(
op.f("ix_justificatifs_user_id"), "justificatifs", ["user_id"], unique=False
)
op.create_foreign_key(
"fk_justificatifs_user_id",
"justificatifs",
"user",
["user_id"],
["id"],
ondelete="SET NULL",
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("fk_justificatifs_user_id", "justificatifs", type_="foreignkey")
op.drop_index(op.f("ix_justificatifs_user_id"), table_name="justificatifs")
op.drop_column("justificatifs", "user_id")
op.drop_constraint("fk_assiduites_user_id", "assiduites", type_="foreignkey")
op.drop_index(op.f("ix_assiduites_user_id"), table_name="assiduites")
op.drop_column("assiduites", "est_just")
op.drop_column("assiduites", "user_id")
# ### end Alembic commands ###

View File

@ -0,0 +1,95 @@
"""modèles assiduites justificatifs
Revision ID: dbcf2175e87f
Revises: 5c7b208355df
Create Date: 2023-02-01 14:21:06.989190
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "dbcf2175e87f"
down_revision = "054dd6133b9c"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"justificatifs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"date_debut",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"date_fin",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("etudid", sa.Integer(), nullable=False),
sa.Column("etat", sa.Integer(), nullable=False),
sa.Column(
"entry_date",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column("raison", sa.Text(), nullable=True),
sa.Column("fichier", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_justificatifs_etudid"), "justificatifs", ["etudid"], unique=False
)
op.create_table(
"assiduites",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"date_debut",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"date_fin",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("moduleimpl_id", sa.Integer(), nullable=True),
sa.Column("etudid", sa.Integer(), nullable=False),
sa.Column("etat", sa.Integer(), nullable=False),
sa.Column("desc", sa.Text(), nullable=True),
sa.Column(
"entry_date",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["moduleimpl_id"], ["notes_moduleimpl.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_assiduites_etudid"), "assiduites", ["etudid"], unique=False
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_assiduites_etudid"), table_name="assiduites")
op.drop_table("assiduites")
op.drop_index(op.f("ix_justificatifs_etudid"), table_name="justificatifs")
op.drop_table("justificatifs")
# ### end Alembic commands ###

View File

@ -642,3 +642,63 @@ def profile(host, port, length, profile_dir):
run_simple(
host, port, app, use_debugger=False
) # use run_simple instead of app.run()
# <== Gestion de l'assiduité ==>
@app.cli.command()
@click.option(
"-d", "--dept", help="Restreint la migration au dept sélectionné (ACRONYME)"
)
@click.option(
"-m",
"--morning",
help="Spécifie l'heure de début des cours format `hh:mm`",
default="08h00",
show_default=True,
)
@click.option(
"-n",
"--noon",
help="Spécifie l'heure de fin du matin (et donc début de l'après-midi) format `hh:mm`",
default="12h00",
show_default=True,
)
@click.option(
"-e",
"--evening",
help="Spécifie l'heure de fin des cours format `hh:mm`",
default="18h00",
show_default=True,
)
@with_appcontext
def migrate_abs_to_assiduites(
dept: str = None, morning: str = None, noon: str = None, evening: str = None
): # migrate-abs-to-assiduites
"""Permet de migrer les absences vers le nouveau module d'assiduités"""
tools.migrate_abs_to_assiduites(dept, morning, noon, evening)
@app.cli.command()
@click.option(
"-d", "--dept", help="Restreint la suppression au dept sélectionné (ACRONYME)"
)
@click.option(
"-a",
"--assiduites",
is_flag=True,
help="Supprime les assiduités de scodoc",
)
@click.option(
"-j",
"--justificatifs",
is_flag=True,
help="Supprime les justificatifs de scodoc",
)
@with_appcontext
def downgrade_assiduites_module(
dept: str = None, assiduites: bool = False, justificatifs: bool = False
):
"""Supprime les assiduites et/ou les justificatifs de tous les départements ou du département sélectionné"""
tools.downgrade_module(dept, assiduites, justificatifs)

View File

@ -7,6 +7,7 @@
Usage:
cd /opt/scodoc/tests/api
python make_samples.py [entry_names]
python make_samples.py -i <filepath> [entrynames]
si entry_names est spécifié, la génération est restreints aux exemples cités. expl: `python make_samples departements departement-formsemestres`
doit être exécutée immédiatement apres une initialisation de la base pour test API! (car dépendant des identifiants générés lors de la création des objets)
@ -37,7 +38,6 @@ Quand la structure est complète, on génére tous les fichiers textes
- le résultat
Le tout mis en forme au format markdown et rangé dans le répertoire DATA_DIR (/tmp/samples) qui est créé ou écrasé si déjà existant
TODO: ajouter un argument au script permettant de ne générer qu'un seul fichier (exemple: `python make_samples.py nom_exemple`)
"""
import os
@ -65,7 +65,7 @@ from setup_test_api import (
)
DATA_DIR = "/tmp/samples/"
SAMPLES_FILENAME = "tests/ressources/samples.csv"
SAMPLES_FILENAME = "tests/ressources/samples/samples.csv"
class Sample:
@ -180,11 +180,13 @@ class Samples:
file.close()
def make_samples():
def make_samples(samples_filename):
if len(sys.argv) == 1:
entry_names = None
else:
entry_names = sys.argv[1:]
elif len(sys.argv) >= 3 and sys.argv[1] == "-i":
samples_filename = sys.argv[2]
entry_names = sys.argv[3:] if len(sys.argv) > 3 else None
if os.path.exists(DATA_DIR):
if not os.path.isdir(DATA_DIR):
raise f"{DATA_DIR} existe déjà et n'est pas un répertoire"
@ -197,7 +199,7 @@ def make_samples():
samples = Samples(entry_names)
df = read_csv(
SAMPLES_FILENAME,
samples_filename,
sep=";",
quotechar='"',
dtype={
@ -217,4 +219,4 @@ def make_samples():
if not CHECK_CERTIFICATE:
urllib3.disable_warnings()
make_samples()
make_samples(SAMPLES_FILENAME)

View File

@ -0,0 +1,392 @@
"""
Test de l'api Assiduité
Ecrit par HARTMANN Matthias
"""
from random import randint
from tests.api.setup_test_api import GET, POST_JSON, APIError, api_headers
ETUDID = 1
FAUX = 42069
FORMSEMESTREID = 1
MODULE = 1
ASSIDUITES_FIELDS = {
"assiduite_id": int,
"etudid": int,
"moduleimpl_id": int,
"date_debut": str,
"date_fin": str,
"etat": str,
"desc": str,
"entry_date": str,
"user_id": str,
"est_just": bool,
}
CREATE_FIELD = {"assiduite_id": int}
BATCH_FIELD = {"errors": dict, "success": dict}
COUNT_FIELDS = {"compte": int, "journee": int, "demi": int, "heure": float}
TO_REMOVE = []
def check_fields(data: dict, fields: dict = None):
"""
Cette fonction permet de vérifier que le dictionnaire data
contient les bonnes clés et les bons types de valeurs.
Args:
data (dict): un dictionnaire (json de retour de l'api)
fields (dict, optional): Un dictionnaire représentant les clés et les types d'une réponse.
"""
if fields is None:
fields = ASSIDUITES_FIELDS
assert set(data.keys()) == set(fields.keys())
for key in data:
if key in ("moduleimpl_id", "desc", "user_id"):
assert isinstance(data[key], fields[key]) or data[key] is None
else:
assert isinstance(data[key], fields[key])
def check_failure_get(path: str, headers: dict, err: str = None):
"""
Cette fonction vérifiée que la requête GET renvoie bien un 404
Args:
path (str): la route de l'api
headers (dict): le token d'auth de l'api
err (str, optional): L'erreur qui est sensée être fournie par l'api.
Raises:
APIError: Une erreur car la requête a fonctionné (mauvais comportement)
"""
try:
GET(path=path, headers=headers)
# ^ Renvoi un 404
except APIError as api_err:
if err is not None:
assert api_err.payload["message"] == err
else:
raise APIError("Le GET n'aurait pas du fonctionner")
def check_failure_post(path: str, headers: dict, data: dict, err: str = None):
"""
Cette fonction vérifiée que la requête POST renvoie bien un 404
Args:
path (str): la route de l'api
headers (dict): le token d'auth
data (dict): un dictionnaire (json) à envoyer
err (str, optional): L'erreur qui est sensée être fournie par l'api.
Raises:
APIError: Une erreur car la requête a fonctionné (mauvais comportement)
"""
try:
data = POST_JSON(path=path, headers=headers, data=data)
# ^ Renvoi un 404
except APIError as api_err:
if err is not None:
assert api_err.payload["message"] == err
else:
raise APIError("Le GET n'aurait pas du fonctionner")
def create_data(etat: str, day: str, module: int = None, desc: str = None):
"""
Permet de créer un dictionnaire assiduité
Args:
etat (str): l'état de l'assiduité (PRESENT,ABSENT,RETARD)
day (str): Le jour de l'assiduité
module (int, optional): Le moduleimpl_id associé
desc (str, optional): Une description de l'assiduité (eg: motif retard )
Returns:
dict: la représentation d'une assiduité
"""
data = {
"date_debut": f"2022-01-{day}T08:00",
"date_fin": f"2022-01-{day}T10:00",
"etat": etat,
}
if module is not None:
data["moduleimpl_id"] = module
if desc is not None:
data["desc"] = desc
return data
def test_route_assiduite(api_headers):
"""test de la route /assiduite/<assiduite_id:int>"""
# Bon fonctionnement == id connu
data = GET(path="/assiduite/1", headers=api_headers)
check_fields(data)
# Mauvais Fonctionnement == id inconnu
check_failure_get(
f"/assiduite/{FAUX}",
api_headers,
)
def test_route_count_assiduites(api_headers):
"""test de la route /assiduites/<etudid:int>/count"""
# Bon fonctionnement
data = GET(path=f"/assiduites/{ETUDID}/count", headers=api_headers)
check_fields(data, COUNT_FIELDS)
metrics = {"heure", "compte"}
data = GET(
path=f"/assiduites/{ETUDID}/count/query?metric={','.join(metrics)}",
headers=api_headers,
)
assert set(data.keys()) == metrics
# Mauvais fonctionnement
check_failure_get(f"/assiduites/{FAUX}/count", api_headers)
def test_route_assiduites(api_headers):
"""test de la route /assiduites/<etudid:int>"""
# Bon fonctionnement
data = GET(path=f"/assiduites/{ETUDID}", headers=api_headers)
assert isinstance(data, list)
for ass in data:
check_fields(ass, ASSIDUITES_FIELDS)
data = GET(path=f"/assiduites/{ETUDID}/query?", headers=api_headers)
assert isinstance(data, list)
for ass in data:
check_fields(ass, ASSIDUITES_FIELDS)
# Mauvais fonctionnement
check_failure_get(f"/assiduites/{FAUX}", api_headers)
check_failure_get(f"/assiduites/{FAUX}/query?", api_headers)
def test_route_formsemestre_assiduites(api_headers):
"""test de la route /assiduites/formsemestre/<formsemestre_id:int>"""
# Bon fonctionnement
data = GET(path=f"/assiduites/formsemestre/{FORMSEMESTREID}", headers=api_headers)
assert isinstance(data, list)
for ass in data:
check_fields(ass, ASSIDUITES_FIELDS)
data = GET(
path=f"/assiduites/formsemestre/{FORMSEMESTREID}/query?", headers=api_headers
)
assert isinstance(data, list)
for ass in data:
check_fields(ass, ASSIDUITES_FIELDS)
# Mauvais fonctionnement
check_failure_get(
f"/assiduites/formsemestre/{FAUX}",
api_headers,
err="le paramètre 'formsemestre_id' n'existe pas",
)
check_failure_get(
f"/assiduites/formsemestre/{FAUX}/query?",
api_headers,
err="le paramètre 'formsemestre_id' n'existe pas",
)
def test_route_count_formsemestre_assiduites(api_headers):
"""test de la route /assiduites/formsemestre/<formsemestre_id:int>/count"""
# Bon fonctionnement
data = GET(
path=f"/assiduites/formsemestre/{FORMSEMESTREID}/count", headers=api_headers
)
check_fields(data, COUNT_FIELDS)
metrics = {"heure", "compte"}
data = GET(
path=f"/assiduites/formsemestre/{FORMSEMESTREID}/count/query?metric={','.join(metrics)}",
headers=api_headers,
)
assert set(data.keys()) == metrics
# Mauvais fonctionnement
check_failure_get(
f"/assiduites/formsemestre/{FAUX}/count",
api_headers,
err="le paramètre 'formsemestre_id' n'existe pas",
)
check_failure_get(
f"/assiduites/formsemestre/{FAUX}/count/query?",
api_headers,
err="le paramètre 'formsemestre_id' n'existe pas",
)
def test_route_create(api_headers):
"""test de la route /assiduite/<etudid:int>/create"""
# -== Unique ==-
# Bon fonctionnement
data = create_data("present", "01")
res = POST_JSON(f"/assiduite/{ETUDID}/create", [data], api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["assiduite_id"])
data2 = create_data("absent", "02", MODULE, "desc")
res = POST_JSON(f"/assiduite/{ETUDID}/create", [data2], api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["assiduite_id"])
# Mauvais fonctionnement
check_failure_post(f"/assiduite/{FAUX}/create", api_headers, [data])
res = POST_JSON(f"/assiduite/{ETUDID}/create", [data], api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
assert (
res["errors"]["0"]
== "Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
res = POST_JSON(
f"/assiduite/{ETUDID}/create", [create_data("absent", "03", FAUX)], api_headers
)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
assert res["errors"]["0"] == "param 'moduleimpl_id': invalide"
# -== Multiple ==-
# Bon Fonctionnement
etats = ["present", "absent", "retard"]
data = [
create_data(etats[d % 3], 10 + d, MODULE if d % 2 else None)
for d in range(randint(3, 5))
]
res = POST_JSON(f"/assiduite/{ETUDID}/create", data, api_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
check_fields(res["success"][dat], CREATE_FIELD)
TO_REMOVE.append(res["success"][dat]["assiduite_id"])
# Mauvais Fonctionnement
data2 = [
create_data("present", "01"),
create_data("present", "25", FAUX),
create_data("blabla", 26),
create_data("absent", 32),
]
res = POST_JSON(f"/assiduite/{ETUDID}/create", data2, api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 4
assert (
res["errors"]["0"]
== "Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
assert res["errors"]["1"] == "param 'moduleimpl_id': invalide"
assert res["errors"]["2"] == "param 'etat': invalide"
assert (
res["errors"]["3"]
== "param 'date_debut': format invalide, param 'date_fin': format invalide"
)
def test_route_edit(api_headers):
"""test de la route /assiduite/<assiduite_id:int>/edit"""
# Bon fonctionnement
data = {"etat": "retard", "moduleimpl_id": MODULE}
res = POST_JSON(f"/assiduite/{TO_REMOVE[0]}/edit", data, api_headers)
assert res == {"OK": True}
data["moduleimpl_id"] = None
res = POST_JSON(f"/assiduite/{TO_REMOVE[1]}/edit", data, api_headers)
assert res == {"OK": True}
# Mauvais fonctionnement
check_failure_post(f"/assiduite/{FAUX}/edit", api_headers, data)
data["etat"] = "blabla"
check_failure_post(
f"/assiduite/{TO_REMOVE[2]}/edit",
api_headers,
data,
err="param 'etat': invalide",
)
def test_route_delete(api_headers):
"""test de la route /assiduite/delete"""
# -== Unique ==-
# Bon fonctionnement
data = TO_REMOVE[0]
res = POST_JSON("/assiduite/delete", [data], api_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
# Mauvais fonctionnement
res = POST_JSON("/assiduite/delete", [data], api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
# -== Multiple ==-
# Bon Fonctionnement
data = TO_REMOVE[1:]
res = POST_JSON("/assiduite/delete", data, api_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
# Mauvais Fonctionnement
data2 = [
FAUX,
FAUX + 1,
FAUX + 2,
]
res = POST_JSON("/assiduite/delete", data2, api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 3
assert all([res["errors"][i] == "Assiduite non existante" for i in res["errors"]])

View File

@ -0,0 +1 @@
test de l'importation des fichiers / archive justificatif

View File

@ -0,0 +1 @@
test de l'importation des fichiers / archive justificatif

View File

@ -0,0 +1,469 @@
"""
Test de l'api justificatif
Ecrit par HARTMANN Matthias
"""
from random import randint
import requests
from tests.api.setup_test_api import (
API_URL,
CHECK_CERTIFICATE,
GET,
POST_JSON,
APIError,
api_headers,
)
ETUDID = 1
FAUX = 42069
JUSTIFICATIFS_FIELDS = {
"justif_id": int,
"etudid": int,
"date_debut": str,
"date_fin": str,
"etat": str,
"raison": str,
"entry_date": str,
"fichier": str,
"user_id": int,
}
CREATE_FIELD = {"justif_id": int, "couverture": list}
BATCH_FIELD = {"errors": dict, "success": dict}
TO_REMOVE = []
def check_fields(data, fields: dict = None):
"""
Cette fonction permet de vérifier que le dictionnaire data
contient les bonnes clés et les bons types de valeurs.
Args:
data (dict): un dictionnaire (json de retour de l'api)
fields (dict, optional): Un dictionnaire représentant les clés et les types d'une réponse.
"""
if fields is None:
fields = JUSTIFICATIFS_FIELDS
assert set(data.keys()) == set(fields.keys())
for key in data:
if key in ("raison", "fichier", "user_id"):
assert isinstance(data[key], fields[key]) or data[key] is None
else:
assert isinstance(data[key], fields[key])
def check_failure_get(path, headers, err=None):
"""
Cette fonction vérifiée que la requête GET renvoie bien un 404
Args:
path (str): la route de l'api
headers (dict): le token d'auth de l'api
err (str, optional): L'erreur qui est sensée être fournie par l'api.
Raises:
APIError: Une erreur car la requête a fonctionné (mauvais comportement)
"""
try:
GET(path=path, headers=headers)
# ^ Renvoi un 404
except APIError as api_err:
if err is not None:
assert api_err.payload["message"] == err
else:
raise APIError("Le GET n'aurait pas du fonctionner")
def check_failure_post(path, headers, data, err=None):
"""
Cette fonction vérifiée que la requête POST renvoie bien un 404
Args:
path (str): la route de l'api
headers (dict): le token d'auth
data (dict): un dictionnaire (json) à envoyer
err (str, optional): L'erreur qui est sensée être fournie par l'api.
Raises:
APIError: Une erreur car la requête a fonctionné (mauvais comportement)
"""
try:
data = POST_JSON(path=path, headers=headers, data=data)
# ^ Renvoi un 404
except APIError as api_err:
if err is not None:
assert api_err.payload["message"] == err
else:
raise APIError("Le POST n'aurait pas du fonctionner")
def create_data(etat: str, day: str, raison: str = None):
"""
Permet de créer un dictionnaire assiduité
Args:
etat (str): l'état du justificatif (VALIDE,NON_VALIDE,MODIFIE, ATTENTE)
day (str): Le jour du justificatif
raison (str, optional): Une description du justificatif (eg: motif retard )
Returns:
dict: la représentation d'une assiduité
"""
data = {
"date_debut": f"2022-01-{day}T08:00",
"date_fin": f"2022-01-{day}T10:00",
"etat": etat,
}
if raison is not None:
data["desc"] = raison
return data
def test_route_justificatif(api_headers):
"""test de la route /justificatif/<justif_id:int>"""
# Bon fonctionnement == id connu
data = GET(path="/justificatif/1", headers=api_headers)
check_fields(data)
# Mauvais Fonctionnement == id inconnu
check_failure_get(
f"/justificatif/{FAUX}",
api_headers,
)
def test_route_justificatifs(api_headers):
"""test de la route /justificatifs/<etudid:int>"""
# Bon fonctionnement
data = GET(path=f"/justificatifs/{ETUDID}", headers=api_headers)
assert isinstance(data, list)
for just in data:
check_fields(just, JUSTIFICATIFS_FIELDS)
data = GET(path=f"/justificatifs/{ETUDID}/query?", headers=api_headers)
assert isinstance(data, list)
for just in data:
check_fields(just, JUSTIFICATIFS_FIELDS)
# Mauvais fonctionnement
check_failure_get(f"/justificatifs/{FAUX}", api_headers)
check_failure_get(f"/justificatifs/{FAUX}/query?", api_headers)
def test_route_create(api_headers):
"""test de la route /justificatif/<justif_id:int>/create"""
# -== Unique ==-
# Bon fonctionnement
data = create_data("valide", "01")
res = POST_JSON(f"/justificatif/{ETUDID}/create", [data], api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["justif_id"])
data2 = create_data("modifie", "02", "raison")
res = POST_JSON(f"/justificatif/{ETUDID}/create", [data2], api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["success"]) == 1
TO_REMOVE.append(res["success"]["0"]["justif_id"])
# Mauvais fonctionnement
check_failure_post(f"/justificatif/{FAUX}/create", api_headers, [data])
res = POST_JSON(
f"/justificatif/{ETUDID}/create",
[create_data("absent", "03")],
api_headers,
)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
assert res["errors"]["0"] == "param 'etat': invalide"
# -== Multiple ==-
# Bon Fonctionnement
etats = ["valide", "modifie", "non_valide", "attente"]
data = [
create_data(etats[d % 4], 10 + d, "raison" if d % 2 else None)
for d in range(randint(3, 5))
]
res = POST_JSON(f"/justificatif/{ETUDID}/create", data, api_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
check_fields(res["success"][dat], CREATE_FIELD)
TO_REMOVE.append(res["success"][dat]["justif_id"])
# Mauvais Fonctionnement
data2 = [
create_data(None, "25"),
create_data("blabla", 26),
create_data("valide", 32),
]
res = POST_JSON(f"/justificatif/{ETUDID}/create", data2, api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 3
assert res["errors"]["0"] == "param 'etat': manquant"
assert res["errors"]["1"] == "param 'etat': invalide"
assert (
res["errors"]["2"]
== "param 'date_debut': format invalide, param 'date_fin': format invalide"
)
def test_route_edit(api_headers):
"""test de la route /justificatif/<justif_id:int>/edit"""
# Bon fonctionnement
data = {"etat": "modifie", "raison": "test"}
res = POST_JSON(f"/justificatif/{TO_REMOVE[0]}/edit", data, api_headers)
assert isinstance(res, dict) and "couverture" in res.keys()
data["raison"] = None
res = POST_JSON(f"/justificatif/{TO_REMOVE[1]}/edit", data, api_headers)
assert isinstance(res, dict) and "couverture" in res.keys()
# Mauvais fonctionnement
check_failure_post(f"/justificatif/{FAUX}/edit", api_headers, data)
data["etat"] = "blabla"
check_failure_post(
f"/justificatif/{TO_REMOVE[2]}/edit",
api_headers,
data,
err="param 'etat': invalide",
)
def test_route_delete(api_headers):
"""test de la route /justificatif/delete"""
# -== Unique ==-
# Bon fonctionnement
data = TO_REMOVE[0]
res = POST_JSON("/justificatif/delete", [data], api_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
# Mauvais fonctionnement
res = POST_JSON("/justificatif/delete", [data], api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 1
# -== Multiple ==-
# Bon Fonctionnement
data = TO_REMOVE[1:]
res = POST_JSON("/justificatif/delete", data, api_headers)
check_fields(res, BATCH_FIELD)
for dat in res["success"]:
assert res["success"][dat] == {"OK": True}
# Mauvais Fonctionnement
data2 = [
FAUX,
FAUX + 1,
FAUX + 2,
]
res = POST_JSON("/justificatif/delete", data2, api_headers)
check_fields(res, BATCH_FIELD)
assert len(res["errors"]) == 3
assert all([res["errors"][i] == "Justificatif non existant" for i in res["errors"]])
# Gestion de l'archivage
def send_file(justif_id: int, filename: str, headers):
"""
Envoi un fichier vers la route d'importation
"""
with open(filename, "rb") as file:
url: str = API_URL + f"/justificatif/{justif_id}/import"
req = requests.post(
url,
files={filename: file},
headers=headers,
verify=CHECK_CERTIFICATE,
)
if req.status_code != 200:
raise APIError(f"erreur status={req.status_code} !", req.json())
return req.json()
def check_failure_send(
justif_id: int,
headers,
filename: str = "tests/api/test_api_justificatif.txt",
err: str = None,
):
"""
Vérifie si l'envoie d'un fichier renvoie bien un 404
Args:
justif_id (int): l'id du justificatif
headers (dict): token d'auth de l'api
filename (str, optional): le chemin vers le fichier.
Defaults to "tests/api/test_api_justificatif.txt".
err (str, optional): l'erreur attendue.
Raises:
APIError: Si l'envoie fonction (mauvais comportement)
"""
try:
send_file(justif_id, filename, headers)
# ^ Renvoi un 404
except APIError as api_err:
if err is not None:
assert api_err.payload["message"] == err
else:
raise APIError("Le POST n'aurait pas du fonctionner")
def test_import_justificatif(api_headers):
"""test de la route /justificatif/<justif_id:int>/import"""
# Bon fonctionnement
filename: str = "tests/api/test_api_justificatif.txt"
resp: dict = send_file(1, filename, api_headers)
assert "filename" in resp
assert resp["filename"] == "test_api_justificatif.txt"
filename: str = "tests/api/test_api_justificatif2.txt"
resp: dict = send_file(1, filename, api_headers)
assert "filename" in resp
assert resp["filename"] == "test_api_justificatif2.txt"
# Mauvais fonctionnement
check_failure_send(FAUX, api_headers)
def test_list_justificatifs(api_headers):
"""test de la route /justificatif/<justif_id:int>/list"""
# Bon fonctionnement
res: list = GET("/justificatif/1/list", api_headers)
assert isinstance(res, list)
assert len(res) == 2
res: list = GET("/justificatif/2/list", api_headers)
assert isinstance(res, list)
assert len(res) == 0
# Mauvais fonctionnement
check_failure_get(f"/justificatif/{FAUX}/list", api_headers)
def post_export(justif_id: int, fname: str, api_headers):
"""
Envoie une requête poste sans data et la retourne
Args:
id (int): justif_id
fname (str): nom du fichier (coté serv)
api_headers (dict): token auth de l'api
Returns:
request: la réponse de l'api
"""
url: str = API_URL + f"/justificatif/{justif_id}/export/{fname}"
res = requests.post(url, headers=api_headers)
return res
def test_export(api_headers):
"""test de la route /justificatif/<justif_id:int>/export/<filename:str>"""
# Bon fonctionnement
assert post_export(1, "test_api_justificatif.txt", api_headers).status_code == 200
# Mauvais fonctionnement
assert (
post_export(FAUX, "test_api_justificatif.txt", api_headers).status_code == 404
)
assert post_export(1, "blabla.txt", api_headers).status_code == 404
assert post_export(2, "blabla.txt", api_headers).status_code == 404
def test_remove_justificatif(api_headers):
"""test de la route /justificatif/<justif_id:int>/remove"""
# Bon fonctionnement
filename: str = "tests/api/test_api_justificatif.txt"
send_file(2, filename, api_headers)
filename: str = "tests/api/test_api_justificatif2.txt"
send_file(2, filename, api_headers)
res: dict = POST_JSON("/justificatif/1/remove", {"remove": "all"}, api_headers)
assert res == {"response": "removed"}
assert len(GET("/justificatif/1/list", api_headers)) == 0
res: dict = POST_JSON(
"/justificatif/2/remove",
{"remove": "list", "filenames": ["test_api_justificatif2.txt"]},
api_headers,
)
assert res == {"response": "removed"}
assert len(GET("/justificatif/2/list", api_headers)) == 1
res: dict = POST_JSON(
"/justificatif/2/remove",
{"remove": "list", "filenames": ["test_api_justificatif.txt"]},
api_headers,
)
assert res == {"response": "removed"}
assert len(GET("/justificatif/2/list", api_headers)) == 0
# Mauvais fonctionnement
check_failure_post("/justificatif/2/remove", api_headers, {})
check_failure_post(f"/justificatif/{FAUX}/remove", api_headers, {"remove": "all"})
check_failure_post("/justificatif/1/remove", api_headers, {"remove": "all"})
def test_justifies(api_headers):
"""test la route /justificatif/<justif_id:int>/justifies"""
# Bon fonctionnement
res: list = GET("/justificatif/1/justifies", api_headers)
assert isinstance(res, list)
# Mauvais fonctionnement
check_failure_get(f"/justificatif/{FAUX}/justifies", api_headers)

3
tests/api/test_api_permissions.py Normal file → Executable file
View File

@ -60,6 +60,9 @@ def test_permissions(api_headers):
"role_name": "Ens",
"uid": 1,
"version": "long",
"assiduite_id": 1,
"justif_id": 1,
"etudids": "1",
}
for rule in api_rules:
path = rule.build(args)[1]

View File

@ -0,0 +1,26 @@
"entry_name";"url";"permission";"method";"content"
"assiduite";"/assiduite/1";"ScoView";"GET";
"assiduites";"/assiduites/1";"ScoView";"GET";
"assiduites";"/assiduites/1/query?etat=retard";"ScoView";"GET";
"assiduites";"/assiduites/1/query?moduleimpl_id=1";"ScoView";"GET";
"assiduites_count";"/assiduites/1/count";"ScoView";"GET";
"assiduites_count";"/assiduites/1/count/query?etat=retard";"ScoView";"GET";
"assiduites_count";"/assiduites/1/count/query?etat=present,retard&metric=compte,heure";"ScoView";"GET";
"assiduites_formsemestre";"/assiduites/formsemestre/1";"ScoView";"GET";
"assiduites_formsemestre";"/assiduites/formsemestre/1/query?etat=retard";"ScoView";"GET";
"assiduites_formsemestre";"/assiduites/formsemestre/1/query?moduleimpl_id=1";"ScoView";"GET";
"assiduites_formsemestre_count";"/assiduites/formsemestre/1/count";"ScoView";"GET";
"assiduites_formsemestre_count";"/assiduites/formsemestre/1/count/query?etat=retard";"ScoView";"GET";
"assiduites_formsemestre_count";"/assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure";"ScoView";"GET";
"assiduite_create";"/assiduite/1/create";"ScoView";"POST";"[{""date_debut"": ""2022-10-27T08:00"",""date_fin"": ""2022-10-27T10:00"",""etat"": ""absent""}]"
"assiduite_edit";"/assiduite/1/edit";"ScoView";"POST";"{""etat"":""absent""}"
"assiduite_edit";"/assiduite/1/edit";"ScoView";"POST";"{""moduleimpl_id"":2}"
"assiduite_edit";"/assiduite/1/edit";"ScoView";"POST";"{""etat"": ""retard"",""moduleimpl_id"":3}"
"assiduite_delete";"/assiduite/delete";"ScoView";"POST";"[2,2,3]"
"justificatif";"/justificatif/1";"ScoView";"GET";
"justificatifs";"/justificatifs/1";"ScoView";"GET";
"justificatifs";"/justificatifs/1/query?etat=attente";"ScoView";"GET";
"justificatif_create";"/justificatif/1/create";"ScoView";"POST";"[{""date_debut"": ""2022-10-27T08:00"",""date_fin"": ""2022-10-27T10:00"",""etat"": ""attente""}]"
"justificatif_edit";"/justificatif/1/edit";"ScoView";"POST";"{""etat"":""valide""}"
"justificatif_edit";"/justificatif/1/edit";"ScoView";"POST";"{""raison"":""MEDIC""}"
"justificatif_delete";"/justificatif/delete";"ScoView";"POST";"[2,2,3]"
1 entry_name url permission method content
2 assiduite /assiduite/1 ScoView GET
3 assiduites /assiduites/1 ScoView GET
4 assiduites /assiduites/1/query?etat=retard ScoView GET
5 assiduites /assiduites/1/query?moduleimpl_id=1 ScoView GET
6 assiduites_count /assiduites/1/count ScoView GET
7 assiduites_count /assiduites/1/count/query?etat=retard ScoView GET
8 assiduites_count /assiduites/1/count/query?etat=present,retard&metric=compte,heure ScoView GET
9 assiduites_formsemestre /assiduites/formsemestre/1 ScoView GET
10 assiduites_formsemestre /assiduites/formsemestre/1/query?etat=retard ScoView GET
11 assiduites_formsemestre /assiduites/formsemestre/1/query?moduleimpl_id=1 ScoView GET
12 assiduites_formsemestre_count /assiduites/formsemestre/1/count ScoView GET
13 assiduites_formsemestre_count /assiduites/formsemestre/1/count/query?etat=retard ScoView GET
14 assiduites_formsemestre_count /assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure ScoView GET
15 assiduite_create /assiduite/1/create ScoView POST [{"date_debut": "2022-10-27T08:00","date_fin": "2022-10-27T10:00","etat": "absent"}]
16 assiduite_edit /assiduite/1/edit ScoView POST {"etat":"absent"}
17 assiduite_edit /assiduite/1/edit ScoView POST {"moduleimpl_id":2}
18 assiduite_edit /assiduite/1/edit ScoView POST {"etat": "retard","moduleimpl_id":3}
19 assiduite_delete /assiduite/delete ScoView POST [2,2,3]
20 justificatif /justificatif/1 ScoView GET
21 justificatifs /justificatifs/1 ScoView GET
22 justificatifs /justificatifs/1/query?etat=attente ScoView GET
23 justificatif_create /justificatif/1/create ScoView POST [{"date_debut": "2022-10-27T08:00","date_fin": "2022-10-27T10:00","etat": "attente"}]
24 justificatif_edit /justificatif/1/edit ScoView POST {"etat":"valide"}
25 justificatif_edit /justificatif/1/edit ScoView POST {"raison":"MEDIC"}
26 justificatif_delete /justificatif/delete ScoView POST [2,2,3]

View File

@ -1,4 +1,24 @@
"entry_name";"url";"permission";"method";"content"
"assiduite";"/assiduite/1";"ScoView";"GET";
"assiduites";"/assiduites/1";"ScoView";"GET";
"assiduites";"/assiduites/1/query?etat=retard";"ScoView";"GET";
"assiduites";"/assiduites/1/query?moduleimpl_id=1";"ScoView";"GET";
"assiduites_count";"/assiduites/1/count";"ScoView";"GET";
"assiduites_count";"/assiduites/1/count/query?etat=retard";"ScoView";"GET";
"assiduites_count";"/assiduites/1/count/query?etat=present,retard&metric=compte,heure";"ScoView";"GET";
"assiduites_formsemestre";"/assiduites/formsemestre/1";"ScoView";"GET";
"assiduites_formsemestre";"/assiduites/formsemestre/1/query?etat=retard";"ScoView";"GET";
"assiduites_formsemestre";"/assiduites/formsemestre/1/query?moduleimpl_id=1";"ScoView";"GET";
"assiduites_formsemestre_count";"/assiduites/formsemestre/1/count";"ScoView";"GET";
"assiduites_formsemestre_count";"/assiduites/formsemestre/1/count/query?etat=retard";"ScoView";"GET";
"assiduites_formsemestre_count";"/assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure";"ScoView";"GET";
"assiduite_create";"/assiduite/1/create";"ScoView";"POST";"{""date_debut"": ""2022-10-27T08:00"",""date_fin"": ""2022-10-27T10:00"",""etat"": ""absent""}"
"assiduite_create";"/assiduite/1/create/batch";"ScoView";"POST";"{""batch"":[{""date_debut"": ""2022-10-27T08:00"",""date_fin"": ""2022-10-27T10:00"",""etat"": ""absent""},{""date_debut"": ""2022-10-27T08:00"",""date_fin"": ""2022-10-27T10:00"",""etat"": ""retard""},{""date_debut"": ""2022-10-27T11:00"",""date_fin"": ""2022-10-27T13:00"",""etat"": ""present""}]}"
"assiduite_edit";"/assiduite/1/edit";"ScoView";"POST";"{""etat"":""absent""}"
"assiduite_edit";"/assiduite/1/edit";"ScoView";"POST";"{""moduleimpl_id"":2}"
"assiduite_edit";"/assiduite/1/edit";"ScoView";"POST";"{""etat"": ""retard"",""moduleimpl_id"":3}"
"assiduite_delete";"/assiduite/delete";"ScoView";"POST";"{""assiduite_id"": 1}"
"assiduite_delete";"/assiduite/delete/batch";"ScoView";"POST";"{""batch"":[2,2,3]}"
"departements";"/departements";"ScoView";"GET";
"departements-ids";"/departements_ids";"ScoView";"GET";
"departement";"/departement/TAPI";"ScoView";"GET";
1 entry_name url permission method content
2 assiduite /assiduite/1 ScoView GET
3 assiduites /assiduites/1 ScoView GET
4 assiduites /assiduites/1/query?etat=retard ScoView GET
5 assiduites /assiduites/1/query?moduleimpl_id=1 ScoView GET
6 assiduites_count /assiduites/1/count ScoView GET
7 assiduites_count /assiduites/1/count/query?etat=retard ScoView GET
8 assiduites_count /assiduites/1/count/query?etat=present,retard&metric=compte,heure ScoView GET
9 assiduites_formsemestre /assiduites/formsemestre/1 ScoView GET
10 assiduites_formsemestre /assiduites/formsemestre/1/query?etat=retard ScoView GET
11 assiduites_formsemestre /assiduites/formsemestre/1/query?moduleimpl_id=1 ScoView GET
12 assiduites_formsemestre_count /assiduites/formsemestre/1/count ScoView GET
13 assiduites_formsemestre_count /assiduites/formsemestre/1/count/query?etat=retard ScoView GET
14 assiduites_formsemestre_count /assiduites/formsemestre/1/count/query?etat=present,retard&metric=compte,heure ScoView GET
15 assiduite_create /assiduite/1/create ScoView POST {"date_debut": "2022-10-27T08:00","date_fin": "2022-10-27T10:00","etat": "absent"}
16 assiduite_create /assiduite/1/create/batch ScoView POST {"batch":[{"date_debut": "2022-10-27T08:00","date_fin": "2022-10-27T10:00","etat": "absent"},{"date_debut": "2022-10-27T08:00","date_fin": "2022-10-27T10:00","etat": "retard"},{"date_debut": "2022-10-27T11:00","date_fin": "2022-10-27T13:00","etat": "present"}]}
17 assiduite_edit /assiduite/1/edit ScoView POST {"etat":"absent"}
18 assiduite_edit /assiduite/1/edit ScoView POST {"moduleimpl_id":2}
19 assiduite_edit /assiduite/1/edit ScoView POST {"etat": "retard","moduleimpl_id":3}
20 assiduite_delete /assiduite/delete ScoView POST {"assiduite_id": 1}
21 assiduite_delete /assiduite/delete/batch ScoView POST {"batch":[2,2,3]}
22 departements /departements ScoView GET
23 departements-ids /departements_ids ScoView GET
24 departement /departement/TAPI ScoView GET

View File

@ -0,0 +1,728 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
"""
Tests unitaires vérifiant le bon fonctionnement du modèle Assiduité et de
ses fonctions liées
Ecrit par HARTMANN Matthias (en s'inspirant de tests.unit.test_abs_count.py par Fares Amer )
"""
from tests.unit import sco_fake_gen
from app import db
from app.scodoc import sco_formsemestre
import app.scodoc.sco_assiduites as scass
from app.models import Assiduite, Justificatif, Identite, FormSemestre, ModuleImpl
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu
from app.scodoc.sco_abs import (
get_abs_count_in_interval,
get_assiduites_count_in_interval,
)
from app.scodoc import sco_abs_views
from tools import migrate_abs_to_assiduites, downgrade_module
class BiInt(int, scu.BiDirectionalEnum):
"""Classe pour tester la classe BiDirectionalEnum"""
A = 1
B = 2
def test_bi_directional_enum(test_client):
"""Test le bon fonctionnement de la classe BiDirectionalEnum"""
assert BiInt.get("A") == BiInt.get("a") == BiInt.A == 1
assert BiInt.get("B") == BiInt.get("b") == BiInt.B == 2
assert BiInt.get("blabla") is None
assert BiInt.get("blabla", -1) == -1
assert isinstance(BiInt.inverse(), dict)
assert BiInt.inverse()[1] == BiInt.A and BiInt.inverse()[2] == BiInt.B
def test_general(test_client):
"""tests général du modèle assiduite"""
g_fake = sco_fake_gen.ScoFake(verbose=False)
# Création d'une formation (1)
formation_id = g_fake.create_formation()
ue_id = g_fake.create_ue(
formation_id=formation_id, acronyme="T1", titre="UE TEST 1"
)
matiere_id = g_fake.create_matiere(ue_id=ue_id, titre="test matière")
module_id_1 = g_fake.create_module(
matiere_id=matiere_id, code="Mo1", coefficient=1.0, titre="test module"
)
module_id_2 = g_fake.create_module(
matiere_id=matiere_id, code="Mo2", coefficient=1.0, titre="test module2"
)
# Création semestre (2)
formsemestre_id_1 = g_fake.create_formsemestre(
formation_id=formation_id,
semestre_id=1,
date_debut="01/09/2022",
date_fin="31/12/2022",
)
formsemestre_id_2 = g_fake.create_formsemestre(
formation_id=formation_id,
semestre_id=2,
date_debut="01/01/2023",
date_fin="31/07/2023",
)
formsemestre_id_3 = g_fake.create_formsemestre(
formation_id=formation_id,
semestre_id=3,
date_debut="01/01/2024",
date_fin="31/07/2024",
)
formsemestre_1 = sco_formsemestre.get_formsemestre(formsemestre_id_1)
formsemestre_2 = sco_formsemestre.get_formsemestre(formsemestre_id_2)
formsemestre_3 = sco_formsemestre.get_formsemestre(formsemestre_id_3)
# Création des modulesimpls (4, 2 par semestre)
moduleimpl_1_1 = g_fake.create_moduleimpl(
module_id=module_id_1,
formsemestre_id=formsemestre_id_1,
)
moduleimpl_1_2 = g_fake.create_moduleimpl(
module_id=module_id_2,
formsemestre_id=formsemestre_id_1,
)
moduleimpl_2_1 = g_fake.create_moduleimpl(
module_id=module_id_1,
formsemestre_id=formsemestre_id_2,
)
moduleimpl_2_2 = g_fake.create_moduleimpl(
module_id=module_id_2,
formsemestre_id=formsemestre_id_2,
)
moduleimpls = [
moduleimpl_1_1,
moduleimpl_1_2,
moduleimpl_2_1,
moduleimpl_2_2,
]
moduleimpls = [
ModuleImpl.query.filter_by(id=mi_id).first() for mi_id in moduleimpls
]
# Création des étudiants (3)
etuds_dict = [
g_fake.create_etud(code_nip=None, prenom=f"etud{i}") for i in range(3)
]
etuds = []
for etud in etuds_dict:
g_fake.inscrit_etudiant(formsemestre_id=formsemestre_id_1, etud=etud)
g_fake.inscrit_etudiant(formsemestre_id=formsemestre_id_2, etud=etud)
etuds.append(Identite.query.filter_by(id=etud["id"]).first())
assert None not in etuds, "Problème avec la conversion en Identite"
# Etudiant faux
etud_faux_dict = g_fake.create_etud(code_nip=None, prenom="etudfaux")
etud_faux = Identite.query.filter_by(id=etud_faux_dict["id"]).first()
verif_migration_abs_assiduites()
ajouter_assiduites(etuds, moduleimpls, etud_faux)
justificatifs: list[Justificatif] = ajouter_justificatifs(etuds[0])
verifier_comptage_et_filtrage_assiduites(
etuds, moduleimpls, (formsemestre_1, formsemestre_2, formsemestre_3)
)
verifier_filtrage_justificatifs(etuds[0], justificatifs)
editer_supprimer_assiduites(etuds, moduleimpls)
editer_supprimer_justificatif(etuds[0])
def verif_migration_abs_assiduites():
"""Vérification que le script de migration fonctionne correctement"""
downgrade_module(assiduites=True, justificatifs=True)
etudid: int = 1
for debut, fin, demijournee in [
(
"02/01/2023",
"10/01/2023",
2,
), # 2 assiduités 02/01: 08h -> 06/01: 18h & assiduités 09/01: 08h -> 10/01: 18h | 14dj
("16/01/2023", "16/01/2023", 1), # 1 assiduité 16/01: 08h -> 16/01: 12h | 1dj
("19/01/2023", "19/01/2023", 0), # 1 assiduité 19/01: 12h -> 19/01: 18h | 1dj
("18/01/2023", "18/01/2023", 2), # 1 assiduité 18/01: 08h -> 18/01: 18h | 2dj
("23/01/2023", "23/01/2023", 0), # 1 assiduité 23/01: 12h -> 24/01: 18h | 3dj
("24/01/2023", "24/01/2023", 2),
]:
sco_abs_views.doSignaleAbsence(
datedebut=debut,
datefin=fin,
demijournee=demijournee,
etudid=etudid,
)
# --- Justification de certaines absences
for debut, fin, demijournee in [
(
"02/01/2023",
"10/01/2023",
2,
), # 2 justificatif 02/01: 08h -> 06/01: 18h & justificatif 09/01: 08h -> 10/01: 18h | 14dj
(
"19/01/2023",
"19/01/2023",
0,
), # 1 justificatif 19/01: 12h -> 19/01: 18h | 1dj
(
"18/01/2023",
"18/01/2023",
2,
), # 1 justificatif 18/01: 08h -> 18/01: 18h | 2dj
]:
sco_abs_views.doJustifAbsence(
datedebut=debut,
datefin=fin,
demijournee=demijournee,
etudid=etudid,
)
migrate_abs_to_assiduites()
assert Assiduite.query.count() == 6, "Erreur migration assiduites"
assert Justificatif.query.count() == 4, "Erreur migration justificatifs"
essais_cache(etudid)
downgrade_module(assiduites=True, justificatifs=True)
def essais_cache(etudid):
"""Vérification des fonctionnalités du cache TODO:WIP"""
date_deb: str = "2023-01-01T07:00"
date_fin: str = "2023-03-31T19:00"
abs_count_no_cache: int = get_abs_count_in_interval(etudid, date_deb, date_fin)
abs_count_cache = get_abs_count_in_interval(etudid, date_deb, date_fin)
assiduites_count_no_cache = get_assiduites_count_in_interval(
etudid, date_deb, date_fin
)
assiduites_count_cache = get_assiduites_count_in_interval(
etudid, date_deb, date_fin
)
assert (
abs_count_cache
== abs_count_no_cache
== assiduites_count_cache
== assiduites_count_no_cache
== (21, 17)
), "Erreur cache"
def ajouter_justificatifs(etud):
"""test de l'ajout des justificatifs"""
obj_justificatifs = [
{
"etat": scu.EtatJustificatif.ATTENTE,
"deb": "2022-09-03T08:00+01:00",
"fin": "2022-09-03T09:59:59+01:00",
"raison": None,
},
{
"etat": scu.EtatJustificatif.VALIDE,
"deb": "2023-01-03T07:00+01:00",
"fin": "2023-01-03T11:00+01:00",
"raison": None,
},
{
"etat": scu.EtatJustificatif.VALIDE,
"deb": "2022-09-03T10:00:00+01:00",
"fin": "2022-09-03T12:00+01:00",
"raison": None,
},
{
"etat": scu.EtatJustificatif.NON_VALIDE,
"deb": "2022-09-03T14:00:00+01:00",
"fin": "2022-09-03T15:00+01:00",
"raison": "Description",
},
{
"etat": scu.EtatJustificatif.MODIFIE,
"deb": "2023-01-03T11:30+01:00",
"fin": "2023-01-03T12:00+01:00",
"raison": None,
},
]
justificatifs = [
Justificatif.create_justificatif(
etud,
scu.is_iso_formated(just["deb"], True),
scu.is_iso_formated(just["fin"], True),
just["etat"],
just["raison"],
)
for just in obj_justificatifs
]
# Vérification de la création des justificatifs
assert [
justi for justi in justificatifs if not isinstance(justi, Justificatif)
] == [], "La création des justificatifs de base n'est pas OK"
# Vérification de la gestion des erreurs
test_assiduite = {
"etat": scu.EtatJustificatif.ATTENTE,
"deb": "2023-01-03T11:00:01+01:00",
"fin": "2023-01-03T12:00+01:00",
"raison": "Description",
}
return justificatifs
def verifier_filtrage_justificatifs(etud: Identite, justificatifs: list[Justificatif]):
"""
- vérifier le filtrage des justificatifs (etat, debut, fin)
"""
# Vérification du filtrage classique
# Etat
assert (
scass.filter_justificatifs_by_etat(etud.justificatifs, "valide").count() == 2
), "Filtrage de l'état 'valide' mauvais"
assert (
scass.filter_justificatifs_by_etat(etud.justificatifs, "attente").count() == 1
), "Filtrage de l'état 'attente' mauvais"
assert (
scass.filter_justificatifs_by_etat(etud.justificatifs, "modifie").count() == 1
), "Filtrage de l'état 'modifie' mauvais"
assert (
scass.filter_justificatifs_by_etat(etud.justificatifs, "non_valide").count()
== 1
), "Filtrage de l'état 'non_valide' mauvais"
assert (
scass.filter_justificatifs_by_etat(etud.justificatifs, "valide,modifie").count()
== 3
), "Filtrage de l'état 'valide,modifie' mauvais"
assert (
scass.filter_justificatifs_by_etat(
etud.justificatifs, "valide,modifie,attente"
).count()
== 4
), "Filtrage de l'état 'valide,modifie,attente' mauvais"
assert (
scass.filter_justificatifs_by_etat(
etud.justificatifs, "valide,modifie,attente,non_valide"
).count()
== 5
), "Filtrage de l'état 'valide,modifie,attente,_non_valide' mauvais"
assert (
scass.filter_justificatifs_by_etat(etud.justificatifs, "autre").count() == 0
), "Filtrage de l'état 'autre' mauvais"
# Dates
assert (
scass.filter_by_date(etud.justificatifs, Justificatif).count() == 5
), "Filtrage 'Toute Date' mauvais 1"
date = scu.localize_datetime("2022-09-01T10:00+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count()
== 5
), "Filtrage 'Toute Date' mauvais 2"
date = scu.localize_datetime("2022-09-03T08:00+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count()
== 5
), "Filtrage 'date début' mauvais 3"
date = scu.localize_datetime("2022-09-03T08:00:01+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count()
== 5
), "Filtrage 'date début' mauvais 4"
date = scu.localize_datetime("2022-09-03T10:00+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_deb=date).count()
== 4
), "Filtrage 'date début' mauvais 5"
date = scu.localize_datetime("2022-09-01T10:00+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count()
== 0
), "Filtrage 'Toute Date' mauvais 6"
date = scu.localize_datetime("2022-09-03T08:00+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count()
== 1
), "Filtrage 'date début' mauvais 7"
date = scu.localize_datetime("2022-09-03T10:00:01+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count()
== 2
), "Filtrage 'date début' mauvais 8"
date = scu.localize_datetime("2023-01-03T12:00+01:00")
assert (
scass.filter_by_date(etud.justificatifs, Justificatif, date_fin=date).count()
== 5
), "Filtrage 'date début' mauvais 9"
# Justifications des assiduites
assert len(scass.justifies(justificatifs[2])) == 1, "Justifications mauvais"
assert len(scass.justifies(justificatifs[0])) == 0, "Justifications mauvais"
def editer_supprimer_justificatif(etud: Identite):
"""
Troisième Partie:
- Vérification de l'édition des justificatifs
- Vérification de la suppression des justificatifs
"""
justi: Justificatif = etud.justificatifs.first()
# Modification de l'état
justi.etat = scu.EtatJustificatif.MODIFIE
# Modification du moduleimpl
justi.date_debut = scu.localize_datetime("2023-02-03T11:00:01+01:00")
justi.date_fin = scu.localize_datetime("2023-02-03T12:00:01+01:00")
db.session.add(justi)
db.session.commit()
# Vérification du changement
assert (
scass.filter_justificatifs_by_etat(etud.justificatifs, "modifie").count() == 2
), "Edition de justificatif mauvais"
assert (
scass.filter_by_date(
etud.justificatifs,
Justificatif,
date_deb=scu.localize_datetime("2023-02-01T11:00:00+01:00"),
).count()
== 1
), "Edition de justificatif mauvais 2"
# Supression d'une assiduité
db.session.delete(justi)
db.session.commit()
assert etud.justificatifs.count() == 4, "Supression de justificatif mauvais"
def editer_supprimer_assiduites(etuds: list[Identite], moduleimpls: list[int]):
"""
Troisième Partie:
- Vérification de l'édition des assiduitées
- Vérification de la suppression des assiduitées
"""
ass1: Assiduite = etuds[0].assiduites.first()
ass2: Assiduite = etuds[1].assiduites.first()
ass3: Assiduite = etuds[2].assiduites.first()
# Modification de l'état
ass1.etat = scu.EtatAssiduite.RETARD
db.session.add(ass1)
# Modification du moduleimpl
ass2.moduleimpl_id = moduleimpls[0].id
db.session.add(ass2)
db.session.commit()
# Vérification du changement
assert (
scass.filter_assiduites_by_etat(etuds[0].assiduites, "retard").count() == 4
), "Edition d'assiduité mauvais"
assert (
scass.filter_by_module_impl(etuds[1].assiduites, moduleimpls[0].id).count() == 2
), "Edition d'assiduité mauvais"
# Supression d'une assiduité
db.session.delete(ass3)
db.session.commit()
assert etuds[2].assiduites.count() == 6, "Supression d'assiduité mauvais"
def ajouter_assiduites(
etuds: list[Identite], moduleimpls: list[ModuleImpl], etud_faux: Identite
):
"""
Première partie:
- Ajoute 6 assiduités à chaque étudiant
- 2 présence (semestre 1 et 2)
- 2 retard (semestre 2)
- 2 absence (semestre 1)
- Vérifie la création des assiduités
"""
for etud in etuds:
obj_assiduites = [
{
"etat": scu.EtatAssiduite.PRESENT,
"deb": "2022-09-03T08:00+01:00",
"fin": "2022-09-03T10:00+01:00",
"moduleimpl": None,
"desc": None,
},
{
"etat": scu.EtatAssiduite.PRESENT,
"deb": "2023-01-03T08:00+01:00",
"fin": "2023-01-03T10:00+01:00",
"moduleimpl": moduleimpls[2],
"desc": None,
},
{
"etat": scu.EtatAssiduite.ABSENT,
"deb": "2022-09-03T10:00:01+01:00",
"fin": "2022-09-03T11:00+01:00",
"moduleimpl": moduleimpls[0],
"desc": None,
},
{
"etat": scu.EtatAssiduite.ABSENT,
"deb": "2022-09-03T14:00:00+01:00",
"fin": "2022-09-03T15:00+01:00",
"moduleimpl": moduleimpls[1],
"desc": "Description",
},
{
"etat": scu.EtatAssiduite.RETARD,
"deb": "2023-01-03T11:00:01+01:00",
"fin": "2023-01-03T12:00+01:00",
"moduleimpl": moduleimpls[3],
"desc": None,
},
{
"etat": scu.EtatAssiduite.RETARD,
"deb": "2023-01-04T11:00:01+01:00",
"fin": "2023-01-04T12:00+01:00",
"moduleimpl": moduleimpls[3],
"desc": "Description",
},
{
"etat": scu.EtatAssiduite.RETARD,
"deb": "2022-11-04T11:00:01+01:00",
"fin": "2022-12-05T12:00+01:00",
"moduleimpl": None,
"desc": "Description",
},
]
assiduites = [
Assiduite.create_assiduite(
etud,
scu.is_iso_formated(ass["deb"], True),
scu.is_iso_formated(ass["fin"], True),
ass["etat"],
ass["moduleimpl"],
ass["desc"],
)
for ass in obj_assiduites
]
# Vérification de la création des assiduités
assert [
ass for ass in assiduites if not isinstance(ass, Assiduite)
] == [], "La création des assiduités de base n'est pas OK"
# Vérification de la gestion des erreurs
test_assiduite = {
"etat": scu.EtatAssiduite.RETARD,
"deb": "2023-01-04T11:00:01+01:00",
"fin": "2023-01-04T12:00+01:00",
"moduleimpl": moduleimpls[3],
"desc": "Description",
}
try:
Assiduite.create_assiduite(
etuds[0],
scu.is_iso_formated(test_assiduite["deb"], True),
scu.is_iso_formated(test_assiduite["fin"], True),
test_assiduite["etat"],
test_assiduite["moduleimpl"],
test_assiduite["desc"],
)
except ScoValueError as excp:
assert (
excp.args[0]
== "Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
try:
Assiduite.create_assiduite(
etud_faux,
scu.is_iso_formated(test_assiduite["deb"], True),
scu.is_iso_formated(test_assiduite["fin"], True),
test_assiduite["etat"],
test_assiduite["moduleimpl"],
test_assiduite["desc"],
)
except ScoValueError as excp:
assert excp.args[0] == "L'étudiant n'est pas inscrit au moduleimpl"
def verifier_comptage_et_filtrage_assiduites(
etuds: list[Identite], moduleimpls: list[int], formsemestres: tuple[int]
):
"""
Deuxième partie:
- vérifier les valeurs du comptage (compte, heure, journée, demi-journée)
- vérifier le filtrage des assiduites (etat, debut, fin, module, formsemestre)
"""
etu1, etu2, etu3 = etuds
mod11, mod12, mod21, mod22 = moduleimpls
# Vérification du comptage classique
comptage = scass.get_assiduites_stats(etu1.assiduites)
assert comptage["compte"] == 6 + 1, "la métrique 'Comptage' n'est pas bien calculée"
assert (
comptage["journee"] == 3 + 22
), "la métrique 'Journée' n'est pas bien calculée"
assert (
comptage["demi"] == 4 + 43
), "la métrique 'Demi-Journée' n'est pas bien calculée"
assert comptage["heure"] == float(
8 + 169
), "la métrique 'Heure' n'est pas bien calculée"
# Vérification du filtrage classique
# Etat
assert (
scass.filter_assiduites_by_etat(etu2.assiduites, "present").count() == 2
), "Filtrage de l'état 'présent' mauvais"
assert (
scass.filter_assiduites_by_etat(etu2.assiduites, "retard").count() == 3
), "Filtrage de l'état 'retard' mauvais"
assert (
scass.filter_assiduites_by_etat(etu2.assiduites, "absent").count() == 2
), "Filtrage de l'état 'absent' mauvais"
assert (
scass.filter_assiduites_by_etat(etu2.assiduites, "absent,retard").count() == 5
), "Filtrage de l'état 'absent,retard' mauvais"
assert (
scass.filter_assiduites_by_etat(
etu2.assiduites, "absent,retard,present"
).count()
== 7
), "Filtrage de l'état 'absent,retard,present' mauvais"
assert (
scass.filter_assiduites_by_etat(etu2.assiduites, "autre").count() == 0
), "Filtrage de l'état 'autre' mauvais"
# Module
assert (
scass.filter_by_module_impl(etu3.assiduites, mod11.id).count() == 1
), "Filtrage par 'Moduleimpl' mauvais"
assert (
scass.filter_by_module_impl(etu3.assiduites, mod12.id).count() == 1
), "Filtrage par 'Moduleimpl' mauvais"
assert (
scass.filter_by_module_impl(etu3.assiduites, mod21.id).count() == 1
), "Filtrage par 'Moduleimpl' mauvais"
assert (
scass.filter_by_module_impl(etu3.assiduites, mod22.id).count() == 2
), "Filtrage par 'Moduleimpl' mauvais"
assert (
scass.filter_by_module_impl(etu3.assiduites, None).count() == 2
), "Filtrage par 'Moduleimpl' mauvais"
assert (
scass.filter_by_module_impl(etu3.assiduites, 152).count() == 0
), "Filtrage par 'Moduleimpl' mauvais"
# Formsemestre
formsemestres = [
FormSemestre.query.filter_by(id=fms["id"]).first() for fms in formsemestres
]
assert (
scass.filter_by_formsemestre(etu1.assiduites, formsemestres[0]).count() == 4
), "Filtrage 'Formsemestre' mauvais"
assert (
scass.filter_by_formsemestre(etu1.assiduites, formsemestres[1]).count() == 3
), "Filtrage 'Formsemestre' mauvais"
assert (
scass.filter_by_formsemestre(etu1.assiduites, formsemestres[2]).count() == 0
), "Filtrage 'Formsemestre' mauvais"
# Date début
assert (
scass.filter_by_date(etu2.assiduites, Assiduite).count() == 7
), "Filtrage 'Date début' mauvais 1"
date = scu.localize_datetime("2022-09-01T10:00+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_deb=date).count() == 7
), "Filtrage 'Date début' mauvais 2"
date = scu.localize_datetime("2022-09-03T10:00+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_deb=date).count() == 7
), "Filtrage 'Date début' mauvais 3"
date = scu.localize_datetime("2022-09-03T16:00+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_deb=date).count() == 4
), "Filtrage 'Date début' mauvais 4"
# Date Fin
date = scu.localize_datetime("2022-09-01T10:00+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 0
), "Filtrage 'Date fin' mauvais 1"
date = scu.localize_datetime("2022-09-03T10:00+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 1
), "Filtrage 'Date fin' mauvais 2"
date = scu.localize_datetime("2022-09-03T10:00:01+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 2
), "Filtrage 'Date fin' mauvais 3"
date = scu.localize_datetime("2022-09-03T16:00+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 3
), "Filtrage 'Date fin' mauvais 4"
date = scu.localize_datetime("2023-01-04T16:00+01:00")
assert (
scass.filter_by_date(etu2.assiduites, Assiduite, date_fin=date).count() == 7
), "Filtrage 'Date fin' mauvais 5"

View File

@ -8,3 +8,5 @@ from tools.import_scodoc7_user_db import import_scodoc7_user_db
from tools.import_scodoc7_dept import import_scodoc7_dept
from tools.migrate_scodoc7_archives import migrate_scodoc7_dept_archives
from tools.migrate_scodoc7_logos import migrate_scodoc7_dept_logos
from tools.migrate_abs_to_assiduites import migrate_abs_to_assiduites
from tools.downgrade_assiduites import downgrade_module

View File

@ -0,0 +1,71 @@
"""
Commande permettant de supprimer les assiduités et les justificatifs
Ecrit par Matthias HARTMANN
"""
from app import db
from app.models import Justificatif, Assiduite, Departement
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_utils import TerminalColor
def downgrade_module(
dept: str = None, assiduites: bool = False, justificatifs: bool = False
):
"""
Supprime les assiduités et/ou justificatifs du dept sélectionné ou de tous les départements
Args:
dept (str, optional): l'acronym du département. Par défaut tous les départements.
assiduites (bool, optional): suppression des assiduités. Par défaut : Non
justificatifs (bool, optional): supression des justificatifs. Par défaut : Non
"""
dept_etudid: list[int] = None
dept_id: int = None
if dept is not None:
departement: Departement = Departement.query.filter_by(acronym=dept).first()
assert departement is not None, "Le département n'existe pas."
dept_etudid = [etud.id for etud in departement.etudiants]
dept_id = departement.id
if assiduites:
_remove_assiduites(dept_etudid)
if justificatifs:
_remove_justificatifs(dept_etudid)
_remove_justificatifs_archive(dept_id)
if dept is None:
if assiduites:
db.session.execute("ALTER SEQUENCE assiduites_id_seq RESTART WITH 1")
if justificatifs:
db.session.execute("ALTER SEQUENCE justificatifs_id_seq RESTART WITH 1")
db.session.commit()
print(
f"{TerminalColor.GREEN}Le module assiduité a bien été remis à zero.{TerminalColor.RESET}"
)
def _remove_assiduites(dept_etudid: str = None):
if dept_etudid is None:
Assiduite.query.delete()
else:
Assiduite.query.filter(Assiduite.etudid.in_(dept_etudid)).delete()
def _remove_justificatifs(dept_etudid: str = None):
if dept_etudid is None:
Justificatif.query.delete()
else:
Justificatif.query.filter(Justificatif.etudid.in_(dept_etudid)).delete()
def _remove_justificatifs_archive(dept_id: int = None):
JustificatifArchiver().remove_dept_archive(dept_id)

View File

@ -21,11 +21,13 @@ from app import models
from app.models import departements
from app.models import (
Absence,
Assiduite,
Departement,
Formation,
FormSemestre,
FormSemestreEtape,
Identite,
Justificatif,
ModuleImpl,
NotesNotes,
)
@ -37,6 +39,7 @@ from app.scodoc import (
sco_groups,
)
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import localize_datetime
from tools.fakeportal.gen_nomprenoms import nomprenom
random.seed(12345678) # tests reproductibles
@ -378,6 +381,56 @@ def create_logos():
)
def ajouter_assiduites_justificatifs(formsemestre: FormSemestre):
"""
Ajoute des assiduités semi-aléatoires à chaque étudiant du semestre
"""
MODS = [moduleimpl for moduleimpl in formsemestre.modimpls]
MODS.append(None)
for etud in formsemestre.etuds:
base_date = datetime.datetime(2022, 9, random.randint(1, 30), 8, 0, 0)
base_date = localize_datetime(base_date)
for i in range(random.randint(1, 5)):
etat = random.randint(0, 2)
moduleimpl = random.choice(MODS)
deb_date = base_date + datetime.timedelta(days=i)
fin_date = deb_date + datetime.timedelta(hours=i)
code = Assiduite.create_assiduite(
etud, deb_date, fin_date, etat, moduleimpl
)
assert isinstance(
code, Assiduite
), "Erreur dans la génération des assiduités"
db.session.add(code)
for i in range(random.randint(0, 2)):
etat = random.randint(0, 3)
deb_date = base_date + datetime.timedelta(days=i)
fin_date = deb_date + datetime.timedelta(hours=8)
raison = random.choice(["raison", None])
code = Justificatif.create_justificatif(
etud=etud,
date_debut=deb_date,
date_fin=fin_date,
etat=etat,
raison=raison,
)
assert isinstance(
code, Justificatif
), "Erreur dans la génération des justificatifs"
db.session.add(code)
db.session.commit()
def init_test_database():
"""Appelé par la commande `flask init-test-database`
@ -398,6 +451,7 @@ def init_test_database():
saisie_notes_evaluations(formsemestre, user_lecteur)
add_absences(formsemestre)
create_etape_apo(formsemestre)
ajouter_assiduites_justificatifs(formsemestre)
create_logos()
# à compléter
# - groupes

View File

@ -0,0 +1,421 @@
"""
Script de migration des données de la base "absences" -> "assiduites"/"justificatifs"
Ecrit par Matthias HARTMANN
"""
from datetime import date, datetime, time, timedelta
from json import dump, dumps
from sqlalchemy import not_
from app import db
from app.models import (
Absence,
Assiduite,
Departement,
Identite,
Justificatif,
ModuleImplInscription,
)
from app.models.assiduites import (
compute_assiduites_justified,
)
from app.profiler import Profiler
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
TerminalColor,
localize_datetime,
print_progress_bar,
)
class _Merger:
"""pour typage"""
class _glob:
"""variables globales du script"""
DEBUG: bool = False
PROBLEMS: dict[int, list[str]] = {}
CURRENT_ETU: list = []
MODULES: list[tuple[int, int]] = []
COMPTE: list[int, int] = []
ERR_ETU: list[int] = []
MERGER_ASSI: _Merger = None
MERGER_JUST: _Merger = None
MORNING: time = None
NOON: time = None
EVENING: time = None
class _Merger:
def __init__(self, abs_: Absence, est_abs: bool) -> None:
self.deb = (abs_.jour, abs_.matin)
self.fin = (abs_.jour, abs_.matin)
self.moduleimpl = abs_.moduleimpl_id
self.etudid = abs_.etudid
self.est_abs = est_abs
self.raison = abs_.description
self.entry_date = abs_.entry_date
def merge(self, abs_: Absence) -> bool:
"""Fusionne les absences"""
if self.etudid != abs_.etudid:
return False
# Cas d'une même absence enregistrée plusieurs fois
if self.fin == (abs_.jour, abs_.matin):
self.moduleimpl = None
else:
if self.fin[1]:
if abs_.jour != self.fin[0]:
return False
else:
day_after: date = abs_.jour - timedelta(days=1) == self.fin[0]
if not (day_after and abs_.matin):
return False
self.fin = (abs_.jour, abs_.matin)
return True
@staticmethod
def _tuple_to_date(couple: tuple[date, bool], end=False):
if couple[1]:
time_ = _glob.NOON if end else _glob.MORNING
date_ = datetime.combine(couple[0], time_)
else:
time_ = _glob.EVENING if end else _glob.NOON
date_ = datetime.combine(couple[0], time_)
d = localize_datetime(date_)
return d
def _to_justif(self):
date_deb = _Merger._tuple_to_date(self.deb)
date_fin = _Merger._tuple_to_date(self.fin, end=True)
retour = Justificatif.fast_create_justificatif(
etudid=self.etudid,
date_debut=date_deb,
date_fin=date_fin,
etat=EtatJustificatif.VALIDE,
raison=self.raison,
entry_date=self.entry_date,
)
return retour
def _to_assi(self):
date_deb = _Merger._tuple_to_date(self.deb)
date_fin = _Merger._tuple_to_date(self.fin, end=True)
retour = Assiduite.fast_create_assiduite(
etudid=self.etudid,
date_debut=date_deb,
date_fin=date_fin,
etat=EtatAssiduite.ABSENT,
moduleimpl_id=self.moduleimpl,
description=self.raison,
entry_date=self.entry_date,
)
return retour
def export(self):
"""Génère un nouvel objet Assiduité ou Justificatif"""
obj: Assiduite or Justificatif = None
if self.est_abs:
_glob.COMPTE[0] += 1
obj = self._to_assi()
else:
_glob.COMPTE[1] += 1
obj = self._to_justif()
db.session.add(obj)
class _Statistics:
def __init__(self) -> None:
self.object: dict[str, dict or int] = {"total": 0}
self.year: int = None
def __set_year(self, year: int):
if year not in self.object:
self.object[year] = {
"etuds_inexistant": [],
"abs_invalide": {},
}
self.year = year
return self
def __add_etud(self, etudid: int):
if etudid not in self.object[self.year]["etuds_inexistant"]:
self.object[self.year]["etuds_inexistant"].append(etudid)
return self
def __add_abs(self, abs_: int, err: str):
if abs_ not in self.object[self.year]["abs_invalide"]:
self.object[self.year]["abs_invalide"][abs_] = [err]
else:
self.object[self.year]["abs_invalide"][abs_].append(err)
return self
def add_problem(self, abs_: Absence, err: str):
"""Ajoute un nouveau problème dans les statistiques"""
abs_.jour: date
pivot: date = date(abs_.jour.year, 9, 15)
year: int = abs_.jour.year
if pivot < abs_.jour:
year += 1
self.__set_year(year)
if err == "Etudiant inexistant":
self.__add_etud(abs_.etudid)
else:
self.__add_abs(abs_.id, err)
self.object["total"] += 1
def compute_stats(self) -> dict:
"""Comptage des statistiques"""
stats: dict = {"total": self.object["total"]}
for year, item in self.object.items():
if year == "total":
continue
stats[year] = {}
stats[year]["etuds_inexistant"] = len(item["etuds_inexistant"])
stats[year]["abs_invalide"] = len(item["abs_invalide"])
return stats
def export(self, file):
"""Sérialise les statistiques dans un fichier"""
dump(self.object, file, indent=2)
def migrate_abs_to_assiduites(
dept: str = None,
morning: str = None,
noon: str = None,
evening: str = None,
debug: bool = False,
):
"""
une absence à 3 états:
|.estabs|.estjust|
|1|0| -> absence non justifiée
|1|1| -> absence justifiée
|0|1| -> justifié
dualité des temps :
.matin: bool (0:00 -> time_pref | time_pref->23:59:59)
.jour : date (jour de l'absence/justificatif)
.moduleimpl_id: relation -> moduleimpl_id
description:str -> motif abs / raision justif
.entry_date: datetime -> timestamp d'entrée de l'abs
.etudid: relation -> Identite
"""
Profiler.clear()
_glob.DEBUG = debug
if morning is None:
_glob.MORNING = time(8, 0)
else:
morning: list[str] = morning.split("h")
_glob.MORNING = time(int(morning[0]), int(morning[1]))
if noon is None:
_glob.NOON = time(12, 0)
else:
noon: list[str] = noon.split("h")
_glob.NOON = time(int(noon[0]), int(noon[1]))
if evening is None:
_glob.EVENING = time(18, 0)
else:
evening: list[str] = evening.split("h")
_glob.EVENING = time(int(evening[0]), int(evening[1]))
if dept is None:
prof_total = Profiler("MigrationTotal")
prof_total.start()
depart: Departement
for depart in Departement.query.order_by(Departement.id):
migrate_dept(
depart.acronym, _Statistics(), Profiler(f"Migration_{depart.acronym}")
)
prof_total.stop()
print(
TerminalColor.GREEN
+ f"Fin de la migration, elle a durée {prof_total.elapsed():.2f}"
+ TerminalColor.RESET
)
else:
migrate_dept(dept, _Statistics(), Profiler("Migration"))
def migrate_dept(dept_name: str, stats: _Statistics, time_elapsed: Profiler):
time_elapsed.start()
absences_query = Absence.query
dept: Departement = Departement.query.filter_by(acronym=dept_name).first()
if dept is None:
return
etuds_id: list[int] = [etud.id for etud in dept.etudiants]
absences_query = absences_query.filter(Absence.etudid.in_(etuds_id))
absences: Absence = absences_query.order_by(
Absence.etudid, Absence.jour, not_(Absence.matin)
)
absences_len: int = absences.count()
if absences_len == 0:
print(
f"{TerminalColor.BLUE}Le département {dept_name} ne possède aucune absence.{TerminalColor.RESET}"
)
return
_glob.CURRENT_ETU = []
_glob.MODULES = []
_glob.COMPTE = [0, 0]
_glob.ERR_ETU = []
_glob.MERGER_ASSI = None
_glob.MERGER_JUST = None
print(
f"{TerminalColor.BLUE}{absences_len} absences du département {dept_name} vont être migrées{TerminalColor.RESET}"
)
print_progress_bar(0, absences_len, "Progression", "effectué", autosize=True)
for i, abs_ in enumerate(absences):
try:
_from_abs_to_assiduite_justificatif(abs_)
except ValueError as e:
stats.add_problem(abs_, e.args[0])
if i % 10 == 0:
print_progress_bar(
i,
absences_len,
"Progression",
"effectué",
autosize=True,
)
if i % 1000 == 0:
print_progress_bar(
i,
absences_len,
"Progression",
"effectué",
autosize=True,
)
db.session.commit()
_glob.MERGER_ASSI.export()
_glob.MERGER_JUST.export()
db.session.commit()
justifs: Justificatif = Justificatif.query
if dept_name is not None:
justifs.filter(Justificatif.etudid.in_(etuds_id))
print_progress_bar(
absences_len,
absences_len,
"Progression",
"effectué",
autosize=True,
)
print(
TerminalColor.RED
+ f"Justification des absences du département {dept_name}, veuillez patienter, ceci peut prendre un certain temps."
+ TerminalColor.RESET
)
compute_assiduites_justified(justifs, reset=True)
time_elapsed.stop()
statistiques: dict = stats.compute_stats()
print(
f"{TerminalColor.GREEN}La migration a pris {time_elapsed.elapsed():.2f} secondes {TerminalColor.RESET}"
)
print(
f"{TerminalColor.RED}{statistiques['total']} absences qui n'ont pas pu être migrées."
)
print(
f"Vous retrouverez un fichier json {TerminalColor.GREEN}/opt/scodoc-data/log/scodoc_migration_abs_{dept_name}.json{TerminalColor.RED} contenant les problèmes de migrations"
)
with open(
f"/opt/scodoc-data/log/scodoc_migration_abs_{dept_name}.json",
"w",
encoding="utf-8",
) as file:
stats.export(file)
print(
f"{TerminalColor.CYAN}{_glob.COMPTE[0]} assiduités et {_glob.COMPTE[1]} justificatifs ont été générés pour le département {dept_name}.{TerminalColor.RESET}"
)
if _glob.DEBUG:
print(dumps(statistiques, indent=2))
def _from_abs_to_assiduite_justificatif(_abs: Absence):
if _abs.etudid not in _glob.CURRENT_ETU:
etud: Identite = Identite.query.filter_by(id=_abs.etudid).first()
if etud is None:
raise ValueError("Etudiant inexistant")
_glob.CURRENT_ETU.append(_abs.etudid)
if _abs.estabs:
moduleimpl_id: int = _abs.moduleimpl_id
if (
moduleimpl_id is not None
and (_abs.etudid, _abs.moduleimpl_id) not in _glob.MODULES
):
moduleimpl_inscription: ModuleImplInscription = (
ModuleImplInscription.query.filter_by(
moduleimpl_id=_abs.moduleimpl_id, etudid=_abs.etudid
).first()
)
if moduleimpl_inscription is None:
raise ValueError("Moduleimpl_id incorrect ou étudiant non inscrit")
if _glob.MERGER_ASSI is None:
_glob.MERGER_ASSI = _Merger(_abs, True)
return True
elif _glob.MERGER_ASSI.merge(_abs):
return True
else:
_glob.MERGER_ASSI.export()
_glob.MERGER_ASSI = _Merger(_abs, True)
return False
if _glob.MERGER_JUST is None:
_glob.MERGER_JUST = _Merger(_abs, False)
return True
elif _glob.MERGER_JUST.merge(_abs):
return True
else:
_glob.MERGER_JUST.export()
_glob.MERGER_JUST = _Merger(_abs, False)
return False