forked from ScoDoc/ScoDoc
Assiduites :Création des models
This commit is contained in:
parent
47ed37e90e
commit
4691ed8f36
@ -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
|
||||
|
343
app/models/assiduites.py
Normal file
343
app/models/assiduites.py
Normal file
@ -0,0 +1,343 @@
|
||||
# -*- 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(Assiduite.etat != EtatAssiduite.PRESENT)
|
||||
.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))
|
||||
.filter(Assiduite.etat != EtatAssiduite.PRESENT)
|
||||
.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)
|
@ -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}>"
|
||||
|
@ -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(
|
||||
|
@ -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."""
|
||||
|
Loading…
Reference in New Issue
Block a user