diff --git a/app/api/__init__.py b/app/api/__init__.py index 886c26f8..55c708a7 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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, diff --git a/app/api/assiduites.py b/app/api/assiduites.py new file mode 100644 index 00000000..9e5951ae --- /dev/null +++ b/app/api/assiduites.py @@ -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/") +@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/", defaults={"with_query": False}) +@bp.route("/assiduites//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//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//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//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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 23c677c8..cc95c85f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 7ceddcfe..986c4aca 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -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 diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 89bb90de..bd683ce9 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -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"" diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index 07d606e8..aad4ee91 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -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"), ) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index d00c4939..7898e090 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -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.""" diff --git a/migrations/versions/7b762fcbf644_models_assiduites.py b/migrations/versions/7b762fcbf644_models_assiduites.py new file mode 100644 index 00000000..ba4172bc --- /dev/null +++ b/migrations/versions/7b762fcbf644_models_assiduites.py @@ -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 ###