api assiduite faite, test unitaire à venir

This commit is contained in:
iziram 2022-11-03 10:29:30 +01:00
parent 3e0f43d5ea
commit b7fb8879df
8 changed files with 517 additions and 24 deletions

View File

@ -34,6 +34,7 @@ def requested_format(default_format="json", allowed_formats=None):
from app.api import tokens
from app.api import (
absences,
assiduites,
billets_absences,
departements,
etudiants,

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

@ -0,0 +1,309 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from pytz import UTC
from typing import List
from flask import g, jsonify, request
from app import db
from app.api import api_bp as bp
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.scodoc.sco_permissions import Permission
from flask_login import login_required
from app.models import Identite, Assiduite
import app.scodoc.sco_utils as scu
@bp.route("/assiduite/<int:assiduiteid>")
@scodoc
@permission_required(Permission.ScoView)
def assiduite(assiduiteid: int = None):
"""Retourne un objet assiduité à partir de son id
Exemple de résultat:
{
"assiduiteid": 1,
"etuid": 2,
"moduleimpl_id": 3,
"date_debut": "2022-10-31T08:00",
"date_fin": "2022-10-31T10:00",
"etat": "retard"
}
"""
assiduite = Assiduite.query.get(assiduiteid)
if assiduite is None:
return json_error(404, message="assiduité inexistante")
data = assiduite.to_dict()
return jsonify(change_etat(data))
@bp.route("/assiduites/<int:etuid>", defaults={"with_query": False})
@bp.route("/assiduites/<int:etuid>/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites(etuid: int = None, with_query: bool = False):
"""Retourne toutes les assiduités d'un étudiant"""
query = Identite.query.filter_by(id=etuid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etuid)
assiduites: List[Assiduite] = etud.assiduites.all()
if with_query:
# cas 1 : etat assiduite
etat = request.args.get("etat")
if etat is not None:
etat = list(etat.split(","))
etat = [scu.ETATS_ASSIDUITE.get(e, "absent") for e in etat]
assiduites = [ass for ass in assiduites if ass.etat in etat]
# cas 2 : date de début
deb = request.args.get("date_debut")
deb: datetime = is_iso_formated(deb, True)
if deb is not None:
filtered_assiduites = []
for ass in assiduites:
if deb.tzinfo is None:
deb: datetime = deb.replace(tzinfo=ass.date_debut.tzinfo)
if ass.date_debut >= deb:
filtered_assiduites.append(ass)
assiduites.clear()
assiduites.extend(filtered_assiduites)
# cas 3 : date de fin
fin = request.args.get("date_fin")
fin = is_iso_formated(fin, True)
if fin is not None:
filtered_assiduites = []
for ass in assiduites:
if fin.tzinfo is None:
fin: datetime = fin.replace(tzinfo=ass.date_fin.tzinfo)
if ass.date_fin <= fin:
filtered_assiduites.append(ass)
assiduites.clear()
assiduites.extend(filtered_assiduites)
# cas 4 : moduleimpl_id
module = request.args.get("moduleimpl_id")
try:
module = int(module)
except Exception:
module = None
if module is not None:
assiduites = [ass for ass in assiduites if ass.moduleimpl_id == module]
data_set: List[dict] = []
for ass in assiduites:
data = ass.to_dict()
data_set.append(change_etat(data))
return jsonify(data_set)
@bp.route("/assiduite/<int:etuid>/create", methods=["POST"])
@scodoc
@permission_required(Permission.ScoView)
def create(etuid: int = None):
"""
Création d'une assiduité pour l'étudiant (etuid)
La requête doit avoir un content type "application/json":
{
"date_debut": str,
"date_fin": str,
"etat": str,
}
ou
{
"date_debut": str,
"date_fin": str,
"etat": str,
"moduleimpl_id": int,
}
"""
etud: Identite = Identite.query.filter_by(id=etuid).first_or_404()
data = request.get_json(force=True)
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 etat not in scu.ETATS_ASSIDUITE.keys():
errors.append("param 'etat': invalide")
data = change_etat(data, False)
etat = data.get("etat", None)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = is_iso_formated(date_debut, 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 = is_iso_formated(date_fin, True)
if fin is None:
errors.append(f"param 'date_fin': format invalide")
# cas 4 : moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", None)
if moduleimpl_id is not None:
try:
moduleimpl_id: int = int(moduleimpl_id)
if moduleimpl_id < 0:
raise Exception
except:
errors.append("param 'moduleimpl_id': invalide")
if errors != []:
err: str = ", ".join(errors)
return json_error(404, err)
# TOUT EST OK
nouv_assiduite: Assiduite or str = Assiduite.create_assiduite(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
module=moduleimpl_id,
)
if type(nouv_assiduite) is Assiduite:
return jsonify({"assiduiteid": nouv_assiduite.assiduiteid})
return json_error(
404,
{
1: "La période sélectionnée est déjà couverte par une autre assiduite",
2: "L'étudiant ne participe pas au moduleimpl sélectionné",
}.get(nouv_assiduite),
)
@bp.route("/assiduite/<int:assiduiteid>/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoAssiduiteChange)
def delete(assiduiteid: int):
"""
Suppression d'une assiduité à partir de son id
"""
assiduite: Assiduite = Assiduite.query.filter_by(id=assiduiteid).first_or_404()
db.session.delete(assiduite)
db.session.commit()
return jsonify({"OK": True})
@bp.route("/assiduite/<int:assiduiteid>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoAssiduiteChange)
def edit(assiduiteid: int):
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat": str,
"moduleimpl_id": int
}
"""
assiduite: Assiduite = Assiduite.query.filter_by(id=assiduiteid).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:
data = change_etat(data, False)
if data.get("etat") is None:
errors.append("param 'etat': invalide")
else:
assiduite.etat = data.get("etat")
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
if moduleimpl_id is not False:
try:
if moduleimpl_id is not None:
moduleimpl_id: int = int(moduleimpl_id)
if moduleimpl_id < 0 or not Assiduite.verif_moduleimpl(
moduleimpl_id, assiduite.etudid
):
raise Exception
assiduite.moduleimpl_id = moduleimpl_id
except:
errors.append("param 'moduleimpl_id': invalide")
if errors != []:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(assiduite)
db.session.commit()
return jsonify({"OK": True})
# -- Utils --
def change_etat(data: dict, from_int: bool = True):
"""change dans un json la valeur du champs état"""
if from_int:
data["etat"] = scu.ETAT_ASSIDUITE_NAME.get(data["etat"])
else:
data["etat"] = scu.ETATS_ASSIDUITE.get(data["etat"])
return data
def is_iso_formated(date: str, convert=False) -> bool or 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
"""
import dateutil.parser as dtparser
try:
date: datetime = dtparser.isoparse(date)
if date.tzinfo is None:
date = UTC.localize(date)
return date if convert else True
except Exception:
return None if convert else False

View File

@ -82,3 +82,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

View File

@ -1,9 +1,14 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
from app import db
from app.models import CODE_STR_LEN, SHORT_STR_LEN
from app.models import ModuleImpl
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc.sco_utils import EtatAssiduite
from datetime import datetime
from typing import Tuple, List
class Assiduite(db.Model):
@ -15,7 +20,8 @@ class Assiduite(db.Model):
__tablename__ = "assiduites"
assiduiteid = db.Column(db.Integer, primary_key=True)
id = db.Column(db.Integer, primary_key=True)
assiduiteid = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
@ -34,37 +40,92 @@ class Assiduite(db.Model):
index=True,
nullable=False,
)
etat = db.Column(db.String(CODE_STR_LEN), nullable=False)
etat = db.Column(db.Integer, nullable=False)
def to_dict(self) -> dict:
data = {
"assiduiteid": self.assiduiteid,
"etudid": self.etudid,
"moduleid": self.moduleimpl_id,
"moduleimpl_id": self.moduleimpl_id,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": self.etat,
}
return data
@classmethod
def create_assiduite(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
module: int or None = None or int,
) -> object or int:
"Créer une nouvelle assiduité pour l'étudiant"
class EtatJustificatif(db.Model):
"""
Représente les différents états de validation d'un justificatif:
- un couple ID et description (32 caractères max)
Par Défaut :
0 -> Non validé
1 -> Validé
# Vérification de non duplication des périodes
assiduites: List[Assiduite] = etud.assiduites.all()
assiduites = [
ass
for ass in assiduites
if verif_interval((date_debut, date_fin), (ass.date_debut, ass.date_fin))
]
if len(assiduites) != 0:
return 1
Tout id différent de 1 sera considéré par ScoDoc comme "Non Justifié"
mais cela permet d'avoir des états transitoires (Modifié, en attente, etc)
"""
if module is not None:
# Vérification de l'existance du module pour l'étudiant
if cls.verif_moduleimpl(module, etud):
nouv_assiduite = Assiduite(
date_debut=date_debut.isoformat(),
date_fin=date_fin.isoformat(),
etat=etat,
etudiant=etud,
moduleimpl_id=module,
)
else:
return 2
else:
nouv_assiduite = Assiduite(
date_debut=date_debut.isoformat(),
date_fin=date_fin.isoformat(),
etat=etat,
etudiant=etud,
)
db.session.add(nouv_assiduite)
db.session.commit()
return nouv_assiduite
__tablename__ = "etat_justificatif"
@staticmethod
def verif_moduleimpl(moduleimpl_id: int, etud: Identite or int) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl
id = db.Column(db.Integer, primary_key=True)
description = db.Column(db.String(SHORT_STR_LEN), nullable=False)
Retourne Vrai si c'est le cas, faux sinon
"""
# -> get obj module impl -> get obj formsemestres -> query etuds avec etuid -> si vide = Error sinon good
module: ModuleImpl = ModuleImpl.query.filter_by(
moduleimpl_id=moduleimpl_id
).first()
if module is None:
retour = False
semestre: FormSemestre = FormSemestre.query.filter_by(
id=module.formsemestre_id
).first()
if semestre is None:
retour = False
etudiants: List[Identite] = semestre.etuds.all()
if type(etud) is Identite:
retour = etud in etudiants
else:
retour = etud in [e.id for e in etudiants]
return retour
class Justificatif(db.Model):
@ -94,7 +155,6 @@ class Justificatif(db.Model):
)
etat = db.Column(
db.Integer,
db.ForeignKey("etat_justificatif.id", ondelete="SET NULL"),
)
raison = db.Column(db.Text())
@ -107,9 +167,30 @@ class Justificatif(db.Model):
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": self.etat,
"raison": self.raison,
"fichier": self.fichier,
}
if self.raison != None:
data["raison"] = self.raison
if self.fichier != None:
data["fichier"] = self.fichier
return data
def verif_interval(periode: Tuple[datetime], interval: Tuple[datetime]) -> bool:
"""
Vérifie si une période est comprise dans un interval, chevauche l'interval ou comprend l'interval
Retourne Vrai si c'est le cas, faux sinon
"""
p_deb, p_fin = periode
i_deb, i_fin = interval
from app.scodoc.intervals import intervalmap
i = intervalmap()
p = intervalmap()
i[:] = 0
p[:] = 0
i[i_deb:i_fin] = 1
p[p_deb:p_fin] = 1
res: int = sum((i[p_deb], i[p_fin], p[i_deb], p[i_fin]))
return res > 0

View File

@ -59,6 +59,10 @@ class Identite(db.Model):
#
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
# 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

@ -56,6 +56,8 @@ _SCO_PERMISSIONS = (
# 27 à 39 ... réservé pour "entreprises"
# Api scodoc9
# XXX à revoir
(1 << 40, "ScoAssiduiteChange", "Modifier les assiduités"),
(1 << 41, "ScoJustifChange", "Modifier les justificatifs"),
# (1 << 42, "APIEditAllNotes", "API: Modifier toutes les notes"),
# (1 << 43, "APIAbsChange", "API: Saisir des absences"),
)

View File

@ -88,6 +88,46 @@ ETATS_INSCRIPTION = {
}
class EtatAssiduite(IntEnum):
"""Code des états d'assiduité"""
# Stockés en BD ne pas modifier
PRESENT = 0
RETARD = 1
ABSENT = 2
ETAT_ASSIDUITE_NAME = {
EtatAssiduite.PRESENT: "present",
EtatAssiduite.RETARD: "retard",
EtatAssiduite.ABSENT: "absent",
}
ETATS_ASSIDUITE = {
"present": EtatAssiduite.PRESENT,
"retard": EtatAssiduite.RETARD,
"absent": EtatAssiduite.ABSENT,
}
class EtatJustificatif(IntEnum):
"""Code des états des justificatifs"""
# Stockés en BD ne pas modifier
VALIDE = 0
NON_VALIDE = 1
ATTENTE = 2
MODIFIE = 3
ETAT_JUSTIFICATIF_NAME = {
EtatJustificatif.VALIDE: "validé",
EtatJustificatif.NON_VALIDE: "non validé",
EtatJustificatif.ATTENTE: "en attente",
EtatJustificatif.MODIFIE: "modifié",
}
# Types de modules
class ModuleType(IntEnum):
"""Code des types de module."""

View File

@ -0,0 +1,54 @@
"""models assiduites
Revision ID: 7b762fcbf644
Revises: 52f5f35c077f
Create Date: 2022-11-03 09:09:19.213260
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7b762fcbf644'
down_revision = '52f5f35c077f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('justificatifs',
sa.Column('justifid', 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=True),
sa.Column('raison', sa.Text(), nullable=True),
sa.Column('fichier', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['etudid'], ['identite.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('justifid')
)
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.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 ###