From c48c07bd21078a86fea7b12892f948f04cccc5fd Mon Sep 17 00:00:00 2001 From: iziram Date: Mon, 17 Apr 2023 15:34:00 +0200 Subject: [PATCH 001/101] =?UTF-8?q?Assiduites=20:Cr=C3=A9ation=20des=20mod?= =?UTF-8?q?els?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/__init__.py | 2 + app/models/assiduites.py | 343 ++++++++++++++++++++++++++++++++++++++ app/models/etudiants.py | 4 + app/models/moduleimpls.py | 16 ++ app/scodoc/sco_utils.py | 162 +++++++++++++++++- 5 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 app/models/assiduites.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 39a8d3e292..032ddc861d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/assiduites.py b/app/models/assiduites.py new file mode 100644 index 0000000000..7471c11e23 --- /dev/null +++ b/app/models/assiduites.py @@ -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) diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 8a6047097e..42b0e63c0e 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -73,6 +73,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"" diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 8a7dcb0171..c4d7c3fe0b 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -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( diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 79313e6b05..dbcdfffae4 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -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.""" From c5fb15fbe80a1425f35fbd4465ff8893c97bb190 Mon Sep 17 00:00:00 2001 From: iziram Date: Mon, 17 Apr 2023 15:38:28 +0200 Subject: [PATCH 002/101] =?UTF-8?q?Assiduit=C3=A9s=20:=20Ajout=20des=20mig?= =?UTF-8?q?rations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...780b2_assiduites_ajout_user_id_est_just.py | 71 ++++++++++++++ ...75e87f_modèles_assiduites_justificatifs.py | 95 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100755 migrations/versions/b555390780b2_assiduites_ajout_user_id_est_just.py create mode 100755 migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py diff --git a/migrations/versions/b555390780b2_assiduites_ajout_user_id_est_just.py b/migrations/versions/b555390780b2_assiduites_ajout_user_id_est_just.py new file mode 100755 index 0000000000..2a577fcbf5 --- /dev/null +++ b/migrations/versions/b555390780b2_assiduites_ajout_user_id_est_just.py @@ -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 ### diff --git a/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py new file mode 100755 index 0000000000..b74fa27d7f --- /dev/null +++ b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py @@ -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 = "6520faf67508" +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 ### From 9a0852917fdd33713192b83c36757cd512643ed3 Mon Sep 17 00:00:00 2001 From: iziram Date: Mon, 17 Apr 2023 15:35:42 +0200 Subject: [PATCH 003/101] Assiduites : Fonctionnement BackEnd + API --- app/api/__init__.py | 21 +- app/api/assiduites.py | 868 +++++++++++++++++++++++ app/api/etudiants.py | 37 + app/api/justificatifs.py | 591 +++++++++++++++ app/scodoc/sco_archives.py | 15 +- app/scodoc/sco_archives_justificatifs.py | 215 ++++++ app/scodoc/sco_assiduites.py | 355 +++++++++ 7 files changed, 2096 insertions(+), 6 deletions(-) create mode 100644 app/api/assiduites.py mode change 100644 => 100755 app/api/etudiants.py create mode 100644 app/api/justificatifs.py create mode 100644 app/scodoc/sco_archives_justificatifs.py create mode 100644 app/scodoc/sco_assiduites.py diff --git a/app/api/__init__.py b/app/api/__init__.py index d5b436881f..a3c77eb976 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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, diff --git a/app/api/assiduites.py b/app/api/assiduites.py new file mode 100644 index 0000000000..5853e3ff0f --- /dev/null +++ b/app/api/assiduites.py @@ -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/") +@api_web_bp.route("/assiduite/") +@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//count", defaults={"with_query": False}) +@bp.route("/assiduites//count/query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites//count", defaults={"with_query": False}) +@api_web_bp.route("/assiduites//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//count + + Un filtrage peut être donné avec une query + chemin : /assiduites//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/", defaults={"with_query": False}) +@bp.route("/assiduites//query", defaults={"with_query": True}) +@api_web_bp.route("/assiduites/", defaults={"with_query": False}) +@api_web_bp.route("/assiduites//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/ + + Un filtrage peut être donné avec une query + chemin : /assiduites//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/", defaults={"with_query": False} +) +@api_web_bp.route( + "/assiduites/formsemestre/", defaults={"with_query": False} +) +@bp.route( + "/assiduites/formsemestre//query", + defaults={"with_query": True}, +) +@api_web_bp.route( + "/assiduites/formsemestre//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//count", + defaults={"with_query": False}, +) +@api_web_bp.route( + "/assiduites/formsemestre//count", + defaults={"with_query": False}, +) +@bp.route( + "/assiduites/formsemestre//count/query", + defaults={"with_query": True}, +) +@api_web_bp.route( + "/assiduites/formsemestre//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//create", methods=["POST"]) +@api_web_bp.route("/assiduite//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 : + + [ + , + ... + ] + + + """ + 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//edit", methods=["POST"]) +@api_web_bp.route("/assiduite//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 diff --git a/app/api/etudiants.py b/app/api/etudiants.py old mode 100644 new mode 100755 index e8030b0199..a5d63e4dca --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -34,6 +34,7 @@ from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import json_error, suppress_accents +import app.scodoc.sco_photos as sco_photos # Un exemple: # @bp.route("/api_function/") @@ -136,6 +137,42 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None): return etud.to_dict_api() +@api_web_bp.route("/etudiant/etudid//photo") +@api_web_bp.route("/etudiant/nip//photo") +@api_web_bp.route("/etudiant/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/", methods=["GET"]) @bp.route("/etudiants/nip/", methods=["GET"]) @bp.route("/etudiants/ine/", methods=["GET"]) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py new file mode 100644 index 0000000000..7017ca1b4b --- /dev/null +++ b/app/api/justificatifs.py @@ -0,0 +1,591 @@ +############################################################################## +# 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/") +@api_web_bp.route("/justificatif/") +@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/", defaults={"with_query": False}) +@bp.route("/justificatifs//query", defaults={"with_query": True}) +@api_web_bp.route("/justificatifs/", defaults={"with_query": False}) +@api_web_bp.route("/justificatifs//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/ + + Un filtrage peut être donné avec une query + chemin : /justificatifs//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//create", methods=["POST"]) +@api_web_bp.route("/justificatif//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//edit", methods=["POST"]) +@api_web_bp.route("/justificatif//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 : + + [ + , + ... + ] + + + """ + 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) + return (200, "OK") + + +# Partie archivage +@bp.route("/justificatif//import", methods=["POST"]) +@api_web_bp.route("/justificatif//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//export/", methods=["POST"]) +@api_web_bp.route("/justificatif//export/", 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//remove", methods=["POST"]) +@api_web_bp.route("/justificatif//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"?: [ + , + ... + ] + } + """ + + 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//list", methods=["GET"]) +@api_web_bp.route("/justificatif//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//justifies", methods=["GET"]) +@api_web_bp.route("/justificatif//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 diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py index 5e09bf96c0..17a0b86372 100644 --- a/app/scodoc/sco_archives.py +++ b/app/scodoc/sco_archives.py @@ -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 ScoException, 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): @@ -140,8 +146,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] diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py new file mode 100644 index 0000000000..3cad18e96e --- /dev/null +++ b/app/scodoc/sco_archives_justificatifs.py @@ -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 + └── + └── + ├── [_trace.csv] + └── + ├── [_description.txt] + └── [] + + """ + + 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) diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py new file mode 100644 index 0000000000..3ba865a224 --- /dev/null +++ b/app/scodoc/sco_assiduites.py @@ -0,0 +1,355 @@ +""" +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.etat != scu.EtatAssiduite.PRESENT) + .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 From e748973ae1e639b0520fafb3b7eff63505b02ebd Mon Sep 17 00:00:00 2001 From: iziram Date: Mon, 17 Apr 2023 15:39:32 +0200 Subject: [PATCH 004/101] =?UTF-8?q?Assiduit=C3=A9s=20:=20Ajout=20des=20tes?= =?UTF-8?q?ts=20(Unit/API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/api/make_samples.py | 16 +- tests/api/test_api_assiduites.py | 392 ++++++++++ tests/api/test_api_justificatif.txt | 1 + tests/api/test_api_justificatif2.txt | 1 + tests/api/test_api_justificatifs.py | 469 +++++++++++ tests/api/test_api_permissions.py | 3 + .../ressources/samples/assiduites_samples.csv | 26 + tests/ressources/{ => samples}/samples.csv | 20 + tests/unit/test_assiduites.py | 728 ++++++++++++++++++ .../fakedatabase/create_test_api_database.py | 54 ++ 10 files changed, 1703 insertions(+), 7 deletions(-) create mode 100644 tests/api/test_api_assiduites.py create mode 100644 tests/api/test_api_justificatif.txt create mode 100644 tests/api/test_api_justificatif2.txt create mode 100644 tests/api/test_api_justificatifs.py mode change 100644 => 100755 tests/api/test_api_permissions.py create mode 100644 tests/ressources/samples/assiduites_samples.csv rename tests/ressources/{ => samples}/samples.csv (79%) create mode 100644 tests/unit/test_assiduites.py diff --git a/tests/api/make_samples.py b/tests/api/make_samples.py index fd61346f9e..08701ca139 100644 --- a/tests/api/make_samples.py +++ b/tests/api/make_samples.py @@ -7,6 +7,7 @@ Usage: cd /opt/scodoc/tests/api python make_samples.py [entry_names] + python make_samples.py -i [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) diff --git a/tests/api/test_api_assiduites.py b/tests/api/test_api_assiduites.py new file mode 100644 index 0000000000..6d2a5b6892 --- /dev/null +++ b/tests/api/test_api_assiduites.py @@ -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/""" + + # 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//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/""" + + # 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/""" + + # 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//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//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//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"]]) diff --git a/tests/api/test_api_justificatif.txt b/tests/api/test_api_justificatif.txt new file mode 100644 index 0000000000..370b0a4f66 --- /dev/null +++ b/tests/api/test_api_justificatif.txt @@ -0,0 +1 @@ +test de l'importation des fichiers / archive justificatif \ No newline at end of file diff --git a/tests/api/test_api_justificatif2.txt b/tests/api/test_api_justificatif2.txt new file mode 100644 index 0000000000..370b0a4f66 --- /dev/null +++ b/tests/api/test_api_justificatif2.txt @@ -0,0 +1 @@ +test de l'importation des fichiers / archive justificatif \ No newline at end of file diff --git a/tests/api/test_api_justificatifs.py b/tests/api/test_api_justificatifs.py new file mode 100644 index 0000000000..6f405aea9d --- /dev/null +++ b/tests/api/test_api_justificatifs.py @@ -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/""" + + # 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/""" + # 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//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//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//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//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//export/""" + + # 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//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//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) diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py old mode 100644 new mode 100755 index 602834f1d7..d2195e30f8 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -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] diff --git a/tests/ressources/samples/assiduites_samples.csv b/tests/ressources/samples/assiduites_samples.csv new file mode 100644 index 0000000000..f251635d69 --- /dev/null +++ b/tests/ressources/samples/assiduites_samples.csv @@ -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]" \ No newline at end of file diff --git a/tests/ressources/samples.csv b/tests/ressources/samples/samples.csv similarity index 79% rename from tests/ressources/samples.csv rename to tests/ressources/samples/samples.csv index 819d39c28b..a8d92875f3 100644 --- a/tests/ressources/samples.csv +++ b/tests/ressources/samples/samples.csv @@ -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"; diff --git a/tests/unit/test_assiduites.py b/tests/unit/test_assiduites.py new file mode 100644 index 0000000000..567c49ad5d --- /dev/null +++ b/tests/unit/test_assiduites.py @@ -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" diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py index cd06048b08..1825a884fa 100644 --- a/tools/fakedatabase/create_test_api_database.py +++ b/tools/fakedatabase/create_test_api_database.py @@ -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 From 94347657f6d6e1272c0afe88991dd856f7083b0f Mon Sep 17 00:00:00 2001 From: iziram Date: Mon, 17 Apr 2023 15:43:58 +0200 Subject: [PATCH 005/101] Assiduites : script de migration et de suppression --- app/profiler.py | 43 +++ scodoc.py | 60 ++++ tools/__init__.py | 2 + tools/downgrade_assiduites.py | 71 +++++ tools/migrate_abs_to_assiduites.py | 421 +++++++++++++++++++++++++++++ 5 files changed, 597 insertions(+) create mode 100644 app/profiler.py create mode 100644 tools/downgrade_assiduites.py create mode 100644 tools/migrate_abs_to_assiduites.py diff --git a/app/profiler.py b/app/profiler.py new file mode 100644 index 0000000000..0e61d38560 --- /dev/null +++ b/app/profiler.py @@ -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("") diff --git a/scodoc.py b/scodoc.py index 2fd0f9a2e3..697361b665 100755 --- a/scodoc.py +++ b/scodoc.py @@ -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) diff --git a/tools/__init__.py b/tools/__init__.py index ac9e681c24..da9214bfa3 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -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 diff --git a/tools/downgrade_assiduites.py b/tools/downgrade_assiduites.py new file mode 100644 index 0000000000..ac38e06828 --- /dev/null +++ b/tools/downgrade_assiduites.py @@ -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) diff --git a/tools/migrate_abs_to_assiduites.py b/tools/migrate_abs_to_assiduites.py new file mode 100644 index 0000000000..10b4e7851c --- /dev/null +++ b/tools/migrate_abs_to_assiduites.py @@ -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 From 96b1512e24d88a37369482e14c9ca2ad407372f0 Mon Sep 17 00:00:00 2001 From: iziram Date: Mon, 17 Apr 2023 15:44:55 +0200 Subject: [PATCH 006/101] Assiduites : Front End --- app/__init__.py | 4 + app/scodoc/html_sidebar.py | 2 +- app/scodoc/sco_abs.py | 41 + app/scodoc/sco_formsemestre_status.py | 8 +- app/scodoc/sco_photos.py | 4 +- app/static/css/assiduites.css | 475 +++ app/static/icons/absent.svg | 11 + app/static/icons/present.svg | 13 + app/static/icons/retard.svg | 12 + app/static/js/assiduites.js | 1792 +++++++++ app/static/libjs/moment-timezone.js | 1597 ++++++++ app/static/libjs/moment.new.min.js | 3309 +++++++++++++++++ app/templates/assiduites/alert.j2 | 156 + app/templates/assiduites/minitimeline.j2 | 105 + .../assiduites/moduleimpl_dynamic_selector.j2 | 113 + .../assiduites/moduleimpl_selector.j2 | 14 + app/templates/assiduites/prompt.j2 | 211 ++ .../assiduites/signal_assiduites_etud.j2 | 99 + .../assiduites/signal_assiduites_group.j2 | 76 + app/templates/assiduites/timeline.j2 | 212 ++ app/templates/assiduites/toast.j2 | 88 + app/templates/sidebar.j2 | 2 +- app/views/__init__.py | 2 + app/views/assiduites.py | 377 ++ 24 files changed, 8715 insertions(+), 8 deletions(-) mode change 100644 => 100755 app/__init__.py mode change 100644 => 100755 app/scodoc/html_sidebar.py mode change 100644 => 100755 app/scodoc/sco_abs.py mode change 100644 => 100755 app/scodoc/sco_formsemestre_status.py mode change 100644 => 100755 app/scodoc/sco_photos.py create mode 100644 app/static/css/assiduites.css create mode 100755 app/static/icons/absent.svg create mode 100755 app/static/icons/present.svg create mode 100755 app/static/icons/retard.svg create mode 100644 app/static/js/assiduites.js create mode 100644 app/static/libjs/moment-timezone.js create mode 100644 app/static/libjs/moment.new.min.js create mode 100644 app/templates/assiduites/alert.j2 create mode 100644 app/templates/assiduites/minitimeline.j2 create mode 100644 app/templates/assiduites/moduleimpl_dynamic_selector.j2 create mode 100644 app/templates/assiduites/moduleimpl_selector.j2 create mode 100644 app/templates/assiduites/prompt.j2 create mode 100644 app/templates/assiduites/signal_assiduites_etud.j2 create mode 100644 app/templates/assiduites/signal_assiduites_group.j2 create mode 100644 app/templates/assiduites/timeline.j2 create mode 100644 app/templates/assiduites/toast.j2 mode change 100644 => 100755 app/templates/sidebar.j2 create mode 100644 app/views/assiduites.py diff --git a/app/__init__.py b/app/__init__.py old mode 100644 new mode 100755 index c2b7895971..86646eb407 --- a/app/__init__.py +++ b/app/__init__.py @@ -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//Scolarite/Absences" ) + app.register_blueprint( + assiduites_bp, url_prefix="/ScoDoc//Scolarite/Assiduites" + ) app.register_blueprint(api_bp, url_prefix="/ScoDoc/api") app.register_blueprint(api_web_bp, url_prefix="/ScoDoc//api") diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py old mode 100644 new mode 100755 index 33132a0566..5403b4d961 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -126,7 +126,7 @@ def sidebar(etudid: int = None): if current_user.has_permission(Permission.ScoAbsChange): H.append( f""" -
  • Ajouter
  • +
  • Ajouter
  • Justifier
  • Supprimer
  • """ diff --git a/app/scodoc/sco_abs.py b/app/scodoc/sco_abs.py old mode 100644 new mode 100755 index 1e56ca87e3..e1672492b1 --- a/app/scodoc/sco_abs.py +++ b/app/scodoc/sco_abs.py @@ -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"] diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py old mode 100644 new mode 100755 index f971171c0e..e7f47b0b4c --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -829,9 +829,9 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
    - @@ -848,8 +848,8 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True): saisie par semaine + 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
    """ else: diff --git a/app/scodoc/sco_photos.py b/app/scodoc/sco_photos.py old mode 100644 new mode 100755 index b1e73dcb1f..44acb4483c --- a/app/scodoc/sco_photos.py +++ b/app/scodoc/sco_photos.py @@ -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 diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css new file mode 100644 index 0000000000..eb6086a767 --- /dev/null +++ b/app/static/css/assiduites.css @@ -0,0 +1,475 @@ +* { + 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: 10% 20% 30% 30%; + gap: 3.33%; + + background-color: white; + border-radius: 15px; + width: 80%; + height: 50px; + + 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); +} + +.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; +} + +/* --- Barre assiduités --- */ +.etud_row .assiduites_bar { + display: flex; + flex-wrap: wrap; + 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: #ec5c49 !important; +} + +.etud_row .assiduites_bar .present { + background-color: #37f05f !important; +} + +.etud_row .assiduites_bar .retard { + background-color: #ecb52a !important; +} + + +/* --- 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; +} \ No newline at end of file diff --git a/app/static/icons/absent.svg b/app/static/icons/absent.svg new file mode 100755 index 0000000000..697635cd96 --- /dev/null +++ b/app/static/icons/absent.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/static/icons/present.svg b/app/static/icons/present.svg new file mode 100755 index 0000000000..e1628c8363 --- /dev/null +++ b/app/static/icons/present.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/static/icons/retard.svg b/app/static/icons/retard.svg new file mode 100755 index 0000000000..b8a7f3d254 --- /dev/null +++ b/app/static/icons/retard.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js new file mode 100644 index 0000000000..796015bd03 --- /dev/null +++ b/app/static/js/assiduites.js @@ -0,0 +1,1792 @@ +// <=== CONSTANTS and GLOBALS ===> + +const TIMEZONE = "Europe/Paris"; +let url; + +function getUrl() { + if (!url) { + url = SCO_URL.substring(0, SCO_URL.lastIndexOf("/")); + } + return url; +} + +//Les valeurs par défaut de la timeline (8h -> 18h) +let currentValues = [8.0, 10.0]; + +//Objet stockant les étudiants et les assiduités +let etuds = {}; +let assiduites = {}; + +// Variable qui définit si le processus d'action de masse est lancé +let currentMassAction = false; + +/** + * Variable de gestion des conflits + */ +let modal; +let closeBtn; +let timeline; +let deleteBtn; +let splitBtn; +let editBtn; +let selectedAssiduite; + +/** + * Ajout d'une fonction `capitalize` sur tous les strings + * alice.capitalize() -> Alice + */ +Object.defineProperty(String.prototype, "capitalize", { + value: function () { + return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase(); + }, + enumerable: false, +}); +// <<== Outils ==>> + +/** + * Ajout des évents sur les boutons d'assiduité + * @param {Document | HTMLFieldSetElement} parent par défaut le document, un field sinon + */ +function setupCheckBox(parent = document) { + const checkboxes = Array.from(parent.querySelectorAll(".rbtn")); + checkboxes.forEach((box) => { + box.addEventListener("click", (event) => { + if (!uniqueCheckBox(box)) { + event.preventDefault(); + } + if (!box.parentElement.classList.contains("mass")) { + assiduiteAction(box); + } + }); + }); +} + +/** + * Validation préalable puis désactivation des chammps : + * - Groupe + * - Module impl + * - Date + */ +function validateSelectors() { + const action = () => { + const group_ids = getGroupIds(); + + etuds = {}; + group_ids.forEach((group_id) => { + sync_get( + getUrl() + `/api/group/${group_id}/etudiants`, + (data, status) => { + if (status === "success") { + data.forEach((etud) => { + if (!(etud.id in etuds)) { + etuds[etud.id] = etud; + } + }); + } + } + ); + }); + + getAssiduitesFromEtuds(true); + + document.querySelector(".selectors").disabled = true; + generateMassAssiduites(); + generateAllEtudRow(); + }; + + if (!verifyDateInSemester()) { + const HTML = ` +

    Attention, la date sélectionnée n'est pas comprise dans le semestre.

    +

    Cette page permet l'affichage et la modification des assiduités uniquement pour le semestre sélectionné.

    +

    Vous n'aurez donc pas accès aux assiduités.

    +

    Appuyer sur "Valider" uniquement si vous souhaitez poursuivre sans modifier la date.

    + `; + + const content = document.createElement("div"); + content.innerHTML = HTML; + + openPromptModal("Vérification de la date", content, action); + return; + } + + action(); +} + +/** + * Limite le nombre de checkbox marquée + * Vérifie aussi si le cliqué est fait sur des assiduités conflictuelles + * @param {HTMLInputElement} box la checkbox utilisée + * @returns {boolean} Faux si il y a un conflit d'assiduité, Vrai sinon + */ +function uniqueCheckBox(box) { + const type = box.parentElement.getAttribute("type") === "conflit"; + if (!type) { + const checkboxs = Array.from(box.parentElement.children); + + checkboxs.forEach((chbox) => { + if (chbox.checked && chbox.value !== box.value) { + chbox.checked = false; + } + }); + return true; + } + + return false; +} + +/** + * Fait une requête GET de façon synchrone + * @param {String} path adresse distante + * @param {CallableFunction} success fonction à effectuer en cas de succès + * @param {CallableFunction} errors fonction à effectuer en cas d'échec + */ +function sync_get(path, success, errors) { + $.ajax({ + async: false, + type: "GET", + url: path, + success: success, + error: errors, + }); +} +/** + * Fait une requête POST de façon synchrone + * @param {String} path adresse distante + * @param {object} data données à envoyer (objet js) + * @param {CallableFunction} success fonction à effectuer en cas de succès + * @param {CallableFunction} errors fonction à effectuer en cas d'échec + */ +function sync_post(path, data, success, errors) { + $.ajax({ + async: false, + type: "POST", + url: path, + data: JSON.stringify(data), + success: success, + error: errors, + }); +} +// <<== Gestion des actions de masse ==>> +const massActionQueue = new Map(); + +/** + * Cette fonction remet à zero la gestion des actions de masse + */ +function resetMassActionQueue() { + massActionQueue.set("supprimer", []); + massActionQueue.set("editer", []); + massActionQueue.set("creer", []); +} + +/** + * Fonction pour alimenter la queue des actions de masse + * @param {String} type Le type de queue ("creer", "supprimer", "editer") + * @param {*} obj L'objet qui sera utilisé par les API + */ +function addToMassActionQueue(type, obj) { + massActionQueue.get(type)?.push(obj); +} + +/** + * Fonction pour exécuter les actions de masse + */ +function executeMassActionQueue() { + if (!currentMassAction) return; + + //Récupération des queues + const toCreate = massActionQueue.get("creer"); + const toEdit = massActionQueue.get("editer"); + const toDelete = massActionQueue.get("supprimer"); + + //Fonction qui créé les assidutiés de la queue "creer" + const create = () => { + /** + * Création du template de l'assiduité + * + * { + * date_debut: #debut_timeline, + * date_fin: #fin_timeline, + * moduleimpl_id ?: <> + * } + */ + const tlTimes = getTimeLineTimes(); + const assiduite = { + date_debut: tlTimes.deb.format(), + date_fin: tlTimes.fin.format(), + }; + const moduleimpl = getModuleImplId(); + + if (moduleimpl !== null) { + assiduite["moduleimpl_id"] = moduleimpl; + } + + const createQueue = []; //liste des assiduités qui seront créées. + + /** + * Pour chaque état de la queue 'creer' on génère une + * assiduitée précise depuis le template + */ + toCreate.forEach((obj) => { + const curAssiduite = structuredClone(assiduite); + curAssiduite.etudid = obj.etudid; + curAssiduite.etat = obj.etat; + + createQueue.push(curAssiduite); + }); + + /** + * On envoie les données à l'API + */ + const path = getUrl() + `/api/assiduites/create`; + sync_post( + path, + createQueue, + (data, status) => { + //success + }, + (data, status) => { + //error + console.error(data, status); + } + ); + }; + + //Fonction qui modifie les assiduités de la queue 'edition' + const edit = () => { + //On ajoute le moduleimpl (s'il existe) aux assiduités à modifier + const editQueue = toEdit.map((assiduite) => { + const moduleimpl = getModuleImplId(); + if (moduleimpl !== null) { + assiduite["moduleimpl_id"] = moduleimpl; + } + return assiduite; + }); + + const path = getUrl() + `/api/assiduites/edit`; + sync_post( + path, + editQueue, + (data, status) => { + //success + }, + (data, status) => { + //error + console.error(data, status); + } + ); + }; + + //Fonction qui supprime les assiduités de la queue 'supprimer' + const supprimer = () => { + const path = getUrl() + `/api/assiduite/delete`; + sync_post( + path, + toDelete, + (data, status) => { + //success + }, + (data, status) => { + //error + console.error(data, status); + } + ); + }; + + //On exécute les fonctions de queue + create(); + edit(); + supprimer(); + //On récupère les assiduités puis on regénère les lignes d'étudiants + getAssiduitesFromEtuds(true); + generateAllEtudRow(); +} +/** + * Processus de peuplement des queues + * puis d'exécution + */ +function massAction() { + //On récupère tous les boutons d'assiduités + const fields = Array.from(document.querySelectorAll(".btns_field.single")); + //On récupère l'état de l'action de masse + const action = getAssiduiteValue(document.querySelector(".btns_field.mass")); + //On remet à 0 les queues + resetMassActionQueue(); + + //on met à vrai la variable pour la suite + currentMassAction = true; + + //On affiche le "loader" le temps du processus + showLoader(); + + //On timeout 0 pour le mettre à la fin de l'event queue de JS + setTimeout(() => { + const conflicts = []; + /** + * Pour chaque étudiant : + * On vérifie s'il y a un conflit -> on place l'étudiant dans l'array conflicts + * Sinon -> on fait comme si l'utilisateur cliquait sur le bouton d'assiduité + */ + fields.forEach((field) => { + if (field.getAttribute("type") != "conflit") { + field.querySelector(`.rbtn.${action}`).click(); + } else { + const etudid = field.getAttribute("etudid"); + conflicts.push(etuds[parseInt(etudid)]); + } + }); + + //on exécute les queues puis on cache le loader + executeMassActionQueue(); + hideLoader(); + + //Fin du processus, on remet à false + currentMassAction = false; + + //On remet à zero les boutons d'assiduité de masse + const boxes = Array.from( + document.querySelector(".btns_field.mass").querySelectorAll(".rbtn") + ); + boxes.forEach((box) => { + box.checked = false; + }); + + //Si il y a des conflits d'assiduité, on affiche la liste dans une alert + if (conflicts.length > 0) { + const div = document.createElement("div"); + const sub = document.createElement("p"); + sub.textContent = + "L'assiduité des étudiants suivant n'a pas pu être modifiée"; + div.appendChild(sub); + const ul = document.createElement("ul"); + conflicts.forEach((etu) => { + const li = document.createElement("li"); + li.textContent = `${etu.nom} ${etu.prenom.capitalize()}`; + ul.appendChild(li); + }); + div.appendChild(ul); + openAlertModal("Conflits d'assiduités", div, ""); + } + }, 0); +} + +/** + * On génère les boutons d'assiduités de masse + * puis on ajoute les événements associés + */ +function generateMassAssiduites() { + const content = document.getElementById("content"); + + const mass = document.createElement("div"); + mass.className = "mass-selection"; + mass.innerHTML = ` + Mettre tout le monde : +
    + + + +
    `; + + content.insertBefore(mass, content.querySelector(".etud_holder")); + + const mass_btn = Array.from(mass.querySelectorAll(".rbtn")); + mass_btn.forEach((btn) => { + btn.addEventListener("click", () => { + massAction(); + }); + }); + + if (!verifyDateInSemester()) { + content.querySelector(".btns_field.mass").setAttribute("disabled", "true"); + } +} + +/** + * Affichage du loader + */ +function showLoader() { + document.getElementById("loaderContainer").style.display = "block"; +} +/** + * Dissimulation du loader + */ +function hideLoader() { + document.getElementById("loaderContainer").style.display = "none"; +} + +// <<== Gestion du temps ==>> + +/** + * Transforme un temps numérique en string + * 8.75 -> 08h45 + * @param {number} time Le temps (float) + * @returns {string} le temps (string) + */ +function toTime(time) { + let heure = Math.floor(time); + let minutes = (time - heure) * 60; + if (minutes < 1) { + minutes = "00"; + } + if (heure < 10) { + heure = `0${heure}`; + } + return `${heure}h${minutes}`; +} +/** + * Transforme une date iso en une date lisible: + * new Date('2023-03-03') -> "vendredi 3 mars 2023" + * @param {Date} date + * @param {object} styles + * @returns + */ +function formatDate(date, styles = { dateStyle: "full" }) { + return new Intl.DateTimeFormat("fr-FR", styles).format(date); +} + +/** + * Met à jour la date visible sur la page en la formatant + */ +function updateDate() { + const dateInput = document.querySelector("#tl_date"); + + const date = dateInput.valueAsDate; + + $("#datestr").text(formatDate(date).capitalize()); +} + +function verifyDateInSemester() { + const date = new moment.tz( + document.querySelector("#tl_date").value, + TIMEZONE + ); + + const periodSemester = getFormSemestreDates(); + + return date.isBetween(periodSemester.deb, periodSemester.fin); +} + +/** + * Ajoute la possibilité d'ouvrir le calendrier + * lorsqu'on clique sur la date + */ +function setupDate(onchange = null) { + const datestr = document.querySelector("#datestr"); + const input = document.querySelector("#tl_date"); + + datestr.addEventListener("click", () => { + if (!input.disabled) { + input.showPicker(); + } + }); + + if (onchange != null) { + input.addEventListener("change", onchange); + } +} + +/** + * GetAssiduitesOnDateChange + * (Utilisé uniquement avec étudiant unique) + */ + +function getAssiduitesOnDateChange() { + if (!isSingleEtud()) return; + actualizeEtud(etudid); +} +/** + * Transforme une date iso en date intelligible + * @param {String} str date iso + * @param {String} separator le séparateur de la date intelligible (01/01/2000 {separtor} 10:00) + * @returns {String} la date intelligible + */ +function formatDateModal(str, separator = "·") { + return new moment.tz(str, TIMEZONE).format(`DD/MM/Y ${separator} HH:mm`); +} + +/** + * Fonction qui vérifie si une période est dans un interval + * Objet période / interval + * { + * deb: moment.tz(), + * fin: moment.tz(), + * } + * @param {object} period + * @param {object} interval + * @returns {boolean} Vrai si la période est dans l'interval + */ +function hasTimeConflict(period, interval) { + return period.deb.isBefore(interval.fin) && period.fin.isAfter(interval.deb); +} + +/** + * On récupère la période de la timeline + * @returns {deb : moment.tz(), fin: moment.tz()} + */ +function getTimeLineTimes() { + //getPeriodValues() -> retourne la position de la timeline [a,b] avec a et b des number + let values = getPeriodValues(); + //On récupère la date + const dateiso = document.querySelector("#tl_date").value; + + //On génère des objets temps (moment.tz) + values = values.map((el) => { + el = toTime(el).replace("h", ":"); + el = `${dateiso}T${el}`; + return moment.tz(el, TIMEZONE); + }); + + return { deb: values[0], fin: values[1] }; +} + +/** + * Vérification de l'égalité entre un conflit et la période de la timeline + * @param {object} conflict + * @returns {boolean} Renvoie Vrai si la période de la timeline est égal au conflit + */ +function isConflictSameAsTimeLine(conflict) { + const tlTimes = getTimeLineTimes(); + const clTimes = { + deb: moment.tz(conflict.date_debut, TIMEZONE), + fin: moment.tz(conflict.date_fin, TIMEZONE), + }; + return tlTimes.deb.isSame(clTimes.deb) && tlTimes.fin.isSame(clTimes.fin); +} + +/** + * Retourne un objet Date de la date sélectionnée + * @returns {Date} la date sélectionnée + */ +function getDate() { + const date = document.querySelector("#tl_date").valueAsDate; + date.setHours(0, 0, 0, 0); + return date; +} + +/** + * Retourne un objet date représentant le jour suivant + * @returns {Date} le jour suivant + */ +function getNextDate() { + const date = getDate(); + const next = new Date(date.valueOf()); + next.setDate(date.getDate() + 1); + next.setHours(0, 0, 0, 0); + return next; +} +/** + * Retourne un objet date représentant le jour précédent + * @returns {Date} le jour précédent + */ +function getPrevDate() { + const date = getDate(); + const next = new Date(date.valueOf()); + next.setDate(date.getDate() - 1); + next.setHours(0, 0, 0, 0); + return next; +} + +/** + * Transformation d'un objet Date en chaîne ISO + * @param {Date} date + * @returns {string} la date iso avec le timezone + */ +function toIsoString(date) { + var tzo = -date.getTimezoneOffset(), + dif = tzo >= 0 ? "+" : "-", + pad = function (num) { + return (num < 10 ? "0" : "") + num; + }; + + return ( + date.getFullYear() + + "-" + + pad(date.getMonth() + 1) + + "-" + + pad(date.getDate()) + + "T" + + pad(date.getHours()) + + ":" + + pad(date.getMinutes()) + + ":" + + pad(date.getSeconds()) + + dif + + pad(Math.floor(Math.abs(tzo) / 60)) + + ":" + + pad(Math.abs(tzo) % 60) + ); +} + +/** + * Transforme un temps numérique en une date moment.tz + * @param {number} nb + * @returns {moment.tz} Une date formée du temps donné et de la date courante + */ +function numberTimeToDate(nb) { + time = toTime(nb).replace("h", ":"); + date = document.querySelector("#tl_date").value; + + datetime = `${date}T${time}`; + + return moment.tz(datetime, TIMEZONE); +} + +// <<== Gestion des assiduités ==>> + +/** + * Récupère les assiduités des étudiants + * en fonction de : + * - du semestre + * - de la date courant et du jour précédent. + * @param {boolean} clear vidage de l'objet "assiduites" ou non + * @returns {object} l'objets Assiduités { : [,]} + */ +function getAssiduitesFromEtuds(clear, has_formsemestre = true) { + const etudIds = Object.keys(etuds).join(","); + const formsemestre_id = has_formsemestre + ? `formsemestre_id=${getFormSemestreId()}&` + : ""; + + const date_debut = toIsoString(getPrevDate()); + const date_fin = toIsoString(getNextDate()); + + if (clear) { + assiduites = {}; + } + + const url_api = + getUrl() + + `/api/assiduites/group/query?date_debut=${formsemestre_id}${date_debut}&date_fin=${date_fin}&etudids=${etudIds}`; + sync_get(url_api, (data, status) => { + if (status === "success") { + const dataKeys = Object.keys(data); + dataKeys.forEach((key) => { + assiduites[key] = data[key]; + }); + } + }); + return assiduites; +} + +/** + * Création d'une assiduité pour un étudiant + * @param {String} etat l'état de l'étudiant + * @param {Number | String} etudid l'identifiant de l'étudiant + * + * TODO : Rendre asynchrone + */ +function createAssiduite(etat, etudid) { + const tlTimes = getTimeLineTimes(); + const assiduite = { + date_debut: tlTimes.deb.format(), + date_fin: tlTimes.fin.format(), + etat: etat, + }; + + const moduleimpl = getModuleImplId(); + + if (moduleimpl !== null) { + assiduite["moduleimpl_id"] = moduleimpl; + } + + const path = getUrl() + `/api/assiduite/${etudid}/create`; + sync_post( + path, + [assiduite], + (data, status) => { + //success + if (data.success.length > 0) { + let obj = data.success["0"].assiduite_id; + } + }, + (data, status) => { + //error + console.error(data, status); + } + ); +} + +/** + * Suppression d'une assiduité + * @param {String | Number} assiduite_id l'identifiant de l'assiduité + * TODO : Rendre asynchrone + */ +function deleteAssiduite(assiduite_id) { + const path = getUrl() + `/api/assiduite/delete`; + sync_post( + path, + [assiduite_id], + (data, status) => { + //success + if (data.success.length > 0) { + let obj = data.success["0"].assiduite_id; + } + }, + (data, status) => { + //error + console.error(data, status); + } + ); +} + +/** + * + * @param {String | Number} assiduite_id l'identifiant d'une assiduité + * @param {String} etat l'état à modifier + * @returns {boolean} si l'édition a fonctionné + * TODO : Rendre asynchrone + */ +function editAssiduite(assiduite_id, etat) { + const assiduite = { + etat: etat, + moduleimpl_id: getModuleImplId(), + }; + const path = getUrl() + `/api/assiduite/${assiduite_id}/edit`; + let bool = false; + sync_post( + path, + assiduite, + (data, status) => { + bool = true; + }, + (data, status) => { + //error + console.error(data, status); + } + ); + + return bool; +} + +/** + * Récupération des assiduités conflictuelles avec la période de la time line + * @param {String | Number} etudid identifiant de l'étudiant + * @returns {Array[Assiduité]} un tableau d'assiduité + */ +function getAssiduitesConflict(etudid) { + const etudAssiduites = assiduites[etudid]; + if (!etudAssiduites) { + return []; + } + const period = getTimeLineTimes(); + return etudAssiduites.filter((assi) => { + const interval = { + deb: moment.tz(assi.date_debut, TIMEZONE), + fin: moment.tz(assi.date_fin, TIMEZONE), + }; + return hasTimeConflict(period, interval); + }); +} + +/** + * Récupération de la dernière assiduité du jour précédent + * @param {String | Number} etudid l'identifiant de l'étudiant + * @returns {Assiduité} la dernière assiduité du jour précédent + */ +function getLastAssiduiteOfPrevDate(etudid) { + const etudAssiduites = assiduites[etudid]; + if (!etudAssiduites) { + return ""; + } + const period = { + deb: moment.tz(getPrevDate(), TIMEZONE), + fin: moment.tz(getDate(), TIMEZONE), + }; + const prevAssiduites = etudAssiduites + .filter((assi) => { + const interval = { + deb: moment.tz(assi.date_debut, TIMEZONE), + fin: moment.tz(assi.date_fin, TIMEZONE), + }; + + return hasTimeConflict(period, interval); + }) + .sort((a, b) => { + const a_fin = moment.tz(a.date_fin, TIMEZONE); + const b_fin = moment.tz(b.date_fin, TIMEZONE); + return b_fin < a_fin; + }); + + if (prevAssiduites.length < 1) { + return null; + } + + return prevAssiduites.pop(); +} + +/** + * Récupération de l'état appointé + * @param {HTMLFieldSetElement} field le conteneur des boutons d'assiduité d'une ligne étudiant + * @returns {String} l'état appointé : ('present','absent','retard', 'remove') + * + * état = 'remove' si le clic désélectionne une assiduité appointée + */ +function getAssiduiteValue(field) { + const checkboxs = Array.from(field.children); + let value = "remove"; + checkboxs.forEach((chbox) => { + if (chbox.checked) { + value = chbox.value; + } + }); + + return value; +} + +/** + * Mise à jour des assiduités d'un étudiant + * @param {String | Number} etudid identifiant de l'étudiant + */ +function actualizeEtudAssiduite(etudid, has_formsemestre = true) { + const formsemestre_id = has_formsemestre + ? `formsemestre_id=${getFormSemestreId()}&` + : ""; + const date_debut = toIsoString(getPrevDate()); + const date_fin = toIsoString(getNextDate()); + + const url_api = + getUrl() + + `/api/assiduites/${etudid}/query?${formsemestre_id}date_debut=${date_debut}&date_fin=${date_fin}`; + + sync_get(url_api, (data, status) => { + if (status === "success") { + assiduites[etudid] = data; + } + }); +} + +/** + * Déclenchement d'une action après appuie sur un bouton d'assiduité + * @param {HTMLInputElement} element Bouton d'assiduité appuyé + */ +function assiduiteAction(element) { + const field = element.parentElement; + + const type = field.getAttribute("type"); + const etudid = parseInt(field.getAttribute("etudid")); + const assiduite_id = parseInt(field.getAttribute("assiduite_id")); + const etat = getAssiduiteValue(field); + + // Cas de l'action de masse -> peuplement des queues + if (currentMassAction) { + switch (type) { + case "création": + addToMassActionQueue("creer", { etat: etat, etudid: etudid }); + break; + case "édition": + if (etat === "remove") { + addToMassActionQueue("supprimer", assiduite_id); + } else { + addToMassActionQueue("editer", { + etat: etat, + assiduite_id: assiduite_id, + }); + } + break; + } + } else { + // Cas normal -> mise à jour en base + switch (type) { + case "création": + createAssiduite(etat, etudid); + break; + case "édition": + if (etat === "remove") { + deleteAssiduite(assiduite_id); + } else { + editAssiduite(assiduite_id, etat); + } + break; + case "conflit": + openModal(assiduites[etudid]); + break; + } + + if (type != "conflit") { + document + .querySelector(".toast-holder") + .appendChild( + generateToast( + document.createTextNode("L'assiduité a bien été enregistrée.") + ) + ); + } + + actualizeEtud(etudid, !isSingleEtud); + } +} + +// <<== Gestion de l'affichage des barres étudiant ==>> + +/** + * Génère l'HTML lié à la barre d'un étudiant + * @param {Etudiant} etud représentation objet d'un étudiant + * @param {Number} index l'index de l'étudiant dans la liste + * @param {AssiduitéMod} assiduite Objet représentant l'état de l'étudiant pour la période de la timeline + * @returns {String} l'HTML généré + */ +function generateEtudRow( + etud, + index, + assiduite = { + etatAssiduite: "", + type: "création", + id: -1, + date_debut: null, + date_fin: null, + prevAssiduites: "", + } +) { + // Génération des boutons du choix de l'assiduité + let assi = ""; + ["present", "retard", "absent"].forEach((abs) => { + if (abs.toLowerCase() === assiduite.etatAssiduite.toLowerCase()) { + assi += ``; + } else { + assi += ``; + } + }); + const conflit = assiduite.type == "conflit" ? "conflit" : ""; + const pdp_url = `${getUrl()}/api/etudiant/etudid/${etud.id}/photo?size=small`; + const HTML = `
    + +
    ${index}
    +
    + + + +
    + +

    ${etud.nom}

    +
    ${etud.prenom}
    + +
    + +
    +
    +
    + 13h +
    +
    +
    + + ${assi} + +
    + + +
    `; + + return HTML; +} + +/** + * Insertion de la ligne étudiant + * @param {Etudiant} etud l'objet représentant un étudiant + * @param {Number} index le n° de l'étudiant dans la liste des étudiants + * @param {boolean} output ajout automatique dans la page ou non (default : Non) + * @returns {String} HTML si output sinon rien + */ +function insertEtudRow(etud, index, output = false) { + const etudHolder = document.querySelector(".etud_holder"); + const conflict = getAssiduitesConflict(etud.id); + const prevAssiduite = getLastAssiduiteOfPrevDate(etud.id); + let assiduite = { + etatAssiduite: "", + type: "création", + id: -1, + date_debut: null, + date_fin: null, + prevAssiduites: prevAssiduite, + }; + + if (conflict.length > 0) { + assiduite.etatAssiduite = conflict[0].etat; + + assiduite.id = conflict[0].assiduite_id; + assiduite.date_debut = conflict[0].date_debut; + assiduite.date_fin = conflict[0].date_fin; + if (isConflictSameAsTimeLine(conflict[0])) { + assiduite.type = "édition"; + } else { + assiduite.type = "conflit"; + } + } + let row = generateEtudRow(etud, index, assiduite); + + if (output) { + return row; + } + etudHolder.insertAdjacentHTML("beforeend", row); + + row = document.getElementById(`etud_row_${etud.id}`); + const prev = row.querySelector("#prevDateAssi"); + setupAssiduiteBuble(prev, prevAssiduite); + const bar = row.querySelector(".assiduites_bar"); + + bar.appendChild(createMiniTimeline(assiduites[etud.id])); + + if (!verifyDateInSemester()) { + row.querySelector(".btns_field.single").setAttribute("disabled", "true"); + } +} + +/** + * Création de la minitiline d'un étudiant + * @param {Array[Assiduité]} assiduitesArray + * @returns {HTMLElement} l'élément correspondant à la mini timeline + */ +function createMiniTimeline(assiduitesArray) { + const dateiso = document.getElementById("tl_date").value; + const timeline = document.createElement("div"); + timeline.className = "mini-timeline"; + if (isSingleEtud()) { + timeline.classList.add("single"); + } + const timelineDate = moment(dateiso).startOf("day"); + const dayStart = timelineDate.clone().add(8, "hours"); + const dayEnd = timelineDate.clone().add(18, "hours"); + const dayDuration = moment.duration(dayEnd.diff(dayStart)).asMinutes(); + + assiduitesArray.forEach((assiduité) => { + const startDate = moment(assiduité.date_debut); + const endDate = moment(assiduité.date_fin); + + if (startDate.isBefore(dayStart)) { + startDate.startOf("day").add(8, "hours"); + } + + if (endDate.isAfter(dayEnd)) { + endDate.startOf("day").add(18, "hours"); + } + + const block = document.createElement("div"); + block.className = "mini-timeline-block"; + + const startOffset = moment.duration(startDate.diff(dayStart)).asMinutes(); + const duration = moment.duration(endDate.diff(startDate)).asMinutes(); + const leftPercentage = (startOffset / dayDuration) * 100; + const widthPercentage = (duration / dayDuration) * 100; + + block.style.left = `${leftPercentage}%`; + block.style.width = `${widthPercentage}%`; + + if (isSingleEtud()) { + block.addEventListener("click", () => { + let deb = startDate.hours() + startDate.minutes() / 60; + let fin = endDate.hours() + endDate.minutes() / 60; + deb = Math.max(8, deb); + fin = Math.min(18, fin); + + setPeriodValues(deb, fin); + updateSelectedSelect(getCurrentAssiduiteModuleImplId()); + }); + } + + //ajouter affichage assiduites on over + setupAssiduiteBuble(block, assiduité); + + switch (assiduité.etat) { + case "PRESENT": + block.classList.add("present"); + break; + case "RETARD": + block.classList.add("retard"); + break; + case "ABSENT": + block.classList.add("absent"); + break; + default: + block.style.backgroundColor = "white"; + } + + timeline.appendChild(block); + }); + + return timeline; +} + +/** + * Ajout de la visualisation des assiduités de la mini timeline + * @param {HTMLElement} el l'élément survollé + * @param {Assiduité} assiduite l'assiduité représentée par l'élément + */ +function setupAssiduiteBuble(el, assiduite) { + if (!assiduite) return; + el.addEventListener("mouseenter", (event) => { + const bubble = document.querySelector(".assiduite-bubble"); + bubble.className = "assiduite-bubble"; + bubble.classList.add("is-active", assiduite.etat.toLowerCase()); + + bubble.innerHTML = ""; + + const idDiv = document.createElement("div"); + idDiv.className = "assiduite-id"; + idDiv.textContent = `ID: ${assiduite.assiduite_id}`; + bubble.appendChild(idDiv); + + const periodDivDeb = document.createElement("div"); + periodDivDeb.className = "assiduite-period"; + periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`; + bubble.appendChild(periodDivDeb); + const periodDivFin = document.createElement("div"); + periodDivFin.className = "assiduite-period"; + periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`; + bubble.appendChild(periodDivFin); + + const stateDiv = document.createElement("div"); + stateDiv.className = "assiduite-state"; + stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`; + bubble.appendChild(stateDiv); + + const userIdDiv = document.createElement("div"); + userIdDiv.className = "assiduite-user_id"; + userIdDiv.textContent = `saisi le ${formatDateModal( + assiduite.entry_date, + "à" + )} \npar ${getUserFromId(assiduite.user_id)}`; + bubble.appendChild(userIdDiv); + + bubble.style.left = `${event.clientX - bubble.offsetWidth / 2}px`; + bubble.style.top = `${event.clientY + 20}px`; + }); + el.addEventListener("mouseout", () => { + const bubble = document.querySelector(".assiduite-bubble"); + bubble.classList.remove("is-active"); + }); +} + +/** + * Mise à jour d'une ligne étudiant + * @param {String | Number} etudid l'identifiant de l'étudiant + */ +function actualizeEtud(etudid) { + actualizeEtudAssiduite(etudid, !isSingleEtud()); + //Actualize row + const etudHolder = document.querySelector(".etud_holder"); + const ancient_row = document.getElementById(`etud_row_${etudid}`); + + let new_row = document.createElement("div"); + new_row.innerHTML = insertEtudRow( + etuds[etudid], + ancient_row.querySelector(".index").textContent, + true + ); + setupCheckBox(new_row.firstElementChild); + const bar = new_row.firstElementChild.querySelector(".assiduites_bar"); + bar.appendChild(createMiniTimeline(assiduites[etudid])); + const prev = new_row.firstElementChild.querySelector("#prevDateAssi"); + if (isSingleEtud()) { + prev.classList.add("single"); + } + setupAssiduiteBuble(prev, getLastAssiduiteOfPrevDate(etudid)); + etudHolder.replaceChild(new_row.firstElementChild, ancient_row); +} + +/** + * Génération de toutes les lignes étudiant + */ +function generateAllEtudRow() { + if (isSingleEtud()) { + actualizeEtud(etudid); + return; + } + + if (!document.querySelector(".selectors")?.disabled) { + return; + } + + document.querySelector(".etud_holder").innerHTML = ""; + etuds_ids = Object.keys(etuds).sort((a, b) => + etuds[a].nom > etuds[b].nom ? 1 : etuds[b].nom > etuds[a].nom ? -1 : 0 + ); + + for (let i = 0; i < etuds_ids.length; i++) { + const etud = etuds[etuds_ids[i]]; + insertEtudRow(etud, i + 1); + } + + setupCheckBox(); +} + +// <== Gestion du modal de conflit ==> +/** + * Mise à jour du modal de conflit + * @param {Array[Assiduité]} assiduiteList Liste des assiduités de l'étudiant + */ +function refreshModal(assiduiteList) { + const tlTime = getTimeLineTimes(); + + renderTimeline(assiduiteList, { + date_debut: tlTime.deb, + date_fin: tlTime.fin, + }); +} +/** + * Ouverture du modal de conflit + * @param {Array[Assiduité]} assiduiteList Liste des assiduités de l'étudiant + */ +function openModal(assiduiteList) { + modal.style.display = "block"; + + const tlTime = getTimeLineTimes(); + + renderTimeline(assiduiteList, { + date_debut: tlTime.deb, + date_fin: tlTime.fin, + }); +} + +/** + * Fermeture du modal de conflit + */ +function closeModal() { + modal.style.display = "none"; +} + +/** + * Génération du modal + * @param {Array[Assiduité]} assiduites la liste des assiduités à afficher + * @param {Période} specialAssiduite Une assiduité représentant la période conflictuelle + */ +function renderTimeline(assiduites, specialAssiduite) { + const timeLabels = document.querySelector(".time-labels"); + const assiduitesContainer = document.querySelector(".assiduites-container"); + + timeLabels.innerHTML = ""; + assiduitesContainer.innerHTML = '
    '; + + // Ajout des labels d'heure sur la frise chronologique + // TODO permettre la modification des bornes (8 et 18) + for (let i = 8; i <= 18; i++) { + const timeLabel = document.createElement("div"); + timeLabel.className = "time-label"; + timeLabel.textContent = i < 10 ? `0${i}:00` : `${i}:00`; + timeLabels.appendChild(timeLabel); + } + + //Placement de la période conflictuelle sur la timeline + const specialAssiduiteEl = document.querySelector(".assiduite-special"); + specialAssiduiteEl.style.width = getWidth( + specialAssiduite.date_debut, + specialAssiduite.date_fin + ); + specialAssiduiteEl.style.left = getLeftPosition(specialAssiduite.date_debut); + specialAssiduiteEl.style.top = "0"; + specialAssiduiteEl.style.zIndex = "0"; // Place l'assiduité spéciale en arrière-plan + assiduitesContainer.appendChild(specialAssiduiteEl); + + //Placement des assiduités sur la timeline + assiduites.forEach((assiduite) => { + const el = document.createElement("div"); + el.className = "assiduite"; + el.style.backgroundColor = getColor(assiduite.etat); + el.style.width = getWidth(assiduite.date_debut, assiduite.date_fin); + el.style.left = getLeftPosition(assiduite.date_debut); + el.style.top = "10px"; + el.setAttribute("data-id", assiduite.assiduite_id); + el.addEventListener("click", () => selectAssiduite(assiduite)); + + // Ajout des informations dans la visualisation d'une assiduité + const infoContainer = document.createElement("div"); + infoContainer.className = "assiduite-info"; + + const idDiv = document.createElement("div"); + idDiv.className = "assiduite-id"; + idDiv.textContent = `ID: ${assiduite.assiduite_id}`; + infoContainer.appendChild(idDiv); + + const periodDivDeb = document.createElement("div"); + periodDivDeb.className = "assiduite-period"; + periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`; + infoContainer.appendChild(periodDivDeb); + const periodDivFin = document.createElement("div"); + periodDivFin.className = "assiduite-period"; + periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`; + infoContainer.appendChild(periodDivFin); + + const stateDiv = document.createElement("div"); + stateDiv.className = "assiduite-state"; + stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`; + infoContainer.appendChild(stateDiv); + + const userIdDiv = document.createElement("div"); + userIdDiv.className = "assiduite-user_id"; + userIdDiv.textContent = `saisi le ${formatDateModal( + assiduite.entry_date, + "à" + )} \npar ${getUserFromId(assiduite.user_id)}`; + infoContainer.appendChild(userIdDiv); + + el.appendChild(infoContainer); + assiduitesContainer.appendChild(el); + }); +} + +/** + * Transformation d'une date de début en position sur la timeline + * @param {String} start + * @returns {String} un déplacement par rapport à la gauche en % + */ +function getLeftPosition(start) { + const startTime = new moment.tz(start, TIMEZONE); + const startMins = (startTime.hours() - 8) * 60 + startTime.minutes(); + return (startMins / (18 * 60 - 8 * 60)) * 100 + "%"; +} + +/** + * Ajustement de l'espacement vertical entre les assiduités superposées + * @param {HTMLElement} container le conteneur des assiduités + * @param {String} start la date début de l'assiduité à placer + * @param {String} end la date de fin de l'assiduité à placer + * @returns {String} La position en px + */ +function getTopPosition(container, start, end) { + const overlaps = (a, b) => { + return a.start < b.end && a.end > b.start; + }; + + const startTime = new moment.tz(start, TIMEZONE); + const endTime = new moment.tz(end, TIMEZONE); + const assiduiteDuration = { start: startTime, end: endTime }; + + let position = 0; + let hasOverlap = true; + + while (hasOverlap) { + hasOverlap = false; + Array.from(container.children).some((el) => { + const elStart = new moment.tz(el.getAttribute("data-start")); + const elEnd = new moment.tz(el.getAttribute("data-end")); + const elDuration = { start: elStart, end: elEnd }; + + if (overlaps(assiduiteDuration, elDuration)) { + position += 25; // Pour ajuster l'espacement vertical entre les assiduités superposées + hasOverlap = true; + return true; + } + return false; + }); + } + return position + "px"; +} + +/** + * Transformation d'un état en couleur + * @param {String} state l'état + * @returns {String} la couleur correspondant à l'état + */ +function getColor(state) { + switch (state) { + case "PRESENT": + return "#37f05f"; + case "ABSENT": + return "#ec5c49"; + case "RETARD": + return "#ecb52a"; + default: + return "gray"; + } +} + +/** + * Calcule de la largeur de l'assiduité sur la timeline + * @param {String} start date iso de début + * @param {String} end date iso de fin + * @returns {String} la taille en % + */ +function getWidth(start, end) { + const startTime = new moment.tz(start, TIMEZONE); + const endTime = new moment.tz(end, TIMEZONE); + const duration = (endTime - startTime) / 1000 / 60; + return (duration / (18 * 60 - 8 * 60)) * 100 + "%"; +} + +/** + * Sélection d'une assiduité sur la timeline + * @param {Assiduité} assiduite l'assiduité sélectionnée + */ +function selectAssiduite(assiduite) { + // Désélectionner l'assiduité précédemment sélectionnée + if (selectedAssiduite) { + const prevSelectedEl = document.querySelector( + `.assiduite[data-id="${selectedAssiduite.assiduite_id}"]` + ); + if (prevSelectedEl) { + prevSelectedEl.classList.remove("selected"); + } + } + + // Sélectionner la nouvelle assiduité + selectedAssiduite = assiduite; + const selectedEl = document.querySelector( + `.assiduite[data-id="${assiduite.assiduite_id}"]` + ); + if (selectedEl) { + selectedEl.classList.add("selected"); + } + + //Mise à jour de la partie information du modal + const selectedModal = document.querySelector(".modal-assiduite-content"); + + selectedModal.classList.add("show"); + + document.getElementById("modal-assiduite-id").textContent = + assiduite.assiduite_id; + document.getElementById( + "modal-assiduite-user" + ).textContent = `saisi le ${formatDateModal( + assiduite.entry_date, + "à" + )} \npar ${getUserFromId(assiduite.user_id)}`; + document.getElementById("modal-assiduite-module").textContent = + assiduite.moduleimpl_id; + document.getElementById("modal-assiduite-deb").textContent = formatDateModal( + assiduite.date_debut + ); + document.getElementById("modal-assiduite-fin").textContent = formatDateModal( + assiduite.date_fin + ); + document.getElementById("modal-assiduite-etat").textContent = + assiduite.etat.capitalize(); + + //Activation des boutons d'actions de conflit + deleteBtn.disabled = false; + splitBtn.disabled = false; + editBtn.disabled = false; +} +/** + * Suppression de l'assiduité sélectionnée + */ +function deleteAssiduiteModal() { + if (!selectedAssiduite) return; + deleteAssiduite(selectedAssiduite.assiduite_id); + actualizeEtud(selectedAssiduite.etudid); + refreshModal(assiduites[selectedAssiduite.etudid]); + + // Désélection de l'assiduité + resetSelection(); +} + +/** + * Division d'une assiduité + * @param {Assiduité} assiduite l'assiduité sélectionnée + */ +function splitAssiduiteModal(assiduite) { + //Préparation du prompt + const htmlPrompt = `Entrez l'heure de séparation (HH:mm) : + `; + + const fieldSet = document.createElement("fieldset"); + fieldSet.classList.add("fieldsplit"); + fieldSet.innerHTML = htmlPrompt; + + //Callback de division + const success = () => { + const separatorTime = document.getElementById("promptTime").value; + const dateString = + document.querySelector("#tl_date").value + `T${separatorTime}`; + const separtorDate = new moment.tz(dateString, TIMEZONE); + + const assiduite_debut = new moment.tz(assiduite.date_debut, TIMEZONE); + const assiduite_fin = new moment.tz(assiduite.date_fin, TIMEZONE); + + if ( + separtorDate.isAfter(assiduite_debut) && + separtorDate.isBefore(assiduite_fin) + ) { + const assiduite_avant = { + etat: assiduite.etat, + date_debut: assiduite_debut.format(), + date_fin: separtorDate.format(), + }; + + const assiduite_apres = { + etat: assiduite.etat, + date_debut: separtorDate.format(), + date_fin: assiduite_fin.format(), + }; + + if (assiduite.moduleimpl_id) { + assiduite_apres["moduleimpl_id"] = assiduite.moduleimpl_id; + assiduite_avant["moduleimpl_id"] = assiduite.moduleimpl_id; + } + + deleteAssiduite(assiduite.assiduite_id); + + const path = getUrl() + `/api/assiduite/${assiduite.etudid}/create`; + sync_post( + path, + [assiduite_avant, assiduite_apres], + (data, status) => { + //success + }, + (data, status) => { + //error + console.error(data, status); + } + ); + + actualizeEtud(assiduite.etudid); + refreshModal(assiduites[assiduite.etudid]); + resetSelection(); + } else { + const att = document.createTextNode( + "L'heure de séparation doit être compris dans la période de l'assiduité sélectionnée." + ); + + openAlertModal("Attention", att, "", "#ecb52a"); + } + }; + + openPromptModal("Entrée demandée", fieldSet, success, () => {}, "#37f05f"); +} +/** + * Modification d'une assiduité conflictuelle + * @param {Assiduité} selectedAssiduite l'assiduité sélectionnée + */ +function editAssiduiteModal(selectedAssiduite) { + if (!selectedAssiduite) return; + + //Préparation du modal d'édition + const htmlPrompt = `Entrez l'état de l'assiduité : + `; + + const fieldSet = document.createElement("fieldset"); + fieldSet.classList.add("fieldsplit"); + fieldSet.innerHTML = htmlPrompt; + + //Callback d'action d'édition + const success = () => { + const newState = document.getElementById("promptSelect").value; + if (!["present", "absent", "retard"].includes(newState.toLowerCase())) { + const att = document.createTextNode( + "L'état doit être 'present', 'absent' ou 'retard'." + ); + openAlertModal("Attention", att, "", "#ecb52a"); + return; + } + + // Actualiser l'affichage + + editAssiduite(selectedAssiduite.assiduite_id, newState); + actualizeEtud(selectedAssiduite.etudid); + refreshModal(assiduites[selectedAssiduite.etudid]); + + // Désélection de l'assiduité + resetSelection(); + }; + + //Affichage du prompt + openPromptModal("Entrée demandée", fieldSet, success, () => {}, "#37f05f"); +} + +/** + * Remise à zéro de la sélection + * Désactivation des boutons d'actions de conflit + */ +function resetSelection() { + selectedAssiduite = null; + deleteBtn.disabled = true; + splitBtn.disabled = true; + editBtn.disabled = true; + + document.querySelector(".modal-assiduite-content").classList.remove("show"); +} +/** + * Ajout des évents sur les boutons du modal + */ +window.onload = () => { + modal = document.getElementById("myModal"); + closeBtn = document.querySelector(".close"); + timeline = document.getElementById("timeline"); + deleteBtn = document.getElementById("delete"); + splitBtn = document.getElementById("split"); + editBtn = document.getElementById("edit"); + selectedAssiduite = null; + + closeBtn?.addEventListener("click", closeModal); + + deleteBtn?.addEventListener("click", deleteAssiduiteModal); + splitBtn?.addEventListener("click", () => { + if (selectedAssiduite) { + splitAssiduiteModal(selectedAssiduite); + } + }); + editBtn.addEventListener("click", () => { + if (selectedAssiduite) { + editAssiduiteModal(selectedAssiduite); + } + }); +}; + +// <<== Gestion de la récupération d'informations ==>> + +/** + * Récupération d'un nom d'utilisateur à partir d'un identifiant + * @param {Number} id identifiant de l'utilisateur + * @returns {String} le nom de l'utilisateur ou son pseudo ou "Non Renseigné" + */ +function getUserFromId(id) { + if (id == "") { + return "Non Renseigné"; + } + + let name = "Non Renseigné"; + + sync_get(`/ScoDoc/api/user/${id}`, (data) => { + if (data.nom != "" && data.prenom != "") { + name = `${data.nom} ${data.prenom}`; + } else { + name = data.user_name; + } + }); + + return name; +} + +/** + * Récupération des ids des groupes + * @returns la liste des ids des groupes + */ +function getGroupIds() { + const btns = document.querySelector(".multiselect-container.dropdown-menu"); + + const groups = Array.from(btns.querySelectorAll(".active")).map((el) => { + return el.querySelector("input").value; + }); + + return groups; +} + +/** + * Récupération du moduleimpl_id + * @returns {String} l'identifiant ou null si inéxistant + */ +function getModuleImplId() { + const val = document.querySelector("#moduleimpl_select")?.value; + return ["", undefined, null].includes(val) ? null : val; +} + +/** + * Récupération de l'id du formsemestre + * @returns {String} l'identifiant du formsemestre + */ +function getFormSemestreId() { + return document.querySelector(".formsemestre_id").textContent; +} + +/** + * Récupère la période du semestre + * @returns {object} période {deb,fin} + */ +function getFormSemestreDates() { + const dateDeb = document.getElementById( + "formsemestre_date_debut" + ).textContent; + const dateFin = document.getElementById("formsemestre_date_fin").textContent; + + return { + deb: dateDeb, + fin: dateFin, + }; +} + +/** + * Récupère un objet étudiant à partir de son id + * @param {Number} etudid + */ +function getSingleEtud(etudid) { + sync_get(getUrl() + `/api/etudiant/etudid/${etudid}`, (data) => { + etuds[etudid] = data; + }); +} + +function isSingleEtud() { + return location.href.includes("SignaleAssiduiteEtud"); +} + +function getCurrentAssiduiteModuleImplId() { + const currentAssiduites = getAssiduitesConflict(etudid); + if (currentAssiduites.length > 0) { + const mod = currentAssiduites[0].moduleimpl_id; + return mod == null ? "" : mod; + } + return ""; +} + +function getCurrentAssiduite(etudid) { + const field = document.querySelector( + `fieldset.btns_field.single[etudid='${etudid}']` + ); + + if (!field) return null; + + const assiduite_id = parseInt(field.getAttribute("assiduite_id")); + const type = field.getAttribute("type"); + + if (type == "edition") { + let assi = null; + assiduites[etudid].forEach((a) => { + if (a.assiduite_id === assiduite_id) { + assi = a; + } + }); + return assi; + } else { + return null; + } +} + +// <<== Gestion de la justification ==>> + +function getJustificatifFromPeriod(date) { + let justifs = []; + sync_get( + getUrl() + + `/api/justificatifs/${etudid}/query?date_debut=${date.deb.format()}&date_fin=${date.fin.format()}`, + (data) => { + justifs = data; + } + ); + + return justifs; +} + +function updateJustifieButton(isJustified, isDisabled = true) { + const btn = document.getElementById("justif-rapide"); + if (isJustified) { + btn.classList.add("justifie"); + } else { + btn.classList.remove("justifie"); + } + + if (isDisabled) { + btn.setAttribute("disabled", "true"); + } else { + btn.removeAttribute("disabled"); + } +} + +function fastJustify(assiduite) { + const period = { + deb: new moment.tz(assiduite.date_debut, TIMEZONE), + fin: new moment.tz(assiduite.date_fin, TIMEZONE), + }; + const justifs = getJustificatifFromPeriod(period); + + if (justifs.length > 0) { + //modifier l'assiduité + } else { + //créer un nouveau justificatif + // Afficher prompt -> demander raison et état + } +} diff --git a/app/static/libjs/moment-timezone.js b/app/static/libjs/moment-timezone.js new file mode 100644 index 0000000000..56fc279973 --- /dev/null +++ b/app/static/libjs/moment-timezone.js @@ -0,0 +1,1597 @@ +//! moment-timezone.js +//! version : 0.5.40 +//! Copyright (c) JS Foundation and other contributors +//! license : MIT +//! github.com/moment/moment-timezone + +(function (root, factory) { + "use strict"; + + /*global define*/ + if (typeof module === "object" && module.exports) { + module.exports = factory(require("moment")); // Node + } else if (typeof define === "function" && define.amd) { + define(["moment"], factory); // AMD + } else { + factory(root.moment); // Browser + } +})(this, function (moment) { + "use strict"; + + // Resolves es6 module loading issue + if (moment.version === undefined && moment.default) { + moment = moment.default; + } + + // Do not load moment-timezone a second time. + // if (moment.tz !== undefined) { + // logError('Moment Timezone ' + moment.tz.version + ' was already loaded ' + (moment.tz.dataVersion ? 'with data from ' : 'without any data') + moment.tz.dataVersion); + // return moment; + // } + + var VERSION = "0.5.40", + zones = {}, + links = {}, + countries = {}, + names = {}, + guesses = {}, + cachedGuess; + + if (!moment || typeof moment.version !== "string") { + logError( + "Moment Timezone requires Moment.js. See https://momentjs.com/timezone/docs/#/use-it/browser/" + ); + } + + var momentVersion = moment.version.split("."), + major = +momentVersion[0], + minor = +momentVersion[1]; + + // Moment.js version check + if (major < 2 || (major === 2 && minor < 6)) { + logError( + "Moment Timezone requires Moment.js >= 2.6.0. You are using Moment.js " + + moment.version + + ". See momentjs.com" + ); + } + + /************************************ + Unpacking + ************************************/ + + function charCodeToInt(charCode) { + if (charCode > 96) { + return charCode - 87; + } else if (charCode > 64) { + return charCode - 29; + } + return charCode - 48; + } + + function unpackBase60(string) { + var i = 0, + parts = string.split("."), + whole = parts[0], + fractional = parts[1] || "", + multiplier = 1, + num, + out = 0, + sign = 1; + + // handle negative numbers + if (string.charCodeAt(0) === 45) { + i = 1; + sign = -1; + } + + // handle digits before the decimal + for (i; i < whole.length; i++) { + num = charCodeToInt(whole.charCodeAt(i)); + out = 60 * out + num; + } + + // handle digits after the decimal + for (i = 0; i < fractional.length; i++) { + multiplier = multiplier / 60; + num = charCodeToInt(fractional.charCodeAt(i)); + out += num * multiplier; + } + + return out * sign; + } + + function arrayToInt(array) { + for (var i = 0; i < array.length; i++) { + array[i] = unpackBase60(array[i]); + } + } + + function intToUntil(array, length) { + for (var i = 0; i < length; i++) { + array[i] = Math.round((array[i - 1] || 0) + array[i] * 60000); // minutes to milliseconds + } + + array[length - 1] = Infinity; + } + + function mapIndices(source, indices) { + var out = [], + i; + + for (i = 0; i < indices.length; i++) { + out[i] = source[indices[i]]; + } + + return out; + } + + function unpack(string) { + var data = string.split("|"), + offsets = data[2].split(" "), + indices = data[3].split(""), + untils = data[4].split(" "); + + arrayToInt(offsets); + arrayToInt(indices); + arrayToInt(untils); + + intToUntil(untils, indices.length); + + return { + name: data[0], + abbrs: mapIndices(data[1].split(" "), indices), + offsets: mapIndices(offsets, indices), + untils: untils, + population: data[5] | 0, + }; + } + + /************************************ + Zone object + ************************************/ + + function Zone(packedString) { + if (packedString) { + this._set(unpack(packedString)); + } + } + + Zone.prototype = { + _set: function (unpacked) { + this.name = unpacked.name; + this.abbrs = unpacked.abbrs; + this.untils = unpacked.untils; + this.offsets = unpacked.offsets; + this.population = unpacked.population; + }, + + _index: function (timestamp) { + var target = +timestamp, + untils = this.untils, + i; + + for (i = 0; i < untils.length; i++) { + if (target < untils[i]) { + return i; + } + } + }, + + countries: function () { + var zone_name = this.name; + return Object.keys(countries).filter(function (country_code) { + return countries[country_code].zones.indexOf(zone_name) !== -1; + }); + }, + + parse: function (timestamp) { + var target = +timestamp, + offsets = this.offsets, + untils = this.untils, + max = untils.length - 1, + offset, + offsetNext, + offsetPrev, + i; + + for (i = 0; i < max; i++) { + offset = offsets[i]; + offsetNext = offsets[i + 1]; + offsetPrev = offsets[i ? i - 1 : i]; + + if (offset < offsetNext && tz.moveAmbiguousForward) { + offset = offsetNext; + } else if (offset > offsetPrev && tz.moveInvalidForward) { + offset = offsetPrev; + } + + if (target < untils[i] - offset * 60000) { + return offsets[i]; + } + } + + return offsets[max]; + }, + + abbr: function (mom) { + return this.abbrs[this._index(mom)]; + }, + + offset: function (mom) { + logError("zone.offset has been deprecated in favor of zone.utcOffset"); + return this.offsets[this._index(mom)]; + }, + + utcOffset: function (mom) { + return this.offsets[this._index(mom)]; + }, + }; + + /************************************ + Country object + ************************************/ + + function Country(country_name, zone_names) { + this.name = country_name; + this.zones = zone_names; + } + + /************************************ + Current Timezone + ************************************/ + + function OffsetAt(at) { + var timeString = at.toTimeString(); + var abbr = timeString.match(/\([a-z ]+\)/i); + if (abbr && abbr[0]) { + // 17:56:31 GMT-0600 (CST) + // 17:56:31 GMT-0600 (Central Standard Time) + abbr = abbr[0].match(/[A-Z]/g); + abbr = abbr ? abbr.join("") : undefined; + } else { + // 17:56:31 CST + // 17:56:31 GMT+0800 (台北標準時間) + abbr = timeString.match(/[A-Z]{3,5}/g); + abbr = abbr ? abbr[0] : undefined; + } + + if (abbr === "GMT") { + abbr = undefined; + } + + this.at = +at; + this.abbr = abbr; + this.offset = at.getTimezoneOffset(); + } + + function ZoneScore(zone) { + this.zone = zone; + this.offsetScore = 0; + this.abbrScore = 0; + } + + ZoneScore.prototype.scoreOffsetAt = function (offsetAt) { + this.offsetScore += Math.abs( + this.zone.utcOffset(offsetAt.at) - offsetAt.offset + ); + if (this.zone.abbr(offsetAt.at).replace(/[^A-Z]/g, "") !== offsetAt.abbr) { + this.abbrScore++; + } + }; + + function findChange(low, high) { + var mid, diff; + + while ((diff = (((high.at - low.at) / 12e4) | 0) * 6e4)) { + mid = new OffsetAt(new Date(low.at + diff)); + if (mid.offset === low.offset) { + low = mid; + } else { + high = mid; + } + } + + return low; + } + + function userOffsets() { + var startYear = new Date().getFullYear() - 2, + last = new OffsetAt(new Date(startYear, 0, 1)), + offsets = [last], + change, + next, + i; + + for (i = 1; i < 48; i++) { + next = new OffsetAt(new Date(startYear, i, 1)); + if (next.offset !== last.offset) { + change = findChange(last, next); + offsets.push(change); + offsets.push(new OffsetAt(new Date(change.at + 6e4))); + } + last = next; + } + + for (i = 0; i < 4; i++) { + offsets.push(new OffsetAt(new Date(startYear + i, 0, 1))); + offsets.push(new OffsetAt(new Date(startYear + i, 6, 1))); + } + + return offsets; + } + + function sortZoneScores(a, b) { + if (a.offsetScore !== b.offsetScore) { + return a.offsetScore - b.offsetScore; + } + if (a.abbrScore !== b.abbrScore) { + return a.abbrScore - b.abbrScore; + } + if (a.zone.population !== b.zone.population) { + return b.zone.population - a.zone.population; + } + return b.zone.name.localeCompare(a.zone.name); + } + + function addToGuesses(name, offsets) { + var i, offset; + arrayToInt(offsets); + for (i = 0; i < offsets.length; i++) { + offset = offsets[i]; + guesses[offset] = guesses[offset] || {}; + guesses[offset][name] = true; + } + } + + function guessesForUserOffsets(offsets) { + var offsetsLength = offsets.length, + filteredGuesses = {}, + out = [], + i, + j, + guessesOffset; + + for (i = 0; i < offsetsLength; i++) { + guessesOffset = guesses[offsets[i].offset] || {}; + for (j in guessesOffset) { + if (guessesOffset.hasOwnProperty(j)) { + filteredGuesses[j] = true; + } + } + } + + for (i in filteredGuesses) { + if (filteredGuesses.hasOwnProperty(i)) { + out.push(names[i]); + } + } + + return out; + } + + function rebuildGuess() { + // use Intl API when available and returning valid time zone + try { + var intlName = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (intlName && intlName.length > 3) { + var name = names[normalizeName(intlName)]; + if (name) { + return name; + } + logError( + "Moment Timezone found " + + intlName + + " from the Intl api, but did not have that data loaded." + ); + } + } catch (e) { + // Intl unavailable, fall back to manual guessing. + } + + var offsets = userOffsets(), + offsetsLength = offsets.length, + guesses = guessesForUserOffsets(offsets), + zoneScores = [], + zoneScore, + i, + j; + + for (i = 0; i < guesses.length; i++) { + zoneScore = new ZoneScore(getZone(guesses[i]), offsetsLength); + for (j = 0; j < offsetsLength; j++) { + zoneScore.scoreOffsetAt(offsets[j]); + } + zoneScores.push(zoneScore); + } + + zoneScores.sort(sortZoneScores); + + return zoneScores.length > 0 ? zoneScores[0].zone.name : undefined; + } + + function guess(ignoreCache) { + if (!cachedGuess || ignoreCache) { + cachedGuess = rebuildGuess(); + } + return cachedGuess; + } + + /************************************ + Global Methods + ************************************/ + + function normalizeName(name) { + return (name || "").toLowerCase().replace(/\//g, "_"); + } + + function addZone(packed) { + var i, name, split, normalized; + + if (typeof packed === "string") { + packed = [packed]; + } + + for (i = 0; i < packed.length; i++) { + split = packed[i].split("|"); + name = split[0]; + normalized = normalizeName(name); + zones[normalized] = packed[i]; + names[normalized] = name; + addToGuesses(normalized, split[2].split(" ")); + } + } + + function getZone(name, caller) { + name = normalizeName(name); + + var zone = zones[name]; + var link; + + if (zone instanceof Zone) { + return zone; + } + + if (typeof zone === "string") { + zone = new Zone(zone); + zones[name] = zone; + return zone; + } + + // Pass getZone to prevent recursion more than 1 level deep + if ( + links[name] && + caller !== getZone && + (link = getZone(links[name], getZone)) + ) { + zone = zones[name] = new Zone(); + zone._set(link); + zone.name = names[name]; + return zone; + } + + return null; + } + + function getNames() { + var i, + out = []; + + for (i in names) { + if ( + names.hasOwnProperty(i) && + (zones[i] || zones[links[i]]) && + names[i] + ) { + out.push(names[i]); + } + } + + return out.sort(); + } + + function getCountryNames() { + return Object.keys(countries); + } + + function addLink(aliases) { + var i, alias, normal0, normal1; + + if (typeof aliases === "string") { + aliases = [aliases]; + } + + for (i = 0; i < aliases.length; i++) { + alias = aliases[i].split("|"); + + normal0 = normalizeName(alias[0]); + normal1 = normalizeName(alias[1]); + + links[normal0] = normal1; + names[normal0] = alias[0]; + + links[normal1] = normal0; + names[normal1] = alias[1]; + } + } + + function addCountries(data) { + var i, country_code, country_zones, split; + if (!data || !data.length) return; + for (i = 0; i < data.length; i++) { + split = data[i].split("|"); + country_code = split[0].toUpperCase(); + country_zones = split[1].split(" "); + countries[country_code] = new Country(country_code, country_zones); + } + } + + function getCountry(name) { + name = name.toUpperCase(); + return countries[name] || null; + } + + function zonesForCountry(country, with_offset) { + country = getCountry(country); + + if (!country) return null; + + var zones = country.zones.sort(); + + if (with_offset) { + return zones.map(function (zone_name) { + var zone = getZone(zone_name); + return { + name: zone_name, + offset: zone.utcOffset(new Date()), + }; + }); + } + + return zones; + } + + function loadData(data) { + addZone(data.zones); + addLink(data.links); + addCountries(data.countries); + tz.dataVersion = data.version; + } + + function zoneExists(name) { + if (!zoneExists.didShowError) { + zoneExists.didShowError = true; + logError( + "moment.tz.zoneExists('" + + name + + "') has been deprecated in favor of !moment.tz.zone('" + + name + + "')" + ); + } + return !!getZone(name); + } + + function needsOffset(m) { + var isUnixTimestamp = m._f === "X" || m._f === "x"; + return !!(m._a && m._tzm === undefined && !isUnixTimestamp); + } + + function logError(message) { + if (typeof console !== "undefined" && typeof console.error === "function") { + console.error(message); + } + } + + /************************************ + moment.tz namespace + ************************************/ + + function tz(input) { + var args = Array.prototype.slice.call(arguments, 0, -1), + name = arguments[arguments.length - 1], + zone = getZone(name), + out = moment.utc.apply(null, args); + + if (zone && !moment.isMoment(input) && needsOffset(out)) { + out.add(zone.parse(out), "minutes"); + } + + out.tz(name); + + return out; + } + + tz.version = VERSION; + tz.dataVersion = ""; + tz._zones = zones; + tz._links = links; + tz._names = names; + tz._countries = countries; + tz.add = addZone; + tz.link = addLink; + tz.load = loadData; + tz.zone = getZone; + tz.zoneExists = zoneExists; // deprecated in 0.1.0 + tz.guess = guess; + tz.names = getNames; + tz.Zone = Zone; + tz.unpack = unpack; + tz.unpackBase60 = unpackBase60; + tz.needsOffset = needsOffset; + tz.moveInvalidForward = true; + tz.moveAmbiguousForward = false; + tz.countries = getCountryNames; + tz.zonesForCountry = zonesForCountry; + + /************************************ + Interface with Moment.js + ************************************/ + + var fn = moment.fn; + + moment.tz = tz; + + moment.defaultZone = null; + + moment.updateOffset = function (mom, keepTime) { + var zone = moment.defaultZone, + offset; + + if (mom._z === undefined) { + if (zone && needsOffset(mom) && !mom._isUTC) { + mom._d = moment.utc(mom._a)._d; + mom.utc().add(zone.parse(mom), "minutes"); + } + mom._z = zone; + } + if (mom._z) { + offset = mom._z.utcOffset(mom); + if (Math.abs(offset) < 16) { + offset = offset / 60; + } + if (mom.utcOffset !== undefined) { + var z = mom._z; + mom.utcOffset(-offset, keepTime); + mom._z = z; + } else { + mom.zone(offset, keepTime); + } + } + }; + + fn.tz = function (name, keepTime) { + if (name) { + if (typeof name !== "string") { + throw new Error( + "Time zone name must be a string, got " + + name + + " [" + + typeof name + + "]" + ); + } + this._z = getZone(name); + if (this._z) { + moment.updateOffset(this, keepTime); + } else { + logError( + "Moment Timezone has no data for " + + name + + ". See http://momentjs.com/timezone/docs/#/data-loading/." + ); + } + return this; + } + if (this._z) { + return this._z.name; + } + }; + + function abbrWrap(old) { + return function () { + if (this._z) { + return this._z.abbr(this); + } + return old.call(this); + }; + } + + function resetZoneWrap(old) { + return function () { + this._z = null; + return old.apply(this, arguments); + }; + } + + function resetZoneWrap2(old) { + return function () { + if (arguments.length > 0) this._z = null; + return old.apply(this, arguments); + }; + } + + fn.zoneName = abbrWrap(fn.zoneName); + fn.zoneAbbr = abbrWrap(fn.zoneAbbr); + fn.utc = resetZoneWrap(fn.utc); + fn.local = resetZoneWrap(fn.local); + fn.utcOffset = resetZoneWrap2(fn.utcOffset); + + moment.tz.setDefault = function (name) { + if (major < 2 || (major === 2 && minor < 9)) { + logError( + "Moment Timezone setDefault() requires Moment.js >= 2.9.0. You are using Moment.js " + + moment.version + + "." + ); + } + moment.defaultZone = name ? getZone(name) : null; + return moment; + }; + + // Cloning a moment should include the _z property. + var momentProperties = moment.momentProperties; + if (Object.prototype.toString.call(momentProperties) === "[object Array]") { + // moment 2.8.1+ + momentProperties.push("_z"); + momentProperties.push("_a"); + } else if (momentProperties) { + // moment 2.7.0 + momentProperties._z = null; + } + + loadData({ + version: "2022g", + zones: [ + "Africa/Abidjan|GMT|0|0||48e5", + "Africa/Nairobi|EAT|-30|0||47e5", + "Africa/Algiers|CET|-10|0||26e5", + "Africa/Lagos|WAT|-10|0||17e6", + "Africa/Maputo|CAT|-20|0||26e5", + "Africa/Cairo|EET|-20|0||15e6", + "Africa/Casablanca|+00 +01|0 -10|01010101010101010101010101|1T0q0 mo0 gM0 LA0 WM0 jA0 e00 28M0 e00 2600 gM0 2600 e00 2600 gM0 2600 gM0 2600 e00 2600 gM0 2600 e00 28M0 e00|32e5", + "Europe/Paris|CET CEST|-10 -20|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|11e6", + "Africa/Johannesburg|SAST|-20|0||84e5", + "Africa/Juba|EAT CAT|-30 -20|01|24nx0|", + "Africa/Khartoum|EAT CAT|-30 -20|01|1Usl0|51e5", + "Africa/Sao_Tome|GMT WAT|0 -10|010|1UQN0 2q00|", + "Africa/Windhoek|CAT WAT|-20 -10|010|1T3c0 11B0|32e4", + "America/Adak|HST HDT|a0 90|01010101010101010101010|1ST00 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|326", + "America/Anchorage|AKST AKDT|90 80|01010101010101010101010|1SSX0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|30e4", + "America/Santo_Domingo|AST|40|0||29e5", + "America/Fortaleza|-03|30|0||34e5", + "America/Asuncion|-03 -04|30 40|01010101010101010101010|1T0r0 1fB0 19X0 1ip0 17b0 1ip0 17b0 1ip0 19X0 1fB0 19X0 1fB0 19X0 1fB0 19X0 1ip0 17b0 1ip0 17b0 1ip0 19X0 1fB0|28e5", + "America/Panama|EST|50|0||15e5", + "America/Mexico_City|CST CDT|60 50|0101010101010|1T3k0 1nX0 11B0 1nX0 14p0 1lb0 14p0 1lb0 14p0 1nX0 11B0 1nX0|20e6", + "America/Managua|CST|60|0||22e5", + "America/Caracas|-04|40|0||29e5", + "America/Lima|-05|50|0||11e6", + "America/Denver|MST MDT|70 60|01010101010101010101010|1SSV0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|26e5", + "America/Campo_Grande|-03 -04|30 40|010101|1SKr0 1zd0 On0 1HB0 FX0|77e4", + "America/Chicago|CST CDT|60 50|01010101010101010101010|1SSU0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|92e5", + "America/Chihuahua|MST MDT CST|70 60 60|0101010101012|1T3l0 1nX0 11B0 1nX0 14p0 1lb0 14p0 1lb0 14p0 1nX0 11B0 1nX0|81e4", + "America/Ciudad_Juarez|MST MDT CST|70 60 60|010101010101201010101010|1SSV0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1wn0 cm0 EP0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|", + "America/Phoenix|MST|70|0||42e5", + "America/Whitehorse|PST PDT MST|80 70 70|010101012|1SSW0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1z90|23e3", + "America/New_York|EST EDT|50 40|01010101010101010101010|1SST0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|21e6", + "America/Los_Angeles|PST PDT|80 70|01010101010101010101010|1SSW0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|15e6", + "America/Halifax|AST ADT|40 30|01010101010101010101010|1SSS0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|39e4", + "America/Godthab|-03 -02|30 20|01010101010101|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0|17e3", + "America/Grand_Turk|AST EDT EST|40 40 50|012121212121212121212|1Vkv0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|37e2", + "America/Havana|CST CDT|50 40|01010101010101010101010|1SSR0 1zc0 Oo0 1zc0 Oo0 1zc0 Oo0 1zc0 Rc0 1zc0 Oo0 1zc0 Oo0 1zc0 Oo0 1zc0 Oo0 1zc0 Oo0 1zc0 Rc0 1zc0|21e5", + "America/Mazatlan|MST MDT|70 60|0101010101010|1T3l0 1nX0 11B0 1nX0 14p0 1lb0 14p0 1lb0 14p0 1nX0 11B0 1nX0|44e4", + "America/Metlakatla|AKST AKDT PST|90 80 80|010120101010101010101010|1SSX0 1zb0 Op0 1zb0 uM0 jB0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|14e2", + "America/Miquelon|-03 -02|30 20|01010101010101010101010|1SSR0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|61e2", + "America/Noronha|-02|20|0||30e2", + "America/Ojinaga|MST MDT CST CDT|70 60 60 50|01010101010123232323232|1SSV0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1wn0 Rc0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|23e3", + "America/Santiago|-03 -04|30 40|01010101010101010101010|1Tk30 Ap0 1Nb0 Ap0 1zb0 11B0 1nX0 11B0 1nX0 11B0 1nX0 14p0 1lb0 11B0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0|62e5", + "America/Sao_Paulo|-02 -03|20 30|010101|1SKq0 1zd0 On0 1HB0 FX0|20e6", + "Atlantic/Azores|-01 +00|10 0|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|25e4", + "America/St_Johns|NST NDT|3u 2u|01010101010101010101010|1SSRu 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0|11e4", + "Antarctica/Casey|+11 +08|-b0 -80|0101010|1Vkh0 1o30 14k0 1kr0 12l0 1o01|10", + "Asia/Bangkok|+07|-70|0||15e6", + "Asia/Vladivostok|+10|-a0|0||60e4", + "Australia/Sydney|AEDT AEST|-b0 -a0|01010101010101010101010|1T340 1cM0 1cM0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0|40e5", + "Asia/Tashkent|+05|-50|0||23e5", + "Pacific/Auckland|NZDT NZST|-d0 -c0|01010101010101010101010|1T320 1a00 1fA0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1io0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00|14e5", + "Europe/Istanbul|+03|-30|0||13e6", + "Antarctica/Troll|+00 +02|0 -20|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|40", + "Asia/Dhaka|+06|-60|0||16e6", + "Asia/Amman|EET EEST +03|-20 -30 -30|0101010101012|1T2m0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 LA0 1C00|25e5", + "Asia/Kamchatka|+12|-c0|0||18e4", + "Asia/Dubai|+04|-40|0||39e5", + "Asia/Beirut|EET EEST|-20 -30|01010101010101010101010|1T0m0 1qL0 WN0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1qL0 WN0 1qL0 WN0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0 1qL0|22e5", + "Asia/Kuala_Lumpur|+08|-80|0||71e5", + "Asia/Kolkata|IST|-5u|0||15e6", + "Asia/Chita|+09|-90|0||33e4", + "Asia/Shanghai|CST|-80|0||23e6", + "Asia/Colombo|+0530|-5u|0||22e5", + "Asia/Damascus|EET EEST +03|-20 -30 -30|0101010101012|1T2m0 1nX0 11B0 1nX0 11B0 1nX0 11B0 1qL0 WN0 1qL0 WN0 1qL0|26e5", + "Asia/Famagusta|+03 EET EEST|-30 -20 -30|0121212121212121212121|1Urd0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|", + "Asia/Gaza|EET EEST|-20 -30|01010101010101010101010|1SXX0 1qL0 WN0 1qL0 11c0 1on0 11B0 1o00 11A0 1qo0 XA0 1qp0 WN0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0 1qL0|18e5", + "Asia/Hong_Kong|HKT|-80|0||73e5", + "Asia/Jakarta|WIB|-70|0||31e6", + "Asia/Jayapura|WIT|-90|0||26e4", + "Asia/Jerusalem|IST IDT|-20 -30|01010101010101010101010|1SXA0 1rz0 W10 1rz0 10N0 1oL0 10N0 1oL0 10N0 1rz0 W10 1rz0 W10 1rz0 10N0 1oL0 10N0 1oL0 10N0 1oL0 10N0 1rz0|81e4", + "Asia/Kabul|+0430|-4u|0||46e5", + "Asia/Karachi|PKT|-50|0||24e6", + "Asia/Kathmandu|+0545|-5J|0||12e5", + "Asia/Sakhalin|+11|-b0|0||58e4", + "Asia/Makassar|WITA|-80|0||15e5", + "Asia/Manila|PST|-80|0||24e6", + "Europe/Athens|EET EEST|-20 -30|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|35e5", + "Asia/Pyongyang|KST KST|-8u -90|01|1VGf0|29e5", + "Asia/Qyzylorda|+06 +05|-60 -50|01|1Xei0|73e4", + "Asia/Rangoon|+0630|-6u|0||48e5", + "Asia/Seoul|KST|-90|0||23e6", + "Asia/Tehran|+0330 +0430|-3u -4u|0101010101010|1SWIu 1dz0 1cp0 1dz0 1cp0 1dz0 1cp0 1dz0 1cN0 1dz0 1cp0 1dz0|14e6", + "Asia/Tokyo|JST|-90|0||38e6", + "Europe/Lisbon|WET WEST|0 -10|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|27e5", + "Atlantic/Cape_Verde|-01|10|0||50e4", + "Australia/Adelaide|ACDT ACST|-au -9u|01010101010101010101010|1T34u 1cM0 1cM0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0|11e5", + "Australia/Brisbane|AEST|-a0|0||20e5", + "Australia/Darwin|ACST|-9u|0||12e4", + "Australia/Eucla|+0845|-8J|0||368", + "Australia/Lord_Howe|+11 +1030|-b0 -au|01010101010101010101010|1T330 1cMu 1cLu 1fAu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu 1fzu 1cMu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu|347", + "Australia/Perth|AWST|-80|0||18e5", + "Pacific/Easter|-05 -06|50 60|01010101010101010101010|1Tk30 Ap0 1Nb0 Ap0 1zb0 11B0 1nX0 11B0 1nX0 11B0 1nX0 14p0 1lb0 11B0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0|30e2", + "Europe/Dublin|GMT IST|0 -10|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|12e5", + "Etc/GMT-1|+01|-10|0||", + "Pacific/Fakaofo|+13|-d0|0||483", + "Pacific/Kiritimati|+14|-e0|0||51e2", + "Etc/GMT-2|+02|-20|0||", + "Pacific/Tahiti|-10|a0|0||18e4", + "Pacific/Niue|-11|b0|0||12e2", + "Etc/GMT+12|-12|c0|0||", + "Pacific/Galapagos|-06|60|0||25e3", + "Etc/GMT+7|-07|70|0||", + "Pacific/Pitcairn|-08|80|0||56", + "Pacific/Gambier|-09|90|0||125", + "Etc/UTC|UTC|0|0||", + "Europe/London|GMT BST|0 -10|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|10e6", + "Europe/Chisinau|EET EEST|-20 -30|01010101010101010101010|1T0o0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|67e4", + "Europe/Moscow|MSK|-30|0||16e6", + "Europe/Volgograd|+03 +04|-30 -40|010|1WQL0 5gn0|10e5", + "Pacific/Honolulu|HST|a0|0||37e4", + "MET|MET MEST|-10 -20|01010101010101010101010|1T0p0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0|", + "Pacific/Chatham|+1345 +1245|-dJ -cJ|01010101010101010101010|1T320 1a00 1fA0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1io0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00|600", + "Pacific/Apia|+14 +13|-e0 -d0|0101010101|1T320 1a00 1fA0 1cM0 1fA0 1a00 1fA0 1a00 1fA0|37e3", + "Pacific/Fiji|+13 +12|-d0 -c0|0101010101|1Swe0 1VA0 s00 1VA0 s00 20o0 pc0 2hc0 bc0|88e4", + "Pacific/Guam|ChST|-a0|0||17e4", + "Pacific/Marquesas|-0930|9u|0||86e2", + "Pacific/Pago_Pago|SST|b0|0||37e2", + "Pacific/Norfolk|+11 +12|-b0 -c0|010101010101010101|219P0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0|25e4", + "Pacific/Tongatapu|+14 +13|-e0 -d0|01|1Swd0|75e3", + ], + links: [ + "Africa/Abidjan|Africa/Accra", + "Africa/Abidjan|Africa/Bamako", + "Africa/Abidjan|Africa/Banjul", + "Africa/Abidjan|Africa/Bissau", + "Africa/Abidjan|Africa/Conakry", + "Africa/Abidjan|Africa/Dakar", + "Africa/Abidjan|Africa/Freetown", + "Africa/Abidjan|Africa/Lome", + "Africa/Abidjan|Africa/Monrovia", + "Africa/Abidjan|Africa/Nouakchott", + "Africa/Abidjan|Africa/Ouagadougou", + "Africa/Abidjan|Africa/Timbuktu", + "Africa/Abidjan|America/Danmarkshavn", + "Africa/Abidjan|Atlantic/Reykjavik", + "Africa/Abidjan|Atlantic/St_Helena", + "Africa/Abidjan|Etc/GMT", + "Africa/Abidjan|Etc/GMT+0", + "Africa/Abidjan|Etc/GMT-0", + "Africa/Abidjan|Etc/GMT0", + "Africa/Abidjan|Etc/Greenwich", + "Africa/Abidjan|GMT", + "Africa/Abidjan|GMT+0", + "Africa/Abidjan|GMT-0", + "Africa/Abidjan|GMT0", + "Africa/Abidjan|Greenwich", + "Africa/Abidjan|Iceland", + "Africa/Algiers|Africa/Tunis", + "Africa/Cairo|Africa/Tripoli", + "Africa/Cairo|Egypt", + "Africa/Cairo|Europe/Kaliningrad", + "Africa/Cairo|Libya", + "Africa/Casablanca|Africa/El_Aaiun", + "Africa/Johannesburg|Africa/Maseru", + "Africa/Johannesburg|Africa/Mbabane", + "Africa/Lagos|Africa/Bangui", + "Africa/Lagos|Africa/Brazzaville", + "Africa/Lagos|Africa/Douala", + "Africa/Lagos|Africa/Kinshasa", + "Africa/Lagos|Africa/Libreville", + "Africa/Lagos|Africa/Luanda", + "Africa/Lagos|Africa/Malabo", + "Africa/Lagos|Africa/Ndjamena", + "Africa/Lagos|Africa/Niamey", + "Africa/Lagos|Africa/Porto-Novo", + "Africa/Maputo|Africa/Blantyre", + "Africa/Maputo|Africa/Bujumbura", + "Africa/Maputo|Africa/Gaborone", + "Africa/Maputo|Africa/Harare", + "Africa/Maputo|Africa/Kigali", + "Africa/Maputo|Africa/Lubumbashi", + "Africa/Maputo|Africa/Lusaka", + "Africa/Nairobi|Africa/Addis_Ababa", + "Africa/Nairobi|Africa/Asmara", + "Africa/Nairobi|Africa/Asmera", + "Africa/Nairobi|Africa/Dar_es_Salaam", + "Africa/Nairobi|Africa/Djibouti", + "Africa/Nairobi|Africa/Kampala", + "Africa/Nairobi|Africa/Mogadishu", + "Africa/Nairobi|Indian/Antananarivo", + "Africa/Nairobi|Indian/Comoro", + "Africa/Nairobi|Indian/Mayotte", + "America/Adak|America/Atka", + "America/Adak|US/Aleutian", + "America/Anchorage|America/Juneau", + "America/Anchorage|America/Nome", + "America/Anchorage|America/Sitka", + "America/Anchorage|America/Yakutat", + "America/Anchorage|US/Alaska", + "America/Campo_Grande|America/Cuiaba", + "America/Caracas|America/Boa_Vista", + "America/Caracas|America/Guyana", + "America/Caracas|America/La_Paz", + "America/Caracas|America/Manaus", + "America/Caracas|America/Porto_Velho", + "America/Caracas|Brazil/West", + "America/Caracas|Etc/GMT+4", + "America/Chicago|America/Indiana/Knox", + "America/Chicago|America/Indiana/Tell_City", + "America/Chicago|America/Knox_IN", + "America/Chicago|America/Matamoros", + "America/Chicago|America/Menominee", + "America/Chicago|America/North_Dakota/Beulah", + "America/Chicago|America/North_Dakota/Center", + "America/Chicago|America/North_Dakota/New_Salem", + "America/Chicago|America/Rainy_River", + "America/Chicago|America/Rankin_Inlet", + "America/Chicago|America/Resolute", + "America/Chicago|America/Winnipeg", + "America/Chicago|CST6CDT", + "America/Chicago|Canada/Central", + "America/Chicago|US/Central", + "America/Chicago|US/Indiana-Starke", + "America/Denver|America/Boise", + "America/Denver|America/Cambridge_Bay", + "America/Denver|America/Edmonton", + "America/Denver|America/Inuvik", + "America/Denver|America/Shiprock", + "America/Denver|America/Yellowknife", + "America/Denver|Canada/Mountain", + "America/Denver|MST7MDT", + "America/Denver|Navajo", + "America/Denver|US/Mountain", + "America/Fortaleza|America/Araguaina", + "America/Fortaleza|America/Argentina/Buenos_Aires", + "America/Fortaleza|America/Argentina/Catamarca", + "America/Fortaleza|America/Argentina/ComodRivadavia", + "America/Fortaleza|America/Argentina/Cordoba", + "America/Fortaleza|America/Argentina/Jujuy", + "America/Fortaleza|America/Argentina/La_Rioja", + "America/Fortaleza|America/Argentina/Mendoza", + "America/Fortaleza|America/Argentina/Rio_Gallegos", + "America/Fortaleza|America/Argentina/Salta", + "America/Fortaleza|America/Argentina/San_Juan", + "America/Fortaleza|America/Argentina/San_Luis", + "America/Fortaleza|America/Argentina/Tucuman", + "America/Fortaleza|America/Argentina/Ushuaia", + "America/Fortaleza|America/Bahia", + "America/Fortaleza|America/Belem", + "America/Fortaleza|America/Buenos_Aires", + "America/Fortaleza|America/Catamarca", + "America/Fortaleza|America/Cayenne", + "America/Fortaleza|America/Cordoba", + "America/Fortaleza|America/Jujuy", + "America/Fortaleza|America/Maceio", + "America/Fortaleza|America/Mendoza", + "America/Fortaleza|America/Montevideo", + "America/Fortaleza|America/Paramaribo", + "America/Fortaleza|America/Punta_Arenas", + "America/Fortaleza|America/Recife", + "America/Fortaleza|America/Rosario", + "America/Fortaleza|America/Santarem", + "America/Fortaleza|Antarctica/Palmer", + "America/Fortaleza|Antarctica/Rothera", + "America/Fortaleza|Atlantic/Stanley", + "America/Fortaleza|Etc/GMT+3", + "America/Godthab|America/Nuuk", + "America/Halifax|America/Glace_Bay", + "America/Halifax|America/Goose_Bay", + "America/Halifax|America/Moncton", + "America/Halifax|America/Thule", + "America/Halifax|Atlantic/Bermuda", + "America/Halifax|Canada/Atlantic", + "America/Havana|Cuba", + "America/Lima|America/Bogota", + "America/Lima|America/Eirunepe", + "America/Lima|America/Guayaquil", + "America/Lima|America/Porto_Acre", + "America/Lima|America/Rio_Branco", + "America/Lima|Brazil/Acre", + "America/Lima|Etc/GMT+5", + "America/Los_Angeles|America/Ensenada", + "America/Los_Angeles|America/Santa_Isabel", + "America/Los_Angeles|America/Tijuana", + "America/Los_Angeles|America/Vancouver", + "America/Los_Angeles|Canada/Pacific", + "America/Los_Angeles|Mexico/BajaNorte", + "America/Los_Angeles|PST8PDT", + "America/Los_Angeles|US/Pacific", + "America/Managua|America/Belize", + "America/Managua|America/Costa_Rica", + "America/Managua|America/El_Salvador", + "America/Managua|America/Guatemala", + "America/Managua|America/Regina", + "America/Managua|America/Swift_Current", + "America/Managua|America/Tegucigalpa", + "America/Managua|Canada/Saskatchewan", + "America/Mazatlan|Mexico/BajaSur", + "America/Mexico_City|America/Bahia_Banderas", + "America/Mexico_City|America/Merida", + "America/Mexico_City|America/Monterrey", + "America/Mexico_City|Mexico/General", + "America/New_York|America/Detroit", + "America/New_York|America/Fort_Wayne", + "America/New_York|America/Indiana/Indianapolis", + "America/New_York|America/Indiana/Marengo", + "America/New_York|America/Indiana/Petersburg", + "America/New_York|America/Indiana/Vevay", + "America/New_York|America/Indiana/Vincennes", + "America/New_York|America/Indiana/Winamac", + "America/New_York|America/Indianapolis", + "America/New_York|America/Iqaluit", + "America/New_York|America/Kentucky/Louisville", + "America/New_York|America/Kentucky/Monticello", + "America/New_York|America/Louisville", + "America/New_York|America/Montreal", + "America/New_York|America/Nassau", + "America/New_York|America/Nipigon", + "America/New_York|America/Pangnirtung", + "America/New_York|America/Port-au-Prince", + "America/New_York|America/Thunder_Bay", + "America/New_York|America/Toronto", + "America/New_York|Canada/Eastern", + "America/New_York|EST5EDT", + "America/New_York|US/East-Indiana", + "America/New_York|US/Eastern", + "America/New_York|US/Michigan", + "America/Noronha|Atlantic/South_Georgia", + "America/Noronha|Brazil/DeNoronha", + "America/Noronha|Etc/GMT+2", + "America/Panama|America/Atikokan", + "America/Panama|America/Cancun", + "America/Panama|America/Cayman", + "America/Panama|America/Coral_Harbour", + "America/Panama|America/Jamaica", + "America/Panama|EST", + "America/Panama|Jamaica", + "America/Phoenix|America/Creston", + "America/Phoenix|America/Dawson_Creek", + "America/Phoenix|America/Fort_Nelson", + "America/Phoenix|America/Hermosillo", + "America/Phoenix|MST", + "America/Phoenix|US/Arizona", + "America/Santiago|Chile/Continental", + "America/Santo_Domingo|America/Anguilla", + "America/Santo_Domingo|America/Antigua", + "America/Santo_Domingo|America/Aruba", + "America/Santo_Domingo|America/Barbados", + "America/Santo_Domingo|America/Blanc-Sablon", + "America/Santo_Domingo|America/Curacao", + "America/Santo_Domingo|America/Dominica", + "America/Santo_Domingo|America/Grenada", + "America/Santo_Domingo|America/Guadeloupe", + "America/Santo_Domingo|America/Kralendijk", + "America/Santo_Domingo|America/Lower_Princes", + "America/Santo_Domingo|America/Marigot", + "America/Santo_Domingo|America/Martinique", + "America/Santo_Domingo|America/Montserrat", + "America/Santo_Domingo|America/Port_of_Spain", + "America/Santo_Domingo|America/Puerto_Rico", + "America/Santo_Domingo|America/St_Barthelemy", + "America/Santo_Domingo|America/St_Kitts", + "America/Santo_Domingo|America/St_Lucia", + "America/Santo_Domingo|America/St_Thomas", + "America/Santo_Domingo|America/St_Vincent", + "America/Santo_Domingo|America/Tortola", + "America/Santo_Domingo|America/Virgin", + "America/Sao_Paulo|Brazil/East", + "America/St_Johns|Canada/Newfoundland", + "America/Whitehorse|America/Dawson", + "America/Whitehorse|Canada/Yukon", + "Asia/Bangkok|Antarctica/Davis", + "Asia/Bangkok|Asia/Barnaul", + "Asia/Bangkok|Asia/Ho_Chi_Minh", + "Asia/Bangkok|Asia/Hovd", + "Asia/Bangkok|Asia/Krasnoyarsk", + "Asia/Bangkok|Asia/Novokuznetsk", + "Asia/Bangkok|Asia/Novosibirsk", + "Asia/Bangkok|Asia/Phnom_Penh", + "Asia/Bangkok|Asia/Saigon", + "Asia/Bangkok|Asia/Tomsk", + "Asia/Bangkok|Asia/Vientiane", + "Asia/Bangkok|Etc/GMT-7", + "Asia/Bangkok|Indian/Christmas", + "Asia/Chita|Asia/Dili", + "Asia/Chita|Asia/Khandyga", + "Asia/Chita|Asia/Yakutsk", + "Asia/Chita|Etc/GMT-9", + "Asia/Chita|Pacific/Palau", + "Asia/Dhaka|Antarctica/Vostok", + "Asia/Dhaka|Asia/Almaty", + "Asia/Dhaka|Asia/Bishkek", + "Asia/Dhaka|Asia/Dacca", + "Asia/Dhaka|Asia/Kashgar", + "Asia/Dhaka|Asia/Omsk", + "Asia/Dhaka|Asia/Qostanay", + "Asia/Dhaka|Asia/Thimbu", + "Asia/Dhaka|Asia/Thimphu", + "Asia/Dhaka|Asia/Urumqi", + "Asia/Dhaka|Etc/GMT-6", + "Asia/Dhaka|Indian/Chagos", + "Asia/Dubai|Asia/Baku", + "Asia/Dubai|Asia/Muscat", + "Asia/Dubai|Asia/Tbilisi", + "Asia/Dubai|Asia/Yerevan", + "Asia/Dubai|Etc/GMT-4", + "Asia/Dubai|Europe/Astrakhan", + "Asia/Dubai|Europe/Samara", + "Asia/Dubai|Europe/Saratov", + "Asia/Dubai|Europe/Ulyanovsk", + "Asia/Dubai|Indian/Mahe", + "Asia/Dubai|Indian/Mauritius", + "Asia/Dubai|Indian/Reunion", + "Asia/Gaza|Asia/Hebron", + "Asia/Hong_Kong|Hongkong", + "Asia/Jakarta|Asia/Pontianak", + "Asia/Jerusalem|Asia/Tel_Aviv", + "Asia/Jerusalem|Israel", + "Asia/Kamchatka|Asia/Anadyr", + "Asia/Kamchatka|Etc/GMT-12", + "Asia/Kamchatka|Kwajalein", + "Asia/Kamchatka|Pacific/Funafuti", + "Asia/Kamchatka|Pacific/Kwajalein", + "Asia/Kamchatka|Pacific/Majuro", + "Asia/Kamchatka|Pacific/Nauru", + "Asia/Kamchatka|Pacific/Tarawa", + "Asia/Kamchatka|Pacific/Wake", + "Asia/Kamchatka|Pacific/Wallis", + "Asia/Kathmandu|Asia/Katmandu", + "Asia/Kolkata|Asia/Calcutta", + "Asia/Kuala_Lumpur|Asia/Brunei", + "Asia/Kuala_Lumpur|Asia/Choibalsan", + "Asia/Kuala_Lumpur|Asia/Irkutsk", + "Asia/Kuala_Lumpur|Asia/Kuching", + "Asia/Kuala_Lumpur|Asia/Singapore", + "Asia/Kuala_Lumpur|Asia/Ulaanbaatar", + "Asia/Kuala_Lumpur|Asia/Ulan_Bator", + "Asia/Kuala_Lumpur|Etc/GMT-8", + "Asia/Kuala_Lumpur|Singapore", + "Asia/Makassar|Asia/Ujung_Pandang", + "Asia/Rangoon|Asia/Yangon", + "Asia/Rangoon|Indian/Cocos", + "Asia/Sakhalin|Asia/Magadan", + "Asia/Sakhalin|Asia/Srednekolymsk", + "Asia/Sakhalin|Etc/GMT-11", + "Asia/Sakhalin|Pacific/Bougainville", + "Asia/Sakhalin|Pacific/Efate", + "Asia/Sakhalin|Pacific/Guadalcanal", + "Asia/Sakhalin|Pacific/Kosrae", + "Asia/Sakhalin|Pacific/Noumea", + "Asia/Sakhalin|Pacific/Pohnpei", + "Asia/Sakhalin|Pacific/Ponape", + "Asia/Seoul|ROK", + "Asia/Shanghai|Asia/Chongqing", + "Asia/Shanghai|Asia/Chungking", + "Asia/Shanghai|Asia/Harbin", + "Asia/Shanghai|Asia/Macao", + "Asia/Shanghai|Asia/Macau", + "Asia/Shanghai|Asia/Taipei", + "Asia/Shanghai|PRC", + "Asia/Shanghai|ROC", + "Asia/Tashkent|Antarctica/Mawson", + "Asia/Tashkent|Asia/Aqtau", + "Asia/Tashkent|Asia/Aqtobe", + "Asia/Tashkent|Asia/Ashgabat", + "Asia/Tashkent|Asia/Ashkhabad", + "Asia/Tashkent|Asia/Atyrau", + "Asia/Tashkent|Asia/Dushanbe", + "Asia/Tashkent|Asia/Oral", + "Asia/Tashkent|Asia/Samarkand", + "Asia/Tashkent|Asia/Yekaterinburg", + "Asia/Tashkent|Etc/GMT-5", + "Asia/Tashkent|Indian/Kerguelen", + "Asia/Tashkent|Indian/Maldives", + "Asia/Tehran|Iran", + "Asia/Tokyo|Japan", + "Asia/Vladivostok|Antarctica/DumontDUrville", + "Asia/Vladivostok|Asia/Ust-Nera", + "Asia/Vladivostok|Etc/GMT-10", + "Asia/Vladivostok|Pacific/Chuuk", + "Asia/Vladivostok|Pacific/Port_Moresby", + "Asia/Vladivostok|Pacific/Truk", + "Asia/Vladivostok|Pacific/Yap", + "Atlantic/Azores|America/Scoresbysund", + "Atlantic/Cape_Verde|Etc/GMT+1", + "Australia/Adelaide|Australia/Broken_Hill", + "Australia/Adelaide|Australia/South", + "Australia/Adelaide|Australia/Yancowinna", + "Australia/Brisbane|Australia/Lindeman", + "Australia/Brisbane|Australia/Queensland", + "Australia/Darwin|Australia/North", + "Australia/Lord_Howe|Australia/LHI", + "Australia/Perth|Australia/West", + "Australia/Sydney|Antarctica/Macquarie", + "Australia/Sydney|Australia/ACT", + "Australia/Sydney|Australia/Canberra", + "Australia/Sydney|Australia/Currie", + "Australia/Sydney|Australia/Hobart", + "Australia/Sydney|Australia/Melbourne", + "Australia/Sydney|Australia/NSW", + "Australia/Sydney|Australia/Tasmania", + "Australia/Sydney|Australia/Victoria", + "Etc/UTC|Etc/UCT", + "Etc/UTC|Etc/Universal", + "Etc/UTC|Etc/Zulu", + "Etc/UTC|UCT", + "Etc/UTC|UTC", + "Etc/UTC|Universal", + "Etc/UTC|Zulu", + "Europe/Athens|Asia/Nicosia", + "Europe/Athens|EET", + "Europe/Athens|Europe/Bucharest", + "Europe/Athens|Europe/Helsinki", + "Europe/Athens|Europe/Kiev", + "Europe/Athens|Europe/Kyiv", + "Europe/Athens|Europe/Mariehamn", + "Europe/Athens|Europe/Nicosia", + "Europe/Athens|Europe/Riga", + "Europe/Athens|Europe/Sofia", + "Europe/Athens|Europe/Tallinn", + "Europe/Athens|Europe/Uzhgorod", + "Europe/Athens|Europe/Vilnius", + "Europe/Athens|Europe/Zaporozhye", + "Europe/Chisinau|Europe/Tiraspol", + "Europe/Dublin|Eire", + "Europe/Istanbul|Antarctica/Syowa", + "Europe/Istanbul|Asia/Aden", + "Europe/Istanbul|Asia/Baghdad", + "Europe/Istanbul|Asia/Bahrain", + "Europe/Istanbul|Asia/Istanbul", + "Europe/Istanbul|Asia/Kuwait", + "Europe/Istanbul|Asia/Qatar", + "Europe/Istanbul|Asia/Riyadh", + "Europe/Istanbul|Etc/GMT-3", + "Europe/Istanbul|Europe/Kirov", + "Europe/Istanbul|Europe/Minsk", + "Europe/Istanbul|Turkey", + "Europe/Lisbon|Atlantic/Canary", + "Europe/Lisbon|Atlantic/Faeroe", + "Europe/Lisbon|Atlantic/Faroe", + "Europe/Lisbon|Atlantic/Madeira", + "Europe/Lisbon|Portugal", + "Europe/Lisbon|WET", + "Europe/London|Europe/Belfast", + "Europe/London|Europe/Guernsey", + "Europe/London|Europe/Isle_of_Man", + "Europe/London|Europe/Jersey", + "Europe/London|GB", + "Europe/London|GB-Eire", + "Europe/Moscow|Europe/Simferopol", + "Europe/Moscow|W-SU", + "Europe/Paris|Africa/Ceuta", + "Europe/Paris|Arctic/Longyearbyen", + "Europe/Paris|Atlantic/Jan_Mayen", + "Europe/Paris|CET", + "Europe/Paris|Europe/Amsterdam", + "Europe/Paris|Europe/Andorra", + "Europe/Paris|Europe/Belgrade", + "Europe/Paris|Europe/Berlin", + "Europe/Paris|Europe/Bratislava", + "Europe/Paris|Europe/Brussels", + "Europe/Paris|Europe/Budapest", + "Europe/Paris|Europe/Busingen", + "Europe/Paris|Europe/Copenhagen", + "Europe/Paris|Europe/Gibraltar", + "Europe/Paris|Europe/Ljubljana", + "Europe/Paris|Europe/Luxembourg", + "Europe/Paris|Europe/Madrid", + "Europe/Paris|Europe/Malta", + "Europe/Paris|Europe/Monaco", + "Europe/Paris|Europe/Oslo", + "Europe/Paris|Europe/Podgorica", + "Europe/Paris|Europe/Prague", + "Europe/Paris|Europe/Rome", + "Europe/Paris|Europe/San_Marino", + "Europe/Paris|Europe/Sarajevo", + "Europe/Paris|Europe/Skopje", + "Europe/Paris|Europe/Stockholm", + "Europe/Paris|Europe/Tirane", + "Europe/Paris|Europe/Vaduz", + "Europe/Paris|Europe/Vatican", + "Europe/Paris|Europe/Vienna", + "Europe/Paris|Europe/Warsaw", + "Europe/Paris|Europe/Zagreb", + "Europe/Paris|Europe/Zurich", + "Europe/Paris|Poland", + "Pacific/Auckland|Antarctica/McMurdo", + "Pacific/Auckland|Antarctica/South_Pole", + "Pacific/Auckland|NZ", + "Pacific/Chatham|NZ-CHAT", + "Pacific/Easter|Chile/EasterIsland", + "Pacific/Fakaofo|Etc/GMT-13", + "Pacific/Fakaofo|Pacific/Enderbury", + "Pacific/Fakaofo|Pacific/Kanton", + "Pacific/Galapagos|Etc/GMT+6", + "Pacific/Gambier|Etc/GMT+9", + "Pacific/Guam|Pacific/Saipan", + "Pacific/Honolulu|HST", + "Pacific/Honolulu|Pacific/Johnston", + "Pacific/Honolulu|US/Hawaii", + "Pacific/Kiritimati|Etc/GMT-14", + "Pacific/Niue|Etc/GMT+11", + "Pacific/Pago_Pago|Pacific/Midway", + "Pacific/Pago_Pago|Pacific/Samoa", + "Pacific/Pago_Pago|US/Samoa", + "Pacific/Pitcairn|Etc/GMT+8", + "Pacific/Tahiti|Etc/GMT+10", + "Pacific/Tahiti|Pacific/Rarotonga", + ], + countries: [ + "AD|Europe/Andorra", + "AE|Asia/Dubai", + "AF|Asia/Kabul", + "AG|America/Puerto_Rico America/Antigua", + "AI|America/Puerto_Rico America/Anguilla", + "AL|Europe/Tirane", + "AM|Asia/Yerevan", + "AO|Africa/Lagos Africa/Luanda", + "AQ|Antarctica/Casey Antarctica/Davis Antarctica/Mawson Antarctica/Palmer Antarctica/Rothera Antarctica/Troll Asia/Urumqi Pacific/Auckland Pacific/Port_Moresby Asia/Riyadh Antarctica/McMurdo Antarctica/DumontDUrville Antarctica/Syowa Antarctica/Vostok", + "AR|America/Argentina/Buenos_Aires America/Argentina/Cordoba America/Argentina/Salta America/Argentina/Jujuy America/Argentina/Tucuman America/Argentina/Catamarca America/Argentina/La_Rioja America/Argentina/San_Juan America/Argentina/Mendoza America/Argentina/San_Luis America/Argentina/Rio_Gallegos America/Argentina/Ushuaia", + "AS|Pacific/Pago_Pago", + "AT|Europe/Vienna", + "AU|Australia/Lord_Howe Antarctica/Macquarie Australia/Hobart Australia/Melbourne Australia/Sydney Australia/Broken_Hill Australia/Brisbane Australia/Lindeman Australia/Adelaide Australia/Darwin Australia/Perth Australia/Eucla", + "AW|America/Puerto_Rico America/Aruba", + "AX|Europe/Helsinki Europe/Mariehamn", + "AZ|Asia/Baku", + "BA|Europe/Belgrade Europe/Sarajevo", + "BB|America/Barbados", + "BD|Asia/Dhaka", + "BE|Europe/Brussels", + "BF|Africa/Abidjan Africa/Ouagadougou", + "BG|Europe/Sofia", + "BH|Asia/Qatar Asia/Bahrain", + "BI|Africa/Maputo Africa/Bujumbura", + "BJ|Africa/Lagos Africa/Porto-Novo", + "BL|America/Puerto_Rico America/St_Barthelemy", + "BM|Atlantic/Bermuda", + "BN|Asia/Kuching Asia/Brunei", + "BO|America/La_Paz", + "BQ|America/Puerto_Rico America/Kralendijk", + "BR|America/Noronha America/Belem America/Fortaleza America/Recife America/Araguaina America/Maceio America/Bahia America/Sao_Paulo America/Campo_Grande America/Cuiaba America/Santarem America/Porto_Velho America/Boa_Vista America/Manaus America/Eirunepe America/Rio_Branco", + "BS|America/Toronto America/Nassau", + "BT|Asia/Thimphu", + "BW|Africa/Maputo Africa/Gaborone", + "BY|Europe/Minsk", + "BZ|America/Belize", + "CA|America/St_Johns America/Halifax America/Glace_Bay America/Moncton America/Goose_Bay America/Toronto America/Iqaluit America/Winnipeg America/Resolute America/Rankin_Inlet America/Regina America/Swift_Current America/Edmonton America/Cambridge_Bay America/Yellowknife America/Inuvik America/Dawson_Creek America/Fort_Nelson America/Whitehorse America/Dawson America/Vancouver America/Panama America/Puerto_Rico America/Phoenix America/Blanc-Sablon America/Atikokan America/Creston", + "CC|Asia/Yangon Indian/Cocos", + "CD|Africa/Maputo Africa/Lagos Africa/Kinshasa Africa/Lubumbashi", + "CF|Africa/Lagos Africa/Bangui", + "CG|Africa/Lagos Africa/Brazzaville", + "CH|Europe/Zurich", + "CI|Africa/Abidjan", + "CK|Pacific/Rarotonga", + "CL|America/Santiago America/Punta_Arenas Pacific/Easter", + "CM|Africa/Lagos Africa/Douala", + "CN|Asia/Shanghai Asia/Urumqi", + "CO|America/Bogota", + "CR|America/Costa_Rica", + "CU|America/Havana", + "CV|Atlantic/Cape_Verde", + "CW|America/Puerto_Rico America/Curacao", + "CX|Asia/Bangkok Indian/Christmas", + "CY|Asia/Nicosia Asia/Famagusta", + "CZ|Europe/Prague", + "DE|Europe/Zurich Europe/Berlin Europe/Busingen", + "DJ|Africa/Nairobi Africa/Djibouti", + "DK|Europe/Berlin Europe/Copenhagen", + "DM|America/Puerto_Rico America/Dominica", + "DO|America/Santo_Domingo", + "DZ|Africa/Algiers", + "EC|America/Guayaquil Pacific/Galapagos", + "EE|Europe/Tallinn", + "EG|Africa/Cairo", + "EH|Africa/El_Aaiun", + "ER|Africa/Nairobi Africa/Asmara", + "ES|Europe/Madrid Africa/Ceuta Atlantic/Canary", + "ET|Africa/Nairobi Africa/Addis_Ababa", + "FI|Europe/Helsinki", + "FJ|Pacific/Fiji", + "FK|Atlantic/Stanley", + "FM|Pacific/Kosrae Pacific/Port_Moresby Pacific/Guadalcanal Pacific/Chuuk Pacific/Pohnpei", + "FO|Atlantic/Faroe", + "FR|Europe/Paris", + "GA|Africa/Lagos Africa/Libreville", + "GB|Europe/London", + "GD|America/Puerto_Rico America/Grenada", + "GE|Asia/Tbilisi", + "GF|America/Cayenne", + "GG|Europe/London Europe/Guernsey", + "GH|Africa/Abidjan Africa/Accra", + "GI|Europe/Gibraltar", + "GL|America/Nuuk America/Danmarkshavn America/Scoresbysund America/Thule", + "GM|Africa/Abidjan Africa/Banjul", + "GN|Africa/Abidjan Africa/Conakry", + "GP|America/Puerto_Rico America/Guadeloupe", + "GQ|Africa/Lagos Africa/Malabo", + "GR|Europe/Athens", + "GS|Atlantic/South_Georgia", + "GT|America/Guatemala", + "GU|Pacific/Guam", + "GW|Africa/Bissau", + "GY|America/Guyana", + "HK|Asia/Hong_Kong", + "HN|America/Tegucigalpa", + "HR|Europe/Belgrade Europe/Zagreb", + "HT|America/Port-au-Prince", + "HU|Europe/Budapest", + "ID|Asia/Jakarta Asia/Pontianak Asia/Makassar Asia/Jayapura", + "IE|Europe/Dublin", + "IL|Asia/Jerusalem", + "IM|Europe/London Europe/Isle_of_Man", + "IN|Asia/Kolkata", + "IO|Indian/Chagos", + "IQ|Asia/Baghdad", + "IR|Asia/Tehran", + "IS|Africa/Abidjan Atlantic/Reykjavik", + "IT|Europe/Rome", + "JE|Europe/London Europe/Jersey", + "JM|America/Jamaica", + "JO|Asia/Amman", + "JP|Asia/Tokyo", + "KE|Africa/Nairobi", + "KG|Asia/Bishkek", + "KH|Asia/Bangkok Asia/Phnom_Penh", + "KI|Pacific/Tarawa Pacific/Kanton Pacific/Kiritimati", + "KM|Africa/Nairobi Indian/Comoro", + "KN|America/Puerto_Rico America/St_Kitts", + "KP|Asia/Pyongyang", + "KR|Asia/Seoul", + "KW|Asia/Riyadh Asia/Kuwait", + "KY|America/Panama America/Cayman", + "KZ|Asia/Almaty Asia/Qyzylorda Asia/Qostanay Asia/Aqtobe Asia/Aqtau Asia/Atyrau Asia/Oral", + "LA|Asia/Bangkok Asia/Vientiane", + "LB|Asia/Beirut", + "LC|America/Puerto_Rico America/St_Lucia", + "LI|Europe/Zurich Europe/Vaduz", + "LK|Asia/Colombo", + "LR|Africa/Monrovia", + "LS|Africa/Johannesburg Africa/Maseru", + "LT|Europe/Vilnius", + "LU|Europe/Brussels Europe/Luxembourg", + "LV|Europe/Riga", + "LY|Africa/Tripoli", + "MA|Africa/Casablanca", + "MC|Europe/Paris Europe/Monaco", + "MD|Europe/Chisinau", + "ME|Europe/Belgrade Europe/Podgorica", + "MF|America/Puerto_Rico America/Marigot", + "MG|Africa/Nairobi Indian/Antananarivo", + "MH|Pacific/Tarawa Pacific/Kwajalein Pacific/Majuro", + "MK|Europe/Belgrade Europe/Skopje", + "ML|Africa/Abidjan Africa/Bamako", + "MM|Asia/Yangon", + "MN|Asia/Ulaanbaatar Asia/Hovd Asia/Choibalsan", + "MO|Asia/Macau", + "MP|Pacific/Guam Pacific/Saipan", + "MQ|America/Martinique", + "MR|Africa/Abidjan Africa/Nouakchott", + "MS|America/Puerto_Rico America/Montserrat", + "MT|Europe/Malta", + "MU|Indian/Mauritius", + "MV|Indian/Maldives", + "MW|Africa/Maputo Africa/Blantyre", + "MX|America/Mexico_City America/Cancun America/Merida America/Monterrey America/Matamoros America/Chihuahua America/Ciudad_Juarez America/Ojinaga America/Mazatlan America/Bahia_Banderas America/Hermosillo America/Tijuana", + "MY|Asia/Kuching Asia/Singapore Asia/Kuala_Lumpur", + "MZ|Africa/Maputo", + "NA|Africa/Windhoek", + "NC|Pacific/Noumea", + "NE|Africa/Lagos Africa/Niamey", + "NF|Pacific/Norfolk", + "NG|Africa/Lagos", + "NI|America/Managua", + "NL|Europe/Brussels Europe/Amsterdam", + "NO|Europe/Berlin Europe/Oslo", + "NP|Asia/Kathmandu", + "NR|Pacific/Nauru", + "NU|Pacific/Niue", + "NZ|Pacific/Auckland Pacific/Chatham", + "OM|Asia/Dubai Asia/Muscat", + "PA|America/Panama", + "PE|America/Lima", + "PF|Pacific/Tahiti Pacific/Marquesas Pacific/Gambier", + "PG|Pacific/Port_Moresby Pacific/Bougainville", + "PH|Asia/Manila", + "PK|Asia/Karachi", + "PL|Europe/Warsaw", + "PM|America/Miquelon", + "PN|Pacific/Pitcairn", + "PR|America/Puerto_Rico", + "PS|Asia/Gaza Asia/Hebron", + "PT|Europe/Lisbon Atlantic/Madeira Atlantic/Azores", + "PW|Pacific/Palau", + "PY|America/Asuncion", + "QA|Asia/Qatar", + "RE|Asia/Dubai Indian/Reunion", + "RO|Europe/Bucharest", + "RS|Europe/Belgrade", + "RU|Europe/Kaliningrad Europe/Moscow Europe/Simferopol Europe/Kirov Europe/Volgograd Europe/Astrakhan Europe/Saratov Europe/Ulyanovsk Europe/Samara Asia/Yekaterinburg Asia/Omsk Asia/Novosibirsk Asia/Barnaul Asia/Tomsk Asia/Novokuznetsk Asia/Krasnoyarsk Asia/Irkutsk Asia/Chita Asia/Yakutsk Asia/Khandyga Asia/Vladivostok Asia/Ust-Nera Asia/Magadan Asia/Sakhalin Asia/Srednekolymsk Asia/Kamchatka Asia/Anadyr", + "RW|Africa/Maputo Africa/Kigali", + "SA|Asia/Riyadh", + "SB|Pacific/Guadalcanal", + "SC|Asia/Dubai Indian/Mahe", + "SD|Africa/Khartoum", + "SE|Europe/Berlin Europe/Stockholm", + "SG|Asia/Singapore", + "SH|Africa/Abidjan Atlantic/St_Helena", + "SI|Europe/Belgrade Europe/Ljubljana", + "SJ|Europe/Berlin Arctic/Longyearbyen", + "SK|Europe/Prague Europe/Bratislava", + "SL|Africa/Abidjan Africa/Freetown", + "SM|Europe/Rome Europe/San_Marino", + "SN|Africa/Abidjan Africa/Dakar", + "SO|Africa/Nairobi Africa/Mogadishu", + "SR|America/Paramaribo", + "SS|Africa/Juba", + "ST|Africa/Sao_Tome", + "SV|America/El_Salvador", + "SX|America/Puerto_Rico America/Lower_Princes", + "SY|Asia/Damascus", + "SZ|Africa/Johannesburg Africa/Mbabane", + "TC|America/Grand_Turk", + "TD|Africa/Ndjamena", + "TF|Asia/Dubai Indian/Maldives Indian/Kerguelen", + "TG|Africa/Abidjan Africa/Lome", + "TH|Asia/Bangkok", + "TJ|Asia/Dushanbe", + "TK|Pacific/Fakaofo", + "TL|Asia/Dili", + "TM|Asia/Ashgabat", + "TN|Africa/Tunis", + "TO|Pacific/Tongatapu", + "TR|Europe/Istanbul", + "TT|America/Puerto_Rico America/Port_of_Spain", + "TV|Pacific/Tarawa Pacific/Funafuti", + "TW|Asia/Taipei", + "TZ|Africa/Nairobi Africa/Dar_es_Salaam", + "UA|Europe/Simferopol Europe/Kyiv", + "UG|Africa/Nairobi Africa/Kampala", + "UM|Pacific/Pago_Pago Pacific/Tarawa Pacific/Honolulu Pacific/Midway Pacific/Wake", + "US|America/New_York America/Detroit America/Kentucky/Louisville America/Kentucky/Monticello America/Indiana/Indianapolis America/Indiana/Vincennes America/Indiana/Winamac America/Indiana/Marengo America/Indiana/Petersburg America/Indiana/Vevay America/Chicago America/Indiana/Tell_City America/Indiana/Knox America/Menominee America/North_Dakota/Center America/North_Dakota/New_Salem America/North_Dakota/Beulah America/Denver America/Boise America/Phoenix America/Los_Angeles America/Anchorage America/Juneau America/Sitka America/Metlakatla America/Yakutat America/Nome America/Adak Pacific/Honolulu", + "UY|America/Montevideo", + "UZ|Asia/Samarkand Asia/Tashkent", + "VA|Europe/Rome Europe/Vatican", + "VC|America/Puerto_Rico America/St_Vincent", + "VE|America/Caracas", + "VG|America/Puerto_Rico America/Tortola", + "VI|America/Puerto_Rico America/St_Thomas", + "VN|Asia/Bangkok Asia/Ho_Chi_Minh", + "VU|Pacific/Efate", + "WF|Pacific/Tarawa Pacific/Wallis", + "WS|Pacific/Apia", + "YE|Asia/Riyadh Asia/Aden", + "YT|Africa/Nairobi Indian/Mayotte", + "ZA|Africa/Johannesburg", + "ZM|Africa/Maputo Africa/Lusaka", + "ZW|Africa/Maputo Africa/Harare", + ], + }); + + return moment; +}); diff --git a/app/static/libjs/moment.new.min.js b/app/static/libjs/moment.new.min.js new file mode 100644 index 0000000000..d63167a81f --- /dev/null +++ b/app/static/libjs/moment.new.min.js @@ -0,0 +1,3309 @@ +!(function (e, t) { + "object" == typeof exports && "undefined" != typeof module + ? (module.exports = t()) + : "function" == typeof define && define.amd + ? define(t) + : (e.moment = t()); +})(this, function () { + "use strict"; + var H; + function f() { + return H.apply(null, arguments); + } + function a(e) { + return ( + e instanceof Array || + "[object Array]" === Object.prototype.toString.call(e) + ); + } + function F(e) { + return null != e && "[object Object]" === Object.prototype.toString.call(e); + } + function c(e, t) { + return Object.prototype.hasOwnProperty.call(e, t); + } + function L(e) { + if (Object.getOwnPropertyNames) + return 0 === Object.getOwnPropertyNames(e).length; + for (var t in e) if (c(e, t)) return; + return 1; + } + function o(e) { + return void 0 === e; + } + function u(e) { + return ( + "number" == typeof e || + "[object Number]" === Object.prototype.toString.call(e) + ); + } + function V(e) { + return ( + e instanceof Date || "[object Date]" === Object.prototype.toString.call(e) + ); + } + function G(e, t) { + for (var n = [], s = e.length, i = 0; i < s; ++i) n.push(t(e[i], i)); + return n; + } + function E(e, t) { + for (var n in t) c(t, n) && (e[n] = t[n]); + return ( + c(t, "toString") && (e.toString = t.toString), + c(t, "valueOf") && (e.valueOf = t.valueOf), + e + ); + } + function l(e, t, n, s) { + return Pt(e, t, n, s, !0).utc(); + } + function m(e) { + return ( + null == e._pf && + (e._pf = { + empty: !1, + unusedTokens: [], + unusedInput: [], + overflow: -2, + charsLeftOver: 0, + nullInput: !1, + invalidEra: null, + invalidMonth: null, + invalidFormat: !1, + userInvalidated: !1, + iso: !1, + parsedDateParts: [], + era: null, + meridiem: null, + rfc2822: !1, + weekdayMismatch: !1, + }), + e._pf + ); + } + function A(e) { + if (null == e._isValid) { + var t = m(e), + n = j.call(t.parsedDateParts, function (e) { + return null != e; + }), + n = + !isNaN(e._d.getTime()) && + t.overflow < 0 && + !t.empty && + !t.invalidEra && + !t.invalidMonth && + !t.invalidWeekday && + !t.weekdayMismatch && + !t.nullInput && + !t.invalidFormat && + !t.userInvalidated && + (!t.meridiem || (t.meridiem && n)); + if ( + (e._strict && + (n = + n && + 0 === t.charsLeftOver && + 0 === t.unusedTokens.length && + void 0 === t.bigHour), + null != Object.isFrozen && Object.isFrozen(e)) + ) + return n; + e._isValid = n; + } + return e._isValid; + } + function I(e) { + var t = l(NaN); + return null != e ? E(m(t), e) : (m(t).userInvalidated = !0), t; + } + var j = + Array.prototype.some || + function (e) { + for (var t = Object(this), n = t.length >>> 0, s = 0; s < n; s++) + if (s in t && e.call(this, t[s], s, t)) return !0; + return !1; + }, + Z = (f.momentProperties = []), + z = !1; + function $(e, t) { + var n, + s, + i, + r = Z.length; + if ( + (o(t._isAMomentObject) || (e._isAMomentObject = t._isAMomentObject), + o(t._i) || (e._i = t._i), + o(t._f) || (e._f = t._f), + o(t._l) || (e._l = t._l), + o(t._strict) || (e._strict = t._strict), + o(t._tzm) || (e._tzm = t._tzm), + o(t._isUTC) || (e._isUTC = t._isUTC), + o(t._offset) || (e._offset = t._offset), + o(t._pf) || (e._pf = m(t)), + o(t._locale) || (e._locale = t._locale), + 0 < r) + ) + for (n = 0; n < r; n++) o((i = t[(s = Z[n])])) || (e[s] = i); + return e; + } + function q(e) { + $(this, e), + (this._d = new Date(null != e._d ? e._d.getTime() : NaN)), + this.isValid() || (this._d = new Date(NaN)), + !1 === z && ((z = !0), f.updateOffset(this), (z = !1)); + } + function h(e) { + return e instanceof q || (null != e && null != e._isAMomentObject); + } + function B(e) { + !1 === f.suppressDeprecationWarnings && + "undefined" != typeof console && + console.warn && + console.warn("Deprecation warning: " + e); + } + function e(r, a) { + var o = !0; + return E(function () { + if ((null != f.deprecationHandler && f.deprecationHandler(null, r), o)) { + for (var e, t, n = [], s = arguments.length, i = 0; i < s; i++) { + if (((e = ""), "object" == typeof arguments[i])) { + for (t in ((e += "\n[" + i + "] "), arguments[0])) + c(arguments[0], t) && (e += t + ": " + arguments[0][t] + ", "); + e = e.slice(0, -2); + } else e = arguments[i]; + n.push(e); + } + B( + r + + "\nArguments: " + + Array.prototype.slice.call(n).join("") + + "\n" + + new Error().stack + ), + (o = !1); + } + return a.apply(this, arguments); + }, a); + } + var J = {}; + function Q(e, t) { + null != f.deprecationHandler && f.deprecationHandler(e, t), + J[e] || (B(t), (J[e] = !0)); + } + function d(e) { + return ( + ("undefined" != typeof Function && e instanceof Function) || + "[object Function]" === Object.prototype.toString.call(e) + ); + } + function X(e, t) { + var n, + s = E({}, e); + for (n in t) + c(t, n) && + (F(e[n]) && F(t[n]) + ? ((s[n] = {}), E(s[n], e[n]), E(s[n], t[n])) + : null != t[n] + ? (s[n] = t[n]) + : delete s[n]); + for (n in e) c(e, n) && !c(t, n) && F(e[n]) && (s[n] = E({}, s[n])); + return s; + } + function K(e) { + null != e && this.set(e); + } + (f.suppressDeprecationWarnings = !1), (f.deprecationHandler = null); + var ee = + Object.keys || + function (e) { + var t, + n = []; + for (t in e) c(e, t) && n.push(t); + return n; + }; + function r(e, t, n) { + var s = "" + Math.abs(e); + return ( + (0 <= e ? (n ? "+" : "") : "-") + + Math.pow(10, Math.max(0, t - s.length)) + .toString() + .substr(1) + + s + ); + } + var te = + /(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|N{1,5}|YYYYYY|YYYYY|YYYY|YY|y{2,4}|yo?|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g, + ne = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g, + se = {}, + ie = {}; + function s(e, t, n, s) { + var i = + "string" == typeof s + ? function () { + return this[s](); + } + : s; + e && (ie[e] = i), + t && + (ie[t[0]] = function () { + return r(i.apply(this, arguments), t[1], t[2]); + }), + n && + (ie[n] = function () { + return this.localeData().ordinal(i.apply(this, arguments), e); + }); + } + function re(e, t) { + return e.isValid() + ? ((t = ae(t, e.localeData())), + (se[t] = + se[t] || + (function (s) { + for (var e, i = s.match(te), t = 0, r = i.length; t < r; t++) + ie[i[t]] + ? (i[t] = ie[i[t]]) + : (i[t] = (e = i[t]).match(/\[[\s\S]/) + ? e.replace(/^\[|\]$/g, "") + : e.replace(/\\/g, "")); + return function (e) { + for (var t = "", n = 0; n < r; n++) + t += d(i[n]) ? i[n].call(e, s) : i[n]; + return t; + }; + })(t)), + se[t](e)) + : e.localeData().invalidDate(); + } + function ae(e, t) { + var n = 5; + function s(e) { + return t.longDateFormat(e) || e; + } + for (ne.lastIndex = 0; 0 <= n && ne.test(e); ) + (e = e.replace(ne, s)), (ne.lastIndex = 0), --n; + return e; + } + var oe = {}; + function t(e, t) { + var n = e.toLowerCase(); + oe[n] = oe[n + "s"] = oe[t] = e; + } + function _(e) { + return "string" == typeof e ? oe[e] || oe[e.toLowerCase()] : void 0; + } + function ue(e) { + var t, + n, + s = {}; + for (n in e) c(e, n) && (t = _(n)) && (s[t] = e[n]); + return s; + } + var le = {}; + function n(e, t) { + le[e] = t; + } + function he(e) { + return (e % 4 == 0 && e % 100 != 0) || e % 400 == 0; + } + function y(e) { + return e < 0 ? Math.ceil(e) || 0 : Math.floor(e); + } + function g(e) { + var e = +e, + t = 0; + return (t = 0 != e && isFinite(e) ? y(e) : t); + } + function de(t, n) { + return function (e) { + return null != e + ? (fe(this, t, e), f.updateOffset(this, n), this) + : ce(this, t); + }; + } + function ce(e, t) { + return e.isValid() ? e._d["get" + (e._isUTC ? "UTC" : "") + t]() : NaN; + } + function fe(e, t, n) { + e.isValid() && + !isNaN(n) && + ("FullYear" === t && he(e.year()) && 1 === e.month() && 29 === e.date() + ? ((n = g(n)), + e._d["set" + (e._isUTC ? "UTC" : "") + t]( + n, + e.month(), + We(n, e.month()) + )) + : e._d["set" + (e._isUTC ? "UTC" : "") + t](n)); + } + var i = /\d/, + w = /\d\d/, + me = /\d{3}/, + _e = /\d{4}/, + ye = /[+-]?\d{6}/, + p = /\d\d?/, + ge = /\d\d\d\d?/, + we = /\d\d\d\d\d\d?/, + pe = /\d{1,3}/, + ve = /\d{1,4}/, + ke = /[+-]?\d{1,6}/, + Me = /\d+/, + De = /[+-]?\d+/, + Se = /Z|[+-]\d\d:?\d\d/gi, + Ye = /Z|[+-]\d\d(?::?\d\d)?/gi, + v = + /[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i; + function k(e, n, s) { + be[e] = d(n) + ? n + : function (e, t) { + return e && s ? s : n; + }; + } + function Oe(e, t) { + return c(be, e) + ? be[e](t._strict, t._locale) + : new RegExp( + M( + e + .replace("\\", "") + .replace( + /\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, + function (e, t, n, s, i) { + return t || n || s || i; + } + ) + ) + ); + } + function M(e) { + return e.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); + } + var be = {}, + xe = {}; + function D(e, n) { + var t, + s, + i = n; + for ( + "string" == typeof e && (e = [e]), + u(n) && + (i = function (e, t) { + t[n] = g(e); + }), + s = e.length, + t = 0; + t < s; + t++ + ) + xe[e[t]] = i; + } + function Te(e, i) { + D(e, function (e, t, n, s) { + (n._w = n._w || {}), i(e, n._w, n, s); + }); + } + var S, + Y = 0, + O = 1, + b = 2, + x = 3, + T = 4, + N = 5, + Ne = 6, + Pe = 7, + Re = 8; + function We(e, t) { + if (isNaN(e) || isNaN(t)) return NaN; + var n = ((t % (n = 12)) + n) % n; + return (e += (t - n) / 12), 1 == n ? (he(e) ? 29 : 28) : 31 - ((n % 7) % 2); + } + (S = + Array.prototype.indexOf || + function (e) { + for (var t = 0; t < this.length; ++t) if (this[t] === e) return t; + return -1; + }), + s("M", ["MM", 2], "Mo", function () { + return this.month() + 1; + }), + s("MMM", 0, 0, function (e) { + return this.localeData().monthsShort(this, e); + }), + s("MMMM", 0, 0, function (e) { + return this.localeData().months(this, e); + }), + t("month", "M"), + n("month", 8), + k("M", p), + k("MM", p, w), + k("MMM", function (e, t) { + return t.monthsShortRegex(e); + }), + k("MMMM", function (e, t) { + return t.monthsRegex(e); + }), + D(["M", "MM"], function (e, t) { + t[O] = g(e) - 1; + }), + D(["MMM", "MMMM"], function (e, t, n, s) { + s = n._locale.monthsParse(e, s, n._strict); + null != s ? (t[O] = s) : (m(n).invalidMonth = e); + }); + var Ce = + "January_February_March_April_May_June_July_August_September_October_November_December".split( + "_" + ), + Ue = "Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"), + He = /D[oD]?(\[[^\[\]]*\]|\s)+MMMM?/, + Fe = v, + Le = v; + function Ve(e, t) { + var n; + if (e.isValid()) { + if ("string" == typeof t) + if (/^\d+$/.test(t)) t = g(t); + else if (!u((t = e.localeData().monthsParse(t)))) return; + (n = Math.min(e.date(), We(e.year(), t))), + e._d["set" + (e._isUTC ? "UTC" : "") + "Month"](t, n); + } + } + function Ge(e) { + return null != e + ? (Ve(this, e), f.updateOffset(this, !0), this) + : ce(this, "Month"); + } + function Ee() { + function e(e, t) { + return t.length - e.length; + } + for (var t, n = [], s = [], i = [], r = 0; r < 12; r++) + (t = l([2e3, r])), + n.push(this.monthsShort(t, "")), + s.push(this.months(t, "")), + i.push(this.months(t, "")), + i.push(this.monthsShort(t, "")); + for (n.sort(e), s.sort(e), i.sort(e), r = 0; r < 12; r++) + (n[r] = M(n[r])), (s[r] = M(s[r])); + for (r = 0; r < 24; r++) i[r] = M(i[r]); + (this._monthsRegex = new RegExp("^(" + i.join("|") + ")", "i")), + (this._monthsShortRegex = this._monthsRegex), + (this._monthsStrictRegex = new RegExp("^(" + s.join("|") + ")", "i")), + (this._monthsShortStrictRegex = new RegExp( + "^(" + n.join("|") + ")", + "i" + )); + } + function Ae(e) { + return he(e) ? 366 : 365; + } + s("Y", 0, 0, function () { + var e = this.year(); + return e <= 9999 ? r(e, 4) : "+" + e; + }), + s(0, ["YY", 2], 0, function () { + return this.year() % 100; + }), + s(0, ["YYYY", 4], 0, "year"), + s(0, ["YYYYY", 5], 0, "year"), + s(0, ["YYYYYY", 6, !0], 0, "year"), + t("year", "y"), + n("year", 1), + k("Y", De), + k("YY", p, w), + k("YYYY", ve, _e), + k("YYYYY", ke, ye), + k("YYYYYY", ke, ye), + D(["YYYYY", "YYYYYY"], Y), + D("YYYY", function (e, t) { + t[Y] = 2 === e.length ? f.parseTwoDigitYear(e) : g(e); + }), + D("YY", function (e, t) { + t[Y] = f.parseTwoDigitYear(e); + }), + D("Y", function (e, t) { + t[Y] = parseInt(e, 10); + }), + (f.parseTwoDigitYear = function (e) { + return g(e) + (68 < g(e) ? 1900 : 2e3); + }); + var Ie = de("FullYear", !0); + function je(e, t, n, s, i, r, a) { + var o; + return ( + e < 100 && 0 <= e + ? ((o = new Date(e + 400, t, n, s, i, r, a)), + isFinite(o.getFullYear()) && o.setFullYear(e)) + : (o = new Date(e, t, n, s, i, r, a)), + o + ); + } + function Ze(e) { + var t; + return ( + e < 100 && 0 <= e + ? (((t = Array.prototype.slice.call(arguments))[0] = e + 400), + (t = new Date(Date.UTC.apply(null, t))), + isFinite(t.getUTCFullYear()) && t.setUTCFullYear(e)) + : (t = new Date(Date.UTC.apply(null, arguments))), + t + ); + } + function ze(e, t, n) { + n = 7 + t - n; + return n - ((7 + Ze(e, 0, n).getUTCDay() - t) % 7) - 1; + } + function $e(e, t, n, s, i) { + var r, + t = 1 + 7 * (t - 1) + ((7 + n - s) % 7) + ze(e, s, i), + n = + t <= 0 + ? Ae((r = e - 1)) + t + : t > Ae(e) + ? ((r = e + 1), t - Ae(e)) + : ((r = e), t); + return { year: r, dayOfYear: n }; + } + function qe(e, t, n) { + var s, + i, + r = ze(e.year(), t, n), + r = Math.floor((e.dayOfYear() - r - 1) / 7) + 1; + return ( + r < 1 + ? (s = r + P((i = e.year() - 1), t, n)) + : r > P(e.year(), t, n) + ? ((s = r - P(e.year(), t, n)), (i = e.year() + 1)) + : ((i = e.year()), (s = r)), + { week: s, year: i } + ); + } + function P(e, t, n) { + var s = ze(e, t, n), + t = ze(e + 1, t, n); + return (Ae(e) - s + t) / 7; + } + s("w", ["ww", 2], "wo", "week"), + s("W", ["WW", 2], "Wo", "isoWeek"), + t("week", "w"), + t("isoWeek", "W"), + n("week", 5), + n("isoWeek", 5), + k("w", p), + k("ww", p, w), + k("W", p), + k("WW", p, w), + Te(["w", "ww", "W", "WW"], function (e, t, n, s) { + t[s.substr(0, 1)] = g(e); + }); + function Be(e, t) { + return e.slice(t, 7).concat(e.slice(0, t)); + } + s("d", 0, "do", "day"), + s("dd", 0, 0, function (e) { + return this.localeData().weekdaysMin(this, e); + }), + s("ddd", 0, 0, function (e) { + return this.localeData().weekdaysShort(this, e); + }), + s("dddd", 0, 0, function (e) { + return this.localeData().weekdays(this, e); + }), + s("e", 0, 0, "weekday"), + s("E", 0, 0, "isoWeekday"), + t("day", "d"), + t("weekday", "e"), + t("isoWeekday", "E"), + n("day", 11), + n("weekday", 11), + n("isoWeekday", 11), + k("d", p), + k("e", p), + k("E", p), + k("dd", function (e, t) { + return t.weekdaysMinRegex(e); + }), + k("ddd", function (e, t) { + return t.weekdaysShortRegex(e); + }), + k("dddd", function (e, t) { + return t.weekdaysRegex(e); + }), + Te(["dd", "ddd", "dddd"], function (e, t, n, s) { + s = n._locale.weekdaysParse(e, s, n._strict); + null != s ? (t.d = s) : (m(n).invalidWeekday = e); + }), + Te(["d", "e", "E"], function (e, t, n, s) { + t[s] = g(e); + }); + var Je = "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split( + "_" + ), + Qe = "Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"), + Xe = "Su_Mo_Tu_We_Th_Fr_Sa".split("_"), + Ke = v, + et = v, + tt = v; + function nt() { + function e(e, t) { + return t.length - e.length; + } + for (var t, n, s, i = [], r = [], a = [], o = [], u = 0; u < 7; u++) + (s = l([2e3, 1]).day(u)), + (t = M(this.weekdaysMin(s, ""))), + (n = M(this.weekdaysShort(s, ""))), + (s = M(this.weekdays(s, ""))), + i.push(t), + r.push(n), + a.push(s), + o.push(t), + o.push(n), + o.push(s); + i.sort(e), + r.sort(e), + a.sort(e), + o.sort(e), + (this._weekdaysRegex = new RegExp("^(" + o.join("|") + ")", "i")), + (this._weekdaysShortRegex = this._weekdaysRegex), + (this._weekdaysMinRegex = this._weekdaysRegex), + (this._weekdaysStrictRegex = new RegExp("^(" + a.join("|") + ")", "i")), + (this._weekdaysShortStrictRegex = new RegExp( + "^(" + r.join("|") + ")", + "i" + )), + (this._weekdaysMinStrictRegex = new RegExp( + "^(" + i.join("|") + ")", + "i" + )); + } + function st() { + return this.hours() % 12 || 12; + } + function it(e, t) { + s(e, 0, 0, function () { + return this.localeData().meridiem(this.hours(), this.minutes(), t); + }); + } + function rt(e, t) { + return t._meridiemParse; + } + s("H", ["HH", 2], 0, "hour"), + s("h", ["hh", 2], 0, st), + s("k", ["kk", 2], 0, function () { + return this.hours() || 24; + }), + s("hmm", 0, 0, function () { + return "" + st.apply(this) + r(this.minutes(), 2); + }), + s("hmmss", 0, 0, function () { + return "" + st.apply(this) + r(this.minutes(), 2) + r(this.seconds(), 2); + }), + s("Hmm", 0, 0, function () { + return "" + this.hours() + r(this.minutes(), 2); + }), + s("Hmmss", 0, 0, function () { + return "" + this.hours() + r(this.minutes(), 2) + r(this.seconds(), 2); + }), + it("a", !0), + it("A", !1), + t("hour", "h"), + n("hour", 13), + k("a", rt), + k("A", rt), + k("H", p), + k("h", p), + k("k", p), + k("HH", p, w), + k("hh", p, w), + k("kk", p, w), + k("hmm", ge), + k("hmmss", we), + k("Hmm", ge), + k("Hmmss", we), + D(["H", "HH"], x), + D(["k", "kk"], function (e, t, n) { + e = g(e); + t[x] = 24 === e ? 0 : e; + }), + D(["a", "A"], function (e, t, n) { + (n._isPm = n._locale.isPM(e)), (n._meridiem = e); + }), + D(["h", "hh"], function (e, t, n) { + (t[x] = g(e)), (m(n).bigHour = !0); + }), + D("hmm", function (e, t, n) { + var s = e.length - 2; + (t[x] = g(e.substr(0, s))), (t[T] = g(e.substr(s))), (m(n).bigHour = !0); + }), + D("hmmss", function (e, t, n) { + var s = e.length - 4, + i = e.length - 2; + (t[x] = g(e.substr(0, s))), + (t[T] = g(e.substr(s, 2))), + (t[N] = g(e.substr(i))), + (m(n).bigHour = !0); + }), + D("Hmm", function (e, t, n) { + var s = e.length - 2; + (t[x] = g(e.substr(0, s))), (t[T] = g(e.substr(s))); + }), + D("Hmmss", function (e, t, n) { + var s = e.length - 4, + i = e.length - 2; + (t[x] = g(e.substr(0, s))), + (t[T] = g(e.substr(s, 2))), + (t[N] = g(e.substr(i))); + }); + v = de("Hours", !0); + var at, + ot = { + calendar: { + sameDay: "[Today at] LT", + nextDay: "[Tomorrow at] LT", + nextWeek: "dddd [at] LT", + lastDay: "[Yesterday at] LT", + lastWeek: "[Last] dddd [at] LT", + sameElse: "L", + }, + longDateFormat: { + LTS: "h:mm:ss A", + LT: "h:mm A", + L: "MM/DD/YYYY", + LL: "MMMM D, YYYY", + LLL: "MMMM D, YYYY h:mm A", + LLLL: "dddd, MMMM D, YYYY h:mm A", + }, + invalidDate: "Invalid date", + ordinal: "%d", + dayOfMonthOrdinalParse: /\d{1,2}/, + relativeTime: { + future: "in %s", + past: "%s ago", + s: "a few seconds", + ss: "%d seconds", + m: "a minute", + mm: "%d minutes", + h: "an hour", + hh: "%d hours", + d: "a day", + dd: "%d days", + w: "a week", + ww: "%d weeks", + M: "a month", + MM: "%d months", + y: "a year", + yy: "%d years", + }, + months: Ce, + monthsShort: Ue, + week: { dow: 0, doy: 6 }, + weekdays: Je, + weekdaysMin: Xe, + weekdaysShort: Qe, + meridiemParse: /[ap]\.?m?\.?/i, + }, + R = {}, + ut = {}; + function lt(e) { + return e && e.toLowerCase().replace("_", "-"); + } + function ht(e) { + for (var t, n, s, i, r = 0; r < e.length; ) { + for ( + t = (i = lt(e[r]).split("-")).length, + n = (n = lt(e[r + 1])) ? n.split("-") : null; + 0 < t; + + ) { + if ((s = dt(i.slice(0, t).join("-")))) return s; + if ( + n && + n.length >= t && + (function (e, t) { + for (var n = Math.min(e.length, t.length), s = 0; s < n; s += 1) + if (e[s] !== t[s]) return s; + return n; + })(i, n) >= + t - 1 + ) + break; + t--; + } + r++; + } + return at; + } + function dt(t) { + var e; + if ( + void 0 === R[t] && + "undefined" != typeof module && + module && + module.exports && + null != t.match("^[^/\\\\]*$") + ) + try { + (e = at._abbr), require("./locale/" + t), ct(e); + } catch (e) { + R[t] = null; + } + return R[t]; + } + function ct(e, t) { + return ( + e && + ((t = o(t) ? mt(e) : ft(e, t)) + ? (at = t) + : "undefined" != typeof console && + console.warn && + console.warn( + "Locale " + e + " not found. Did you forget to load it?" + )), + at._abbr + ); + } + function ft(e, t) { + if (null === t) return delete R[e], null; + var n, + s = ot; + if (((t.abbr = e), null != R[e])) + Q( + "defineLocaleOverride", + "use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info." + ), + (s = R[e]._config); + else if (null != t.parentLocale) + if (null != R[t.parentLocale]) s = R[t.parentLocale]._config; + else { + if (null == (n = dt(t.parentLocale))) + return ( + ut[t.parentLocale] || (ut[t.parentLocale] = []), + ut[t.parentLocale].push({ name: e, config: t }), + null + ); + s = n._config; + } + return ( + (R[e] = new K(X(s, t))), + ut[e] && + ut[e].forEach(function (e) { + ft(e.name, e.config); + }), + ct(e), + R[e] + ); + } + function mt(e) { + var t; + if (!(e = e && e._locale && e._locale._abbr ? e._locale._abbr : e)) + return at; + if (!a(e)) { + if ((t = dt(e))) return t; + e = [e]; + } + return ht(e); + } + function _t(e) { + var t = e._a; + return ( + t && + -2 === m(e).overflow && + ((t = + t[O] < 0 || 11 < t[O] + ? O + : t[b] < 1 || t[b] > We(t[Y], t[O]) + ? b + : t[x] < 0 || + 24 < t[x] || + (24 === t[x] && (0 !== t[T] || 0 !== t[N] || 0 !== t[Ne])) + ? x + : t[T] < 0 || 59 < t[T] + ? T + : t[N] < 0 || 59 < t[N] + ? N + : t[Ne] < 0 || 999 < t[Ne] + ? Ne + : -1), + m(e)._overflowDayOfYear && (t < Y || b < t) && (t = b), + m(e)._overflowWeeks && -1 === t && (t = Pe), + m(e)._overflowWeekday && -1 === t && (t = Re), + (m(e).overflow = t)), + e + ); + } + var yt = + /^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, + gt = + /^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d|))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([+-]\d\d(?::?\d\d)?|\s*Z)?)?$/, + wt = /Z|[+-]\d\d(?::?\d\d)?/, + pt = [ + ["YYYYYY-MM-DD", /[+-]\d{6}-\d\d-\d\d/], + ["YYYY-MM-DD", /\d{4}-\d\d-\d\d/], + ["GGGG-[W]WW-E", /\d{4}-W\d\d-\d/], + ["GGGG-[W]WW", /\d{4}-W\d\d/, !1], + ["YYYY-DDD", /\d{4}-\d{3}/], + ["YYYY-MM", /\d{4}-\d\d/, !1], + ["YYYYYYMMDD", /[+-]\d{10}/], + ["YYYYMMDD", /\d{8}/], + ["GGGG[W]WWE", /\d{4}W\d{3}/], + ["GGGG[W]WW", /\d{4}W\d{2}/, !1], + ["YYYYDDD", /\d{7}/], + ["YYYYMM", /\d{6}/, !1], + ["YYYY", /\d{4}/, !1], + ], + vt = [ + ["HH:mm:ss.SSSS", /\d\d:\d\d:\d\d\.\d+/], + ["HH:mm:ss,SSSS", /\d\d:\d\d:\d\d,\d+/], + ["HH:mm:ss", /\d\d:\d\d:\d\d/], + ["HH:mm", /\d\d:\d\d/], + ["HHmmss.SSSS", /\d\d\d\d\d\d\.\d+/], + ["HHmmss,SSSS", /\d\d\d\d\d\d,\d+/], + ["HHmmss", /\d\d\d\d\d\d/], + ["HHmm", /\d\d\d\d/], + ["HH", /\d\d/], + ], + kt = /^\/?Date\((-?\d+)/i, + Mt = + /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/, + Dt = { + UT: 0, + GMT: 0, + EDT: -240, + EST: -300, + CDT: -300, + CST: -360, + MDT: -360, + MST: -420, + PDT: -420, + PST: -480, + }; + function St(e) { + var t, + n, + s, + i, + r, + a, + o = e._i, + u = yt.exec(o) || gt.exec(o), + o = pt.length, + l = vt.length; + if (u) { + for (m(e).iso = !0, t = 0, n = o; t < n; t++) + if (pt[t][1].exec(u[1])) { + (i = pt[t][0]), (s = !1 !== pt[t][2]); + break; + } + if (null == i) e._isValid = !1; + else { + if (u[3]) { + for (t = 0, n = l; t < n; t++) + if (vt[t][1].exec(u[3])) { + r = (u[2] || " ") + vt[t][0]; + break; + } + if (null == r) return void (e._isValid = !1); + } + if (s || null == r) { + if (u[4]) { + if (!wt.exec(u[4])) return void (e._isValid = !1); + a = "Z"; + } + (e._f = i + (r || "") + (a || "")), Tt(e); + } else e._isValid = !1; + } + } else e._isValid = !1; + } + function Yt(e, t, n, s, i, r) { + e = [ + (function (e) { + e = parseInt(e, 10); + { + if (e <= 49) return 2e3 + e; + if (e <= 999) return 1900 + e; + } + return e; + })(e), + Ue.indexOf(t), + parseInt(n, 10), + parseInt(s, 10), + parseInt(i, 10), + ]; + return r && e.push(parseInt(r, 10)), e; + } + function Ot(e) { + var t, + n, + s, + i, + r = Mt.exec( + e._i + .replace(/\([^()]*\)|[\n\t]/g, " ") + .replace(/(\s\s+)/g, " ") + .replace(/^\s\s*/, "") + .replace(/\s\s*$/, "") + ); + r + ? ((t = Yt(r[4], r[3], r[2], r[5], r[6], r[7])), + (n = r[1]), + (s = t), + (i = e), + n && Qe.indexOf(n) !== new Date(s[0], s[1], s[2]).getDay() + ? ((m(i).weekdayMismatch = !0), (i._isValid = !1)) + : ((e._a = t), + (e._tzm = + ((n = r[8]), + (s = r[9]), + (i = r[10]), + n + ? Dt[n] + : s + ? 0 + : 60 * (((n = parseInt(i, 10)) - (s = n % 100)) / 100) + s)), + (e._d = Ze.apply(null, e._a)), + e._d.setUTCMinutes(e._d.getUTCMinutes() - e._tzm), + (m(e).rfc2822 = !0))) + : (e._isValid = !1); + } + function bt(e, t, n) { + return null != e ? e : null != t ? t : n; + } + function xt(e) { + var t, + n, + s, + i, + r, + a, + o, + u, + l, + h, + d, + c = []; + if (!e._d) { + for ( + s = e, + i = new Date(f.now()), + n = s._useUTC + ? [i.getUTCFullYear(), i.getUTCMonth(), i.getUTCDate()] + : [i.getFullYear(), i.getMonth(), i.getDate()], + e._w && + null == e._a[b] && + null == e._a[O] && + (null != (i = (s = e)._w).GG || null != i.W || null != i.E + ? ((u = 1), + (l = 4), + (r = bt(i.GG, s._a[Y], qe(W(), 1, 4).year)), + (a = bt(i.W, 1)), + ((o = bt(i.E, 1)) < 1 || 7 < o) && (h = !0)) + : ((u = s._locale._week.dow), + (l = s._locale._week.doy), + (d = qe(W(), u, l)), + (r = bt(i.gg, s._a[Y], d.year)), + (a = bt(i.w, d.week)), + null != i.d + ? ((o = i.d) < 0 || 6 < o) && (h = !0) + : null != i.e + ? ((o = i.e + u), (i.e < 0 || 6 < i.e) && (h = !0)) + : (o = u)), + a < 1 || a > P(r, u, l) + ? (m(s)._overflowWeeks = !0) + : null != h + ? (m(s)._overflowWeekday = !0) + : ((d = $e(r, a, o, u, l)), + (s._a[Y] = d.year), + (s._dayOfYear = d.dayOfYear))), + null != e._dayOfYear && + ((i = bt(e._a[Y], n[Y])), + (e._dayOfYear > Ae(i) || 0 === e._dayOfYear) && + (m(e)._overflowDayOfYear = !0), + (h = Ze(i, 0, e._dayOfYear)), + (e._a[O] = h.getUTCMonth()), + (e._a[b] = h.getUTCDate())), + t = 0; + t < 3 && null == e._a[t]; + ++t + ) + e._a[t] = c[t] = n[t]; + for (; t < 7; t++) + e._a[t] = c[t] = null == e._a[t] ? (2 === t ? 1 : 0) : e._a[t]; + 24 === e._a[x] && + 0 === e._a[T] && + 0 === e._a[N] && + 0 === e._a[Ne] && + ((e._nextDay = !0), (e._a[x] = 0)), + (e._d = (e._useUTC ? Ze : je).apply(null, c)), + (r = e._useUTC ? e._d.getUTCDay() : e._d.getDay()), + null != e._tzm && e._d.setUTCMinutes(e._d.getUTCMinutes() - e._tzm), + e._nextDay && (e._a[x] = 24), + e._w && + void 0 !== e._w.d && + e._w.d !== r && + (m(e).weekdayMismatch = !0); + } + } + function Tt(e) { + if (e._f === f.ISO_8601) St(e); + else if (e._f === f.RFC_2822) Ot(e); + else { + (e._a = []), (m(e).empty = !0); + for ( + var t, + n, + s, + i, + r, + a = "" + e._i, + o = a.length, + u = 0, + l = ae(e._f, e._locale).match(te) || [], + h = l.length, + d = 0; + d < h; + d++ + ) + (n = l[d]), + (t = (a.match(Oe(n, e)) || [])[0]) && + (0 < (s = a.substr(0, a.indexOf(t))).length && + m(e).unusedInput.push(s), + (a = a.slice(a.indexOf(t) + t.length)), + (u += t.length)), + ie[n] + ? (t ? (m(e).empty = !1) : m(e).unusedTokens.push(n), + (s = n), + (r = e), + null != (i = t) && c(xe, s) && xe[s](i, r._a, r, s)) + : e._strict && !t && m(e).unusedTokens.push(n); + (m(e).charsLeftOver = o - u), + 0 < a.length && m(e).unusedInput.push(a), + e._a[x] <= 12 && + !0 === m(e).bigHour && + 0 < e._a[x] && + (m(e).bigHour = void 0), + (m(e).parsedDateParts = e._a.slice(0)), + (m(e).meridiem = e._meridiem), + (e._a[x] = (function (e, t, n) { + if (null == n) return t; + return null != e.meridiemHour + ? e.meridiemHour(t, n) + : null != e.isPM + ? ((e = e.isPM(n)) && t < 12 && (t += 12), + (t = e || 12 !== t ? t : 0)) + : t; + })(e._locale, e._a[x], e._meridiem)), + null !== (o = m(e).era) && + (e._a[Y] = e._locale.erasConvertYear(o, e._a[Y])), + xt(e), + _t(e); + } + } + function Nt(e) { + var t, + n, + s, + i = e._i, + r = e._f; + if ( + ((e._locale = e._locale || mt(e._l)), + null === i || (void 0 === r && "" === i)) + ) + return I({ nullInput: !0 }); + if (("string" == typeof i && (e._i = i = e._locale.preparse(i)), h(i))) + return new q(_t(i)); + if (V(i)) e._d = i; + else if (a(r)) + !(function (e) { + var t, + n, + s, + i, + r, + a, + o = !1, + u = e._f.length; + if (0 === u) return (m(e).invalidFormat = !0), (e._d = new Date(NaN)); + for (i = 0; i < u; i++) + (r = 0), + (a = !1), + (t = $({}, e)), + null != e._useUTC && (t._useUTC = e._useUTC), + (t._f = e._f[i]), + Tt(t), + A(t) && (a = !0), + (r = (r += m(t).charsLeftOver) + 10 * m(t).unusedTokens.length), + (m(t).score = r), + o + ? r < s && ((s = r), (n = t)) + : (null == s || r < s || a) && ((s = r), (n = t), a && (o = !0)); + E(e, n || t); + })(e); + else if (r) Tt(e); + else if (o((r = (i = e)._i))) i._d = new Date(f.now()); + else + V(r) + ? (i._d = new Date(r.valueOf())) + : "string" == typeof r + ? ((n = i), + null !== (t = kt.exec(n._i)) + ? (n._d = new Date(+t[1])) + : (St(n), + !1 === n._isValid && + (delete n._isValid, + Ot(n), + !1 === n._isValid && + (delete n._isValid, + n._strict + ? (n._isValid = !1) + : f.createFromInputFallback(n))))) + : a(r) + ? ((i._a = G(r.slice(0), function (e) { + return parseInt(e, 10); + })), + xt(i)) + : F(r) + ? (t = i)._d || + ((s = void 0 === (n = ue(t._i)).day ? n.date : n.day), + (t._a = G( + [n.year, n.month, s, n.hour, n.minute, n.second, n.millisecond], + function (e) { + return e && parseInt(e, 10); + } + )), + xt(t)) + : u(r) + ? (i._d = new Date(r)) + : f.createFromInputFallback(i); + return A(e) || (e._d = null), e; + } + function Pt(e, t, n, s, i) { + var r = {}; + return ( + (!0 !== t && !1 !== t) || ((s = t), (t = void 0)), + (!0 !== n && !1 !== n) || ((s = n), (n = void 0)), + ((F(e) && L(e)) || (a(e) && 0 === e.length)) && (e = void 0), + (r._isAMomentObject = !0), + (r._useUTC = r._isUTC = i), + (r._l = n), + (r._i = e), + (r._f = t), + (r._strict = s), + (i = new q(_t(Nt((i = r)))))._nextDay && + (i.add(1, "d"), (i._nextDay = void 0)), + i + ); + } + function W(e, t, n, s) { + return Pt(e, t, n, s, !1); + } + (f.createFromInputFallback = e( + "value provided is not in a recognized RFC2822 or ISO format. moment construction falls back to js Date(), which is not reliable across all browsers and versions. Non RFC2822/ISO date formats are discouraged. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.", + function (e) { + e._d = new Date(e._i + (e._useUTC ? " UTC" : "")); + } + )), + (f.ISO_8601 = function () {}), + (f.RFC_2822 = function () {}); + (ge = e( + "moment().min is deprecated, use moment.max instead. http://momentjs.com/guides/#/warnings/min-max/", + function () { + var e = W.apply(null, arguments); + return this.isValid() && e.isValid() ? (e < this ? this : e) : I(); + } + )), + (we = e( + "moment().max is deprecated, use moment.min instead. http://momentjs.com/guides/#/warnings/min-max/", + function () { + var e = W.apply(null, arguments); + return this.isValid() && e.isValid() ? (this < e ? this : e) : I(); + } + )); + function Rt(e, t) { + var n, s; + if (!(t = 1 === t.length && a(t[0]) ? t[0] : t).length) return W(); + for (n = t[0], s = 1; s < t.length; ++s) + (t[s].isValid() && !t[s][e](n)) || (n = t[s]); + return n; + } + var Wt = [ + "year", + "quarter", + "month", + "week", + "day", + "hour", + "minute", + "second", + "millisecond", + ]; + function Ct(e) { + var e = ue(e), + t = e.year || 0, + n = e.quarter || 0, + s = e.month || 0, + i = e.week || e.isoWeek || 0, + r = e.day || 0, + a = e.hour || 0, + o = e.minute || 0, + u = e.second || 0, + l = e.millisecond || 0; + (this._isValid = (function (e) { + var t, + n, + s = !1, + i = Wt.length; + for (t in e) + if (c(e, t) && (-1 === S.call(Wt, t) || (null != e[t] && isNaN(e[t])))) + return !1; + for (n = 0; n < i; ++n) + if (e[Wt[n]]) { + if (s) return !1; + parseFloat(e[Wt[n]]) !== g(e[Wt[n]]) && (s = !0); + } + return !0; + })(e)), + (this._milliseconds = +l + 1e3 * u + 6e4 * o + 1e3 * a * 60 * 60), + (this._days = +r + 7 * i), + (this._months = +s + 3 * n + 12 * t), + (this._data = {}), + (this._locale = mt()), + this._bubble(); + } + function Ut(e) { + return e instanceof Ct; + } + function Ht(e) { + return e < 0 ? -1 * Math.round(-1 * e) : Math.round(e); + } + function Ft(e, n) { + s(e, 0, 0, function () { + var e = this.utcOffset(), + t = "+"; + return ( + e < 0 && ((e = -e), (t = "-")), + t + r(~~(e / 60), 2) + n + r(~~e % 60, 2) + ); + }); + } + Ft("Z", ":"), + Ft("ZZ", ""), + k("Z", Ye), + k("ZZ", Ye), + D(["Z", "ZZ"], function (e, t, n) { + (n._useUTC = !0), (n._tzm = Vt(Ye, e)); + }); + var Lt = /([\+\-]|\d\d)/gi; + function Vt(e, t) { + var t = (t || "").match(e); + return null === t + ? null + : 0 === + (t = + 60 * + (e = ((t[t.length - 1] || []) + "").match(Lt) || ["-", 0, 0])[1] + + g(e[2])) + ? 0 + : "+" === e[0] + ? t + : -t; + } + function Gt(e, t) { + var n; + return t._isUTC + ? ((t = t.clone()), + (n = (h(e) || V(e) ? e : W(e)).valueOf() - t.valueOf()), + t._d.setTime(t._d.valueOf() + n), + f.updateOffset(t, !1), + t) + : W(e).local(); + } + function Et(e) { + return -Math.round(e._d.getTimezoneOffset()); + } + function At() { + return !!this.isValid() && this._isUTC && 0 === this._offset; + } + f.updateOffset = function () {}; + var It = /^(-|\+)?(?:(\d*)[. ])?(\d+):(\d+)(?::(\d+)(\.\d*)?)?$/, + jt = + /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/; + function C(e, t) { + var n, + s = e, + i = null; + return ( + Ut(e) + ? (s = { ms: e._milliseconds, d: e._days, M: e._months }) + : u(e) || !isNaN(+e) + ? ((s = {}), t ? (s[t] = +e) : (s.milliseconds = +e)) + : (i = It.exec(e)) + ? ((n = "-" === i[1] ? -1 : 1), + (s = { + y: 0, + d: g(i[b]) * n, + h: g(i[x]) * n, + m: g(i[T]) * n, + s: g(i[N]) * n, + ms: g(Ht(1e3 * i[Ne])) * n, + })) + : (i = jt.exec(e)) + ? ((n = "-" === i[1] ? -1 : 1), + (s = { + y: Zt(i[2], n), + M: Zt(i[3], n), + w: Zt(i[4], n), + d: Zt(i[5], n), + h: Zt(i[6], n), + m: Zt(i[7], n), + s: Zt(i[8], n), + })) + : null == s + ? (s = {}) + : "object" == typeof s && + ("from" in s || "to" in s) && + ((t = (function (e, t) { + var n; + if (!e.isValid() || !t.isValid()) + return { milliseconds: 0, months: 0 }; + (t = Gt(t, e)), + e.isBefore(t) + ? (n = zt(e, t)) + : (((n = zt(t, e)).milliseconds = -n.milliseconds), + (n.months = -n.months)); + return n; + })(W(s.from), W(s.to))), + ((s = {}).ms = t.milliseconds), + (s.M = t.months)), + (i = new Ct(s)), + Ut(e) && c(e, "_locale") && (i._locale = e._locale), + Ut(e) && c(e, "_isValid") && (i._isValid = e._isValid), + i + ); + } + function Zt(e, t) { + e = e && parseFloat(e.replace(",", ".")); + return (isNaN(e) ? 0 : e) * t; + } + function zt(e, t) { + var n = {}; + return ( + (n.months = t.month() - e.month() + 12 * (t.year() - e.year())), + e.clone().add(n.months, "M").isAfter(t) && --n.months, + (n.milliseconds = +t - +e.clone().add(n.months, "M")), + n + ); + } + function $t(s, i) { + return function (e, t) { + var n; + return ( + null === t || + isNaN(+t) || + (Q( + i, + "moment()." + + i + + "(period, number) is deprecated. Please use moment()." + + i + + "(number, period). See http://momentjs.com/guides/#/warnings/add-inverted-param/ for more info." + ), + (n = e), + (e = t), + (t = n)), + qt(this, C(e, t), s), + this + ); + }; + } + function qt(e, t, n, s) { + var i = t._milliseconds, + r = Ht(t._days), + t = Ht(t._months); + e.isValid() && + ((s = null == s || s), + t && Ve(e, ce(e, "Month") + t * n), + r && fe(e, "Date", ce(e, "Date") + r * n), + i && e._d.setTime(e._d.valueOf() + i * n), + s && f.updateOffset(e, r || t)); + } + (C.fn = Ct.prototype), + (C.invalid = function () { + return C(NaN); + }); + (Ce = $t(1, "add")), (Je = $t(-1, "subtract")); + function Bt(e) { + return "string" == typeof e || e instanceof String; + } + function Jt(e) { + return ( + h(e) || + V(e) || + Bt(e) || + u(e) || + (function (t) { + var e = a(t), + n = !1; + e && + (n = + 0 === + t.filter(function (e) { + return !u(e) && Bt(t); + }).length); + return e && n; + })(e) || + (function (e) { + var t, + n, + s = F(e) && !L(e), + i = !1, + r = [ + "years", + "year", + "y", + "months", + "month", + "M", + "days", + "day", + "d", + "dates", + "date", + "D", + "hours", + "hour", + "h", + "minutes", + "minute", + "m", + "seconds", + "second", + "s", + "milliseconds", + "millisecond", + "ms", + ], + a = r.length; + for (t = 0; t < a; t += 1) (n = r[t]), (i = i || c(e, n)); + return s && i; + })(e) || + null == e + ); + } + function Qt(e, t) { + if (e.date() < t.date()) return -Qt(t, e); + var n = 12 * (t.year() - e.year()) + (t.month() - e.month()), + s = e.clone().add(n, "months"), + t = + t - s < 0 + ? (t - s) / (s - e.clone().add(n - 1, "months")) + : (t - s) / (e.clone().add(1 + n, "months") - s); + return -(n + t) || 0; + } + function Xt(e) { + return void 0 === e + ? this._locale._abbr + : (null != (e = mt(e)) && (this._locale = e), this); + } + (f.defaultFormat = "YYYY-MM-DDTHH:mm:ssZ"), + (f.defaultFormatUtc = "YYYY-MM-DDTHH:mm:ss[Z]"); + Xe = e( + "moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.", + function (e) { + return void 0 === e ? this.localeData() : this.locale(e); + } + ); + function Kt() { + return this._locale; + } + var en = 126227808e5; + function tn(e, t) { + return ((e % t) + t) % t; + } + function nn(e, t, n) { + return e < 100 && 0 <= e + ? new Date(e + 400, t, n) - en + : new Date(e, t, n).valueOf(); + } + function sn(e, t, n) { + return e < 100 && 0 <= e ? Date.UTC(e + 400, t, n) - en : Date.UTC(e, t, n); + } + function rn(e, t) { + return t.erasAbbrRegex(e); + } + function an() { + for ( + var e = [], t = [], n = [], s = [], i = this.eras(), r = 0, a = i.length; + r < a; + ++r + ) + t.push(M(i[r].name)), + e.push(M(i[r].abbr)), + n.push(M(i[r].narrow)), + s.push(M(i[r].name)), + s.push(M(i[r].abbr)), + s.push(M(i[r].narrow)); + (this._erasRegex = new RegExp("^(" + s.join("|") + ")", "i")), + (this._erasNameRegex = new RegExp("^(" + t.join("|") + ")", "i")), + (this._erasAbbrRegex = new RegExp("^(" + e.join("|") + ")", "i")), + (this._erasNarrowRegex = new RegExp("^(" + n.join("|") + ")", "i")); + } + function on(e, t) { + s(0, [e, e.length], 0, t); + } + function un(e, t, n, s, i) { + var r; + return null == e + ? qe(this, s, i).year + : ((r = P(e, s, i)), + function (e, t, n, s, i) { + (e = $e(e, t, n, s, i)), (t = Ze(e.year, 0, e.dayOfYear)); + return ( + this.year(t.getUTCFullYear()), + this.month(t.getUTCMonth()), + this.date(t.getUTCDate()), + this + ); + }.call(this, e, (t = r < t ? r : t), n, s, i)); + } + s("N", 0, 0, "eraAbbr"), + s("NN", 0, 0, "eraAbbr"), + s("NNN", 0, 0, "eraAbbr"), + s("NNNN", 0, 0, "eraName"), + s("NNNNN", 0, 0, "eraNarrow"), + s("y", ["y", 1], "yo", "eraYear"), + s("y", ["yy", 2], 0, "eraYear"), + s("y", ["yyy", 3], 0, "eraYear"), + s("y", ["yyyy", 4], 0, "eraYear"), + k("N", rn), + k("NN", rn), + k("NNN", rn), + k("NNNN", function (e, t) { + return t.erasNameRegex(e); + }), + k("NNNNN", function (e, t) { + return t.erasNarrowRegex(e); + }), + D(["N", "NN", "NNN", "NNNN", "NNNNN"], function (e, t, n, s) { + s = n._locale.erasParse(e, s, n._strict); + s ? (m(n).era = s) : (m(n).invalidEra = e); + }), + k("y", Me), + k("yy", Me), + k("yyy", Me), + k("yyyy", Me), + k("yo", function (e, t) { + return t._eraYearOrdinalRegex || Me; + }), + D(["y", "yy", "yyy", "yyyy"], Y), + D(["yo"], function (e, t, n, s) { + var i; + n._locale._eraYearOrdinalRegex && + (i = e.match(n._locale._eraYearOrdinalRegex)), + n._locale.eraYearOrdinalParse + ? (t[Y] = n._locale.eraYearOrdinalParse(e, i)) + : (t[Y] = parseInt(e, 10)); + }), + s(0, ["gg", 2], 0, function () { + return this.weekYear() % 100; + }), + s(0, ["GG", 2], 0, function () { + return this.isoWeekYear() % 100; + }), + on("gggg", "weekYear"), + on("ggggg", "weekYear"), + on("GGGG", "isoWeekYear"), + on("GGGGG", "isoWeekYear"), + t("weekYear", "gg"), + t("isoWeekYear", "GG"), + n("weekYear", 1), + n("isoWeekYear", 1), + k("G", De), + k("g", De), + k("GG", p, w), + k("gg", p, w), + k("GGGG", ve, _e), + k("gggg", ve, _e), + k("GGGGG", ke, ye), + k("ggggg", ke, ye), + Te(["gggg", "ggggg", "GGGG", "GGGGG"], function (e, t, n, s) { + t[s.substr(0, 2)] = g(e); + }), + Te(["gg", "GG"], function (e, t, n, s) { + t[s] = f.parseTwoDigitYear(e); + }), + s("Q", 0, "Qo", "quarter"), + t("quarter", "Q"), + n("quarter", 7), + k("Q", i), + D("Q", function (e, t) { + t[O] = 3 * (g(e) - 1); + }), + s("D", ["DD", 2], "Do", "date"), + t("date", "D"), + n("date", 9), + k("D", p), + k("DD", p, w), + k("Do", function (e, t) { + return e + ? t._dayOfMonthOrdinalParse || t._ordinalParse + : t._dayOfMonthOrdinalParseLenient; + }), + D(["D", "DD"], b), + D("Do", function (e, t) { + t[b] = g(e.match(p)[0]); + }); + ve = de("Date", !0); + s("DDD", ["DDDD", 3], "DDDo", "dayOfYear"), + t("dayOfYear", "DDD"), + n("dayOfYear", 4), + k("DDD", pe), + k("DDDD", me), + D(["DDD", "DDDD"], function (e, t, n) { + n._dayOfYear = g(e); + }), + s("m", ["mm", 2], 0, "minute"), + t("minute", "m"), + n("minute", 14), + k("m", p), + k("mm", p, w), + D(["m", "mm"], T); + var ln, + _e = de("Minutes", !1), + ke = + (s("s", ["ss", 2], 0, "second"), + t("second", "s"), + n("second", 15), + k("s", p), + k("ss", p, w), + D(["s", "ss"], N), + de("Seconds", !1)); + for ( + s("S", 0, 0, function () { + return ~~(this.millisecond() / 100); + }), + s(0, ["SS", 2], 0, function () { + return ~~(this.millisecond() / 10); + }), + s(0, ["SSS", 3], 0, "millisecond"), + s(0, ["SSSS", 4], 0, function () { + return 10 * this.millisecond(); + }), + s(0, ["SSSSS", 5], 0, function () { + return 100 * this.millisecond(); + }), + s(0, ["SSSSSS", 6], 0, function () { + return 1e3 * this.millisecond(); + }), + s(0, ["SSSSSSS", 7], 0, function () { + return 1e4 * this.millisecond(); + }), + s(0, ["SSSSSSSS", 8], 0, function () { + return 1e5 * this.millisecond(); + }), + s(0, ["SSSSSSSSS", 9], 0, function () { + return 1e6 * this.millisecond(); + }), + t("millisecond", "ms"), + n("millisecond", 16), + k("S", pe, i), + k("SS", pe, w), + k("SSS", pe, me), + ln = "SSSS"; + ln.length <= 9; + ln += "S" + ) + k(ln, Me); + function hn(e, t) { + t[Ne] = g(1e3 * ("0." + e)); + } + for (ln = "S"; ln.length <= 9; ln += "S") D(ln, hn); + (ye = de("Milliseconds", !1)), + s("z", 0, 0, "zoneAbbr"), + s("zz", 0, 0, "zoneName"); + i = q.prototype; + function dn(e) { + return e; + } + (i.add = Ce), + (i.calendar = function (e, t) { + 1 === arguments.length && + (arguments[0] + ? Jt(arguments[0]) + ? ((e = arguments[0]), (t = void 0)) + : (function (e) { + for ( + var t = F(e) && !L(e), + n = !1, + s = [ + "sameDay", + "nextDay", + "lastDay", + "nextWeek", + "lastWeek", + "sameElse", + ], + i = 0; + i < s.length; + i += 1 + ) + n = n || c(e, s[i]); + return t && n; + })(arguments[0]) && ((t = arguments[0]), (e = void 0)) + : (t = e = void 0)); + var e = e || W(), + n = Gt(e, this).startOf("day"), + n = f.calendarFormat(this, n) || "sameElse", + t = t && (d(t[n]) ? t[n].call(this, e) : t[n]); + return this.format(t || this.localeData().calendar(n, this, W(e))); + }), + (i.clone = function () { + return new q(this); + }), + (i.diff = function (e, t, n) { + var s, i, r; + if (!this.isValid()) return NaN; + if (!(s = Gt(e, this)).isValid()) return NaN; + switch (((i = 6e4 * (s.utcOffset() - this.utcOffset())), (t = _(t)))) { + case "year": + r = Qt(this, s) / 12; + break; + case "month": + r = Qt(this, s); + break; + case "quarter": + r = Qt(this, s) / 3; + break; + case "second": + r = (this - s) / 1e3; + break; + case "minute": + r = (this - s) / 6e4; + break; + case "hour": + r = (this - s) / 36e5; + break; + case "day": + r = (this - s - i) / 864e5; + break; + case "week": + r = (this - s - i) / 6048e5; + break; + default: + r = this - s; + } + return n ? r : y(r); + }), + (i.endOf = function (e) { + var t, n; + if (void 0 === (e = _(e)) || "millisecond" === e || !this.isValid()) + return this; + switch (((n = this._isUTC ? sn : nn), e)) { + case "year": + t = n(this.year() + 1, 0, 1) - 1; + break; + case "quarter": + t = n(this.year(), this.month() - (this.month() % 3) + 3, 1) - 1; + break; + case "month": + t = n(this.year(), this.month() + 1, 1) - 1; + break; + case "week": + t = + n(this.year(), this.month(), this.date() - this.weekday() + 7) - 1; + break; + case "isoWeek": + t = + n( + this.year(), + this.month(), + this.date() - (this.isoWeekday() - 1) + 7 + ) - 1; + break; + case "day": + case "date": + t = n(this.year(), this.month(), this.date() + 1) - 1; + break; + case "hour": + (t = this._d.valueOf()), + (t += + 36e5 - + tn(t + (this._isUTC ? 0 : 6e4 * this.utcOffset()), 36e5) - + 1); + break; + case "minute": + (t = this._d.valueOf()), (t += 6e4 - tn(t, 6e4) - 1); + break; + case "second": + (t = this._d.valueOf()), (t += 1e3 - tn(t, 1e3) - 1); + } + return this._d.setTime(t), f.updateOffset(this, !0), this; + }), + (i.format = function (e) { + return ( + (e = e || (this.isUtc() ? f.defaultFormatUtc : f.defaultFormat)), + (e = re(this, e)), + this.localeData().postformat(e) + ); + }), + (i.from = function (e, t) { + return this.isValid() && ((h(e) && e.isValid()) || W(e).isValid()) + ? C({ to: this, from: e }).locale(this.locale()).humanize(!t) + : this.localeData().invalidDate(); + }), + (i.fromNow = function (e) { + return this.from(W(), e); + }), + (i.to = function (e, t) { + return this.isValid() && ((h(e) && e.isValid()) || W(e).isValid()) + ? C({ from: this, to: e }).locale(this.locale()).humanize(!t) + : this.localeData().invalidDate(); + }), + (i.toNow = function (e) { + return this.to(W(), e); + }), + (i.get = function (e) { + return d(this[(e = _(e))]) ? this[e]() : this; + }), + (i.invalidAt = function () { + return m(this).overflow; + }), + (i.isAfter = function (e, t) { + return ( + (e = h(e) ? e : W(e)), + !(!this.isValid() || !e.isValid()) && + ("millisecond" === (t = _(t) || "millisecond") + ? this.valueOf() > e.valueOf() + : e.valueOf() < this.clone().startOf(t).valueOf()) + ); + }), + (i.isBefore = function (e, t) { + return ( + (e = h(e) ? e : W(e)), + !(!this.isValid() || !e.isValid()) && + ("millisecond" === (t = _(t) || "millisecond") + ? this.valueOf() < e.valueOf() + : this.clone().endOf(t).valueOf() < e.valueOf()) + ); + }), + (i.isBetween = function (e, t, n, s) { + return ( + (e = h(e) ? e : W(e)), + (t = h(t) ? t : W(t)), + !!(this.isValid() && e.isValid() && t.isValid()) && + ("(" === (s = s || "()")[0] + ? this.isAfter(e, n) + : !this.isBefore(e, n)) && + (")" === s[1] ? this.isBefore(t, n) : !this.isAfter(t, n)) + ); + }), + (i.isSame = function (e, t) { + var e = h(e) ? e : W(e); + return ( + !(!this.isValid() || !e.isValid()) && + ("millisecond" === (t = _(t) || "millisecond") + ? this.valueOf() === e.valueOf() + : ((e = e.valueOf()), + this.clone().startOf(t).valueOf() <= e && + e <= this.clone().endOf(t).valueOf())) + ); + }), + (i.isSameOrAfter = function (e, t) { + return this.isSame(e, t) || this.isAfter(e, t); + }), + (i.isSameOrBefore = function (e, t) { + return this.isSame(e, t) || this.isBefore(e, t); + }), + (i.isValid = function () { + return A(this); + }), + (i.lang = Xe), + (i.locale = Xt), + (i.localeData = Kt), + (i.max = we), + (i.min = ge), + (i.parsingFlags = function () { + return E({}, m(this)); + }), + (i.set = function (e, t) { + if ("object" == typeof e) + for ( + var n = (function (e) { + var t, + n = []; + for (t in e) c(e, t) && n.push({ unit: t, priority: le[t] }); + return ( + n.sort(function (e, t) { + return e.priority - t.priority; + }), + n + ); + })((e = ue(e))), + s = n.length, + i = 0; + i < s; + i++ + ) + this[n[i].unit](e[n[i].unit]); + else if (d(this[(e = _(e))])) return this[e](t); + return this; + }), + (i.startOf = function (e) { + var t, n; + if (void 0 === (e = _(e)) || "millisecond" === e || !this.isValid()) + return this; + switch (((n = this._isUTC ? sn : nn), e)) { + case "year": + t = n(this.year(), 0, 1); + break; + case "quarter": + t = n(this.year(), this.month() - (this.month() % 3), 1); + break; + case "month": + t = n(this.year(), this.month(), 1); + break; + case "week": + t = n(this.year(), this.month(), this.date() - this.weekday()); + break; + case "isoWeek": + t = n( + this.year(), + this.month(), + this.date() - (this.isoWeekday() - 1) + ); + break; + case "day": + case "date": + t = n(this.year(), this.month(), this.date()); + break; + case "hour": + (t = this._d.valueOf()), + (t -= tn(t + (this._isUTC ? 0 : 6e4 * this.utcOffset()), 36e5)); + break; + case "minute": + (t = this._d.valueOf()), (t -= tn(t, 6e4)); + break; + case "second": + (t = this._d.valueOf()), (t -= tn(t, 1e3)); + } + return this._d.setTime(t), f.updateOffset(this, !0), this; + }), + (i.subtract = Je), + (i.toArray = function () { + var e = this; + return [ + e.year(), + e.month(), + e.date(), + e.hour(), + e.minute(), + e.second(), + e.millisecond(), + ]; + }), + (i.toObject = function () { + var e = this; + return { + years: e.year(), + months: e.month(), + date: e.date(), + hours: e.hours(), + minutes: e.minutes(), + seconds: e.seconds(), + milliseconds: e.milliseconds(), + }; + }), + (i.toDate = function () { + return new Date(this.valueOf()); + }), + (i.toISOString = function (e) { + if (!this.isValid()) return null; + var t = (e = !0 !== e) ? this.clone().utc() : this; + return t.year() < 0 || 9999 < t.year() + ? re( + t, + e + ? "YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]" + : "YYYYYY-MM-DD[T]HH:mm:ss.SSSZ" + ) + : d(Date.prototype.toISOString) + ? e + ? this.toDate().toISOString() + : new Date(this.valueOf() + 60 * this.utcOffset() * 1e3) + .toISOString() + .replace("Z", re(t, "Z")) + : re( + t, + e ? "YYYY-MM-DD[T]HH:mm:ss.SSS[Z]" : "YYYY-MM-DD[T]HH:mm:ss.SSSZ" + ); + }), + (i.inspect = function () { + if (!this.isValid()) return "moment.invalid(/* " + this._i + " */)"; + var e, + t = "moment", + n = ""; + return ( + this.isLocal() || + ((t = 0 === this.utcOffset() ? "moment.utc" : "moment.parseZone"), + (n = "Z")), + (t = "[" + t + '("]'), + (e = 0 <= this.year() && this.year() <= 9999 ? "YYYY" : "YYYYYY"), + this.format(t + e + "-MM-DD[T]HH:mm:ss.SSS" + (n + '[")]')) + ); + }), + "undefined" != typeof Symbol && + null != Symbol.for && + (i[Symbol.for("nodejs.util.inspect.custom")] = function () { + return "Moment<" + this.format() + ">"; + }), + (i.toJSON = function () { + return this.isValid() ? this.toISOString() : null; + }), + (i.toString = function () { + return this.clone() + .locale("en") + .format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ"); + }), + (i.unix = function () { + return Math.floor(this.valueOf() / 1e3); + }), + (i.valueOf = function () { + return this._d.valueOf() - 6e4 * (this._offset || 0); + }), + (i.creationData = function () { + return { + input: this._i, + format: this._f, + locale: this._locale, + isUTC: this._isUTC, + strict: this._strict, + }; + }), + (i.eraName = function () { + for ( + var e, t = this.localeData().eras(), n = 0, s = t.length; + n < s; + ++n + ) { + if ( + ((e = this.clone().startOf("day").valueOf()), + t[n].since <= e && e <= t[n].until) + ) + return t[n].name; + if (t[n].until <= e && e <= t[n].since) return t[n].name; + } + return ""; + }), + (i.eraNarrow = function () { + for ( + var e, t = this.localeData().eras(), n = 0, s = t.length; + n < s; + ++n + ) { + if ( + ((e = this.clone().startOf("day").valueOf()), + t[n].since <= e && e <= t[n].until) + ) + return t[n].narrow; + if (t[n].until <= e && e <= t[n].since) return t[n].narrow; + } + return ""; + }), + (i.eraAbbr = function () { + for ( + var e, t = this.localeData().eras(), n = 0, s = t.length; + n < s; + ++n + ) { + if ( + ((e = this.clone().startOf("day").valueOf()), + t[n].since <= e && e <= t[n].until) + ) + return t[n].abbr; + if (t[n].until <= e && e <= t[n].since) return t[n].abbr; + } + return ""; + }), + (i.eraYear = function () { + for ( + var e, t, n = this.localeData().eras(), s = 0, i = n.length; + s < i; + ++s + ) + if ( + ((e = n[s].since <= n[s].until ? 1 : -1), + (t = this.clone().startOf("day").valueOf()), + (n[s].since <= t && t <= n[s].until) || + (n[s].until <= t && t <= n[s].since)) + ) + return (this.year() - f(n[s].since).year()) * e + n[s].offset; + return this.year(); + }), + (i.year = Ie), + (i.isLeapYear = function () { + return he(this.year()); + }), + (i.weekYear = function (e) { + return un.call( + this, + e, + this.week(), + this.weekday(), + this.localeData()._week.dow, + this.localeData()._week.doy + ); + }), + (i.isoWeekYear = function (e) { + return un.call(this, e, this.isoWeek(), this.isoWeekday(), 1, 4); + }), + (i.quarter = i.quarters = + function (e) { + return null == e + ? Math.ceil((this.month() + 1) / 3) + : this.month(3 * (e - 1) + (this.month() % 3)); + }), + (i.month = Ge), + (i.daysInMonth = function () { + return We(this.year(), this.month()); + }), + (i.week = i.weeks = + function (e) { + var t = this.localeData().week(this); + return null == e ? t : this.add(7 * (e - t), "d"); + }), + (i.isoWeek = i.isoWeeks = + function (e) { + var t = qe(this, 1, 4).week; + return null == e ? t : this.add(7 * (e - t), "d"); + }), + (i.weeksInYear = function () { + var e = this.localeData()._week; + return P(this.year(), e.dow, e.doy); + }), + (i.weeksInWeekYear = function () { + var e = this.localeData()._week; + return P(this.weekYear(), e.dow, e.doy); + }), + (i.isoWeeksInYear = function () { + return P(this.year(), 1, 4); + }), + (i.isoWeeksInISOWeekYear = function () { + return P(this.isoWeekYear(), 1, 4); + }), + (i.date = ve), + (i.day = i.days = + function (e) { + if (!this.isValid()) return null != e ? this : NaN; + var t, + n, + s = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); + return null != e + ? ((t = e), + (n = this.localeData()), + (e = + "string" != typeof t + ? t + : isNaN(t) + ? "number" == typeof (t = n.weekdaysParse(t)) + ? t + : null + : parseInt(t, 10)), + this.add(e - s, "d")) + : s; + }), + (i.weekday = function (e) { + if (!this.isValid()) return null != e ? this : NaN; + var t = (this.day() + 7 - this.localeData()._week.dow) % 7; + return null == e ? t : this.add(e - t, "d"); + }), + (i.isoWeekday = function (e) { + return this.isValid() + ? null != e + ? ((t = e), + (n = this.localeData()), + (n = + "string" == typeof t + ? n.weekdaysParse(t) % 7 || 7 + : isNaN(t) + ? null + : t), + this.day(this.day() % 7 ? n : n - 7)) + : this.day() || 7 + : null != e + ? this + : NaN; + var t, n; + }), + (i.dayOfYear = function (e) { + var t = + Math.round( + (this.clone().startOf("day") - this.clone().startOf("year")) / 864e5 + ) + 1; + return null == e ? t : this.add(e - t, "d"); + }), + (i.hour = i.hours = v), + (i.minute = i.minutes = _e), + (i.second = i.seconds = ke), + (i.millisecond = i.milliseconds = ye), + (i.utcOffset = function (e, t, n) { + var s, + i = this._offset || 0; + if (!this.isValid()) return null != e ? this : NaN; + if (null == e) return this._isUTC ? i : Et(this); + if ("string" == typeof e) { + if (null === (e = Vt(Ye, e))) return this; + } else Math.abs(e) < 16 && !n && (e *= 60); + return ( + !this._isUTC && t && (s = Et(this)), + (this._offset = e), + (this._isUTC = !0), + null != s && this.add(s, "m"), + i !== e && + (!t || this._changeInProgress + ? qt(this, C(e - i, "m"), 1, !1) + : this._changeInProgress || + ((this._changeInProgress = !0), + f.updateOffset(this, !0), + (this._changeInProgress = null))), + this + ); + }), + (i.utc = function (e) { + return this.utcOffset(0, e); + }), + (i.local = function (e) { + return ( + this._isUTC && + (this.utcOffset(0, e), + (this._isUTC = !1), + e && this.subtract(Et(this), "m")), + this + ); + }), + (i.parseZone = function () { + var e; + return ( + null != this._tzm + ? this.utcOffset(this._tzm, !1, !0) + : "string" == typeof this._i && + (null != (e = Vt(Se, this._i)) + ? this.utcOffset(e) + : this.utcOffset(0, !0)), + this + ); + }), + (i.hasAlignedHourOffset = function (e) { + return ( + !!this.isValid() && + ((e = e ? W(e).utcOffset() : 0), (this.utcOffset() - e) % 60 == 0) + ); + }), + (i.isDST = function () { + return ( + this.utcOffset() > this.clone().month(0).utcOffset() || + this.utcOffset() > this.clone().month(5).utcOffset() + ); + }), + (i.isLocal = function () { + return !!this.isValid() && !this._isUTC; + }), + (i.isUtcOffset = function () { + return !!this.isValid() && this._isUTC; + }), + (i.isUtc = At), + (i.isUTC = At), + (i.zoneAbbr = function () { + return this._isUTC ? "UTC" : ""; + }), + (i.zoneName = function () { + return this._isUTC ? "Coordinated Universal Time" : ""; + }), + (i.dates = e("dates accessor is deprecated. Use date instead.", ve)), + (i.months = e("months accessor is deprecated. Use month instead", Ge)), + (i.years = e("years accessor is deprecated. Use year instead", Ie)), + (i.zone = e( + "moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/", + function (e, t) { + return null != e + ? (this.utcOffset((e = "string" != typeof e ? -e : e), t), this) + : -this.utcOffset(); + } + )), + (i.isDSTShifted = e( + "isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information", + function () { + if (!o(this._isDSTShifted)) return this._isDSTShifted; + var e, + t = {}; + return ( + $(t, this), + (t = Nt(t))._a + ? ((e = (t._isUTC ? l : W)(t._a)), + (this._isDSTShifted = + this.isValid() && + 0 < + (function (e, t, n) { + for ( + var s = Math.min(e.length, t.length), + i = Math.abs(e.length - t.length), + r = 0, + a = 0; + a < s; + a++ + ) + ((n && e[a] !== t[a]) || (!n && g(e[a]) !== g(t[a]))) && + r++; + return r + i; + })(t._a, e.toArray()))) + : (this._isDSTShifted = !1), + this._isDSTShifted + ); + } + )); + w = K.prototype; + function cn(e, t, n, s) { + var i = mt(), + s = l().set(s, t); + return i[n](s, e); + } + function fn(e, t, n) { + if ((u(e) && ((t = e), (e = void 0)), (e = e || ""), null != t)) + return cn(e, t, n, "month"); + for (var s = [], i = 0; i < 12; i++) s[i] = cn(e, i, n, "month"); + return s; + } + function mn(e, t, n, s) { + t = + ("boolean" == typeof e + ? u(t) && ((n = t), (t = void 0)) + : ((t = e), (e = !1), u((n = t)) && ((n = t), (t = void 0))), + t || ""); + var i, + r = mt(), + a = e ? r._week.dow : 0, + o = []; + if (null != n) return cn(t, (n + a) % 7, s, "day"); + for (i = 0; i < 7; i++) o[i] = cn(t, (i + a) % 7, s, "day"); + return o; + } + (w.calendar = function (e, t, n) { + return d((e = this._calendar[e] || this._calendar.sameElse)) + ? e.call(t, n) + : e; + }), + (w.longDateFormat = function (e) { + var t = this._longDateFormat[e], + n = this._longDateFormat[e.toUpperCase()]; + return t || !n + ? t + : ((this._longDateFormat[e] = n + .match(te) + .map(function (e) { + return "MMMM" === e || "MM" === e || "DD" === e || "dddd" === e + ? e.slice(1) + : e; + }) + .join("")), + this._longDateFormat[e]); + }), + (w.invalidDate = function () { + return this._invalidDate; + }), + (w.ordinal = function (e) { + return this._ordinal.replace("%d", e); + }), + (w.preparse = dn), + (w.postformat = dn), + (w.relativeTime = function (e, t, n, s) { + var i = this._relativeTime[n]; + return d(i) ? i(e, t, n, s) : i.replace(/%d/i, e); + }), + (w.pastFuture = function (e, t) { + return d((e = this._relativeTime[0 < e ? "future" : "past"])) + ? e(t) + : e.replace(/%s/i, t); + }), + (w.set = function (e) { + var t, n; + for (n in e) + c(e, n) && (d((t = e[n])) ? (this[n] = t) : (this["_" + n] = t)); + (this._config = e), + (this._dayOfMonthOrdinalParseLenient = new RegExp( + (this._dayOfMonthOrdinalParse.source || this._ordinalParse.source) + + "|" + + /\d{1,2}/.source + )); + }), + (w.eras = function (e, t) { + for ( + var n, s = this._eras || mt("en")._eras, i = 0, r = s.length; + i < r; + ++i + ) + switch ( + ("string" == typeof s[i].since && + ((n = f(s[i].since).startOf("day")), (s[i].since = n.valueOf())), + typeof s[i].until) + ) { + case "undefined": + s[i].until = 1 / 0; + break; + case "string": + (n = f(s[i].until).startOf("day").valueOf()), + (s[i].until = n.valueOf()); + } + return s; + }), + (w.erasParse = function (e, t, n) { + var s, + i, + r, + a, + o, + u = this.eras(); + for (e = e.toUpperCase(), s = 0, i = u.length; s < i; ++s) + if ( + ((r = u[s].name.toUpperCase()), + (a = u[s].abbr.toUpperCase()), + (o = u[s].narrow.toUpperCase()), + n) + ) + switch (t) { + case "N": + case "NN": + case "NNN": + if (a === e) return u[s]; + break; + case "NNNN": + if (r === e) return u[s]; + break; + case "NNNNN": + if (o === e) return u[s]; + } + else if (0 <= [r, a, o].indexOf(e)) return u[s]; + }), + (w.erasConvertYear = function (e, t) { + var n = e.since <= e.until ? 1 : -1; + return void 0 === t + ? f(e.since).year() + : f(e.since).year() + (t - e.offset) * n; + }), + (w.erasAbbrRegex = function (e) { + return ( + c(this, "_erasAbbrRegex") || an.call(this), + e ? this._erasAbbrRegex : this._erasRegex + ); + }), + (w.erasNameRegex = function (e) { + return ( + c(this, "_erasNameRegex") || an.call(this), + e ? this._erasNameRegex : this._erasRegex + ); + }), + (w.erasNarrowRegex = function (e) { + return ( + c(this, "_erasNarrowRegex") || an.call(this), + e ? this._erasNarrowRegex : this._erasRegex + ); + }), + (w.months = function (e, t) { + return e + ? (a(this._months) + ? this._months + : this._months[ + (this._months.isFormat || He).test(t) ? "format" : "standalone" + ])[e.month()] + : a(this._months) + ? this._months + : this._months.standalone; + }), + (w.monthsShort = function (e, t) { + return e + ? (a(this._monthsShort) + ? this._monthsShort + : this._monthsShort[He.test(t) ? "format" : "standalone"])[ + e.month() + ] + : a(this._monthsShort) + ? this._monthsShort + : this._monthsShort.standalone; + }), + (w.monthsParse = function (e, t, n) { + var s, i; + if (this._monthsParseExact) + return function (e, t, n) { + var s, + i, + r, + e = e.toLocaleLowerCase(); + if (!this._monthsParse) + for ( + this._monthsParse = [], + this._longMonthsParse = [], + this._shortMonthsParse = [], + s = 0; + s < 12; + ++s + ) + (r = l([2e3, s])), + (this._shortMonthsParse[s] = this.monthsShort( + r, + "" + ).toLocaleLowerCase()), + (this._longMonthsParse[s] = this.months( + r, + "" + ).toLocaleLowerCase()); + return n + ? "MMM" === t + ? -1 !== (i = S.call(this._shortMonthsParse, e)) + ? i + : null + : -1 !== (i = S.call(this._longMonthsParse, e)) + ? i + : null + : "MMM" === t + ? -1 !== (i = S.call(this._shortMonthsParse, e)) || + -1 !== (i = S.call(this._longMonthsParse, e)) + ? i + : null + : -1 !== (i = S.call(this._longMonthsParse, e)) || + -1 !== (i = S.call(this._shortMonthsParse, e)) + ? i + : null; + }.call(this, e, t, n); + for ( + this._monthsParse || + ((this._monthsParse = []), + (this._longMonthsParse = []), + (this._shortMonthsParse = [])), + s = 0; + s < 12; + s++ + ) { + if ( + ((i = l([2e3, s])), + n && + !this._longMonthsParse[s] && + ((this._longMonthsParse[s] = new RegExp( + "^" + this.months(i, "").replace(".", "") + "$", + "i" + )), + (this._shortMonthsParse[s] = new RegExp( + "^" + this.monthsShort(i, "").replace(".", "") + "$", + "i" + ))), + n || + this._monthsParse[s] || + ((i = "^" + this.months(i, "") + "|^" + this.monthsShort(i, "")), + (this._monthsParse[s] = new RegExp(i.replace(".", ""), "i"))), + n && "MMMM" === t && this._longMonthsParse[s].test(e)) + ) + return s; + if (n && "MMM" === t && this._shortMonthsParse[s].test(e)) return s; + if (!n && this._monthsParse[s].test(e)) return s; + } + }), + (w.monthsRegex = function (e) { + return this._monthsParseExact + ? (c(this, "_monthsRegex") || Ee.call(this), + e ? this._monthsStrictRegex : this._monthsRegex) + : (c(this, "_monthsRegex") || (this._monthsRegex = Le), + this._monthsStrictRegex && e + ? this._monthsStrictRegex + : this._monthsRegex); + }), + (w.monthsShortRegex = function (e) { + return this._monthsParseExact + ? (c(this, "_monthsRegex") || Ee.call(this), + e ? this._monthsShortStrictRegex : this._monthsShortRegex) + : (c(this, "_monthsShortRegex") || (this._monthsShortRegex = Fe), + this._monthsShortStrictRegex && e + ? this._monthsShortStrictRegex + : this._monthsShortRegex); + }), + (w.week = function (e) { + return qe(e, this._week.dow, this._week.doy).week; + }), + (w.firstDayOfYear = function () { + return this._week.doy; + }), + (w.firstDayOfWeek = function () { + return this._week.dow; + }), + (w.weekdays = function (e, t) { + return ( + (t = a(this._weekdays) + ? this._weekdays + : this._weekdays[ + e && !0 !== e && this._weekdays.isFormat.test(t) + ? "format" + : "standalone" + ]), + !0 === e ? Be(t, this._week.dow) : e ? t[e.day()] : t + ); + }), + (w.weekdaysMin = function (e) { + return !0 === e + ? Be(this._weekdaysMin, this._week.dow) + : e + ? this._weekdaysMin[e.day()] + : this._weekdaysMin; + }), + (w.weekdaysShort = function (e) { + return !0 === e + ? Be(this._weekdaysShort, this._week.dow) + : e + ? this._weekdaysShort[e.day()] + : this._weekdaysShort; + }), + (w.weekdaysParse = function (e, t, n) { + var s, i; + if (this._weekdaysParseExact) + return function (e, t, n) { + var s, + i, + r, + e = e.toLocaleLowerCase(); + if (!this._weekdaysParse) + for ( + this._weekdaysParse = [], + this._shortWeekdaysParse = [], + this._minWeekdaysParse = [], + s = 0; + s < 7; + ++s + ) + (r = l([2e3, 1]).day(s)), + (this._minWeekdaysParse[s] = this.weekdaysMin( + r, + "" + ).toLocaleLowerCase()), + (this._shortWeekdaysParse[s] = this.weekdaysShort( + r, + "" + ).toLocaleLowerCase()), + (this._weekdaysParse[s] = this.weekdays( + r, + "" + ).toLocaleLowerCase()); + return n + ? "dddd" === t + ? -1 !== (i = S.call(this._weekdaysParse, e)) + ? i + : null + : "ddd" === t + ? -1 !== (i = S.call(this._shortWeekdaysParse, e)) + ? i + : null + : -1 !== (i = S.call(this._minWeekdaysParse, e)) + ? i + : null + : "dddd" === t + ? -1 !== (i = S.call(this._weekdaysParse, e)) || + -1 !== (i = S.call(this._shortWeekdaysParse, e)) || + -1 !== (i = S.call(this._minWeekdaysParse, e)) + ? i + : null + : "ddd" === t + ? -1 !== (i = S.call(this._shortWeekdaysParse, e)) || + -1 !== (i = S.call(this._weekdaysParse, e)) || + -1 !== (i = S.call(this._minWeekdaysParse, e)) + ? i + : null + : -1 !== (i = S.call(this._minWeekdaysParse, e)) || + -1 !== (i = S.call(this._weekdaysParse, e)) || + -1 !== (i = S.call(this._shortWeekdaysParse, e)) + ? i + : null; + }.call(this, e, t, n); + for ( + this._weekdaysParse || + ((this._weekdaysParse = []), + (this._minWeekdaysParse = []), + (this._shortWeekdaysParse = []), + (this._fullWeekdaysParse = [])), + s = 0; + s < 7; + s++ + ) { + if ( + ((i = l([2e3, 1]).day(s)), + n && + !this._fullWeekdaysParse[s] && + ((this._fullWeekdaysParse[s] = new RegExp( + "^" + this.weekdays(i, "").replace(".", "\\.?") + "$", + "i" + )), + (this._shortWeekdaysParse[s] = new RegExp( + "^" + this.weekdaysShort(i, "").replace(".", "\\.?") + "$", + "i" + )), + (this._minWeekdaysParse[s] = new RegExp( + "^" + this.weekdaysMin(i, "").replace(".", "\\.?") + "$", + "i" + ))), + this._weekdaysParse[s] || + ((i = + "^" + + this.weekdays(i, "") + + "|^" + + this.weekdaysShort(i, "") + + "|^" + + this.weekdaysMin(i, "")), + (this._weekdaysParse[s] = new RegExp(i.replace(".", ""), "i"))), + n && "dddd" === t && this._fullWeekdaysParse[s].test(e)) + ) + return s; + if (n && "ddd" === t && this._shortWeekdaysParse[s].test(e)) return s; + if (n && "dd" === t && this._minWeekdaysParse[s].test(e)) return s; + if (!n && this._weekdaysParse[s].test(e)) return s; + } + }), + (w.weekdaysRegex = function (e) { + return this._weekdaysParseExact + ? (c(this, "_weekdaysRegex") || nt.call(this), + e ? this._weekdaysStrictRegex : this._weekdaysRegex) + : (c(this, "_weekdaysRegex") || (this._weekdaysRegex = Ke), + this._weekdaysStrictRegex && e + ? this._weekdaysStrictRegex + : this._weekdaysRegex); + }), + (w.weekdaysShortRegex = function (e) { + return this._weekdaysParseExact + ? (c(this, "_weekdaysRegex") || nt.call(this), + e ? this._weekdaysShortStrictRegex : this._weekdaysShortRegex) + : (c(this, "_weekdaysShortRegex") || (this._weekdaysShortRegex = et), + this._weekdaysShortStrictRegex && e + ? this._weekdaysShortStrictRegex + : this._weekdaysShortRegex); + }), + (w.weekdaysMinRegex = function (e) { + return this._weekdaysParseExact + ? (c(this, "_weekdaysRegex") || nt.call(this), + e ? this._weekdaysMinStrictRegex : this._weekdaysMinRegex) + : (c(this, "_weekdaysMinRegex") || (this._weekdaysMinRegex = tt), + this._weekdaysMinStrictRegex && e + ? this._weekdaysMinStrictRegex + : this._weekdaysMinRegex); + }), + (w.isPM = function (e) { + return "p" === (e + "").toLowerCase().charAt(0); + }), + (w.meridiem = function (e, t, n) { + return 11 < e ? (n ? "pm" : "PM") : n ? "am" : "AM"; + }), + ct("en", { + eras: [ + { + since: "0001-01-01", + until: 1 / 0, + offset: 1, + name: "Anno Domini", + narrow: "AD", + abbr: "AD", + }, + { + since: "0000-12-31", + until: -1 / 0, + offset: 1, + name: "Before Christ", + narrow: "BC", + abbr: "BC", + }, + ], + dayOfMonthOrdinalParse: /\d{1,2}(th|st|nd|rd)/, + ordinal: function (e) { + var t = e % 10; + return ( + e + + (1 === g((e % 100) / 10) + ? "th" + : 1 == t + ? "st" + : 2 == t + ? "nd" + : 3 == t + ? "rd" + : "th") + ); + }, + }), + (f.lang = e("moment.lang is deprecated. Use moment.locale instead.", ct)), + (f.langData = e( + "moment.langData is deprecated. Use moment.localeData instead.", + mt + )); + var _n = Math.abs; + function yn(e, t, n, s) { + t = C(t, n); + return ( + (e._milliseconds += s * t._milliseconds), + (e._days += s * t._days), + (e._months += s * t._months), + e._bubble() + ); + } + function gn(e) { + return e < 0 ? Math.floor(e) : Math.ceil(e); + } + function wn(e) { + return (4800 * e) / 146097; + } + function pn(e) { + return (146097 * e) / 4800; + } + function vn(e) { + return function () { + return this.as(e); + }; + } + (pe = vn("ms")), + (me = vn("s")), + (Ce = vn("m")), + (we = vn("h")), + (ge = vn("d")), + (Je = vn("w")), + (v = vn("M")), + (_e = vn("Q")), + (ke = vn("y")); + function kn(e) { + return function () { + return this.isValid() ? this._data[e] : NaN; + }; + } + var ye = kn("milliseconds"), + ve = kn("seconds"), + Ie = kn("minutes"), + w = kn("hours"), + Mn = kn("days"), + Dn = kn("months"), + Sn = kn("years"); + var Yn = Math.round, + On = { ss: 44, s: 45, m: 45, h: 22, d: 26, w: null, M: 11 }; + function bn(e, t, n, s) { + var i = C(e).abs(), + r = Yn(i.as("s")), + a = Yn(i.as("m")), + o = Yn(i.as("h")), + u = Yn(i.as("d")), + l = Yn(i.as("M")), + h = Yn(i.as("w")), + i = Yn(i.as("y")), + r = + (r <= n.ss ? ["s", r] : r < n.s && ["ss", r]) || + (a <= 1 && ["m"]) || + (a < n.m && ["mm", a]) || + (o <= 1 && ["h"]) || + (o < n.h && ["hh", o]) || + (u <= 1 && ["d"]) || + (u < n.d && ["dd", u]); + return ( + ((r = (r = + null != n.w ? r || (h <= 1 && ["w"]) || (h < n.w && ["ww", h]) : r) || + (l <= 1 && ["M"]) || + (l < n.M && ["MM", l]) || + (i <= 1 && ["y"]) || ["yy", i])[2] = t), + (r[3] = 0 < +e), + (r[4] = s), + function (e, t, n, s, i) { + return i.relativeTime(t || 1, !!n, e, s); + }.apply(null, r) + ); + } + var xn = Math.abs; + function Tn(e) { + return (0 < e) - (e < 0) || +e; + } + function Nn() { + if (!this.isValid()) return this.localeData().invalidDate(); + var e, + t, + n, + s, + i, + r, + a, + o = xn(this._milliseconds) / 1e3, + u = xn(this._days), + l = xn(this._months), + h = this.asSeconds(); + return h + ? ((e = y(o / 60)), + (t = y(e / 60)), + (o %= 60), + (e %= 60), + (n = y(l / 12)), + (l %= 12), + (s = o ? o.toFixed(3).replace(/\.?0+$/, "") : ""), + (i = Tn(this._months) !== Tn(h) ? "-" : ""), + (r = Tn(this._days) !== Tn(h) ? "-" : ""), + (a = Tn(this._milliseconds) !== Tn(h) ? "-" : ""), + (h < 0 ? "-" : "") + + "P" + + (n ? i + n + "Y" : "") + + (l ? i + l + "M" : "") + + (u ? r + u + "D" : "") + + (t || e || o ? "T" : "") + + (t ? a + t + "H" : "") + + (e ? a + e + "M" : "") + + (o ? a + s + "S" : "")) + : "P0D"; + } + var U = Ct.prototype; + return ( + (U.isValid = function () { + return this._isValid; + }), + (U.abs = function () { + var e = this._data; + return ( + (this._milliseconds = _n(this._milliseconds)), + (this._days = _n(this._days)), + (this._months = _n(this._months)), + (e.milliseconds = _n(e.milliseconds)), + (e.seconds = _n(e.seconds)), + (e.minutes = _n(e.minutes)), + (e.hours = _n(e.hours)), + (e.months = _n(e.months)), + (e.years = _n(e.years)), + this + ); + }), + (U.add = function (e, t) { + return yn(this, e, t, 1); + }), + (U.subtract = function (e, t) { + return yn(this, e, t, -1); + }), + (U.as = function (e) { + if (!this.isValid()) return NaN; + var t, + n, + s = this._milliseconds; + if ("month" === (e = _(e)) || "quarter" === e || "year" === e) + switch (((t = this._days + s / 864e5), (n = this._months + wn(t)), e)) { + case "month": + return n; + case "quarter": + return n / 3; + case "year": + return n / 12; + } + else + switch (((t = this._days + Math.round(pn(this._months))), e)) { + case "week": + return t / 7 + s / 6048e5; + case "day": + return t + s / 864e5; + case "hour": + return 24 * t + s / 36e5; + case "minute": + return 1440 * t + s / 6e4; + case "second": + return 86400 * t + s / 1e3; + case "millisecond": + return Math.floor(864e5 * t) + s; + default: + throw new Error("Unknown unit " + e); + } + }), + (U.asMilliseconds = pe), + (U.asSeconds = me), + (U.asMinutes = Ce), + (U.asHours = we), + (U.asDays = ge), + (U.asWeeks = Je), + (U.asMonths = v), + (U.asQuarters = _e), + (U.asYears = ke), + (U.valueOf = function () { + return this.isValid() + ? this._milliseconds + + 864e5 * this._days + + (this._months % 12) * 2592e6 + + 31536e6 * g(this._months / 12) + : NaN; + }), + (U._bubble = function () { + var e = this._milliseconds, + t = this._days, + n = this._months, + s = this._data; + return ( + (0 <= e && 0 <= t && 0 <= n) || + (e <= 0 && t <= 0 && n <= 0) || + ((e += 864e5 * gn(pn(n) + t)), (n = t = 0)), + (s.milliseconds = e % 1e3), + (e = y(e / 1e3)), + (s.seconds = e % 60), + (e = y(e / 60)), + (s.minutes = e % 60), + (e = y(e / 60)), + (s.hours = e % 24), + (t += y(e / 24)), + (n += e = y(wn(t))), + (t -= gn(pn(e))), + (e = y(n / 12)), + (n %= 12), + (s.days = t), + (s.months = n), + (s.years = e), + this + ); + }), + (U.clone = function () { + return C(this); + }), + (U.get = function (e) { + return (e = _(e)), this.isValid() ? this[e + "s"]() : NaN; + }), + (U.milliseconds = ye), + (U.seconds = ve), + (U.minutes = Ie), + (U.hours = w), + (U.days = Mn), + (U.weeks = function () { + return y(this.days() / 7); + }), + (U.months = Dn), + (U.years = Sn), + (U.humanize = function (e, t) { + if (!this.isValid()) return this.localeData().invalidDate(); + var n = !1, + s = On; + return ( + "object" == typeof e && ((t = e), (e = !1)), + "boolean" == typeof e && (n = e), + "object" == typeof t && + ((s = Object.assign({}, On, t)), + null != t.s && null == t.ss && (s.ss = t.s - 1)), + (e = this.localeData()), + (t = bn(this, !n, s, e)), + n && (t = e.pastFuture(+this, t)), + e.postformat(t) + ); + }), + (U.toISOString = Nn), + (U.toString = Nn), + (U.toJSON = Nn), + (U.locale = Xt), + (U.localeData = Kt), + (U.toIsoString = e( + "toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)", + Nn + )), + (U.lang = Xe), + s("X", 0, 0, "unix"), + s("x", 0, 0, "valueOf"), + k("x", De), + k("X", /[+-]?\d+(\.\d{1,3})?/), + D("X", function (e, t, n) { + n._d = new Date(1e3 * parseFloat(e)); + }), + D("x", function (e, t, n) { + n._d = new Date(g(e)); + }), + (f.version = "2.29.4"), + (H = W), + (f.fn = i), + (f.min = function () { + return Rt("isBefore", [].slice.call(arguments, 0)); + }), + (f.max = function () { + return Rt("isAfter", [].slice.call(arguments, 0)); + }), + (f.now = function () { + return Date.now ? Date.now() : +new Date(); + }), + (f.utc = l), + (f.unix = function (e) { + return W(1e3 * e); + }), + (f.months = function (e, t) { + return fn(e, t, "months"); + }), + (f.isDate = V), + (f.locale = ct), + (f.invalid = I), + (f.duration = C), + (f.isMoment = h), + (f.weekdays = function (e, t, n) { + return mn(e, t, n, "weekdays"); + }), + (f.parseZone = function () { + return W.apply(null, arguments).parseZone(); + }), + (f.localeData = mt), + (f.isDuration = Ut), + (f.monthsShort = function (e, t) { + return fn(e, t, "monthsShort"); + }), + (f.weekdaysMin = function (e, t, n) { + return mn(e, t, n, "weekdaysMin"); + }), + (f.defineLocale = ft), + (f.updateLocale = function (e, t) { + var n, s; + return ( + null != t + ? ((s = ot), + null != R[e] && null != R[e].parentLocale + ? R[e].set(X(R[e]._config, t)) + : ((t = X((s = null != (n = dt(e)) ? n._config : s), t)), + null == n && (t.abbr = e), + ((s = new K(t)).parentLocale = R[e]), + (R[e] = s)), + ct(e)) + : null != R[e] && + (null != R[e].parentLocale + ? ((R[e] = R[e].parentLocale), e === ct() && ct(e)) + : null != R[e] && delete R[e]), + R[e] + ); + }), + (f.locales = function () { + return ee(R); + }), + (f.weekdaysShort = function (e, t, n) { + return mn(e, t, n, "weekdaysShort"); + }), + (f.normalizeUnits = _), + (f.relativeTimeRounding = function (e) { + return void 0 === e ? Yn : "function" == typeof e && ((Yn = e), !0); + }), + (f.relativeTimeThreshold = function (e, t) { + return ( + void 0 !== On[e] && + (void 0 === t ? On[e] : ((On[e] = t), "s" === e && (On.ss = t - 1), !0)) + ); + }), + (f.calendarFormat = function (e, t) { + return (e = e.diff(t, "days", !0)) < -6 + ? "sameElse" + : e < -1 + ? "lastWeek" + : e < 0 + ? "lastDay" + : e < 1 + ? "sameDay" + : e < 2 + ? "nextDay" + : e < 7 + ? "nextWeek" + : "sameElse"; + }), + (f.prototype = i), + (f.HTML5_FMT = { + DATETIME_LOCAL: "YYYY-MM-DDTHH:mm", + DATETIME_LOCAL_SECONDS: "YYYY-MM-DDTHH:mm:ss", + DATETIME_LOCAL_MS: "YYYY-MM-DDTHH:mm:ss.SSS", + DATE: "YYYY-MM-DD", + TIME: "HH:mm", + TIME_SECONDS: "HH:mm:ss", + TIME_MS: "HH:mm:ss.SSS", + WEEK: "GGGG-[W]WW", + MONTH: "YYYY-MM", + }), + f + ); +}); diff --git a/app/templates/assiduites/alert.j2 b/app/templates/assiduites/alert.j2 new file mode 100644 index 0000000000..03073e54b7 --- /dev/null +++ b/app/templates/assiduites/alert.j2 @@ -0,0 +1,156 @@ +{% block alertmodal %} +
    + + +
    +
    + × +

    alertModal Header

    +
    +
    +

    Some text in the alertModal Body

    +

    Some other text...

    +
    + +
    + +
    + + + +{% endblock alertmodal %} \ No newline at end of file diff --git a/app/templates/assiduites/minitimeline.j2 b/app/templates/assiduites/minitimeline.j2 new file mode 100644 index 0000000000..4800e71a33 --- /dev/null +++ b/app/templates/assiduites/minitimeline.j2 @@ -0,0 +1,105 @@ +
    + +
    + + + + \ No newline at end of file diff --git a/app/templates/assiduites/moduleimpl_dynamic_selector.j2 b/app/templates/assiduites/moduleimpl_dynamic_selector.j2 new file mode 100644 index 0000000000..3fcf07d553 --- /dev/null +++ b/app/templates/assiduites/moduleimpl_dynamic_selector.j2 @@ -0,0 +1,113 @@ + + + + + + \ No newline at end of file diff --git a/app/templates/assiduites/moduleimpl_selector.j2 b/app/templates/assiduites/moduleimpl_selector.j2 new file mode 100644 index 0000000000..de740684df --- /dev/null +++ b/app/templates/assiduites/moduleimpl_selector.j2 @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/app/templates/assiduites/prompt.j2 b/app/templates/assiduites/prompt.j2 new file mode 100644 index 0000000000..95c0fc279e --- /dev/null +++ b/app/templates/assiduites/prompt.j2 @@ -0,0 +1,211 @@ +{% block promptModal %} +
    + + +
    +
    + × +

    promptModal Header

    +
    +
    +

    Some text in the promptModal Body

    +

    Some other text...

    +
    + +
    + +
    + + + +{% endblock promptModal %} \ No newline at end of file diff --git a/app/templates/assiduites/signal_assiduites_etud.j2 b/app/templates/assiduites/signal_assiduites_etud.j2 new file mode 100644 index 0000000000..00b4bd217e --- /dev/null +++ b/app/templates/assiduites/signal_assiduites_etud.j2 @@ -0,0 +1,99 @@ +{# -*- mode: jinja-html -*- #} + +{% include "assiduites/toast.j2" %} +
    + {% block content %} +

    Signalement de l'assiduité de {{sco.etud.nomprenom}}

    + +
    + Date: + +
    + + {% include "assiduites/timeline.j2" %} + + +
    + {% include "assiduites/moduleimpl_dynamic_selector.j2" %} + +
    + +
    + + + +
    + +
    +
    +
    +
    +
    + + + +
    +
    +
    + + {% include "assiduites/alert.j2" %} + {% include "assiduites/prompt.j2" %} + + + + + + + {% endblock %} + +
    \ No newline at end of file diff --git a/app/templates/assiduites/signal_assiduites_group.j2 b/app/templates/assiduites/signal_assiduites_group.j2 new file mode 100644 index 0000000000..582aa058ff --- /dev/null +++ b/app/templates/assiduites/signal_assiduites_group.j2 @@ -0,0 +1,76 @@ +{% include "assiduites/toast.j2" %} +
    + +
    + + {{formsemestre_id}} + {{formsemestre_date_debut}} + {{formsemestre_date_fin}} + +
    + +

    + Saisie des assiduités {{gr_tit|safe}} {{sem}} +

    + +
    +
    Groupes : {{grp|safe}}
    + +
    Modules :{{moduleimpl_select|safe}}
    + +
    + Date: + +
    + + +
    + + {{timeline|safe}} + +
    +
    + + +
    +
    +
    + + {% include "assiduites/alert.j2" %} + {% include "assiduites/prompt.j2" %} + + +
    \ No newline at end of file diff --git a/app/templates/assiduites/timeline.j2 b/app/templates/assiduites/timeline.j2 new file mode 100644 index 0000000000..7f24b4ddbe --- /dev/null +++ b/app/templates/assiduites/timeline.j2 @@ -0,0 +1,212 @@ +
    +
    +
    +
    +
    +
    + + \ No newline at end of file diff --git a/app/templates/assiduites/toast.j2 b/app/templates/assiduites/toast.j2 new file mode 100644 index 0000000000..6fc7af9d44 --- /dev/null +++ b/app/templates/assiduites/toast.j2 @@ -0,0 +1,88 @@ +
    +
    + + + + \ No newline at end of file diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 old mode 100644 new mode 100755 index ba85137749..d041380760 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -60,7 +60,7 @@ {% endif %}
      {% if current_user.has_permission(sco.Permission.ScoAbsChange) %} -
    • Ajouter
    • Justifier
    • diff --git a/app/views/__init__.py b/app/views/__init__.py index 9d342dbbf4..89d3418b79 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -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 @@ -107,6 +108,7 @@ class ScoData: from app.views import ( absences, + assiduites, but_formation, notes_formsemestre, notes, diff --git a/app/views/assiduites.py b/app/views/assiduites.py new file mode 100644 index 0000000000..83bf22b57e --- /dev/null +++ b/app/views/assiduites.py @@ -0,0 +1,377 @@ +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"" + 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//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"], + ), + """

      Traitement des assiduités

      +

      + 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). +

      + """, + ] + H.append( + """

      Pour signaler, annuler ou justifier une assiduité pour un seul étudiant, + choisissez d'abord concerné:

      """ + ) + 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""" +

      Billets d'absence

      + + """ + ) + 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("/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") + + "

      Aucun étudiant !

      " + + 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 + ' ' + groups_infos.groups_titles + "" + ) + + 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") From 19328cbe703d8499b7f82c775f8bf52c544060c9 Mon Sep 17 00:00:00 2001 From: iziram Date: Tue, 18 Apr 2023 14:04:19 +0200 Subject: [PATCH 007/101] bugfix : placement modaux + affichage conflit --- app/static/js/assiduites.js | 12 +++++++++++- app/templates/assiduites/alert.j2 | 2 +- app/templates/assiduites/prompt.j2 | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 796015bd03..e7d02771aa 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1277,9 +1277,19 @@ function renderTimeline(assiduites, specialAssiduite) { specialAssiduiteEl.style.top = "0"; specialAssiduiteEl.style.zIndex = "0"; // Place l'assiduité spéciale en arrière-plan assiduitesContainer.appendChild(specialAssiduiteEl); - + const interval = { + deb: new moment.tz(getDate(), TIMEZONE), + fin: new moment.tz(getNextDate(), TIMEZONE), + }; //Placement des assiduités sur la timeline assiduites.forEach((assiduite) => { + const period = { + deb: new moment.tz(assiduite.date_debut, TIMEZONE), + fin: new moment.tz(assiduite.date_fin, TIMEZONE), + }; + if (!hasTimeConflict(period, interval)) { + return; + } const el = document.createElement("div"); el.className = "assiduite"; el.style.backgroundColor = getColor(assiduite.etat); diff --git a/app/templates/assiduites/alert.j2 b/app/templates/assiduites/alert.j2 index 03073e54b7..55d82aa87c 100644 --- a/app/templates/assiduites/alert.j2 +++ b/app/templates/assiduites/alert.j2 @@ -24,7 +24,7 @@ /* Hidden by default */ position: fixed; /* Stay in place */ - z-index: 50; + z-index: 750; /* Sit on top */ padding-top: 100px; /* Location of the box */ diff --git a/app/templates/assiduites/prompt.j2 b/app/templates/assiduites/prompt.j2 index 95c0fc279e..8ef15c16b7 100644 --- a/app/templates/assiduites/prompt.j2 +++ b/app/templates/assiduites/prompt.j2 @@ -24,7 +24,7 @@ /* Hidden by default */ position: fixed; /* Stay in place */ - z-index: 50; + z-index: 750; /* Sit on top */ padding-top: 100px; /* Location of the box */ From 5c6f0b3d6b24d1add4cd756ee4ea354a934497df Mon Sep 17 00:00:00 2001 From: iziram Date: Wed, 19 Apr 2023 17:35:48 +0200 Subject: [PATCH 008/101] =?UTF-8?q?Assiduites=20:=20modification=20styles?= =?UTF-8?q?=20(proposition=20S=C3=A9bastien=20Lehmann)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/css/assiduites.css | 26 +++++++------ app/static/js/assiduites.js | 47 ++++++++++++++++-------- app/templates/assiduites/minitimeline.j2 | 13 ++++--- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index eb6086a767..eb6e243ee4 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -87,19 +87,16 @@ .etud_row { display: grid; - grid-template-columns: 10% 20% 30% 30%; - gap: 3.33%; - + grid-template-columns: auto auto 1fr auto; + gap: 16px; background-color: white; border-radius: 15px; - width: 80%; - height: 50px; - + 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 * { @@ -142,10 +139,15 @@ font-size: x-small; } +.etud_row .pdp { + border-radius: 15px; +} + /* --- Barre assiduités --- */ .etud_row .assiduites_bar { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: 7px 1fr; + gap: 13px; grid-column: 3; position: relative; } @@ -179,15 +181,15 @@ } .etud_row .assiduites_bar .absent { - background-color: #ec5c49 !important; + background-color: #F1A69C !important; } .etud_row .assiduites_bar .present { - background-color: #37f05f !important; + background-color: #9CF1AF !important; } .etud_row .assiduites_bar .retard { - background-color: #ecb52a !important; + background-color: #F1D99C !important; } diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index e7d02771aa..0252013de8 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1039,6 +1039,7 @@ function insertEtudRow(etud, index, output = false) { * @returns {HTMLElement} l'élément correspondant à la mini timeline */ function createMiniTimeline(assiduitesArray) { + const array = [...assiduitesArray]; const dateiso = document.getElementById("tl_date").value; const timeline = document.createElement("div"); timeline.className = "mini-timeline"; @@ -1050,7 +1051,17 @@ function createMiniTimeline(assiduitesArray) { const dayEnd = timelineDate.clone().add(18, "hours"); const dayDuration = moment.duration(dayEnd.diff(dayStart)).asMinutes(); - assiduitesArray.forEach((assiduité) => { + const tlTimes = getTimeLineTimes(); + + const period_assi = { + date_debut: tlTimes.deb.format(), + date_fin: tlTimes.fin.format(), + etat: "CRENEAU", + }; + + array.push(period_assi); + + array.forEach((assiduité) => { const startDate = moment(assiduité.date_debut); const endDate = moment(assiduité.date_fin); @@ -1073,21 +1084,22 @@ function createMiniTimeline(assiduitesArray) { block.style.left = `${leftPercentage}%`; block.style.width = `${widthPercentage}%`; - if (isSingleEtud()) { - block.addEventListener("click", () => { - let deb = startDate.hours() + startDate.minutes() / 60; - let fin = endDate.hours() + endDate.minutes() / 60; - deb = Math.max(8, deb); - fin = Math.min(18, fin); + if (assiduité.etat != "CRENEAU") { + if (isSingleEtud()) { + block.addEventListener("click", () => { + let deb = startDate.hours() + startDate.minutes() / 60; + let fin = endDate.hours() + endDate.minutes() / 60; + deb = Math.max(8, deb); + fin = Math.min(18, fin); - setPeriodValues(deb, fin); - updateSelectedSelect(getCurrentAssiduiteModuleImplId()); - }); + setPeriodValues(deb, fin); + updateSelectedSelect(getCurrentAssiduiteModuleImplId()); + }); + } + //ajouter affichage assiduites on over + setupAssiduiteBuble(block, assiduité); } - //ajouter affichage assiduites on over - setupAssiduiteBuble(block, assiduité); - switch (assiduité.etat) { case "PRESENT": block.classList.add("present"); @@ -1098,6 +1110,9 @@ function createMiniTimeline(assiduitesArray) { case "ABSENT": block.classList.add("absent"); break; + case "CRENEAU": + block.classList.add("creneau"); + break; default: block.style.backgroundColor = "white"; } @@ -1391,11 +1406,11 @@ function getTopPosition(container, start, end) { function getColor(state) { switch (state) { case "PRESENT": - return "#37f05f"; + return "#9CF1AF"; case "ABSENT": - return "#ec5c49"; + return "#F1A69C"; case "RETARD": - return "#ecb52a"; + return "#F1D99C"; default: return "gray"; } diff --git a/app/templates/assiduites/minitimeline.j2 b/app/templates/assiduites/minitimeline.j2 index 4800e71a33..27f30b40cc 100644 --- a/app/templates/assiduites/minitimeline.j2 +++ b/app/templates/assiduites/minitimeline.j2 @@ -53,24 +53,22 @@ } .assiduite-bubble.absent { - border-color: #ec5c49 !important; + border-color: #F1A69C !important; } .assiduite-bubble.present { - border-color: #37f05f !important; + border-color: #9CF1AF !important; } .assiduite-bubble.retard { - border-color: #ecb52a !important; + border-color: #F1D99C !important; } .mini-timeline { height: 7px; - width: 90%; border: 1px solid black; position: relative; background-color: white; - overflow: hidden; } .mini-timeline.single { @@ -102,4 +100,9 @@ bottom: -2px; z-index: 2; } + + .mini-timeline-block.creneau { + outline: 3px solid #7059FF; + pointer-events: none; + } \ No newline at end of file From 35646a934bd9bc32a9bf74d419fde9ca91d2c495 Mon Sep 17 00:00:00 2001 From: iziram Date: Wed, 19 Apr 2023 18:17:47 +0200 Subject: [PATCH 009/101] Assiduites : modification automatique du moduleimpl_id --- app/static/js/assiduites.js | 2 +- .../assiduites/moduleimpl_dynamic_selector.j2 | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 0252013de8..79d7308fc2 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -1758,7 +1758,7 @@ function getCurrentAssiduite(etudid) { const assiduite_id = parseInt(field.getAttribute("assiduite_id")); const type = field.getAttribute("type"); - if (type == "edition") { + if (type == "édition") { let assi = null; assiduites[etudid].forEach((a) => { if (a.assiduite_id === assiduite_id) { diff --git a/app/templates/assiduites/moduleimpl_dynamic_selector.j2 b/app/templates/assiduites/moduleimpl_dynamic_selector.j2 index 3fcf07d553..dbbecc8a15 100644 --- a/app/templates/assiduites/moduleimpl_dynamic_selector.j2 +++ b/app/templates/assiduites/moduleimpl_dynamic_selector.j2 @@ -98,7 +98,26 @@ } function updateSelectedSelect(moduleimpl_id) { - document.getElementById('moduleimpl_select').value = 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); + } } From d796c7db93ead95eef6c529966ba235d1b265b76 Mon Sep 17 00:00:00 2001 From: iziram Date: Wed, 19 Apr 2023 20:58:15 +0200 Subject: [PATCH 010/101] Assiduites : Gestion des justificatifs (rapide) WIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assiduites : ajout style justifié (minitimeline) --- app/models/assiduites.py | 14 +- app/scodoc/sco_assiduites.py | 13 +- app/static/css/assiduites.css | 8 + app/static/js/assiduites.js | 177 +++++++++++++++--- .../assiduites/signal_assiduites_etud.j2 | 30 ++- .../assiduites/signal_assiduites_group.j2 | 1 + app/templates/assiduites/timeline.j2 | 109 ++++++----- 7 files changed, 246 insertions(+), 106 deletions(-) diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 7471c11e23..566359d577 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -316,10 +316,10 @@ def compute_assiduites_justified( for justi in justificatifs: assiduites: Assiduite = ( Assiduite.query.join(Justificatif, Justificatif.etudid == Assiduite.etudid) - .filter(Assiduite.etat != EtatAssiduite.PRESENT) + .filter(justi.etat == EtatJustificatif.VALIDE) .filter( - Assiduite.date_debut <= justi.date_fin, - Assiduite.date_fin >= justi.date_debut, + Assiduite.date_debut < justi.date_fin, + Assiduite.date_fin > justi.date_debut, ) ) @@ -329,11 +329,9 @@ def compute_assiduites_justified( 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) - ) + 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 diff --git a/app/scodoc/sco_assiduites.py b/app/scodoc/sco_assiduites.py index 3ba865a224..4af613a599 100644 --- a/app/scodoc/sco_assiduites.py +++ b/app/scodoc/sco_assiduites.py @@ -20,7 +20,6 @@ class CountCalculator: evening: time = time(18, 0), skip_saturday: bool = True, ) -> None: - self.morning: time = morning self.noon: time = noon self.after_noon: time = after_noon @@ -318,13 +317,11 @@ def justifies(justi: Justificatif, obj: bool = False) -> list[int]: if justi.etat != scu.EtatJustificatif.VALIDE: return [] - assiduites_query: Assiduite = ( - Assiduite.query.join(Justificatif, Assiduite.etudid == Justificatif.etudid) - .filter(Assiduite.etat != scu.EtatAssiduite.PRESENT) - .filter( - Assiduite.date_debut <= justi.date_fin, - Assiduite.date_fin >= justi.date_debut, - ) + 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: diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index eb6e243ee4..be487a7e35 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -192,6 +192,14 @@ 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 { diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 79d7308fc2..bd004f4274 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -16,6 +16,7 @@ let currentValues = [8.0, 10.0]; //Objet stockant les étudiants et les assiduités let etuds = {}; let assiduites = {}; +let justificatifs = {}; // Variable qui définit si le processus d'action de masse est lancé let currentMassAction = false; @@ -1094,12 +1095,40 @@ function createMiniTimeline(assiduitesArray) { setPeriodValues(deb, fin); updateSelectedSelect(getCurrentAssiduiteModuleImplId()); + updateJustifyBtn(); }); } //ajouter affichage assiduites on over setupAssiduiteBuble(block, assiduité); + + if (assiduité.est_just) { + block.classList.add("justified"); + } } + const action = (justificatifs) => { + if (justificatifs.length > 0) { + let j = "invalid_justified"; + + justificatifs.forEach((ju) => { + if (ju.etat == "VALIDE") { + j = "justified"; + } + }); + + block.classList.add(j); + } + }; + + getJustificatifFromPeriod( + { + deb: new moment.tz(assiduité.date_debut, TIMEZONE), + fin: new moment.tz(assiduité.date_fin, TIMEZONE), + }, + assiduité.etudid, + action + ); + switch (assiduité.etat) { case "PRESENT": block.classList.add("present"); @@ -1773,31 +1802,33 @@ function getCurrentAssiduite(etudid) { // <<== Gestion de la justification ==>> -function getJustificatifFromPeriod(date) { - let justifs = []; - sync_get( - getUrl() + - `/api/justificatifs/${etudid}/query?date_debut=${date.deb.format()}&date_fin=${date.fin.format()}`, - (data) => { - justifs = data; - } - ); - - return justifs; +function getJustificatifFromPeriod(date, etudid, update) { + $.ajax({ + async: true, + type: "GET", + url: + getUrl() + + `/api/justificatifs/${etudid}/query?date_debut=${date.deb + .add(1, "s") + .format()}&date_fin=${date.fin.subtract(1, "s").format()}`, + success: (data) => { + update(data); + }, + error: () => {}, + }); } -function updateJustifieButton(isJustified, isDisabled = true) { - const btn = document.getElementById("justif-rapide"); - if (isJustified) { - btn.classList.add("justifie"); - } else { - btn.classList.remove("justifie"); - } +function updateJustifyBtn() { + if (isSingleEtud()) { + const assi = getCurrentAssiduite(etudid); - if (isDisabled) { - btn.setAttribute("disabled", "true"); - } else { - btn.removeAttribute("disabled"); + const just = assi ? !assi.est_just : false; + const btn = document.getElementById("justif-rapide"); + if (!just) { + btn.setAttribute("disabled", "true"); + } else { + btn.removeAttribute("disabled"); + } } } @@ -1806,12 +1837,100 @@ function fastJustify(assiduite) { deb: new moment.tz(assiduite.date_debut, TIMEZONE), fin: new moment.tz(assiduite.date_fin, TIMEZONE), }; - const justifs = getJustificatifFromPeriod(period); + const action = (justifs) => { + if (justifs.length > 0) { + justifyAssiduite(assiduite.assiduite_id, !assiduite.est_just); + } else { + console.debug("WIP"); + //créer un nouveau justificatif + // Afficher prompt -> demander raison et état - if (justifs.length > 0) { - //modifier l'assiduité - } else { - //créer un nouveau justificatif - // Afficher prompt -> demander raison et état - } + const success = () => { + const raison = document.getElementById("promptText").value; + const etat = document.getElementById("promptSelect").value; + + //créer justificatif + + const justif = { + date_debut: new moment.tz(assiduite.date_debut, TIMEZONE) + .add(1, "s") + .format(), + date_fin: new moment.tz(assiduite.date_fin, TIMEZONE) + .subtract(1, "s") + .format(), + raison: raison, + etat: etat, + }; + + createJustificatif(justif); + + // justifyAssiduite(assiduite.assiduite_id, true); + generateAllEtudRow(); + }; + + const content = document.createElement("fieldset"); + + const htmlPrompt = `Entrez l'état du justificatif : + + Raison: + + `; + + content.innerHTML = htmlPrompt; + + openPromptModal( + "Nouveau justificatif (Rapide)", + content, + success, + () => {}, + "#7059FF" + ); + } + }; + getJustificatifFromPeriod(period, assiduite.etudid, action); +} + +function justifyAssiduite(assiduite_id, justified) { + const assiduite = { + est_just: justified, + }; + const path = getUrl() + `/api/assiduite/${assiduite_id}/edit`; + let bool = false; + sync_post( + path, + assiduite, + (data, status) => { + bool = true; + }, + (data, status) => { + //error + console.error(data, status); + } + ); + + return bool; +} + +function createJustificatif(justif) { + const path = getUrl() + `/api/justificatif/${etudid}/create`; + sync_post( + path, + [justif], + (data, status) => { + //success + if (data.success.length > 0) { + console.table(data[0]); + } + console.warn(data); + }, + (data, status) => { + //error + console.error(data, status); + } + ); } diff --git a/app/templates/assiduites/signal_assiduites_etud.j2 b/app/templates/assiduites/signal_assiduites_etud.j2 index 00b4bd217e..bd5de04e79 100644 --- a/app/templates/assiduites/signal_assiduites_etud.j2 +++ b/app/templates/assiduites/signal_assiduites_etud.j2 @@ -29,6 +29,8 @@ {% include "assiduites/toast.j2" %} +{% include "assiduites/alert.j2" %} +{% include "assiduites/prompt.j2" %}
      {% block content %}

      Signalement de l'assiduité de {{sco.etud.nomprenom}}

      @@ -43,13 +45,13 @@
      {% include "assiduites/moduleimpl_dynamic_selector.j2" %} - +
      - - - + + +
      @@ -64,8 +66,7 @@
      - {% include "assiduites/alert.j2" %} - {% include "assiduites/prompt.j2" %} + + + \ No newline at end of file diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index d041380760..b579a1f032 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -73,7 +73,7 @@ {% endif %}
    • Calendrier
    • -
    • Liste
    {% endif %} diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 83bf22b57e..d622ebb120 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -201,6 +201,48 @@ def signal_assiduites_etud(): ).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) From 6dc39c25ee601f4213ee2f421ad446cd8d7cfc7e Mon Sep 17 00:00:00 2001 From: iziram Date: Tue, 25 Apr 2023 22:59:06 +0200 Subject: [PATCH 012/101] =?UTF-8?q?Assiduites=20:=20ajout=20pr=C3=A9f?= =?UTF-8?q?=C3=A9rences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/forms/main/config_assiduites.py | 86 +++++++++++++++++++ app/models/config.py | 6 ++ app/scodoc/sco_preferences.py | 35 +++++++- app/static/js/assiduites.js | 36 ++++++-- app/templates/assiduites/config_assiduites.j2 | 28 ++++++ .../assiduites/signal_assiduites_etud.j2 | 9 +- .../assiduites/signal_assiduites_group.j2 | 3 + app/templates/assiduites/timeline.j2 | 79 +++++++++++++---- app/templates/configuration.j2 | 17 ++-- app/views/assiduites.py | 36 +++++++- app/views/scodoc.py | 34 ++++++++ scodoc.py | 6 +- tools/migrate_abs_to_assiduites.py | 17 ++-- 13 files changed, 343 insertions(+), 49 deletions(-) create mode 100644 app/forms/main/config_assiduites.py create mode 100644 app/templates/assiduites/config_assiduites.j2 diff --git a/app/forms/main/config_assiduites.py b/app/forms/main/config_assiduites.py new file mode 100644 index 0000000000..26126f2e62 --- /dev/null +++ b/app/forms/main/config_assiduites.py @@ -0,0 +1,86 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaire configuration Module Assiduités +""" + +from flask_wtf import FlaskForm +from wtforms import SubmitField +from wtforms.fields.simple import StringField +from wtforms.widgets import TimeInput +import datetime + + +class TimeField(StringField): + """HTML5 time input.""" + + widget = TimeInput() + + def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs): + super(TimeField, self).__init__(label, validators, **kwargs) + self.fmt = fmt + self.data = None + + def _value(self): + if self.raw_data: + return " ".join(self.raw_data) + if self.data and isinstance(self.data, str): + self.data = datetime.time(*map(int, self.data.split(":"))) + return self.data and self.data.strftime(self.fmt) or "" + + def process_formdata(self, valuelist): + if valuelist: + time_str = " ".join(valuelist) + try: + components = time_str.split(":") + hour = 0 + minutes = 0 + seconds = 0 + if len(components) in range(2, 4): + hour = int(components[0]) + minutes = int(components[1]) + + if len(components) == 3: + seconds = int(components[2]) + else: + raise ValueError + self.data = datetime.time(hour, minutes, seconds) + except ValueError: + self.data = None + raise ValueError(self.gettext("Not a valid time string")) + + +class ConfigAssiduitesForm(FlaskForm): + "Formulaire paramétrage Module Assiduités" + + morning_time = TimeField("Début de la journée") + lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)") + afternoon_time = TimeField("Fin de la journée") + + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/models/config.py b/app/models/config.py index 71b462bcf9..b989781cfe 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -8,6 +8,8 @@ from app import current_app, db, log from app.comp import bonus_spo from app.scodoc import sco_utils as scu +from datetime import time + from app.scodoc.codes_cursus import ( ABAN, ABL, @@ -94,6 +96,10 @@ class ScoDocSiteConfig(db.Model): "cas_logout_route": str, "cas_validate_route": str, "cas_attribute_id": str, + # Assiduités + "morning_time": str, + "lunch_time": str, + "afternoon_time": str, } def __init__(self, name, value): diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index 8c9bf55477..52a1bf4181 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -204,6 +204,7 @@ PREF_CATEGORIES = ( ("misc", {"title": "Divers"}), ("apc", {"title": "BUT et Approches par Compétences"}), ("abs", {"title": "Suivi des absences", "related": ("bul",)}), + ("assi", {"title": "Gestion de l'assiduité"}), ("portal", {"title": "Liaison avec portail (Apogée, etc)"}), ("apogee", {"title": "Exports Apogée"}), ( @@ -598,6 +599,38 @@ class BasePreferences(object): "category": "abs", }, ), + # Assiduités + ( + "forcer_module", + { + "initvalue": 0, + "title": "Forcer la déclaration du module.", + "input_type": "boolcheckbox", + "labels": ["non", "oui"], + "category": "assi", + }, + ), + ( + "forcer_present", + { + "initvalue": 0, + "title": "Forcer l'appel des présents", + "input_type": "boolcheckbox", + "labels": ["non", "oui"], + "category": "assi", + }, + ), + ( + "etat_defaut", + { + "initvalue": "aucun", + "input_type": "menu", + "labels": ["aucun", "present", "retard", "absent"], + "allowed_values": ["aucun", "present", "retard", "absent"], + "title": "Définir l'état par défaut", + "category": "assi", + }, + ), # portal ( "portal_url", @@ -1700,7 +1733,7 @@ class BasePreferences(object): ( "feuille_releve_abs_taille", { - "initvalue": "A3", + "initvalue": "A4", "input_type": "menu", "labels": ["A3", "A4"], "allowed_values": ["A3", "A4"], diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 94efe5f92e..bf3d821b8e 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -88,6 +88,20 @@ function validateSelectors() { ); }); + if (getModuleImplId() == null && forceModule) { + const HTML = ` +

    Attention, le module doit obligatoirement être renseigné.

    +

    Cela vient de la configuration du semestre ou plus largement du département.

    +

    Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.

    + `; + + const content = document.createElement("div"); + content.innerHTML = HTML; + + openAlertModal("Sélection du module", content); + return; + } + getAssiduitesFromEtuds(true); document.querySelector(".selectors").disabled = true; @@ -1133,14 +1147,16 @@ function createMiniTimeline(assiduitesArray) { } }; - getJustificatifFromPeriod( - { - deb: new moment.tz(assiduité.date_debut, TIMEZONE), - fin: new moment.tz(assiduité.date_fin, TIMEZONE), - }, - assiduité.etudid, - action - ); + if (assiduité.etudid) { + getJustificatifFromPeriod( + { + deb: new moment.tz(assiduité.date_debut, TIMEZONE), + fin: new moment.tz(assiduité.date_fin, TIMEZONE), + }, + assiduité.etudid, + action + ); + } switch (assiduité.etat) { case "PRESENT": @@ -1905,7 +1921,9 @@ function fastJustify(assiduite) { ); } }; - getJustificatifFromPeriod(period, assiduite.etudid, action); + if (assiduite.etudid) { + getJustificatifFromPeriod(period, assiduite.etudid, action); + } } function justifyAssiduite(assiduite_id, justified) { diff --git a/app/templates/assiduites/config_assiduites.j2 b/app/templates/assiduites/config_assiduites.j2 new file mode 100644 index 0000000000..92d9dd246d --- /dev/null +++ b/app/templates/assiduites/config_assiduites.j2 @@ -0,0 +1,28 @@ +{% extends "base.j2" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

    Configuration du Module d'assiduité

    + +
    +
    + +
    + {{ form.hidden_tag() }} + {{ wtf.form_errors(form, hiddens="only") }} + + {{ wtf.form_field(form.morning_time) }} + {{ wtf.form_field(form.lunch_time) }} + {{ wtf.form_field(form.afternoon_time) }} +
    + {{ wtf.form_field(form.submit) }} + {{ wtf.form_field(form.cancel) }} +
    +
    +
    +
    + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/assiduites/signal_assiduites_etud.j2 b/app/templates/assiduites/signal_assiduites_etud.j2 index bd5de04e79..d44ccaccef 100644 --- a/app/templates/assiduites/signal_assiduites_etud.j2 +++ b/app/templates/assiduites/signal_assiduites_etud.j2 @@ -49,9 +49,9 @@
    - - - + + +
    @@ -94,6 +94,9 @@ } + let forceModule = "{{ forcer_module }}" + forceModule = forceModule == "True" ? true : false + diff --git a/app/templates/assiduites/signal_assiduites_group.j2 b/app/templates/assiduites/signal_assiduites_group.j2 index 27f13874b2..0446606cc8 100644 --- a/app/templates/assiduites/signal_assiduites_group.j2 +++ b/app/templates/assiduites/signal_assiduites_group.j2 @@ -73,5 +73,8 @@ updateDate(); setupDate(); setupTimeLine(); + + let forceModule = "{{ forcer_module }}" + forceModule = forceModule == "True" ? true : false \ No newline at end of file diff --git a/app/templates/assiduites/timeline.j2 b/app/templates/assiduites/timeline.j2 index f1977df45f..b67fd0b6cb 100644 --- a/app/templates/assiduites/timeline.j2 +++ b/app/templates/assiduites/timeline.j2 @@ -9,31 +9,60 @@ const timelineContainer = document.querySelector(".timeline-container"); const periodTimeLine = document.querySelector(".period"); + const t_start = {{ t_start }} + const t_end = {{ t_end }} function createTicks() { - for (let i = 8; i <= 18; i++) { + let i = t_start + + while (i <= t_end) { const hourTick = document.createElement("div"); hourTick.classList.add("tick", "hour"); - hourTick.style.left = `${((i - 8) / 10) * 100}%`; + hourTick.style.left = `${((i - t_start) / (t_end - t_start)) * 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`; + tickLabel.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`; + tickLabel.textContent = numberToTime(i); 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); + if (i < t_end) { + let j = Math.floor(i + 1) + + while (i < j) { + i += 0.25; + + if (i <= t_end) { + const quarterTick = document.createElement("div"); + quarterTick.classList.add("tick", "quarter"); + quarterTick.style.left = `${computePercentage(i, t_start)}%`; + timelineContainer.appendChild(quarterTick); + } + } } } } + function numberToTime(num) { + const integer = Math.floor(num) + const decimal = (num % 1) * 60 + + let dec = `:${decimal}` + if (decimal < 10) { + dec = `:0${decimal}` + } + + let int = `${integer}` + if (integer < 10) { + int = `0${integer}` + } + + return int + dec + + } + function snapToQuarter(value) { return Math.round(value * 4) / 4; } @@ -119,18 +148,28 @@ 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 startHour = (leftPercentage / 100) * (t_end - t_start) + t_start; + const endHour = ((leftPercentage + widthPercentage) / 100) * (t_end - t_start) + t_start; const startValue = Math.round(startHour * 4) / 4; const endValue = Math.round(endHour * 4) / 4; - return [startValue, endValue] + const computedValues = [Math.max(startValue, t_start), Math.min(t_end, endValue)] + + if (computedValues[0] > t_end || computedValues[1] < t_start) { + return [8, 10] + } + + if (computedValues[1] - computedValues[0] <= 0.25 && computedValues[1] < t_end - 0.25) { + computedValues[1] += 0.25; + } + + return computedValues } function setPeriodValues(deb, fin) { - let leftPercentage = (deb - 8) / 10 * 100 - let widthPercentage = (fin - deb) / 10 * 100 + let leftPercentage = (deb - t_start) / (t_end - t_start) * 100 + let widthPercentage = (fin - deb) / (t_end - t_start) * 100 periodTimeLine.style.left = `${leftPercentage}%` periodTimeLine.style.width = `${widthPercentage}%` @@ -140,12 +179,13 @@ function snapHandlesToQuarters() { const periodValues = getPeriodValues(); - let lef = Math.min((periodValues[0] - 8) * 10, 97.5) + let lef = Math.min(computePercentage(periodValues[0], t_start), computePercentage(t_end, 0.25)) if (lef < 0) { lef = 0; } const left = `${lef}%` - let wid = Math.max((periodValues[1] - periodValues[0]) * 10, 2.5) + + let wid = Math.max(computePercentage(periodValues[1], periodValues[0]), computePercentage(0.25, 0)) if (wid > 100) { wid = 100; } @@ -154,7 +194,12 @@ periodTimeLine.style.width = width; } + function computePercentage(a, b) { + return ((a - b) / (t_end - t_start)) * 100 + } + createTicks(); + setPeriodValues(8, 9) \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 22652d93ee..978c69c373 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -10,7 +10,7 @@ from app.decorators import ( scodoc, permission_required, ) -from app.models import FormSemestre, Identite, ScoDocSiteConfig +from app.models import FormSemestre, Identite, ScoDocSiteConfig, Assiduite from app.views import assiduites_bp as bp from app.views import ScoData @@ -23,6 +23,8 @@ 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 +from app.scodoc import sco_utils as scu +from app.scodoc import sco_assiduites as scass CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS @@ -284,10 +286,6 @@ def signal_assiduites_group(): 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()) @@ -314,7 +312,6 @@ def signal_assiduites_group(): 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") @@ -415,6 +412,77 @@ def signal_assiduites_group(): ).build() +@bp.route("/EtatAbsencesDate") +@scodoc +@permission_required(Permission.ScoAbsChange) +def get_etat_abs_date(): + evaluation = { + "jour": request.args.get("jour"), + "heure_debut": request.args.get("heure_debut"), + "heure_fin": request.args.get("heure_fin"), + "title": request.args.get("desc"), + } + date: str = evaluation["jour"] + group_ids: list[int] = request.args.get("group_ids", None) + etudiants: list[dict] = [] + + if group_ids is None: + group_ids = [] + else: + group_ids = group_ids.split(",") + map(str, group_ids) + + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + + etuds = [ + sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] + for m in groups_infos.members + ] + + date_debut = scu.is_iso_formated( + f"{evaluation['jour']}T{evaluation['heure_debut'].replace('h',':')}", True + ) + date_fin = scu.is_iso_formated( + f"{evaluation['jour']}T{evaluation['heure_fin'].replace('h',':')}", True + ) + + assiduites: Assiduite = Assiduite.query.filter( + Assiduite.etudid.in_([e["etudid"] for e in etuds]) + ) + assiduites = scass.filter_by_date( + assiduites, Assiduite, date_debut, date_fin, False + ) + + for etud in etuds: + assi = assiduites.filter_by(etudid=etud["etudid"]).first() + + etat = "" + if assi != None and assi.etat != 0: + etat = scu.EtatAssiduite.inverse().get(assi.etat).name + + etudiant = { + "nom": f'{etud["nomprenom"]}', + "etat": etat, + } + + etudiants.append(etudiant) + + etudiants = list(sorted(etudiants, key=lambda x: x["nom"])) + + header: str = html_sco_header.sco_header( + page_title=evaluation["title"], + init_qtip=True, + ) + + return HTMLBuilder( + header, + render_template( + "assiduites/etat_absence_date.j2", etudiants=etudiants, eval=evaluation + ), + html_sco_header.sco_footer(), + ).build() + + def _module_selector( formsemestre: FormSemestre, moduleimpl_id: int = None ) -> HTMLElement: From d5f01e062858d4aa1754410530e522291bb4aca9 Mon Sep 17 00:00:00 2001 From: iziram Date: Thu, 1 Jun 2023 17:32:50 +0200 Subject: [PATCH 021/101] =?UTF-8?q?Assiduites=20:=20Signalement=20diff?= =?UTF-8?q?=C3=A9r=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/bulletin_but.py | 1 - app/scodoc/sco_formsemestre_status.py | 30 +- app/scodoc/sco_preferences.py | 10 +- .../assiduites/signal_assiduites_diff.j2 | 372 ++++++++++++++++++ app/views/assiduites.py | 81 +++- 5 files changed, 465 insertions(+), 29 deletions(-) create mode 100644 app/templates/assiduites/signal_assiduites_diff.j2 diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 4d00821459..e24aac66af 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -390,7 +390,6 @@ class BulletinBUT: "H.": "Heure(s)", "J.": "Journée(s)", "1/2 J.": "1/2 Jour.", - "N.": "Nombre", }.get(sco_preferences.get_preference("assi_metrique")), } decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {} diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index e7f47b0b4c..c8cb25da08 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -823,34 +823,20 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True): first_monday = sco_abs.ddmmyyyy( formsemestre.date_debut.strftime("%d/%m/%Y") ).prev_monday() - form_abs_tmpl = f""" + form_abs_tmpl = """ - absences + -
    - - - - - - - saisie par semaine -
    + }?group_ids=%(group_id)s&jour={datetime.date.today().isoformat()}&formsemestre_id={formsemestre.id}"> + + """ else: form_abs_tmpl = "" diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index 9656f060c2..b7099dabbd 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -632,7 +632,7 @@ class BasePreferences(object): }, ), ( - "etat_defaut", + "assi_etat_defaut", { "initvalue": "aucun", "input_type": "menu", @@ -658,12 +658,12 @@ class BasePreferences(object): { "initvalue": "1/2 J.", "input_type": "menu", - "labels": ["1/2 J.", "J.", "H.", "N."], - "allowed_values": ["1/2 J.", "J.", "H.", "N."], + "labels": ["1/2 J.", "J.", "H."], + "allowed_values": ["1/2 J.", "J.", "H."], "title": "Métrique de l'assiduité", - "explanation": "Unité affichée dans la fiche étudiante et le bilan\n(J. = journée, H. = heure, N. = nombre)", + "explanation": "Unité affichée dans la fiche étudiante et le bilan\n(J. = journée, H. = heure)", "category": "assi", - "only_global" : True, + "only_global": True, }, ), # portal diff --git a/app/templates/assiduites/signal_assiduites_diff.j2 b/app/templates/assiduites/signal_assiduites_diff.j2 new file mode 100644 index 0000000000..e0061e0b3e --- /dev/null +++ b/app/templates/assiduites/signal_assiduites_diff.j2 @@ -0,0 +1,372 @@ +

    Signalement différé des assiduités {{gr |safe}}

    +

    {{sem | safe }}

    + +
    +
    +
    +
    Noms
    + +
    +
    +
    + + {% for etud in etudiants %} +
    +
    {{etud.nomprenom}}
    +
    + {% endfor %} +
    +
    + +{% include "assiduites/alert.j2" %} +{% include "assiduites/prompt.j2" %} + + + + \ No newline at end of file diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 978c69c373..a283797be4 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -325,6 +325,7 @@ def signal_assiduites_group(): # --- 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") @@ -414,7 +415,7 @@ def signal_assiduites_group(): @bp.route("/EtatAbsencesDate") @scodoc -@permission_required(Permission.ScoAbsChange) +@permission_required(Permission.ScoView) def get_etat_abs_date(): evaluation = { "jour": request.args.get("jour"), @@ -483,6 +484,84 @@ def get_etat_abs_date(): ).build() +@bp.route("/SignalAssiduiteDifferee") +@scodoc +@permission_required(Permission.ScoAbsChange) +def signal_assiduites_diff(): + group_ids: list[int] = request.args.get("group_ids", None) + etudid: int = request.args.get("etudid", None) + formsemestre_id: int = request.args.get("formsemestre_id", -1) + etudiants: list[dict] = [] + + titre = None + + # Vérification du formsemestre_id + try: + formsemestre_id = int(formsemestre_id) + except (TypeError, ValueError): + formsemestre_id = None + + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + + if etudid is not None: + etudiants.append(sco_etud.get_etud_info(etudid=int(etudid), filled=True)[0]) + + if group_ids is None: + group_ids = [] + else: + group_ids = group_ids.split(",") + map(str, group_ids) + + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + + etudiants.extend( + [ + sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] + for m in groups_infos.members + ] + ) + + etudiants = list(sorted(etudiants, key=lambda x: x["nom"])) + + header: str = html_sco_header.sco_header( + page_title="Assiduités Différées", + init_qtip=True, + javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + + [ + "js/assiduites.js", + "libjs/moment.new.min.js", + "libjs/moment-timezone.js", + ], + ) + + 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 + ' ' + groups_infos.groups_titles + "" + ) + + + return HTMLBuilder( + header, + render_template( + "assiduites/signal_assiduites_diff.j2", + etudiants=etudiants, + etat_def=sco_preferences.get_preference("assi_etat_defaut"), + moduleimpl_select=_module_selector(formsemestre), + gr=gr_tit, + sem=sem["titre_num"], + ), + html_sco_header.sco_footer(), + ).build() + + def _module_selector( formsemestre: FormSemestre, moduleimpl_id: int = None ) -> HTMLElement: From 5be9d711a767515ea2c6834d677f3bde3c9ad413 Mon Sep 17 00:00:00 2001 From: iziram Date: Fri, 2 Jun 2023 11:42:47 +0200 Subject: [PATCH 022/101] =?UTF-8?q?Assiduites=20:=20mise=20=C3=A0=20jour?= =?UTF-8?q?=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/dbcf2175e87f_modèles_assiduites_justificatifs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py index 8b4b39d432..7a102b4550 100755 --- a/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py +++ b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py @@ -1,7 +1,7 @@ """modèles assiduites justificatifs Revision ID: dbcf2175e87f -Revises: 5c7b208355df +Revises: d84bc592584e Create Date: 2023-02-01 14:21:06.989190 """ @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "dbcf2175e87f" -down_revision = "b8df1b913c79" +down_revision = "d84bc592584e" branch_labels = None depends_on = None From d2a17ffdfb149115086797c0b45815a734312915 Mon Sep 17 00:00:00 2001 From: iziram Date: Fri, 2 Jun 2023 17:19:55 +0200 Subject: [PATCH 023/101] Assiduites : Correction bug timeline --- app/templates/assiduites/timeline.j2 | 66 ++++++++++++++-------------- app/views/assiduites.py | 3 +- app/views/scodoc.py | 2 +- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/app/templates/assiduites/timeline.j2 b/app/templates/assiduites/timeline.j2 index 78e219db01..ce8e59e72f 100644 --- a/app/templates/assiduites/timeline.j2 +++ b/app/templates/assiduites/timeline.j2 @@ -12,13 +12,13 @@ const t_start = {{ t_start }}; const t_end = {{ t_end }}; - const tick_time = 60 / {{ tick_time }} - const tick_delay = 1 / tick_time + const tick_time = 60 / {{ tick_time }}; + const tick_delay = 1 / tick_time; const period_default = {{ periode_defaut }}; function createTicks() { - let i = t_start + let i = t_start; while (i <= t_end) { const hourTick = document.createElement("div"); @@ -33,7 +33,7 @@ timelineContainer.appendChild(tickLabel); if (i < t_end) { - let j = Math.floor(i + 1) + let j = Math.floor(i + 1); while (i < j) { i += tick_delay; @@ -46,26 +46,28 @@ } } - i = j + i = j; + } else { + i++; } } } function numberToTime(num) { - const integer = Math.floor(num) - const decimal = (num % 1) * 60 + const integer = Math.floor(num); + const decimal = (num % 1) * 60; - let dec = `:${decimal}` + let dec = `:${decimal}`; if (decimal < 10) { - dec = `:0${decimal}` + dec = `:0${decimal}`; } - let int = `${integer}` + let int = `${integer}`; if (integer < 10) { - int = `0${integer}` + int = `0${integer}`; } - return int + dec + return int + dec; } @@ -76,7 +78,7 @@ } function setupTimeLine(callback) { - const func_call = callback ? callback : () => { } + const func_call = callback ? callback : () => { }; timelineContainer.addEventListener("mousedown", (event) => { const startX = event.clientX; @@ -96,9 +98,9 @@ "mouseup", () => { generateAllEtudRow(); - snapHandlesToQuarters() + snapHandlesToQuarters(); document.removeEventListener("mousemove", onMouseMove); - func_call() + func_call(); }, { once: true } ); @@ -125,12 +127,12 @@ document.addEventListener( "mouseup", () => { - snapHandlesToQuarters() + snapHandlesToQuarters(); generateAllEtudRow(); document.removeEventListener("mousemove", onMouseMove); - func_call() + func_call(); }, { once: true } @@ -162,40 +164,40 @@ const startValue = snapToQuarter(startHour); const endValue = snapToQuarter(endHour); - const computedValues = [Math.max(startValue, t_start), Math.min(t_end, endValue)] + const computedValues = [Math.max(startValue, t_start), Math.min(t_end, endValue)]; if (computedValues[0] > t_end || computedValues[1] < t_start) { - return [t_start, min(t_end, t_start + period_default)] + return [t_start, min(t_end, t_start + period_default)]; } if (computedValues[1] - computedValues[0] <= tick_delay && computedValues[1] < t_end - tick_delay) { computedValues[1] += tick_delay; } - return computedValues + return computedValues; } function setPeriodValues(deb, fin) { - deb = snapToQuarter(deb) - fin = snapToQuarter(fin) - let leftPercentage = (deb - t_start) / (t_end - t_start) * 100 - let widthPercentage = (fin - deb) / (t_end - t_start) * 100 - periodTimeLine.style.left = `${leftPercentage}%` - periodTimeLine.style.width = `${widthPercentage}%` + deb = snapToQuarter(deb); + fin = snapToQuarter(fin); + let leftPercentage = (deb - t_start) / (t_end - t_start) * 100; + let widthPercentage = (fin - deb) / (t_end - t_start) * 100; + periodTimeLine.style.left = `${leftPercentage}%`; + periodTimeLine.style.width = `${widthPercentage}%`; - snapHandlesToQuarters() + snapHandlesToQuarters(); generateAllEtudRow(); } function snapHandlesToQuarters() { const periodValues = getPeriodValues(); - let lef = Math.min(computePercentage(periodValues[0], t_start), computePercentage(t_end, tick_delay)) + let lef = Math.min(computePercentage(periodValues[0], t_start), computePercentage(t_end, tick_delay)); if (lef < 0) { lef = 0; } - const left = `${lef}%` + const left = `${lef}%`; - let wid = Math.max(computePercentage(periodValues[1], periodValues[0]), computePercentage(tick_delay, 0)) + let wid = Math.max(computePercentage(periodValues[1], periodValues[0]), computePercentage(tick_delay, 0)); if (wid > 100) { wid = 100; } @@ -205,11 +207,11 @@ } function computePercentage(a, b) { - return ((a - b) / (t_end - t_start)) * 100 + return ((a - b) / (t_end - t_start)) * 100; } createTicks(); - setPeriodValues(t_start, t_start + period_default) + setPeriodValues(t_start, t_start + period_default); \ No newline at end of file diff --git a/app/templates/assiduites/differee.j2 b/app/templates/assiduites/differee.j2 new file mode 100644 index 0000000000..54745d30df --- /dev/null +++ b/app/templates/assiduites/differee.j2 @@ -0,0 +1,807 @@ +
    +
    +
    +
    Noms
    + +
    +
    +
    + + {% for etud in etudiants %} +
    +
    + {{etud.nomprenom}} + +
    +
    + {% endfor %} +
    +
    + + + \ No newline at end of file diff --git a/app/templates/assiduites/minitimeline.j2 b/app/templates/assiduites/minitimeline.j2 index 8de471d427..29c0c7c637 100644 --- a/app/templates/assiduites/minitimeline.j2 +++ b/app/templates/assiduites/minitimeline.j2 @@ -177,7 +177,6 @@ const endDate = timelineDate.clone().set({ 'hour': 13, 'minute': 0 }); const duration = moment.duration(endDate.diff(dayStart)).asMinutes(); const widthPercentage = (duration / dayDuration) * 100; - console.log(endDate, duration, widthPercentage) const tick = document.createElement('span'); tick.className = "mini_tick" tick.textContent = "13h" diff --git a/app/templates/assiduites/moduleimpl_dynamic_selector.j2 b/app/templates/assiduites/moduleimpl_dynamic_selector.j2 index dbbecc8a15..6d7bba62b5 100644 --- a/app/templates/assiduites/moduleimpl_dynamic_selector.j2 +++ b/app/templates/assiduites/moduleimpl_dynamic_selector.j2 @@ -1,6 +1,6 @@ @@ -16,14 +16,14 @@ return semestre; } - function filterFormSemestres(semestres) { + function filterFormSemestres(semestres, dateIso) { const date = new moment.tz( - document.querySelector("#tl_date").value, + dateIso, TIMEZONE ); semestres = semestres.filter((fm) => { - return date.isBetween(fm.date_debut_iso, fm.date_fin_iso) + return date.isBetween(fm.date_debut_iso, fm.date_fin_iso, null, '[]') }) return semestres; @@ -67,8 +67,8 @@ } - function populateSelect(sems, selected) { - const select = document.getElementById('moduleimpl_select'); + function populateSelect(sems, selected, query) { + const select = document.querySelector(query); select.innerHTML = `` sems.forEach((mods, label) => { @@ -90,24 +90,25 @@ } - function updateSelect(moduleimpl_id) { + function updateSelect(moduleimpl_id, query = "#moduleimpl_select", dateIso = null) { let sem = getEtudFormSemestres() - sem = filterFormSemestres(sem) + if (dateIso == null) { + dateIso = document.querySelector("#tl_date").value + } + sem = filterFormSemestres(sem, dateIso) const mod = getModulesImplByFormsemestre(sem) - populateSelect(mod, moduleimpl_id); + populateSelect(mod, moduleimpl_id, query); } - function updateSelectedSelect(moduleimpl_id) { + function updateSelectedSelect(moduleimpl_id, query = "#moduleimpl_select") { const mod_id = moduleimpl_id != null ? moduleimpl_id : "" - document.getElementById('moduleimpl_select').value = mod_id; + document.querySelector(query).value = mod_id; } - window.onload = () => { - document.getElementById('moduleimpl_select').addEventListener('change', () => { - const mod_id = document.getElementById('moduleimpl_select').value; - + window.addEventListener("load", () => { + document.getElementById('moduleimpl_select').addEventListener('change', (el) => { const assi = getCurrentAssiduite(etudid); if (assi) { editAssiduite(assi.assiduite_id, assi.etat); @@ -118,7 +119,7 @@ if (conflicts.length > 0) { updateSelectedSelect(conflicts[0].moduleimpl_id); } - } + }, { once: true }); diff --git a/app/templates/assiduites/signal_assiduites_diff.j2 b/app/templates/assiduites/signal_assiduites_diff.j2 index cecfe14693..be5f4a40b3 100644 --- a/app/templates/assiduites/signal_assiduites_diff.j2 +++ b/app/templates/assiduites/signal_assiduites_diff.j2 @@ -1,512 +1,8 @@

    Signalement différé des assiduités {{gr |safe}}

    {{sem | safe }}

    - -
    -
    -
    -
    Noms
    - -
    -
    -
    - {% for etud in etudiants %} -
    -
    {{etud.nomprenom}}
    -
    - {% endfor %} -
    -
    +{{diff | safe}} {% include "assiduites/alert.j2" %} {% include "assiduites/prompt.j2" %} - - - - \ No newline at end of file +{% include "assiduites/conflict.j2" %} \ No newline at end of file diff --git a/app/templates/assiduites/signal_assiduites_etud.j2 b/app/templates/assiduites/signal_assiduites_etud.j2 index 7e77f7b49e..5a884569ea 100644 --- a/app/templates/assiduites/signal_assiduites_etud.j2 +++ b/app/templates/assiduites/signal_assiduites_etud.j2 @@ -1,36 +1,8 @@ {# -*- mode: jinja-html -*- #} - {% include "assiduites/toast.j2" %} {% include "assiduites/alert.j2" %} {% include "assiduites/prompt.j2" %} +{% include "assiduites/conflict.j2" %}
    {% block content %}

    Signalement de l'assiduité de {{sco.etud.nomprenom}}

    @@ -44,7 +16,7 @@
    - {% include "assiduites/moduleimpl_dynamic_selector.j2" %} + {{moduleimpl_select | safe }}
    @@ -59,6 +31,30 @@
    +
    + {{diff | safe}} + +
    +

    Explication diverses

    +

    + Si la période indiquée par la timeline provoque un conflit d'assiduité pour un étudiant sa ligne deviendra + rouge. +
    + Dans ce cas il faut résoudre manuellement le conflit : cliquez sur un des boutons d'assiduités pour ouvrir + le + résolveur de conflit. +
    + Correspondance des couleurs : +

    +
      +
    • Vert -> présence de l'étudiant lors de la période
    • +
    • Orange -> retard de l'étudiant lors de la période
    • +
    • Rouge -> absence de l'étudiant lors de la période
    • +
    • Hachure Bleu -> l'assiduité est justifiée par un justificatif valide
    • +
    • Hachure Rouge -> l'assiduité est justifiée par un justificatif non valide / en attente de validation +
    • +
    +
    @@ -99,8 +95,13 @@ } - let forceModule = "{{ forcer_module }}" - forceModule = forceModule == "True" ? true : false + window.forceModule = "{{ forcer_module }}" + window.forceModule = window.forceModule == "True" ? true : false + + window.addEventListener('load', function () { + loading(); + }, { once: true }); + @@ -110,6 +111,11 @@ background-color: rgb(104, 104, 252); color: whitesmoke; } + + fieldset { + outline: none; + border: none; + } {% endblock %} diff --git a/app/templates/assiduites/signal_assiduites_group.j2 b/app/templates/assiduites/signal_assiduites_group.j2 index 294499d963..0bacda8f2a 100644 --- a/app/templates/assiduites/signal_assiduites_group.j2 +++ b/app/templates/assiduites/signal_assiduites_group.j2 @@ -16,7 +16,7 @@
    Groupes : {{grp|safe}}
    -
    Modules :{{moduleimpl_select|safe}}
    +
    Module :{{moduleimpl_select|safe}}
    Date: @@ -31,35 +31,31 @@ {{timeline|safe}}
    +

    + Veillez à choisir le groupe concerné par la saisie ainsi que la date de la saisie. + Après validation, il faudra recharger la page pour changer les informations de la saisie. +

    -
    `; content.insertBefore(mass, content.querySelector(".etud_holder")); @@ -953,7 +996,6 @@ function actualizeEtudAssiduite(etudid, has_formsemestre = true) { getUrl() + `/api/assiduites/${etudid}/query?${formsemestre_id}date_debut=${date_debut}&date_fin=${date_fin}`; sync_get(url_api, (data, status) => { - console.error(data, status); if (status === "success") { assiduites[etudid] = data; } @@ -991,20 +1033,22 @@ function assiduiteAction(element) { // Cas de l'action de masse -> peuplement des queues if (currentMassAction) { - switch (type) { - case "création": - addToMassActionQueue("creer", { etat: etat, etudid: etudid }); - break; - case "édition": - if (etat === "remove") { - addToMassActionQueue("supprimer", assiduite_id); - } else { - addToMassActionQueue("editer", { - etat: etat, - assiduite_id: assiduite_id, - }); - } - break; + if (currentMassActionEtat != "remove") { + switch (type) { + case "création": + addToMassActionQueue("creer", { etat: etat, etudid: etudid }); + break; + case "édition": + if (etat != "remove") { + addToMassActionQueue("editer", { + etat: etat, + assiduite_id: assiduite_id, + }); + } + break; + } + } else if (type == "édition") { + addToMassActionQueue("supprimer", assiduite_id); } } else { // Cas normal -> mise à jour en base @@ -1042,13 +1086,49 @@ function assiduiteAction(element) { } if (type != "conflit") { - document - .querySelector(".toast-holder") - .appendChild( - generateToast( - document.createTextNode("L'assiduité a bien été enregistrée.") - ) - ); + let etatAffiche; + + switch (etat.toUpperCase()) { + case "PRESENT": + etatAffiche = + "%etud% a été noté(e) présent(e)"; + break; + case "RETARD": + etatAffiche = + "%etud% a été noté(e) en retard"; + break; + case "ABSENT": + etatAffiche = + "%etud% a été noté(e) absent(e)"; + break; + case "REMOVE": + etatAffiche = "L'assiduité de %etud% a été retirée."; + } + + let color; + + switch (etat.toUpperCase()) { + case "PRESENT": + color = "#6bdb83"; + break; + case "ABSENT": + color = "#F1A69C"; + break; + case "RETARD": + color = "#f0c865"; + break; + default: + color = "#AAA"; + break; + } + + const nom_prenom = `${etuds[etudid].nom.toUpperCase()} ${etuds[ + etudid + ].prenom.capitalize()}`; + const span = document.createElement("span"); + span.innerHTML = etatAffiche.replace("%etud%", nom_prenom); + + pushToast(generateToast(span, color, 5)); } actualizeEtud(etudid, !isSingleEtud); diff --git a/app/templates/assiduites/differee.j2 b/app/templates/assiduites/differee.j2 index 54745d30df..756bdf88eb 100644 --- a/app/templates/assiduites/differee.j2 +++ b/app/templates/assiduites/differee.j2 @@ -692,6 +692,52 @@ }) } + function launchToast(etudid, etat) { + let etatAffiche; + + switch (etat.toUpperCase()) { + case "PRESENT": + etatAffiche = + "%etud% a été noté(e) présent(e)"; + break; + case "RETARD": + etatAffiche = + "%etud% a été noté(e) en retard"; + break; + case "ABSENT": + etatAffiche = + "%etud% a été noté(e) absent(e)"; + break; + case "REMOVE": + etatAffiche = "L'assiduité de %etud% a été retirée."; + } + + let color; + + switch (etat.toUpperCase()) { + case "PRESENT": + color = "#6bdb83"; + break; + case "ABSENT": + color = "#F1A69C"; + break; + case "RETARD": + color = "#f0c865"; + break; + default: + color = "#AAA"; + break; + } + + const nom_prenom = `${etuds[etudid].nom.toUpperCase()} ${etuds[ + etudid + ].prenom.capitalize()}`; + const span = document.createElement("span"); + span.innerHTML = etatAffiche.replace("%etud%", nom_prenom); + + pushToast(generateToast(span, color, 5)); + } + function updateEtudAssiduite(rbtn) { const [_, colid, etudid] = rbtn.name.split("_"); @@ -714,8 +760,8 @@ assiduite["assiduite_id"] = assi_id; assiduites[etudid].push(assiduite); updateAllCol() + launchToast(etudid, etat); - // TODO Envoyer toast } }) break; @@ -761,7 +807,12 @@ } asyncEditAssiduite(edit, (data) => { - console.log(data) + const obj = getAssiduite(etudid, assi); + + obj.moduleimpl = edit.moduleimpl_id; + obj.etat = edit.etat; + + launchToast(etudid, etat); }) break; @@ -769,6 +820,10 @@ } + function getAssiduite(etudid, id) { + return assiduites[etudid].filter((a) => a.assiduite_id == id) + } + function asyncCreateAssiduite(assi, callback = () => { }) { const path = getUrl() + `/api/assiduite/${assi.etudid}/create`; async_post( diff --git a/app/templates/assiduites/liste_assiduites.j2 b/app/templates/assiduites/liste_assiduites.j2 index 0f60c91d41..685e191e5f 100644 --- a/app/templates/assiduites/liste_assiduites.j2 +++ b/app/templates/assiduites/liste_assiduites.j2 @@ -10,11 +10,36 @@ - - - - - + + + + + @@ -26,10 +51,30 @@
    DébutFinÉtatModuleJustifiée +
    + Début + +
    +
    +
    + Fin + +
    +
    +
    + État + +
    +
    +
    + Module + +
    +
    +
    + Justifiée + +
    +
    - - - - + + + + @@ -70,6 +115,7 @@ th { background-color: #f2f2f2; + } tr:hover { @@ -138,6 +184,12 @@ color: #fff; border-color: #007bff; } + + th>div { + display: flex; + justify-content: space-between; + align-items: center; + } diff --git a/app/templates/assiduites/signal_assiduites_group.j2 b/app/templates/assiduites/signal_assiduites_group.j2 index 0bacda8f2a..fc80e5fccf 100644 --- a/app/templates/assiduites/signal_assiduites_group.j2 +++ b/app/templates/assiduites/signal_assiduites_group.j2 @@ -22,14 +22,14 @@ Date: - - {{timeline|safe}} + +

    Veillez à choisir le groupe concerné par la saisie ainsi que la date de la saisie. @@ -77,5 +77,23 @@ window.forceModule = "{{ forcer_module }}" window.forceModule = window.forceModule == "True" ? true : false + if (window.forceModule) { + const btn = document.getElementById("validate_selectors"); + + const select = document.getElementById("moduleimpl_select"); + + if (select.value == "") { + btn.disabled = true; + } + + select.addEventListener('change', (e) => { + if (e.target.value != "") { + btn.disabled = false; + } else { + btn.disabled = true; + } + }); + } + \ No newline at end of file diff --git a/app/templates/assiduites/timeline.j2 b/app/templates/assiduites/timeline.j2 index ce8e59e72f..5a53ce6514 100644 --- a/app/templates/assiduites/timeline.j2 +++ b/app/templates/assiduites/timeline.j2 @@ -2,6 +2,7 @@

    +
    Time
    \ No newline at end of file diff --git a/app/templates/assiduites/signal_assiduites_diff.j2 b/app/templates/assiduites/signal_assiduites_diff.j2 index be5f4a40b3..9735aa8a03 100644 --- a/app/templates/assiduites/signal_assiduites_diff.j2 +++ b/app/templates/assiduites/signal_assiduites_diff.j2 @@ -5,4 +5,5 @@ {% include "assiduites/alert.j2" %} {% include "assiduites/prompt.j2" %} -{% include "assiduites/conflict.j2" %} \ No newline at end of file +{% include "assiduites/conflict.j2" %} +{% include "assiduites/toast.j2" %} \ No newline at end of file diff --git a/app/templates/assiduites/timeline.j2 b/app/templates/assiduites/timeline.j2 index 5a53ce6514..4f77c86d17 100644 --- a/app/templates/assiduites/timeline.j2 +++ b/app/templates/assiduites/timeline.j2 @@ -56,7 +56,7 @@ function numberToTime(num) { const integer = Math.floor(num); - const decimal = (num % 1) * 60; + const decimal = Math.round((num % 1) * 60); let dec = `:${decimal}`; if (decimal < 10) { diff --git a/app/templates/assiduites/toast.j2 b/app/templates/assiduites/toast.j2 index f1a0becffd..e15994529b 100644 --- a/app/templates/assiduites/toast.j2 +++ b/app/templates/assiduites/toast.j2 @@ -92,6 +92,25 @@ } + function getToastColorFromEtat(etat) { + let color; + switch (etat.toUpperCase()) { + case "PRESENT": + color = "#6bdb83"; + break; + case "ABSENT": + color = "#F1A69C"; + break; + case "RETARD": + color = "#f0c865"; + break; + default: + color = "#AAA"; + break; + } + return color; + } + \ No newline at end of file From 9e9797c705feeb6627919e71c80df1c84ebfcea5 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 15 Jun 2023 17:14:37 +0200 Subject: [PATCH 039/101] Fix: tri des coefs. de modules apc --- app/models/modules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/modules.py b/app/models/modules.py index cfc6a994f9..85503f2789 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -242,7 +242,7 @@ class Module(db.Model): "les coefs d'UE, trié par numéro et acronyme d'UE" # je n'ai pas su mettre un order_by sur le backref sans avoir # à redéfinir les relationships... - return sorted(self.ue_coefs, key=attrgetter("numero", "acronyme")) + return sorted(self.ue_coefs, key=lambda uc: (uc.ue.numero, uc.ue.acronyme)) def ue_coefs_list( self, include_zeros=True, ues: list["UniteEns"] = None From 6d2c3f8dcc22fb1c5d24287d9ac7c3a8e738dbd4 Mon Sep 17 00:00:00 2001 From: iziram Date: Thu, 15 Jun 2023 17:50:38 +0200 Subject: [PATCH 040/101] Assiduites : Page liste - filtrage des tableaux --- app/static/css/assiduites.css | 12 +- app/static/icons/filter.svg | 1 + app/templates/assiduites/liste_assiduites.j2 | 570 +++++++++++++++++-- 3 files changed, 545 insertions(+), 38 deletions(-) create mode 100644 app/static/icons/filter.svg diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index be4a8842c0..56cef447c7 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -510,16 +510,24 @@ } .order { + background-image: url(../icons/sort.svg); +} + +.filter { + background-image: url(../icons/filter.svg); +} + +.icon { display: block; width: 24px; height: 24px; - background-image: url(../icons/sort.svg); outline: none; border: none; cursor: pointer; + margin: 0 2px; } -.order:focus { +.icon:focus { outline: none; border: none; } \ No newline at end of file diff --git a/app/static/icons/filter.svg b/app/static/icons/filter.svg new file mode 100644 index 0000000000..8259c6401f --- /dev/null +++ b/app/static/icons/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/assiduites/liste_assiduites.j2 b/app/templates/assiduites/liste_assiduites.j2 index 685e191e5f..2fd4daf6c0 100644 --- a/app/templates/assiduites/liste_assiduites.j2 +++ b/app/templates/assiduites/liste_assiduites.j2 @@ -7,37 +7,38 @@

    Liste de l'assiduité et des justificatifs de {{sco.etud.nomprenom}}

    Assiduités :

    +
    DébutFinÉtatRaison +
    + Début + +
    +
    +
    + Fin + +
    +
    +
    + État + +
    +
    +
    + Raison + +
    +
    @@ -48,31 +49,32 @@

    Justificatifs :

    +
    Début - +
    Fin - +
    État - +
    Module - +
    Justifiée - +
    @@ -94,7 +96,7 @@ +{% endblock %} diff --git a/app/views/notes.py b/app/views/notes.py index e29a93c918..3a5e3bbe74 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -47,11 +47,12 @@ from app.but import jury_but, jury_but_validation_auto from app.but.forms import jury_but_forms from app.but import jury_but_pv from app.but import jury_but_view +from app.but import jury_edit_manual from app.comp import jury, res_sem from app.comp.res_compat import NotesTableCompat from app.models import Formation, ScolarAutorisationInscription, ScolarNews, Scolog -from app.models.but_refcomp import ApcNiveau, ApcParcours +from app.models.but_refcomp import ApcNiveau from app.models.config import ScoDocSiteConfig from app.models.etudiants import Identite from app.models.formsemestre import FormSemestre @@ -2940,6 +2941,18 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int): ) +@bp.route( + "/jury_delete_manual/", + methods=["GET", "POST"], +) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +def jury_delete_manual(etudid: int): + """Efface toute les décisions d'une année pour cet étudiant""" + etud: Identite = Identite.query.get_or_404(etudid) + return jury_edit_manual.jury_delete_manual(etud) + + sco_publish( "/formsemestre_lettres_individuelles", sco_pv_forms.formsemestre_lettres_individuelles, From 319be43ba3785bce5f4b32d5802177e2f364c3e7 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 19 Jun 2023 22:32:04 +0200 Subject: [PATCH 050/101] fix html typos --- app/templates/jury/erase_decisions_annee_formation.j2 | 6 +++--- app/templates/jury/jury_delete_manual.j2 | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/templates/jury/erase_decisions_annee_formation.j2 b/app/templates/jury/erase_decisions_annee_formation.j2 index 7d0353eb89..0c231b5325 100644 --- a/app/templates/jury/erase_decisions_annee_formation.j2 +++ b/app/templates/jury/erase_decisions_annee_formation.j2 @@ -3,8 +3,8 @@ {% block app_content %} {% if not validations %} -

    Aucune validation de jury enregistrée pour {{etud.html_link_fiche()}} sur -l'année {{annee}} +

    Aucune validation de jury enregistrée pour {{etud.html_link_fiche()|safe}} +sur l'année {{annee}} de la formation {{ formation.html() }}

    @@ -13,7 +13,7 @@ de la formation {{ formation.html() }} {% else %} -

    Effacer les décisions de jury pour l'année {{annee}} de {{etud.html_link_fiche()}} ?

    +

    Effacer les décisions de jury pour l'année {{annee}} de {{etud.html_link_fiche()|safe}} ?

    Affectera toutes les décisions concernant l'année {{annee}} de la formation, quelle que soit leur origine.

    diff --git a/app/templates/jury/jury_delete_manual.j2 b/app/templates/jury/jury_delete_manual.j2 index ed7a9d2e8e..c7845637b1 100644 --- a/app/templates/jury/jury_delete_manual.j2 +++ b/app/templates/jury/jury_delete_manual.j2 @@ -95,7 +95,8 @@ pages de saisie de jury habituelles). {% endif %}
    - retour à sa fiche +

    retour à la fiche de {{etud.html_link_fiche()|safe}} +

    {% endblock %} From fae4c32db24791f18dbccd91c72a4c6b5251ee84 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 19 Jun 2023 22:33:27 +0200 Subject: [PATCH 051/101] version --- sco_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sco_version.py b/sco_version.py index 02d22d18df..7a95f76a52 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.86" +SCOVERSION = "9.4.87" SCONAME = "ScoDoc" From 54ab56e9bf8e581726a9ac5e0b516b627b5ff116 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 07:51:40 +0200 Subject: [PATCH 052/101] Database creation: add unaccent postgresql extension. Tests unitaires OK. --- sco_version.py | 2 +- tests/api/test_api_permissions.py | 1 + tools/create_database.sh | 2 +- tools/debian/postinst | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/sco_version.py b/sco_version.py index 7a95f76a52..857492bf1c 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.87" +SCOVERSION = "9.4.88" SCONAME = "ScoDoc" diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index bb6ca6095d..05c27b5d68 100755 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -60,6 +60,7 @@ def test_permissions(api_headers): "role_name": "Ens", "start": "abc", "uid": 1, + "validation_id": 1, "version": "long", "assiduite_id": 1, "justif_id": 1, diff --git a/tools/create_database.sh b/tools/create_database.sh index 6e9a939ed5..d2863722b0 100755 --- a/tools/create_database.sh +++ b/tools/create_database.sh @@ -31,4 +31,4 @@ source "$SCRIPT_DIR"/utils.sh || die "config.sh not found, exiting" # --- echo 'Creating postgresql database ' "$db_name" createdb -E UTF-8 -p "$POSTGRES_PORT" -O "$POSTGRES_USER" "$db_name" - +echo 'CREATE EXTENSION IF NOT EXISTS "unaccent";' | psql -p "$POSTGRES_PORT" "$db_name" "$POSTGRES_USER" diff --git a/tools/debian/postinst b/tools/debian/postinst index dad8205ee9..c967a53ec6 100755 --- a/tools/debian/postinst +++ b/tools/debian/postinst @@ -104,7 +104,7 @@ if [ "$n" == 1 ] then echo "Upgrading existing SCODOC database..." # Ajout extension unaccent (postgres superuser, ajout sur base SCODOC) - (cd /tmp; echo 'CREATE EXTENSION IF NOT EXISTS "unaccent";' | su -c psql postgres) + (cd /tmp; echo 'CREATE EXTENSION IF NOT EXISTS "unaccent";' | su -c "psql SCODOC" postgres) # Migrations gérées avec Flask-Migrate (Alembic/SQLAlchemy) # utilise les scripts dans migrations/version/ # pour mettre à jour notre base (en tant qu'utilisateur scodoc) From 93136ee67930f7d02a57696f1248df889d0a83f5 Mon Sep 17 00:00:00 2001 From: iziram Date: Tue, 20 Jun 2023 08:33:49 +0200 Subject: [PATCH 053/101] =?UTF-8?q?Assiduites=20:=20r=C3=A9organisation=20?= =?UTF-8?q?templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => pages}/config_assiduites.j2 | 0 .../{ => pages}/etat_absence_date.j2 | 0 .../{ => pages}/liste_assiduites.j2 | 2 +- .../pages/signal_assiduites_diff.j2 | 9 ++++++++ .../{ => pages}/signal_assiduites_etud.j2 | 8 +++---- .../{ => pages}/signal_assiduites_group.j2 | 8 +++---- .../assiduites/signal_assiduites_diff.j2 | 9 -------- .../assiduites/{ => widgets}/alert.j2 | 0 .../assiduites/{ => widgets}/conflict.j2 | 0 .../assiduites/{ => widgets}/differee.j2 | 0 .../assiduites/{ => widgets}/minitimeline.j2 | 0 .../moduleimpl_dynamic_selector.j2 | 0 .../{ => widgets}/moduleimpl_selector.j2 | 0 .../assiduites/{ => widgets}/prompt.j2 | 0 .../assiduites/{ => widgets}/timeline.j2 | 0 .../assiduites/{ => widgets}/toast.j2 | 0 app/views/assiduites.py | 22 ++++++++++--------- ...75e87f_modèles_assiduites_justificatifs.py | 4 ++-- 18 files changed, 32 insertions(+), 30 deletions(-) rename app/templates/assiduites/{ => pages}/config_assiduites.j2 (100%) rename app/templates/assiduites/{ => pages}/etat_absence_date.j2 (100%) rename app/templates/assiduites/{ => pages}/liste_assiduites.j2 (99%) create mode 100644 app/templates/assiduites/pages/signal_assiduites_diff.j2 rename app/templates/assiduites/{ => pages}/signal_assiduites_etud.j2 (94%) rename app/templates/assiduites/{ => pages}/signal_assiduites_group.j2 (94%) delete mode 100644 app/templates/assiduites/signal_assiduites_diff.j2 rename app/templates/assiduites/{ => widgets}/alert.j2 (100%) rename app/templates/assiduites/{ => widgets}/conflict.j2 (100%) rename app/templates/assiduites/{ => widgets}/differee.j2 (100%) rename app/templates/assiduites/{ => widgets}/minitimeline.j2 (100%) rename app/templates/assiduites/{ => widgets}/moduleimpl_dynamic_selector.j2 (100%) rename app/templates/assiduites/{ => widgets}/moduleimpl_selector.j2 (100%) rename app/templates/assiduites/{ => widgets}/prompt.j2 (100%) rename app/templates/assiduites/{ => widgets}/timeline.j2 (100%) rename app/templates/assiduites/{ => widgets}/toast.j2 (100%) diff --git a/app/templates/assiduites/config_assiduites.j2 b/app/templates/assiduites/pages/config_assiduites.j2 similarity index 100% rename from app/templates/assiduites/config_assiduites.j2 rename to app/templates/assiduites/pages/config_assiduites.j2 diff --git a/app/templates/assiduites/etat_absence_date.j2 b/app/templates/assiduites/pages/etat_absence_date.j2 similarity index 100% rename from app/templates/assiduites/etat_absence_date.j2 rename to app/templates/assiduites/pages/etat_absence_date.j2 diff --git a/app/templates/assiduites/liste_assiduites.j2 b/app/templates/assiduites/pages/liste_assiduites.j2 similarity index 99% rename from app/templates/assiduites/liste_assiduites.j2 rename to app/templates/assiduites/pages/liste_assiduites.j2 index 2fd4daf6c0..87353a5592 100644 --- a/app/templates/assiduites/liste_assiduites.j2 +++ b/app/templates/assiduites/pages/liste_assiduites.j2 @@ -1,4 +1,4 @@ -{% include "assiduites/alert.j2" %} +{% include "assiduites/widgets/alert.j2" %} {% include "assiduites/prompt.j2" %} {% block app_content %} diff --git a/app/templates/assiduites/pages/signal_assiduites_diff.j2 b/app/templates/assiduites/pages/signal_assiduites_diff.j2 new file mode 100644 index 0000000000..abb7fcea5a --- /dev/null +++ b/app/templates/assiduites/pages/signal_assiduites_diff.j2 @@ -0,0 +1,9 @@ +

    Signalement différé des assiduités {{gr |safe}}

    +

    {{sem | safe }}

    + +{{diff | safe}} + +{% include "assiduites/widgets/alert.j2" %} +{% include "assiduites/widgets/prompt.j2" %} +{% include "assiduites/widgets/conflict.j2" %} +{% include "assiduites/widgets/toast.j2" %} \ No newline at end of file diff --git a/app/templates/assiduites/signal_assiduites_etud.j2 b/app/templates/assiduites/pages/signal_assiduites_etud.j2 similarity index 94% rename from app/templates/assiduites/signal_assiduites_etud.j2 rename to app/templates/assiduites/pages/signal_assiduites_etud.j2 index 94811b1371..f03d4f2860 100644 --- a/app/templates/assiduites/signal_assiduites_etud.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_etud.j2 @@ -1,8 +1,8 @@ {# -*- mode: jinja-html -*- #} -{% include "assiduites/toast.j2" %} -{% include "assiduites/alert.j2" %} -{% include "assiduites/prompt.j2" %} -{% include "assiduites/conflict.j2" %} +{% include "assiduites/widgets/toast.j2" %} +{% include "assiduites/widgets/alert.j2" %} +{% include "assiduites/widgets/prompt.j2" %} +{% include "assiduites/widgets/conflict.j2" %}
    {% block content %}

    Signalement de l'assiduité de {{sco.etud.nomprenom}}

    diff --git a/app/templates/assiduites/signal_assiduites_group.j2 b/app/templates/assiduites/pages/signal_assiduites_group.j2 similarity index 94% rename from app/templates/assiduites/signal_assiduites_group.j2 rename to app/templates/assiduites/pages/signal_assiduites_group.j2 index fc80e5fccf..f0da36b0ef 100644 --- a/app/templates/assiduites/signal_assiduites_group.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_group.j2 @@ -1,4 +1,4 @@ -{% include "assiduites/toast.j2" %} +{% include "assiduites/widgets/toast.j2" %}
    @@ -62,9 +62,9 @@
    - {% include "assiduites/alert.j2" %} - {% include "assiduites/prompt.j2" %} - {% include "assiduites/conflict.j2" %} + {% include "assiduites/widgets/alert.j2" %} + {% include "assiduites/widgets/prompt.j2" %} + {% include "assiduites/widgets/conflict.j2" %} +{% endblock pageContent %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/liste_assiduites.j2 b/app/templates/assiduites/pages/liste_assiduites.j2 index 87353a5592..1e59433873 100644 --- a/app/templates/assiduites/pages/liste_assiduites.j2 +++ b/app/templates/assiduites/pages/liste_assiduites.j2 @@ -1,89 +1,14 @@ -{% include "assiduites/widgets/alert.j2" %} -{% include "assiduites/prompt.j2" %} {% block app_content %} -

    Liste de l'assiduité et des justificatifs de {{sco.etud.nomprenom}}

    - + {% include "assiduites/widgets/tableau_base.j2" %}

    Assiduités :

    -
    Début - +
    Fin - +
    État - +
    Raison - +
    - - - - - - - - - - - -
    -
    - Début - -
    -
    -
    - Fin - -
    -
    -
    - État - -
    -
    -
    - Module - -
    -
    -
    - Justifiée - -
    -
    -
    -
    + {% include "assiduites/widgets/tableau_assi.j2" %}

    Justificatifs :

    - - - - - - - - - - - -
    -
    - Début - -
    -
    -
    - Fin - -
    -
    -
    - État - -
    -
    -
    - Raison - -
    -
    -
    -
    + {% include "assiduites/widgets/tableau_justi.j2" %}
    • Detail
    • Editer
    • @@ -93,851 +18,8 @@ {% endblock app_content %} - - \ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_base.j2 b/app/templates/assiduites/widgets/tableau_base.j2 new file mode 100644 index 0000000000..9912a42ce9 --- /dev/null +++ b/app/templates/assiduites/widgets/tableau_base.j2 @@ -0,0 +1,724 @@ +
        +
      • Detail
      • +
      • Editer
      • +
      • Supprimer
      • +
      + +{% include "assiduites/widgets/alert.j2" %} +{% include "assiduites/widgets/prompt.j2" %} + + + + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_justi.j2 b/app/templates/assiduites/widgets/tableau_justi.j2 new file mode 100644 index 0000000000..27dd12e2a5 --- /dev/null +++ b/app/templates/assiduites/widgets/tableau_justi.j2 @@ -0,0 +1,111 @@ + + + + + + + + + + + + +
      +
      + Début + +
      +
      +
      + Fin + +
      +
      +
      + État + +
      +
      +
      + Raison + +
      +
      +
      + Fichier + +
      +
      +
      +
      + + \ No newline at end of file diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 19c2199ff8..5db4039d3f 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -62,10 +62,8 @@ {% if current_user.has_permission(sco.Permission.ScoAbsChange) %}
    • Ajouter
    • -
    • Justifier
    • -
    • Supprimer
    • {% if sco.prefs["handle_billets_abs"] %}
    • Billets
    • diff --git a/app/views/assiduites.py b/app/views/assiduites.py index e312f7f713..8efad887db 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -269,6 +269,47 @@ def liste_assiduites_etud(): ).build() +@bp.route("/AjoutJustificatifEtud") +@scodoc +@permission_required(Permission.ScoAbsChange) +def ajout_justificatif_etud(): + """ + ajout_justificatif_etud : Affichage et création/modification des justificatifs de l'é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="Justificatifs", + 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/pages/ajout_justificatif.j2", + sco=ScoData(etud), + ), + ).build() + + @bp.route("/SignalAssiduiteGr") @scodoc @permission_required(Permission.ScoAbsChange) diff --git a/migrations/versions/c701224fa255_validation_niveaux_inferieurs.py b/migrations/versions/c701224fa255_validation_niveaux_inferieurs.py index 08f275091d..148c010fe6 100644 --- a/migrations/versions/c701224fa255_validation_niveaux_inferieurs.py +++ b/migrations/versions/c701224fa255_validation_niveaux_inferieurs.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker # added by ev # revision identifiers, used by Alembic. revision = "c701224fa255" -down_revision = "d84bc592584e" +down_revision = "b555390780b2" branch_labels = None depends_on = None diff --git a/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py index 08514497d4..d281add58c 100755 --- a/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py +++ b/migrations/versions/dbcf2175e87f_modèles_assiduites_justificatifs.py @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "dbcf2175e87f" -down_revision = "c701224fa255" +down_revision = "d84bc592584e" branch_labels = None depends_on = None From 88d3ef020da0c5a4658ed3f359c4fdbb991b3917 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 12:14:16 +0200 Subject: [PATCH 055/101] =?UTF-8?q?Table=20jury:=20affichage=20stats=20cod?= =?UTF-8?q?es=20annuels=20octroy=C3=A9s=20sous=20la=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scodoc/sco_archives.py | 2 +- app/scodoc/sco_recapcomplet.py | 69 ++++++++++++++++++++-------------- app/static/css/scodoc.css | 2 + app/tables/jury_recap.py | 1 + 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py index 17a0b86372..c6a646ee50 100644 --- a/app/scodoc/sco_archives.py +++ b/app/scodoc/sco_archives.py @@ -349,7 +349,7 @@ def do_formsemestre_archive( if data: PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data) # Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes) - table_html, _ = gen_formsemestre_recapcomplet_html_table( + table_html, _, _ = gen_formsemestre_recapcomplet_html_table( formsemestre, res, include_evaluations=True ) if table_html: diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 9ebe4e87d2..791fdb0f53 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -27,6 +27,7 @@ """Tableau récapitulatif des notes d'un semestre """ +import collections import datetime import time from xml.etree import ElementTree @@ -109,7 +110,7 @@ def formsemestre_recapcomplet( force_publishing=force_publishing, ) - table_html, table = _formsemestre_recapcomplet_to_html( + table_html, table, freq_codes_annuels = _formsemestre_recapcomplet_to_html( formsemestre, filename=filename, mode_jury=mode_jury, @@ -215,33 +216,37 @@ def formsemestre_recapcomplet( """ ) - if mode_jury and table and sum(table.freq_codes_annuels.values()) > 0: + if mode_jury and freq_codes_annuels and sum(freq_codes_annuels.values()) > 0: + nb_etud_avec_decision_annuelle = ( + sum(freq_codes_annuels.values()) - freq_codes_annuels["total"] + ) H.append( f"""
      -
      Nb d'étudiants avec décision annuelle: - {sum(table.freq_codes_annuels.values())} / {len(table)} +
      Nb d'étudiants avec décision annuelle: + {nb_etud_avec_decision_annuelle} / {freq_codes_annuels["total"]}
      -
      Codes annuels octroyés:
      - """ ) - for code in sorted(table.freq_codes_annuels.keys()): + if nb_etud_avec_decision_annuelle > 0: H.append( - f""" - - - - """ + """
      Codes annuels octroyés:
      +
      {code}{table.freq_codes_annuels[code]}{ - (100*table.freq_codes_annuels[code] / len(table)):2.1f}% -
      + """ ) - H.append( - """ -
      -
      - """ - ) + for code in sorted(freq_codes_annuels.keys()): + if code != "total": + H.append( + f""" + {code} + {freq_codes_annuels[code]} + { + (100*freq_codes_annuels[code] / freq_codes_annuels["total"]):2.1f}% + + """ + ) + H.append("""""") + H.append("""
      """) # Légende H.append( """ @@ -272,12 +277,12 @@ def _formsemestre_recapcomplet_to_html( filename: str = "", mode_jury=False, # saisie décisions jury selected_etudid=None, -) -> tuple[str, TableRecap]: +) -> tuple[str, TableRecap, collections.Counter]: """Le tableau recap en html""" if tabformat not in ("html", "evals"): raise ScoValueError("invalid table format") res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - table_html, table = gen_formsemestre_recapcomplet_html_table( + table_html, table, freq_codes_annuels = gen_formsemestre_recapcomplet_html_table( formsemestre, res, include_evaluations=(tabformat == "evals"), @@ -285,7 +290,7 @@ def _formsemestre_recapcomplet_to_html( filename=filename, selected_etudid=selected_etudid, ) - return table_html, table + return table_html, table, freq_codes_annuels def _formsemestre_recapcomplet_to_file( @@ -447,9 +452,9 @@ def gen_formsemestre_recapcomplet_html_table( mode_jury=False, filename="", selected_etudid=None, -) -> tuple[str, TableRecap]: +) -> tuple[str, TableRecap, collections.Counter]: """Construit table recap pour le BUT - Cache le résultat pour le semestre (sauf en mode jury). + Cache le résultat pour le semestre. Note: on cache le HTML et non l'objet Table. Si mode_jury, occultera colonnes modules (en js) @@ -461,6 +466,7 @@ def gen_formsemestre_recapcomplet_html_table( """ table = None table_html = None + table_html_cached = None cache_class = { (True, True): sco_cache.TableJuryWithEvalsCache, (True, False): sco_cache.TableJuryCache, @@ -468,8 +474,8 @@ def gen_formsemestre_recapcomplet_html_table( (False, False): sco_cache.TableRecapCache, }[(bool(mode_jury), bool(include_evaluations))] if not selected_etudid: - table_html = cache_class.get(formsemestre.id) - if table_html is None: + table_html_cached = cache_class.get(formsemestre.id) + if table_html_cached is None: table = _gen_formsemestre_recapcomplet_table( res, include_evaluations, @@ -478,9 +484,14 @@ def gen_formsemestre_recapcomplet_html_table( selected_etudid=selected_etudid, ) table_html = table.html() - cache_class.set(formsemestre.id, table_html) + freq_codes_annuels = ( + table.freq_codes_annuels if hasattr(table, "freq_codes_annuels") else None + ) + cache_class.set(formsemestre.id, (table_html, freq_codes_annuels)) + else: + table_html, freq_codes_annuels = table_html_cached - return table_html, table + return table_html, table, freq_codes_annuels def _gen_formsemestre_recapcomplet_table( diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 4f922c9d94..172e900817 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -2773,6 +2773,8 @@ table.notes_recapcomplet a:hover { div.table_recap_caption { width: fit-content; + margin-top: 8px; + margin-bottom: 8px; padding: 8px; border-radius: 8px; background-color: rgb(202, 255, 180); diff --git a/app/tables/jury_recap.py b/app/tables/jury_recap.py index 8d02065cf7..3e6f5d24da 100644 --- a/app/tables/jury_recap.py +++ b/app/tables/jury_recap.py @@ -78,6 +78,7 @@ class TableJury(TableRecap): dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id) if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau row.add_rcue_cols(dec_rcue) + self.freq_codes_annuels["total"] = len(self.rows) def add_jury(self): """Ajoute la colonne code jury et le lien. From 44cb71615452edc028dba7f36dac7c9f46c71542 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 14:26:01 +0200 Subject: [PATCH 056/101] Fix typo --- app/templates/jury/jury_delete_manual.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/jury/jury_delete_manual.j2 b/app/templates/jury/jury_delete_manual.j2 index c7845637b1..e423501fbf 100644 --- a/app/templates/jury/jury_delete_manual.j2 +++ b/app/templates/jury/jury_delete_manual.j2 @@ -86,7 +86,7 @@ pages de saisie de jury habituelles). {% endif %} {% if not( - sem_vals.first() or sem_ues.first() or sem_rcues.first() + sem_vals.first() or ue_vals.first() or rcue_vals.first() or annee_but_vals.first() or autorisations.first()) %}
      From c928ccdcfe6a92cb9ab24e76c81ab9de5d3a2be9 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 19:56:20 +0200 Subject: [PATCH 057/101] =?UTF-8?q?Jury=20BUT:=20effacement=20d=C3=A9cisio?= =?UTF-8?q?n=20ann=C3=A9e=20+=202=20petits=20bugs=20mineurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index af9b46731e..6861c8ecdf 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -931,7 +931,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): # XXX efface les validations émise depuis ce semestre # et pas toutes celles concernant cette l'année... # (utiliser formation_id pour changer cette politique) - formsemestre_id=self.formsemestre_impair.id, + formsemestre_id=self.formsemestre.id, ordre=self.annee_but, ) for validation in validations: @@ -1286,7 +1286,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): sco_cache.invalidate_formsemestre( formsemestre_id=validation_rcue.formsemestre_id ) - else: + elif ue1 and ue2: # Crée nouvelle validation validation_rcue = ApcValidationRCUE( etudid=self.etud.id, ue1_id=ue1.id, ue2_id=ue2.id, code=sco_codes.ADSUP @@ -1380,20 +1380,20 @@ class DecisionsProposeesRCUE(DecisionsProposees): "Impossible de valider le niveau de compétence inférieur: pas 2 UEs associées'", "warning", ) - return + return [], None, None ues_impaires = [ue for ue in ues if ue.semestre_idx % 2] if len(ues_impaires) != 1: flash( "Impossible de valider le niveau de compétence inférieur: pas d'UE impaire associée" ) - return + return [], None, None ue1 = ues_impaires[0] ues_paires = [ue for ue in ues if not ue.semestre_idx % 2] if len(ues_paires) != 1: flash( "Impossible de valider le niveau de compétence inférieur: pas d'UE paire associée" ) - return + return [], None, None ue2 = ues_paires[0] return ues, ue1, ue2 From 7712de19a2e6ed54c77428b966c794b474c6f678 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 20 Jun 2023 21:01:40 +0200 Subject: [PATCH 058/101] =?UTF-8?q?Modifie=20effacement=20d=C3=A9cisions?= =?UTF-8?q?=20annuelles=20BUT=20et=20RCUE.=20Am=C3=A9liore=20affichage=20d?= =?UTF-8?q?=C3=A9cisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 35 +++++++++++-------- app/models/validations.py | 14 +++++--- app/static/css/jury_delete_manual.css | 6 +++- .../but/formsemestre_validation_auto_but.j2 | 10 ++++-- app/views/notes.py | 7 +++- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 6861c8ecdf..83b1d9ddb3 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -902,6 +902,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): Efface même si étudiant DEM ou DEF. Si à cheval ou only_one_sem, n'efface que les décisions UE et les autorisations de passage du semestre d'origine du deca. + + Dans tous les cas, efface les validations de l'année en cours. (commite la session.) """ if only_one_sem or self.a_cheval: @@ -916,8 +918,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): else: for dec_ue in self.decisions_ues.values(): dec_ue.erase() - for dec_rcue in self.decisions_rcue_by_niveau.values(): - dec_rcue.erase() + if self.formsemestre_impair: ScolarAutorisationInscription.delete_autorisation_etud( self.etud.id, self.formsemestre_impair.id @@ -926,21 +927,27 @@ class DecisionsProposeesAnnee(DecisionsProposees): ScolarAutorisationInscription.delete_autorisation_etud( self.etud.id, self.formsemestre_pair.id ) - validations = ApcValidationAnnee.query.filter_by( + # Efface les RCUEs + for dec_rcue in self.decisions_rcue_by_niveau.values(): + dec_rcue.erase() + + # Efface les validations concernant l'année BUT + # de ce semestre + validations = ( + ApcValidationAnnee.query.filter_by( etudid=self.etud.id, - # XXX efface les validations émise depuis ce semestre - # et pas toutes celles concernant cette l'année... - # (utiliser formation_id pour changer cette politique) - formsemestre_id=self.formsemestre.id, ordre=self.annee_but, ) - for validation in validations: - db.session.delete(validation) - Scolog.logdb( - "jury_but", - etudid=self.etud.id, - msg=f"Validation année BUT{self.annee_but}: effacée", - ) + .join(Formation) + .filter_by(formation_code=self.formsemestre.formation.formation_code) + ) + for validation in validations: + db.session.delete(validation) + Scolog.logdb( + "jury_but", + etudid=self.etud.id, + msg=f"Validation année BUT{self.annee_but}: effacée", + ) # Efface éventuelles validations de semestre # (en principe inutilisées en BUT) diff --git a/app/models/validations.py b/app/models/validations.py index 8a1a8dd0d4..7686d78976 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -79,17 +79,23 @@ class ScolarFormSemestreValidation(db.Model): def html(self, detail=False) -> str: "Affichage html" if self.ue_id is not None: - return f"""Validation de l'UE {self.ue.acronyme} de {self.ue.formation.acronyme} + return f"""Validation de l'UE {self.ue.acronyme} + {('parcours ' + + ", ".join([p.code for p in self.ue.parcours])) + + "" + if self.ue.parcours else ""} + de {self.ue.formation.acronyme} {("émise par " + self.formsemestre.html_link_status()) if self.formsemestre else ""} - :{self.code} + : {self.code} le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")} """ else: return f"""Validation du semestre S{ self.formsemestre.semestre_id if self.formsemestre else "?"} - ({self.code} - le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}) + {self.formsemestre.html_link_status() if self.formsemestre else ""} + : {self.code} + le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")} """ diff --git a/app/static/css/jury_delete_manual.css b/app/static/css/jury_delete_manual.css index 6580e089f3..6fa14f9ea0 100644 --- a/app/static/css/jury_delete_manual.css +++ b/app/static/css/jury_delete_manual.css @@ -6,4 +6,8 @@ div.jury_decisions_list div { div.jury_decisions_list form { display: inline-block; -} \ No newline at end of file +} + +span.parcours { + color:blueviolet; +} diff --git a/app/templates/but/formsemestre_validation_auto_but.j2 b/app/templates/but/formsemestre_validation_auto_but.j2 index db7de789ac..27334aac54 100644 --- a/app/templates/but/formsemestre_validation_auto_but.j2 +++ b/app/templates/but/formsemestre_validation_auto_but.j2 @@ -26,9 +26,13 @@ En conséquence, saisir ensuite manuellement les décisions manquantes, notamment sur les UEs en dessous de 10.

      -

      - Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure ! -

      +
      +
        +
      • Ne jamais lancer ce calcul avant que toutes les notes ne soient saisies ! + (verrouiller le semestre ensuite) +
      • +
      • Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !
      • +
      diff --git a/app/views/notes.py b/app/views/notes.py index 3a5e3bbe74..cf5da2e36b 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2898,7 +2898,12 @@ def formsemestre_jury_but_erase(formsemestre_id: int, etudid: int = None): ) + """

      Les décisions des années scolaires précédentes ne seront pas modifiées.

      -
      Cette opération est irréversible !
      +

      Efface aussi toutes les validations concernant l'année BUT de ce semestre, + même si elles ont été acquises ailleurs. +

      +
      Cette opération est irréversible ! + A n'utiliser que dans des cas exceptionnels, vérifiez bien tous les étudiants ensuite. +
      """, cancel_url=dest_url, ) From d6664835300072af9130c761f825bdb3b9bcf7ab Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Jun 2023 09:51:13 +0200 Subject: [PATCH 059/101] =?UTF-8?q?Am=C3=A9liore=20tri=20jury=5Fdelete=5Fm?= =?UTF-8?q?anual=20et=20table=20recap=20(rang)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_edit_manual.py | 12 ++++++++---- app/tables/recap.py | 1 + sco_version.py | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/but/jury_edit_manual.py b/app/but/jury_edit_manual.py index 73e21ecf54..89ac43a20c 100644 --- a/app/but/jury_edit_manual.py +++ b/app/but/jury_edit_manual.py @@ -9,16 +9,15 @@ Non spécifique au BUT. """ -import flask -from flask import flash, render_template, url_for +from flask import flash, render_template from flask import g, request +import sqlalchemy as sa from app import db from app.models import ( ApcValidationAnnee, ApcValidationRCUE, - FormSemestre, Identite, UniteEns, ScolarAutorisationInscription, @@ -38,7 +37,12 @@ def jury_delete_manual(etud: Identite): ue_vals = ( ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) .join(UniteEns) - .order_by(ScolarFormSemestreValidation.event_date, UniteEns.numero) + .order_by( + sa.extract("year", ScolarFormSemestreValidation.event_date), + UniteEns.semestre_idx, + UniteEns.numero, + UniteEns.acronyme, + ) ) autorisations = ScolarAutorisationInscription.query.filter_by( etudid=etud.id diff --git a/app/tables/recap.py b/app/tables/recap.py index 5fca29127e..09aae91881 100644 --- a/app/tables/recap.py +++ b/app/tables/recap.py @@ -551,6 +551,7 @@ class RowRecap(tb.Row): "etud_codes": "Codes", "identite_detail": "", "identite_court": "", + "rang": "", } ) # --- Codes (seront cachés, mais exportés en excel) diff --git a/sco_version.py b/sco_version.py index 857492bf1c..0ff96a93a0 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.88" +SCOVERSION = "9.4.89" SCONAME = "ScoDoc" From f7a42646bc01bf2874f949fef5aae33495b4ba76 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Jun 2023 12:33:45 +0200 Subject: [PATCH 060/101] Optimisation: table recap jury (x3) --- app/models/but_refcomp.py | 21 ++++++++++++++++++++- app/models/ues.py | 25 ++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index d595cd715b..e9d046e285 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -399,6 +399,12 @@ class ApcNiveau(db.Model, XMLModel): """Vrai si ce niveau fait partie du Tronc Commun""" return len(self.parcours) == self.competence.referentiel.parcours.count() + # Le ref. comp. ne change jamais (pas d'édition dans ScoDoc) + # on cache les résultats: + _niveaux_annee_de_parcours_cache: dict[ + tuple[int, int, int, int], list["ApcNiveau"] + ] = {} + @classmethod def niveaux_annee_de_parcours( cls, @@ -412,6 +418,15 @@ class ApcNiveau(db.Model, XMLModel): (dans ce cas, spécifier referentiel_competence) Si competence est indiquée, filtre les niveaux de cette compétence. """ + key = ( + parcour.id if parcour else None, + annee, + referentiel_competence.id if referentiel_competence else None, + competence.id if competence else None, + ) + result = cls._niveaux_annee_de_parcours_cache.get(key) + if result: + return result if annee not in {1, 2, 3}: raise ValueError("annee invalide pour un parcours BUT") referentiel_competence = ( @@ -428,10 +443,13 @@ class ApcNiveau(db.Model, XMLModel): ) if competence is not None: query = query.filter(ApcCompetence.id == competence.id) - return query.all() + result = query.all() + cls._niveaux_annee_de_parcours_cache[key] = result + return result annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first() if not annee_parcour: + cls._niveaux_annee_de_parcours_cache[key] = [] return [] if competence is None: @@ -446,6 +464,7 @@ class ApcNiveau(db.Model, XMLModel): niveaux: list[ApcNiveau] = competence.niveaux.filter_by( annee=f"BUT{int(annee)}" ).all() + cls._niveaux_annee_de_parcours_cache[key] = niveaux return niveaux diff --git a/app/models/ues.py b/app/models/ues.py index 383f20f8fe..1daf127353 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -1,6 +1,7 @@ """ScoDoc 9 models : Unités d'Enseignement (UE) """ +from flask import g import pandas as pd from app import db, log @@ -8,7 +9,6 @@ from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models.but_refcomp import ApcNiveau, ApcParcours from app.models.modules import Module -from app.scodoc.sco_exceptions import ScoFormationConflict from app.scodoc import sco_utils as scu @@ -107,6 +107,17 @@ class UniteEns(db.Model): If convert_objects, convert all attributes to native types (suitable for json encoding). """ + # cache car très utilisé par anciens codes + key = (self.id, convert_objects, with_module_ue_coefs) + _cache = getattr(g, "_ue_to_dict_cache", None) + if _cache: + result = g._ue_to_dict_cache.get(key, False) + if result is not False: + return result + else: + g._ue_to_dict_cache = {} + _cache = g._ue_to_dict_cache + e = dict(self.__dict__) e.pop("_sa_instance_state", None) e.pop("evaluation_ue_poids", None) @@ -133,6 +144,7 @@ class UniteEns(db.Model): ] else: e.pop("module_ue_coefs", None) + _cache[key] = e return e def annee(self) -> int: @@ -180,12 +192,23 @@ class UniteEns(db.Model): le parcours indiqué. """ if parcour is not None: + key = (parcour.id, self.id, only_parcours) + ue_ects_cache = getattr(g, "_ue_ects_cache", None) + if ue_ects_cache: + ects = g._ue_ects_cache.get(key, False) + if ects is not False: + return ects + else: + g._ue_ects_cache = {} + ue_ects_cache = g._ue_ects_cache ue_parcour = UEParcours.query.filter_by( ue_id=self.id, parcours_id=parcour.id ).first() if ue_parcour is not None and ue_parcour.ects is not None: + ue_ects_cache[key] = ue_parcour.ects return ue_parcour.ects if only_parcours: + ue_ects_cache[key] = None return None return self.ects From 735100de60fd6c8d99a67a135a3f9a5c76a09235 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Jun 2023 13:09:04 +0200 Subject: [PATCH 061/101] Modify caching of ApcNiveaux --- app/models/but_refcomp.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index e9d046e285..824e7e3b6c 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -9,6 +9,7 @@ from datetime import datetime import functools from operator import attrgetter +from flask import g from flask_sqlalchemy.query import Query from sqlalchemy.orm import class_mapper import sqlalchemy @@ -399,12 +400,6 @@ class ApcNiveau(db.Model, XMLModel): """Vrai si ce niveau fait partie du Tronc Commun""" return len(self.parcours) == self.competence.referentiel.parcours.count() - # Le ref. comp. ne change jamais (pas d'édition dans ScoDoc) - # on cache les résultats: - _niveaux_annee_de_parcours_cache: dict[ - tuple[int, int, int, int], list["ApcNiveau"] - ] = {} - @classmethod def niveaux_annee_de_parcours( cls, @@ -424,9 +419,14 @@ class ApcNiveau(db.Model, XMLModel): referentiel_competence.id if referentiel_competence else None, competence.id if competence else None, ) - result = cls._niveaux_annee_de_parcours_cache.get(key) - if result: - return result + _cache = getattr(g, "_niveaux_annee_de_parcours_cache", None) + if _cache: + result = g._niveaux_annee_de_parcours_cache.get(key, False) + if result is not False: + return result + else: + g._niveaux_annee_de_parcours_cache = {} + _cache = g._niveaux_annee_de_parcours_cache if annee not in {1, 2, 3}: raise ValueError("annee invalide pour un parcours BUT") referentiel_competence = ( @@ -444,12 +444,12 @@ class ApcNiveau(db.Model, XMLModel): if competence is not None: query = query.filter(ApcCompetence.id == competence.id) result = query.all() - cls._niveaux_annee_de_parcours_cache[key] = result + _cache[key] = result return result annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first() if not annee_parcour: - cls._niveaux_annee_de_parcours_cache[key] = [] + _cache[key] = [] return [] if competence is None: @@ -464,7 +464,7 @@ class ApcNiveau(db.Model, XMLModel): niveaux: list[ApcNiveau] = competence.niveaux.filter_by( annee=f"BUT{int(annee)}" ).all() - cls._niveaux_annee_de_parcours_cache[key] = niveaux + _cache[key] = niveaux return niveaux From 60c157222b63b91ec88a604e9cb489115cc64d49 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Jun 2023 13:09:29 +0200 Subject: [PATCH 062/101] Enhance exception handling --- app/comp/moy_mod.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index d86eccbf5c..f69999a724 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -322,7 +322,13 @@ class ModuleImplResultsAPC(ModuleImplResults): modimpl = ModuleImpl.query.get(self.moduleimpl_id) nb_etuds, nb_evals = self.evals_notes.shape nb_ues = evals_poids_df.shape[1] - assert evals_poids_df.shape[0] == nb_evals # compat notes/poids + if evals_poids_df.shape[0] != nb_evals: + # compat notes/poids: race condition ? + app.critical_error( + f"""compute_module_moy: evals_poids_df.shape[0] != nb_evals ({ + evals_poids_df.shape[0]} != {nb_evals}) + """ + ) if nb_etuds == 0: return pd.DataFrame(index=[], columns=evals_poids_df.columns) if nb_ues == 0: From 41e065f6ab20fbc2e4c29502ce6e24c1290a26d0 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Jun 2023 13:27:46 +0200 Subject: [PATCH 063/101] =?UTF-8?q?Jury=20BUT:=20pr=C3=A9sente=20toujours?= =?UTF-8?q?=20NAR=20sur=20ann=C3=A9e.=20Tri=20les=20codes=20dans=20les=20m?= =?UTF-8?q?enus.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 83b1d9ddb3..73310cb687 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -206,6 +206,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): sco_codes.DEF, sco_codes.DEM, sco_codes.EXCLU, + sco_codes.NAR, ] def __init__( @@ -444,6 +445,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): + '
      '.join(messages) + "
      " ) + self.codes = [self.codes[0]] + sorted(self.codes[1:]) # WIP TODO XXX def get_moyenne_annuelle(self) @@ -1120,6 +1122,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): self.codes.insert(0, self.code_valide) else: self.codes.insert(1, self.code_valide) + self.codes = [self.codes[0]] + sorted(self.codes[1:]) def __repr__(self) -> str: return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide @@ -1483,6 +1486,7 @@ class DecisionsProposeesUE(DecisionsProposees): self.moy_ue = ue_status["cur_moy_ue"] self.moy_ue_with_cap = ue_status["moy"] self.ue_status = ue_status + self.codes = [self.codes[0]] + sorted(self.codes[1:]) def __repr__(self) -> str: return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide From 2af2ca6c43f358b559a3fd0b1f26be7d4123ebb2 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 21 Jun 2023 16:47:24 +0200 Subject: [PATCH 064/101] =?UTF-8?q?Jury=20BUT:=20corrige=20enregistrement?= =?UTF-8?q?=20d=C3=A9cisions=20d'annee=20BUT=20manuelles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 11 ++++++++--- app/models/but_validations.py | 1 + app/views/notes.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 73310cb687..64c05b7935 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -257,6 +257,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.annee_but = (formsemestre_last.semestre_id + 1) // 2 "le rang de l'année dans le BUT: 1, 2, 3" assert self.annee_but in (1, 2, 3) + self.autorisations_recorded = False + "vrai si on a enregistré l'autorisation de passage" self.rcues_annee = [] """RCUEs de l'année (peuvent concerner l'année scolaire antérieur pour les redoublants @@ -751,7 +753,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): dec_ue.record(code) for dec_rcue, code in codes_rcues: dec_rcue.record(code) - self.record(code_annee, mark_recorded=False) + self.record(code_annee) # XXX , mark_recorded=False) self.record_autorisation_inscription(code_annee) self.record_all() self.recorded = True @@ -794,13 +796,15 @@ class DecisionsProposeesAnnee(DecisionsProposees): etudid=self.etud.id, msg=f"Validation année BUT{self.annee_but}: {code}", ) - if mark_recorded: - self.recorded = True + if mark_recorded: + self.recorded = True self.invalidate_formsemestre_cache() return True def record_autorisation_inscription(self, code: str): """Autorisation d'inscription dans semestre suivant""" + if self.autorisations_recorded: + return if self.inscription_etat != scu.INSCRIT: # les dem et DEF ne continuent jamais return @@ -815,6 +819,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.formsemestre.id, next_semestre_id, ) + self.autorisations_recorded = True def invalidate_formsemestre_cache(self): "invalide le résultats des deux formsemestres" diff --git a/app/models/but_validations.py b/app/models/but_validations.py index 778164765f..4054c9f43b 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -352,6 +352,7 @@ class ApcValidationAnnee(db.Model): "Affichage html" return f"""Validation année BUT{self.ordre} émise par {self.formsemestre.html_link_status() if self.formsemestre else "-"} + : {self.code} le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")} """ diff --git a/app/views/notes.py b/app/views/notes.py index cf5da2e36b..c51571941b 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2497,7 +2497,7 @@ def formsemestre_validation_but( scodoc_dept=g.scodoc_dept, formsemestre_id=deca.formsemestre_id, etudid=deca.etud.id)}" class="stdlink" title="efface décisions issues des jurys de cette année" - >effacer décisions + >effacer décisions de ce jury manquant' else: - ects_by_sem[semestre_idx] = sum(ects) + ects_by_sem[semestre_idx] = f"{sum(ects):g}" arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags() diff --git a/app/templates/pn/form_ues.j2 b/app/templates/pn/form_ues.j2 index e64477e341..d53dde6fd8 100644 --- a/app/templates/pn/form_ues.j2 +++ b/app/templates/pn/form_ues.j2 @@ -2,7 +2,7 @@ {% for semestre_idx in semestre_ids %}
      Unités d'Enseignement - semestre {{semestre_idx}}  -  {{"%g"|format(ects_by_sem[semestre_idx]) | safe}} ECTS + semestre {{semestre_idx}}  -  {{ects_by_sem[semestre_idx] | safe}} ECTS
      """ diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js index 7451b84c2c..de13ba26e1 100644 --- a/app/static/js/assiduites.js +++ b/app/static/js/assiduites.js @@ -170,6 +170,21 @@ function sync_get(path, success, errors) { error: errors, }); } +/** + * Fait une requête GET de façon asynchrone + * @param {String} path adresse distante + * @param {CallableFunction} success fonction à effectuer en cas de succès + * @param {CallableFunction} errors fonction à effectuer en cas d'échec + */ +function async_get(path, success, errors) { + $.ajax({ + async: true, + type: "GET", + url: path, + success: success, + error: errors, + }); +} /** * Fait une requête POST de façon synchrone * @param {String} path adresse distante diff --git a/app/templates/assiduites/pages/calendrier.j2 b/app/templates/assiduites/pages/calendrier.j2 new file mode 100644 index 0000000000..cf1ce4618b --- /dev/null +++ b/app/templates/assiduites/pages/calendrier.j2 @@ -0,0 +1,338 @@ +{% block pageContent %} +{% include "assiduites/widgets/alert.j2" %} + +
      + {{minitimeline | safe }} +

      Assiduités de {{sco.etud.nomprenom}}

      +
      + +
      +
      + Année scolaire 2022-2023 Changer année: + +
      +
      + + + + + +{% endblock pageContent %} \ No newline at end of file diff --git a/app/templates/assiduites/widgets/minitimeline.j2 b/app/templates/assiduites/widgets/minitimeline.j2 index 29c0c7c637..5c387d91ad 100644 --- a/app/templates/assiduites/widgets/minitimeline.j2 +++ b/app/templates/assiduites/widgets/minitimeline.j2 @@ -11,9 +11,9 @@ * @param {Array[Assiduité]} assiduitesArray * @returns {HTMLElement} l'élément correspondant à la mini timeline */ - function createMiniTimeline(assiduitesArray) { + function createMiniTimeline(assiduitesArray, day = null) { const array = [...assiduitesArray]; - const dateiso = document.getElementById("tl_date").value; + const dateiso = day == null ? document.getElementById("tl_date").value : day; const timeline = document.createElement("div"); timeline.className = "mini-timeline"; if (isSingleEtud()) { @@ -26,15 +26,16 @@ timeline.appendChild(setMiniTick(timelineDate, dayStart, dayDuration)); - const tlTimes = getTimeLineTimes(); - const period_assi = { - date_debut: tlTimes.deb.format(), - date_fin: tlTimes.fin.format(), - etat: "CRENEAU", - }; - array.push(period_assi); + if (day == null) { + const tlTimes = getTimeLineTimes(); + array.push({ + date_debut: tlTimes.deb.format(), + date_fin: tlTimes.fin.format(), + etat: "CRENEAU", + }); + } array.forEach((assiduité) => { const startDate = moment(assiduité.date_debut); @@ -65,7 +66,7 @@ deb = Math.max(mt_start, deb); fin = Math.min(mt_end, fin); - setPeriodValues(deb, fin); + if (day == null) setPeriodValues(deb, fin); if (isSingleEtud()) { updateSelectedSelect(getCurrentAssiduiteModuleImplId()); updateJustifyBtn(); @@ -288,4 +289,24 @@ outline: 3px solid #7059FF; pointer-events: none; } + + .mini-timeline-block.absent { + background-color: #F1A69C !important; + } + + .mini-timeline-block.present { + background-color: #9CF1AF !important; + } + + .mini-timeline-block.retard { + background-color: #F1D99C !important; + } + + .mini-timeline-block.justified { + background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #7059FF 4px, #7059FF 8px); + } + + .mini-timeline-block.invalid_justified { + background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #d61616 4px, #d61616 8px); + } \ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_base.j2 b/app/templates/assiduites/widgets/tableau_base.j2 index 9912a42ce9..6e7520b215 100644 --- a/app/templates/assiduites/widgets/tableau_base.j2 +++ b/app/templates/assiduites/widgets/tableau_base.j2 @@ -478,7 +478,7 @@ const filterHead = html.querySelector('.filter-head'); filterHead.innerHTML = "" - let cols = ["entry_date", "date_debut", "date_fin", "etat", "raison"]; + let cols = ["entry_date", "date_debut", "date_fin", "etat", "raison", "fichier"]; cols.forEach((k) => { const label = document.createElement('label') diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 5db4039d3f..6a607db793 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -69,7 +69,7 @@ etudid=sco.etud.id) }}">Billets {% endif %} {% endif %} -
    • Calendrier
    • Liste
    • diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 8efad887db..464b4cc538 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -310,6 +310,50 @@ def ajout_justificatif_etud(): ).build() +@bp.route("/CalendrierAssiduitesEtud") +@scodoc +@permission_required(Permission.ScoView) +def calendrier_etud(): + """ + calendrier_etud : Affichage d'un calendrier des assiduités de l'é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="Calendrier 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/pages/calendrier.j2", + sco=ScoData(etud), + annee=scu.annee_scolaire(), + nonworkdays=_non_work_days(), + minitimeline=_mini_timeline(), + ), + ).build() + + @bp.route("/SignalAssiduiteGr") @scodoc @permission_required(Permission.ScoAbsChange) From b44563666a6dcadd694d5726d6208f597b4cf745 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Jun 2023 07:59:58 +0200 Subject: [PATCH 068/101] =?UTF-8?q?Jury=20BUT:=20modification=20menu=20cho?= =?UTF-8?q?ix=20d=C3=A9cision=20RCUE=20redoublants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but_view.py | 25 +++++++++++++++++-------- sco_version.py | 2 +- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index a61f1f14bb..8775b8bb8c 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -34,6 +34,7 @@ from app.models import ( ) from app.models.config import ScoDocSiteConfig from app.scodoc import html_sco_header +from app.scodoc import codes_cursus as sco_codes from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_preferences from app.scodoc import sco_utils as scu @@ -244,13 +245,21 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
      """ - scoplement = ( - f"""
      { - dec_rcue.validation.html() - }
      """ - if dec_rcue.validation - else "" - ) + code_propose_menu = dec_rcue.code_valide # le code enregistré + if dec_rcue.validation: + if dec_rcue.code_valide == dec_rcue.codes[0]: + descr_validation = dec_rcue.validation.html() + else: # on une validation enregistrée différence de celle proposée + descr_validation = f"""Décision recommandée: {dec_rcue.codes[0]}. + Il y avait {dec_rcue.validation.html()}""" + if ( + sco_codes.BUT_CODES_ORDERED[dec_rcue.codes[0]] + > sco_codes.BUT_CODES_ORDERED[dec_rcue.code_valide] + ): + code_propose_menu = dec_rcue.codes[0] + scoplement = f"""
      {descr_validation}
      """ + else: + scoplement = "" # "pas de validation" # Déjà enregistré ? niveau_rcue_class = "" @@ -270,7 +279,7 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
      {_gen_but_select("code_rcue_"+str(niveau.id), dec_rcue.codes, - dec_rcue.code_valide, + code_propose_menu, disabled=True, klass="manual code_rcue", data = { "niveau_id" : str(niveau.id)} diff --git a/sco_version.py b/sco_version.py index 0ff96a93a0..7be8d568b3 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.89" +SCOVERSION = "9.4.90" SCONAME = "ScoDoc" From b696f772bfddc7d26992baaa6ca157ab8b324959 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Jun 2023 08:22:37 +0200 Subject: [PATCH 069/101] =?UTF-8?q?Modification=20priorit=C3=A9=20codes=20?= =?UTF-8?q?jury:=20PASD=20>=20PAS1NCI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/cursus_but.py | 14 +++++++------- app/but/jury_but.py | 10 +++++----- app/but/jury_but_view.py | 4 ++-- app/comp/res_but.py | 4 ++-- app/scodoc/codes_cursus.py | 6 +++--- app/scodoc/sco_moduleimpl_inscriptions.py | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index d8ca17e154..ea1b956ee4 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -129,8 +129,8 @@ class EtudCursusBUT: ).get(validation_rcue.annee()) # prend la "meilleure" validation if (not previous_validation) or ( - sco_codes.BUT_CODES_ORDERED[validation_rcue.code] - > sco_codes.BUT_CODES_ORDERED[previous_validation.code] + sco_codes.BUT_CODES_ORDER[validation_rcue.code] + > sco_codes.BUT_CODES_ORDER[previous_validation.code] ): self.validation_par_competence_et_annee[niveau.competence.id][ niveau.annee @@ -200,7 +200,7 @@ class EtudCursusBUT: validations_by_niveau[validation_rcue.niveau().id].append(validation_rcue) validation_by_niveau = { niveau_id: sorted( - validations, key=lambda v: sco_codes.BUT_CODES_ORDERED[v.code] + validations, key=lambda v: sco_codes.BUT_CODES_ORDER[v.code] )[0] for niveau_id, validations in validations_by_niveau.items() if validations @@ -304,8 +304,8 @@ class FormSemestreCursusBUT: ).get(validation_rcue.annee()) # prend la "meilleure" validation if (not previous_validation) or ( - sco_codes.BUT_CODES_ORDERED[validation_rcue.code] - > sco_codes.BUT_CODES_ORDERED[previous_validation["code"]] + sco_codes.BUT_CODES_ORDER[validation_rcue.code] + > sco_codes.BUT_CODES_ORDER[previous_validation["code"]] ): self.validation_par_competence_et_annee[niveau.competence.id][ niveau.annee @@ -341,8 +341,8 @@ class FormSemestreCursusBUT: ).get(validation_rcue.annee()) # prend la "meilleure" validation if (not previous_validation) or ( - sco_codes.BUT_CODES_ORDERED[validation_rcue.code] - > sco_codes.BUT_CODES_ORDERED[previous_validation["code"]] + sco_codes.BUT_CODES_ORDER[validation_rcue.code] + > sco_codes.BUT_CODES_ORDER[previous_validation["code"]] ): self.validation_par_competence_et_annee[niveau.competence.id][ niveau.annee diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 64c05b7935..dedb522b98 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -94,7 +94,7 @@ from app.scodoc import sco_cache from app.scodoc import codes_cursus as sco_codes from app.scodoc.codes_cursus import ( code_rcue_validant, - BUT_CODES_ORDERED, + BUT_CODES_ORDER, CODES_RCUE_VALIDES, CODES_UE_CAPITALISANTS, CODES_UE_VALIDES, @@ -878,8 +878,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): (not dec_rcue.recorded) and ( # enregistre seulement si pas déjà validé "mieux" (not dec_rcue.validation) - or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0) - < BUT_CODES_ORDERED.get(code, 0) + or BUT_CODES_ORDER.get(dec_rcue.validation.code, 0) + < BUT_CODES_ORDER.get(code, 0) ) and ( # décision validante de droit ? ( @@ -1121,9 +1121,9 @@ class DecisionsProposeesRCUE(DecisionsProposees): code_default = self.codes[0] if self.code_valide in self.codes: self.codes.remove(self.code_valide) - if sco_codes.BUT_CODES_ORDERED.get( + if sco_codes.BUT_CODES_ORDER.get( self.code_valide, 0 - ) > sco_codes.BUT_CODES_ORDERED.get(code_default, 0): + ) > sco_codes.BUT_CODES_ORDER.get(code_default, 0): self.codes.insert(0, self.code_valide) else: self.codes.insert(1, self.code_valide) diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 8775b8bb8c..bbe992cbf5 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -253,8 +253,8 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: descr_validation = f"""Décision recommandée: {dec_rcue.codes[0]}. Il y avait {dec_rcue.validation.html()}""" if ( - sco_codes.BUT_CODES_ORDERED[dec_rcue.codes[0]] - > sco_codes.BUT_CODES_ORDERED[dec_rcue.code_valide] + sco_codes.BUT_CODES_ORDER[dec_rcue.codes[0]] + > sco_codes.BUT_CODES_ORDER[dec_rcue.code_valide] ): code_propose_menu = dec_rcue.codes[0] scoplement = f"""
      {descr_validation}
      """ diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 2c60f24a02..163d56f1dc 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -20,7 +20,7 @@ from app.models.but_refcomp import ApcParcours, ApcNiveau from app.models.ues import DispenseUE, UniteEns from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.scodoc import sco_preferences -from app.scodoc.codes_cursus import BUT_CODES_ORDERED, UE_SPORT +from app.scodoc.codes_cursus import BUT_CODES_ORDER, UE_SPORT from app.scodoc.sco_utils import ModuleType @@ -353,7 +353,7 @@ class ResultatsSemestreBUT(NotesTableCompat): for validation in validations: if validation.etudid in validation_by_etud: # keep the "best" - if BUT_CODES_ORDERED.get(validation.code, 0) > BUT_CODES_ORDERED.get( + if BUT_CODES_ORDER.get(validation.code, 0) > BUT_CODES_ORDER.get( validation_by_etud[validation.etudid].code, 0 ): validation_by_etud[validation.etudid] = validation diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py index 6c4336a420..f5027eee1f 100644 --- a/app/scodoc/codes_cursus.py +++ b/app/scodoc/codes_cursus.py @@ -222,15 +222,15 @@ BUT_CODES_PASSAGE = { } # les codes, du plus "défavorable" à l'étudiant au plus favorable: # (valeur par défaut 0) -BUT_CODES_ORDERED = { +BUT_CODES_ORDER = { NAR: 0, DEF: 0, AJ: 10, ATJ: 20, CMP: 50, ADC: 50, - PASD: 50, - PAS1NCI: 60, + PAS1NCI: 50, + PASD: 60, ADJR: 90, ADSUP: 90, ADJ: 100, diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 03217dc7ae..9cb7bf69b9 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -581,7 +581,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) -> .all() ) validations_ue.sort( - key=lambda v: codes_cursus.BUT_CODES_ORDERED.get(v.code, 0) + key=lambda v: codes_cursus.BUT_CODES_ORDER.get(v.code, 0) ) validation = validations_ue[-1] if validations_ue else None expl_validation = ( From 449c1f0cb097a8fc4d4acfce0be1fff79bd160a4 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Jun 2023 19:00:56 +0200 Subject: [PATCH 070/101] =?UTF-8?q?Jury=20BUT:=20-=20Modification=20gestio?= =?UTF-8?q?n=20de=20l'enregistrement=20des=20codes.=20-=20Signale=20quand?= =?UTF-8?q?=20un=20RCUE=20change=20de=20code.=20-=20Calcul=20auto=20du=20j?= =?UTF-8?q?ury:=20peut=20modifier=20les=20d=C3=A9cisions=20RCUE.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 97 +++++++++++-------- app/but/jury_but_validation_auto.py | 8 +- app/but/jury_but_view.py | 13 ++- app/scodoc/codes_cursus.py | 11 ++- app/scodoc/sco_apogee_csv.py | 4 +- app/static/css/jury_but.css | 1 + app/static/css/scodoc.css | 3 +- app/templates/but/documentation_codes_jury.j2 | 9 +- .../but/formsemestre_validation_auto_but.j2 | 17 +++- tests/unit/test_but_jury.py | 4 +- 10 files changed, 103 insertions(+), 64 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index dedb522b98..d9f8031a2f 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -278,11 +278,15 @@ class DecisionsProposeesAnnee(DecisionsProposees): ) if self.formsemestre_impair is not None: - self.validation = ApcValidationAnnee.query.filter_by( - etudid=self.etud.id, - formation_id=self.formsemestre.formation_id, - ordre=self.annee_but, - ).first() + self.validation = ( + ApcValidationAnnee.query.filter_by( + etudid=self.etud.id, + ordre=self.annee_but, + ) + .join(Formation) + .filter_by(formation_code=self.formsemestre.formation.formation_code) + .first() + ) else: self.validation = None if self.validation is not None: @@ -721,7 +725,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): et qu'il n'y en a pas déjà, enregistre ceux par défaut. """ log("jury_but.DecisionsProposeesAnnee.record_form") - code_annee = None + code_annee = self.codes[0] # si pas dans le form, valeur par defaut codes_rcues = [] # [ (dec_rcue, code), ... ] codes_ues = [] # [ (dec_ue, code), ... ] for key in form: @@ -753,16 +757,15 @@ class DecisionsProposeesAnnee(DecisionsProposees): dec_ue.record(code) for dec_rcue, code in codes_rcues: dec_rcue.record(code) - self.record(code_annee) # XXX , mark_recorded=False) + self.record(code_annee) self.record_autorisation_inscription(code_annee) self.record_all() self.recorded = True db.session.commit() - def record(self, code: str, no_overwrite=False, mark_recorded: bool = True) -> bool: + def record(self, code: str, mark_recorded: bool = True) -> bool: """Enregistre le code de l'année, et au besoin l'autorisation d'inscription. - Si no_overwrite, ne fait rien si un code est déjà enregistré. Si l'étudiant est DEM ou DEF, ne fait rien. Si mark_recorded est vrai, positionne self.recorded """ @@ -773,23 +776,34 @@ class DecisionsProposeesAnnee(DecisionsProposees): f"code annee {html.escape(code)} invalide pour formsemestre {html.escape(self.formsemestre)}" ) - if code != self.code_valide and (self.code_valide is None or not no_overwrite): + if code != self.code_valide: # Enregistrement du code annuel BUT - if self.validation: - db.session.delete(self.validation) - db.session.commit() if code is None: - self.validation = None + if self.validation: + db.session.delete(self.validation) + self.validation = None + db.session.commit() else: - self.validation = ApcValidationAnnee( - etudid=self.etud.id, - formsemestre=self.formsemestre_impair, - formation_id=self.formsemestre.formation_id, - ordre=self.annee_but, - annee_scolaire=self.annee_scolaire(), - code=code, - ) + if self.validation is None: + self.validation = ApcValidationAnnee( + etudid=self.etud.id, + formsemestre=self.formsemestre_impair, + formation_id=self.formsemestre.formation_id, + ordre=self.annee_but, + annee_scolaire=self.annee_scolaire(), + code=code, + ) + else: # Update validation année BUT + self.validation.etud = self.etud + self.validation.formsemestre = self.formsemestre_impair + self.validation.formation_id = self.formsemestre.formation_id + self.validation.ordre = self.annee_but + self.validation.annee_scolaire = self.annee_scolaire() + self.validation.code = code + self.validation.date = datetime.now() + db.session.add(self.validation) + db.session.commit() log(f"Recording {self}: {code}") Scolog.logdb( method="jury_but", @@ -840,9 +854,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): ) return res and self.etud.id in res.get_etudids_attente() - def record_all( - self, no_overwrite: bool = True, only_validantes: bool = False - ) -> bool: + def record_all(self, only_validantes: bool = False) -> bool: """Enregistre les codes qui n'ont pas été spécifiés par le formulaire, et sont donc en mode "automatique". - Si "à cheval", ne modifie pas les codes UE de l'année scolaire précédente. @@ -868,9 +880,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): # rappel: le code par défaut est en tête code = dec_ue.codes[0] if dec_ue.codes else None if (not only_validantes) or code in sco_codes.CODES_UE_VALIDES_DE_DROIT: - # enregistre le code jury seulement s'il n'y a pas déjà de code - # (no_overwrite=True) sauf en mode test yaml - modif |= dec_ue.record(code, no_overwrite=no_overwrite) + # enregistre le code jury + modif |= dec_ue.record(code) # RCUE : for dec_rcue in self.decisions_rcue_by_niveau.values(): code = dec_rcue.codes[0] if dec_rcue.codes else None @@ -888,17 +899,15 @@ class DecisionsProposeesAnnee(DecisionsProposees): ) ) ): - modif |= dec_rcue.record(code, no_overwrite=no_overwrite) + modif |= dec_rcue.record(code) # Année: if not self.recorded: # rappel: le code par défaut est en tête code = self.codes[0] if self.codes else None - # enregistre le code jury seulement s'il n'y a pas déjà de code - # (no_overwrite=True) sauf en mode test yaml if ( not only_validantes ) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT: - modif |= self.record(code, no_overwrite=no_overwrite) + modif |= self.record(code) self.record_autorisation_inscription(code) return modif @@ -1133,7 +1142,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide } codes={self.codes} explanation={self.explanation}""" - def record(self, code: str, no_overwrite=False) -> bool: + def record(self, code: str) -> bool: """Enregistre le code RCUE. Note: - si le RCUE est ADJ, les UE non validées sont passées à ADJ @@ -1147,7 +1156,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): raise ScoValueError( f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" ) - if code == self.code_valide or (self.code_valide is not None and no_overwrite): + if code == self.code_valide: self.recorded = True return False # no change parcours_id = self.parcour.id if self.parcour is not None else None @@ -1322,11 +1331,15 @@ class DecisionsProposeesRCUE(DecisionsProposees): if annee_inferieure < 1: return # Garde-fou: Année déjà validée ? - validations_annee: ApcValidationAnnee = ApcValidationAnnee.query.filter_by( - etudid=self.etud.id, - ordre=annee_inferieure, - formation_id=self.rcue.formsemestre_1.formation_id, - ).all() + validations_annee: ApcValidationAnnee = ( + ApcValidationAnnee.query.filter_by( + etudid=self.etud.id, + ordre=annee_inferieure, + ) + .join(Formation) + .filter_by(formation_code=self.rcue.formsemestre_1.formation.code) + .all() + ) if len(validations_annee) > 1: log( f"warning: {len(validations_annee)} validations d'année\n{validations_annee}" @@ -1519,16 +1532,15 @@ class DecisionsProposeesUE(DecisionsProposees): self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes self.explanation = "notes insuffisantes" - def record(self, code: str, no_overwrite=False) -> bool: + def record(self, code: str) -> bool: """Enregistre le code jury pour cette UE. - Si no_overwrite, n'enregistre pas s'il y a déjà un code. Return: True si code enregistré (modifié) """ if code and not code in self.codes: raise ScoValueError( f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" ) - if code == self.code_valide or (self.code_valide is not None and no_overwrite): + if code == self.code_valide: self.recorded = True return False # no change self.erase() @@ -1627,7 +1639,6 @@ class BUTCursusEtud: # WIP TODO ApcValidationAnnee.query.filter_by( etudid=self.etud.id, ordre=ordre, - formation_id=self.formsemestre.formation_id, ) .join(Formation) .filter( diff --git a/app/but/jury_but_validation_auto.py b/app/but/jury_but_validation_auto.py index deae9b5931..e698b5dd40 100644 --- a/app/but/jury_but_validation_auto.py +++ b/app/but/jury_but_validation_auto.py @@ -16,14 +16,12 @@ from app.scodoc.sco_exceptions import ScoValueError def formsemestre_validation_auto_but( - formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True + formsemestre: FormSemestre, only_adm: bool = True ) -> int: """Calcul automatique des décisions de jury sur une "année" BUT. - N'enregistre jamais de décisions de l'année scolaire précédente, même si on a des RCUE "à cheval". - - Ne modifie jamais de décisions déjà enregistrées (sauf si no_overwrite est faux, - ce qui est utilisé pour certains tests unitaires). - Normalement, only_adm est True et on n'enregistre que les décisions validantes de droit: ADM ou CMP. En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc @@ -38,9 +36,7 @@ def formsemestre_validation_auto_but( for etudid in formsemestre.etuds_inscriptions: etud = Identite.get_etud(etudid) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) - nb_etud_modif += deca.record_all( - no_overwrite=no_overwrite, only_validantes=only_adm - ) + nb_etud_modif += deca.record_all(only_validantes=only_adm) db.session.commit() return nb_etud_modif diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index bbe992cbf5..87321684e2 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -155,6 +155,7 @@ def _gen_but_select( disabled: bool = False, klass: str = "", data: dict = {}, + code_valide_label: str = "", ) -> str: "Le menu html select avec les codes" # if disabled: # mauvaise idée car le disabled est traité en JS @@ -164,7 +165,10 @@ def _gen_but_select( f"""""" + >{code + if ((code != code_valide) or not code_valide_label) + else code_valide_label + }""" for code in codes ] ) @@ -246,6 +250,7 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: """ code_propose_menu = dec_rcue.code_valide # le code enregistré + code_valide_label = code_propose_menu if dec_rcue.validation: if dec_rcue.code_valide == dec_rcue.codes[0]: descr_validation = dec_rcue.validation.html() @@ -257,6 +262,9 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: > sco_codes.BUT_CODES_ORDER[dec_rcue.code_valide] ): code_propose_menu = dec_rcue.codes[0] + code_valide_label = ( + f"{dec_rcue.codes[0]} (actuel {dec_rcue.code_valide})" + ) scoplement = f"""
      {descr_validation}
      """ else: scoplement = "" # "pas de validation" @@ -282,7 +290,8 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str: code_propose_menu, disabled=True, klass="manual code_rcue", - data = { "niveau_id" : str(niveau.id)} + data = { "niveau_id" : str(niveau.id)}, + code_valide_label = code_valide_label, )}
      diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py index f5027eee1f..85d14b957b 100644 --- a/app/scodoc/codes_cursus.py +++ b/app/scodoc/codes_cursus.py @@ -223,8 +223,15 @@ BUT_CODES_PASSAGE = { # les codes, du plus "défavorable" à l'étudiant au plus favorable: # (valeur par défaut 0) BUT_CODES_ORDER = { - NAR: 0, + ABAN: 0, + ABL: 0, + DEM: 0, DEF: 0, + EXCLU: 0, + NAR: 0, + UEBSL: 0, + RAT: 5, + RED: 6, AJ: 10, ATJ: 20, CMP: 50, @@ -233,7 +240,7 @@ BUT_CODES_ORDER = { PASD: 60, ADJR: 90, ADSUP: 90, - ADJ: 100, + ADJ: 90, ADM: 100, } diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 1c02c3c161..1348cbe8cf 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -495,7 +495,9 @@ class ApoEtud(dict): ApcValidationAnnee.query.filter_by( formsemestre_id=formsemestre.id, etudid=self.etud["etudid"], - formation_id=self.cur_sem["formation_id"], + formation_id=self.cur_sem[ + "formation_id" + ], # XXX utiliser formation_code ).first() ) self.is_nar = ( diff --git a/app/static/css/jury_but.css b/app/static/css/jury_but.css index 61f8db77b4..bf4be05e3b 100644 --- a/app/static/css/jury_but.css +++ b/app/static/css/jury_but.css @@ -168,6 +168,7 @@ div.but_niveau_ue.recorded_different, div.but_niveau_rcue.recorded_different { box-shadow: 0 0 0 3px red; outline: dashed 3px var(--color-recorded); + background-color: yellow; } div.but_niveau_ue.annee_prec { diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 172e900817..0173a14e5e 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1128,7 +1128,8 @@ div.sco_help { padding: 8px; border-radius: 4px; font-style: italic; - background-color: rgb(200, 200, 220); + max-width: 800px; + background-color: rgb(209, 255, 214); } div.vertical_spacing_but { diff --git a/app/templates/but/documentation_codes_jury.j2 b/app/templates/but/documentation_codes_jury.j2 index 9cd90fd1a9..3ea9c80dec 100644 --- a/app/templates/but/documentation_codes_jury.j2 +++ b/app/templates/but/documentation_codes_jury.j2 @@ -8,6 +8,8 @@ (transcription paramétrable par votre administrateur ScoDoc).

      Codes d'année
      + Les codes d'année BUT sont associés à la formation et non au semestre: on ne valide + qu'une seule fois BUT1, BUT2 puis BUT3.
      @@ -100,7 +102,8 @@
      Codes RCUE (niveaux de compétences annuels)
      - + Les codes de RCUE sont associés à la formation: chaque niveau de compétence + est validé une fois au maximum. En cas de redoublement, le code RCUE peut changer.
      @@ -161,7 +164,9 @@
      Codes des Unités d'Enseignement (UE)
      - + Les codes d'UE sont associés aux UE d'un semestre. En cas de redoublement, + l'UE antérieure garde son code, non écrasé par le redoublement. Chaque UE suivie a son code. +
      diff --git a/app/templates/but/formsemestre_validation_auto_but.j2 b/app/templates/but/formsemestre_validation_auto_but.j2 index 27334aac54..5bf69b397d 100644 --- a/app/templates/but/formsemestre_validation_auto_but.j2 +++ b/app/templates/but/formsemestre_validation_auto_but.j2 @@ -8,19 +8,27 @@ {% block app_content %} +

      Calcul automatique des décisions de jury du BUT

      • N'enregistre jamais de décisions de l'année scolaire précédente, même si on a des RCUE "à cheval" sur deux années.
      • -
      • Ne modifie jamais de décisions déjà enregistrées. + +
      • Attention: peut modifier des décisions déjà enregistrées, si la + validation de droit est calculée. Par exemple, vous aviez saisi RAT + pour un étudiant dont les moyennes d'UE dépassent 10 mais qui pour une + raison particulière ne valide pas son année. Le calcul automatique peut + remplacer ce RAT par un ADM, ScoDoc considérant que les + conditions sont satisfaites. On peut éviter cela en laissant une note de + l'étudiant en ATTente.
      • +
      • N'enregistre que les décisions validantes de droit: ADM ou CMP.
      • N'enregistre pas de décision si l'étudiant a une ou plusieurs notes en ATTente.
      • -
      • L'assiduité n'est pas prise en compte. -
      • +
      • L'assiduité n'est pas prise en compte.

      En conséquence, saisir ensuite manuellement les décisions manquantes, @@ -34,9 +42,10 @@

    • Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !
    • +
      -
      +
      {{ wtf.quick_form(form) }}
      diff --git a/tests/unit/test_but_jury.py b/tests/unit/test_but_jury.py index db0b9aa275..acb5c67dae 100644 --- a/tests/unit/test_but_jury.py +++ b/tests/unit/test_but_jury.py @@ -108,9 +108,7 @@ def test_but_jury_GEII_lyon(test_client): # Saisie de toutes les décisions de jury "automatiques" # et vérification des résultats attendus: for formsemestre in formsemestres: - formsemestre_validation_auto_but( - formsemestre, only_adm=False, no_overwrite=False - ) + formsemestre_validation_auto_but(formsemestre, only_adm=False) yaml_setup_but.but_test_jury(formsemestre, doc) From 916edb72ac5d4f4c4e71cd2a5ad22e473afaee87 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 22 Jun 2023 21:15:23 +0200 Subject: [PATCH 071/101] =?UTF-8?q?N'affiche=20pas=20les=20niveaux=20inexi?= =?UTF-8?q?stants=20sur=20le=20r=C3=A9sum=C3=A9=20parcours?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/cursus_but.py | 6 ++++++ app/static/css/cursus_but.css | 4 ++++ app/templates/but/cursus_etud.j2 | 5 +++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index ea1b956ee4..5b5b6f29c2 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -71,6 +71,7 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): class EtudCursusBUT: """L'état de l'étudiant dans son cursus BUT Liste des niveaux validés/à valider + (utilisé pour le résumé sur la fiche étudiant) """ def __init__(self, etud: Identite, formation: Formation): @@ -190,6 +191,11 @@ class EtudCursusBUT: ) return d + def competence_annee_has_niveau(self, competence_id: int, annee: str) -> bool: + "vrai si la compétence à un niveau dans cette annee ('BUT1') pour le parcour de cet etud" + # slow, utile pour affichage fiche + return annee in [n.annee for n in self.competences[competence_id].niveaux] + def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]: """Cherche les validations de jury enregistrées pour chaque niveau Résultat: { niveau_id : [ ApcValidationRCUE ] } diff --git a/app/static/css/cursus_but.css b/app/static/css/cursus_but.css index e998ccda54..83438d2507 100644 --- a/app/static/css/cursus_but.css +++ b/app/static/css/cursus_but.css @@ -40,6 +40,10 @@ div.code_rcue { position: relative; } +div.no_niveau { + background-color: rgb(245, 237, 200); +} + div.code_jury { padding-right: 4px; padding-left: 4px; diff --git a/app/templates/but/cursus_etud.j2 b/app/templates/but/cursus_etud.j2 index 4baaa96f19..34090aa611 100644 --- a/app/templates/but/cursus_etud.j2 +++ b/app/templates/but/cursus_etud.j2 @@ -9,7 +9,8 @@
      {{ cursus.competences[competence_id].titre }}
      {% for annee in ('BUT1', 'BUT2', 'BUT3') %} {% set validation = cursus.validation_par_competence_et_annee.get(competence_id, {}).get(annee) %} -
      + {% set has_niveau = cursus.competence_annee_has_niveau(competence_id, annee) %} +
      {% if validation %}
      {{validation.code}}
      @@ -23,7 +24,7 @@
      {% else %}
      -
      -
      +
      {{'-' if has_niveau else ''}}
      {%endif%}
      From b70e2758c9058d085738df9c5eda5d0594b26ca4 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 23 Jun 2023 10:37:32 +0200 Subject: [PATCH 072/101] =?UTF-8?q?news=20pour=20op=C3=A9rations=20jury.?= =?UTF-8?q?=20Implements=20#668?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/jury.py | 22 +++++++++++++++++- app/but/jury_but.py | 5 +--- app/but/jury_but_validation_auto.py | 14 ++++++++++-- app/but/jury_but_view.py | 11 +++++++++ app/models/events.py | 12 +++++++--- app/scodoc/sco_edit_formation.py | 2 ++ app/scodoc/sco_edit_matiere.py | 2 -- app/scodoc/sco_edit_module.py | 2 -- app/scodoc/sco_edit_ue.py | 2 -- app/scodoc/sco_etud.py | 1 + app/scodoc/sco_formations.py | 1 + app/scodoc/sco_formsemestre.py | 1 + app/scodoc/sco_formsemestre_edit.py | 1 + app/scodoc/sco_formsemestre_validation.py | 28 +++++++++++++++-------- app/scodoc/sco_import_etuds.py | 1 + app/scodoc/sco_synchro_etuds.py | 1 - app/static/css/scodoc.css | 2 +- app/views/notes.py | 18 ++++++++++----- sco_version.py | 2 +- 19 files changed, 94 insertions(+), 34 deletions(-) diff --git a/app/api/jury.py b/app/api/jury.py index 2800c77a99..7de6cdef08 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -5,9 +5,10 @@ ############################################################################## """ - ScoDoc 9 API : jury WIP + ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions """ +from flask import g, url_for from flask_json import as_json from flask_login import login_required @@ -24,6 +25,7 @@ from app.models import ( Identite, ScolarAutorisationInscription, ScolarFormSemestreValidation, + ScolarNews, ) from app.scodoc import sco_cache from app.scodoc.sco_permissions import Permission @@ -47,6 +49,20 @@ def decisions_jury(formsemestre_id: int): raise ScoException("non implemente") +def _news_delete_jury_etud(etud: Identite): + "génère news sur effacement décision" + # n'utilise pas g.scodoc_dept, pas toujours dispo en mode API + url = url_for( + "scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id + ) + ScolarNews.add( + typ=ScolarNews.NEWS_JURY, + obj=etud.id, + text=f"""Suppression décision jury pour {etud.nomprenom}""", + url=url, + ) + + @bp.route( "/etudiant//jury/validation_ue//delete", methods=["POST"], @@ -94,6 +110,7 @@ def _validation_ue_delete(etudid: int, validation_id: int): db.session.delete(validation) sco_cache.invalidate_formsemestre_etud(etud) db.session.commit() + _news_delete_jury_etud(etud) return "ok" @@ -121,6 +138,7 @@ def autorisation_inscription_delete(etudid: int, validation_id: int): db.session.delete(validation) sco_cache.invalidate_formsemestre_etud(etud) db.session.commit() + _news_delete_jury_etud(etud) return "ok" @@ -148,6 +166,7 @@ def validation_rcue_delete(etudid: int, validation_id: int): db.session.delete(validation) sco_cache.invalidate_formsemestre_etud(etud) db.session.commit() + _news_delete_jury_etud(etud) return "ok" @@ -175,4 +194,5 @@ def validation_annee_but_delete(etudid: int, validation_id: int): db.session.delete(validation) sco_cache.invalidate_formsemestre_etud(etud) db.session.commit() + _news_delete_jury_etud(etud) return "ok" diff --git a/app/but/jury_but.py b/app/but/jury_but.py index d9f8031a2f..1a5c1a4c66 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -459,10 +459,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): """informations, for debugging purpose.""" text = f"""DecisionsProposeesAnnee
        -
      • Etudiant: {self.etud.nomprenom} -
      • +
      • Étudiant: {self.etud.html_link_fiche()}
      • """ for formsemestre, title in ( (self.formsemestre_impair, "formsemestre_impair"), diff --git a/app/but/jury_but_validation_auto.py b/app/but/jury_but_validation_auto.py index e698b5dd40..a061b42f11 100644 --- a/app/but/jury_but_validation_auto.py +++ b/app/but/jury_but_validation_auto.py @@ -6,11 +6,11 @@ """Jury BUT: calcul des décisions de jury annuelles "automatiques" """ +from flask import g, url_for from app import db from app.but import jury_but -from app.models.etudiants import Identite -from app.models.formsemestre import FormSemestre +from app.models import Identite, FormSemestre, ScolarNews from app.scodoc import sco_cache from app.scodoc.sco_exceptions import ScoValueError @@ -39,4 +39,14 @@ def formsemestre_validation_auto_but( nb_etud_modif += deca.record_all(only_validantes=only_adm) db.session.commit() + ScolarNews.add( + typ=ScolarNews.NEWS_JURY, + obj=formsemestre.id, + text=f"""Calcul jury automatique du semestre {formsemestre.html_link_status()}""", + url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + ), + ) return nb_etud_modif diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 87321684e2..275d93b1e3 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -31,6 +31,7 @@ from app.models import ( UniteEns, ScolarAutorisationInscription, ScolarFormSemestreValidation, + ScolarNews, ) from app.models.config import ScoDocSiteConfig from app.scodoc import html_sco_header @@ -369,6 +370,16 @@ def jury_but_semestriel( flash( f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée" ) + ScolarNews.add( + typ=ScolarNews.NEWS_JURY, + obj=formsemestre.id, + text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""", + url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + ), + ) return flask.redirect( url_for( "notes.formsemestre_validation_but", diff --git a/app/models/events.py b/app/models/events.py index 6555948d60..d3e7709761 100644 --- a/app/models/events.py +++ b/app/models/events.py @@ -54,14 +54,17 @@ class ScolarNews(db.Model): NEWS_APO = "APO" # changements de codes APO NEWS_FORM = "FORM" # modification formation (object=formation_id) NEWS_INSCR = "INSCR" # inscription d'étudiants (object=None ou formsemestre_id) + NEWS_JURY = "JURY" # saisie jury NEWS_MISC = "MISC" # unused NEWS_NOTE = "NOTES" # saisie note (object=moduleimpl_id) NEWS_SEM = "SEM" # creation semestre (object=None) + NEWS_MAP = { NEWS_ABS: "saisie absence", NEWS_APO: "modif. code Apogée", NEWS_FORM: "modification formation", NEWS_INSCR: "inscription d'étudiants", + NEWS_JURY: "saisie jury", NEWS_MISC: "opération", # unused NEWS_NOTE: "saisie note", NEWS_SEM: "création semestre", @@ -130,10 +133,10 @@ class ScolarNews(db.Model): return query.order_by(cls.date.desc()).limit(n).all() @classmethod - def add(cls, typ, obj=None, text="", url=None, max_frequency=0): + def add(cls, typ, obj=None, text="", url=None, max_frequency=600): """Enregistre une nouvelle Si max_frequency, ne génère pas 2 nouvelles "identiques" - à moins de max_frequency secondes d'intervalle. + à moins de max_frequency secondes d'intervalle (10 minutes par défaut). Deux nouvelles sont considérées comme "identiques" si elles ont même (obj, typ, user). La nouvelle enregistrée est aussi envoyée par mail. @@ -153,7 +156,10 @@ class ScolarNews(db.Model): if last_news: now = datetime.datetime.now(tz=last_news.date.tzinfo) if (now - last_news.date) < datetime.timedelta(seconds=max_frequency): - # on n'enregistre pas + # pas de nouvel event, mais met à jour l'heure + last_news.date = datetime.datetime.now() + db.session.add(last_news) + db.session.commit() return news = ScolarNews( diff --git a/app/scodoc/sco_edit_formation.py b/app/scodoc/sco_edit_formation.py index 384ec0650c..a3ce002a0a 100644 --- a/app/scodoc/sco_edit_formation.py +++ b/app/scodoc/sco_edit_formation.py @@ -132,6 +132,7 @@ def do_formation_delete(formation_id): typ=ScolarNews.NEWS_FORM, obj=formation_id, text=f"Suppression de la formation {acronyme}", + max_frequency=0, ) @@ -329,6 +330,7 @@ def do_formation_create(args: dict) -> Formation: typ=ScolarNews.NEWS_FORM, text=f"""Création de la formation { formation.titre} ({formation.acronyme}) version {formation.version}""", + max_frequency=0, ) return formation diff --git a/app/scodoc/sco_edit_matiere.py b/app/scodoc/sco_edit_matiere.py index 312f01ec12..ef425b4938 100644 --- a/app/scodoc/sco_edit_matiere.py +++ b/app/scodoc/sco_edit_matiere.py @@ -93,7 +93,6 @@ def do_matiere_create(args): typ=ScolarNews.NEWS_FORM, obj=ue["formation_id"], text=f"Modification de la formation {formation.acronyme}", - max_frequency=10 * 60, ) formation.invalidate_cached_sems() return r @@ -199,7 +198,6 @@ def do_matiere_delete(oid): typ=ScolarNews.NEWS_FORM, obj=ue["formation_id"], text=f"Modification de la formation {formation.acronyme}", - max_frequency=10 * 60, ) formation.invalidate_cached_sems() diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index 4d963c6e49..139afa8412 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -114,7 +114,6 @@ def do_module_create(args) -> int: typ=ScolarNews.NEWS_FORM, obj=formation.id, text=f"Modification de la formation {formation.acronyme}", - max_frequency=10 * 60, ) formation.invalidate_cached_sems() return module_id @@ -186,7 +185,6 @@ def do_module_delete(oid): typ=ScolarNews.NEWS_FORM, obj=mod["formation_id"], text=f"Modification de la formation {formation.acronyme}", - max_frequency=10 * 60, ) formation.invalidate_cached_sems() diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index ba6cb918ee..fbe8e2be96 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -145,7 +145,6 @@ def do_ue_create(args): typ=ScolarNews.NEWS_FORM, obj=args["formation_id"], text=f"Modification de la formation {formation.acronyme}", - max_frequency=10 * 60, ) formation.invalidate_cached_sems() return ue_id @@ -230,7 +229,6 @@ def do_ue_delete(ue: UniteEns, delete_validations=False, force=False): typ=ScolarNews.NEWS_FORM, obj=formation.id, text=f"Modification de la formation {formation.acronyme}", - max_frequency=10 * 60, ) # if not force: diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 7bbf12b5f2..827363c89d 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -671,6 +671,7 @@ def create_etud(cnx, args: dict = None): typ=ScolarNews.NEWS_INSCR, text='Nouvel étudiant %(nomprenom)s' % etud, url=etud["url"], + max_frequency=0, ) return etud diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index 981908c971..398ed7d47e 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -638,6 +638,7 @@ def formation_create_new_version(formation_id, redirect=True): typ=ScolarNews.NEWS_FORM, obj=new_id, text=f"Nouvelle version de la formation {formation.acronyme}", + max_frequency=0, ) if redirect: flash("Nouvelle version !") diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py index 3d46b1de78..bac3352a71 100644 --- a/app/scodoc/sco_formsemestre.py +++ b/app/scodoc/sco_formsemestre.py @@ -261,6 +261,7 @@ def do_formsemestre_create(args, silent=False): typ=ScolarNews.NEWS_SEM, text='Création du semestre %(titre)s' % args, url=args["url"], + max_frequency=0, ) return formsemestre_id diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 4961e8a21e..4a94f0bde8 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -1521,6 +1521,7 @@ def do_formsemestre_delete(formsemestre_id): typ=ScolarNews.NEWS_SEM, obj=formsemestre_id, text="Suppression du semestre %(titre)s" % sem, + max_frequency=0, ) diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 1822e3a655..15254a0e5e 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -39,7 +39,7 @@ from app import db, log from app.comp import res_sem from app.comp.res_compat import NotesTableCompat -from app.models import Formation, FormSemestre, UniteEns +from app.models import Formation, FormSemestre, UniteEns, ScolarNews from app.models.notes import etud_has_notes_attente from app.models.validations import ( ScolarAutorisationInscription, @@ -992,16 +992,26 @@ def do_formsemestre_validation_auto(formsemestre_id): ) nb_valid += 1 log( - "do_formsemestre_validation_auto: %d validations, %d conflicts" - % (nb_valid, len(conflicts)) + f"do_formsemestre_validation_auto: {nb_valid} validations, {len(conflicts)} conflicts" ) - H = [html_sco_header.sco_header(page_title="Saisie automatique")] - H.append( - """

        Saisie automatique des décisions du semestre %s

        + ScolarNews.add( + typ=ScolarNews.NEWS_JURY, + obj=formsemestre.id, + text=f"""Calcul jury automatique du semestre {formsemestre.html_link_status() + } ({nb_valid} décisions)""", + url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + ), + ) + H = [ + f"""{html_sco_header.sco_header(page_title="Saisie automatique")} +

        Saisie automatique des décisions du semestre {formsemestre.titre_annee()}

        Opération effectuée.

        -

        %d étudiants validés (sur %s)

        """ - % (sem["titreannee"], nb_valid, len(etudids)) - ) +

        {nb_valid} étudiants validés sur {len(etudids)}

        + """ + ] if conflicts: H.append( f"""

        Attention: {len(conflicts)} étudiants non modifiés diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py index 736d602121..e7eb8dceb3 100644 --- a/app/scodoc/sco_import_etuds.py +++ b/app/scodoc/sco_import_etuds.py @@ -480,6 +480,7 @@ def scolars_import_excel_file( text="Inscription de %d étudiants" # peuvent avoir ete inscrits a des semestres differents % len(created_etudids), obj=formsemestre_id, + max_frequency=0, ) log("scolars_import_excel_file: completing transaction") diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index 6685909c4b..764d889f42 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -704,7 +704,6 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident): typ=ScolarNews.NEWS_INSCR, text=f"Import Apogée de {len(created_etudids)} étudiants en ", obj=sem["formsemestre_id"], - max_frequency=10 * 60, # 10' ) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 0173a14e5e..ca1b1f1290 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -629,7 +629,7 @@ div.news { border-radius: 8px; } -div.news a { +div.news a, div.news a.stdlink { color: black; text-decoration: none; } diff --git a/app/views/notes.py b/app/views/notes.py index c51571941b..1ef706934a 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -2410,6 +2410,16 @@ def formsemestre_validation_but( if request.method == "POST": if not read_only: deca.record_form(request.form) + ScolarNews.add( + typ=ScolarNews.NEWS_JURY, + obj=formsemestre.id, + text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""", + url=url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + ), + ) flash("codes enregistrés") return flask.redirect( url_for( @@ -3059,7 +3069,6 @@ def formsemestre_set_apo_etapes(): ScolarNews.add( typ=ScolarNews.NEWS_APO, text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", - max_frequency=10 * 60, ) return ("", 204) @@ -3081,7 +3090,6 @@ def formsemestre_set_elt_annee_apo(): ScolarNews.add( typ=ScolarNews.NEWS_APO, text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", - max_frequency=10 * 60, ) return ("", 204) @@ -3103,7 +3111,6 @@ def formsemestre_set_elt_sem_apo(): ScolarNews.add( typ=ScolarNews.NEWS_APO, text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})", - max_frequency=10 * 60, ) return ("", 204) @@ -3125,7 +3132,6 @@ def ue_set_apo(): ScolarNews.add( typ=ScolarNews.NEWS_FORM, text=f"Modification code Apogée d'UE dans la formation {ue.formation.titre} ({ue.formation.acronyme})", - max_frequency=10 * 60, ) return ("", 204) @@ -3146,8 +3152,8 @@ def module_set_apo(): db.session.commit() ScolarNews.add( typ=ScolarNews.NEWS_FORM, - text=f"Modification code Apogée d'UE dans la formation {mod.formation.titre} ({mod.formation.acronyme})", - max_frequency=10 * 60, + text=f"""Modification code Apogée d'UE dans la formation { + mod.formation.titre} ({mod.formation.acronyme})""", ) return ("", 204) diff --git a/sco_version.py b/sco_version.py index 7be8d568b3..bd238565a6 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.90" +SCOVERSION = "9.4.91" SCONAME = "ScoDoc" From 46e03c0f615351a1facb61e92fbc846d7f1a37d9 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 23 Jun 2023 15:35:52 +0200 Subject: [PATCH 073/101] Ajout recap. parcours BUT sur page saisie jury --- app/but/jury_but_view.py | 2 +- app/scodoc/sco_page_etud.py | 2 +- app/static/css/jury_but.css | 6 ++++-- app/views/notes.py | 35 ++++++++++++++++++++++++++++------- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 275d93b1e3..4af88cae57 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -423,7 +423,7 @@ def jury_but_semestriel( {warning}

      -
      + """, ] diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 4b1973768f..e2dbd1cbce 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -36,7 +36,7 @@ from flask_login import current_user import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log -from app.but import cursus_but, jury_but_view +from app.but import cursus_but from app.models.etudiants import Identite, make_etud_args from app.models.formsemestre import FormSemestre from app.scodoc import html_sco_header diff --git a/app/static/css/jury_but.css b/app/static/css/jury_but.css index bf4be05e3b..2e3f0f713e 100644 --- a/app/static/css/jury_but.css +++ b/app/static/css/jury_but.css @@ -23,7 +23,7 @@ margin-top: 0px; } -form#jury_but { +.jury_but_box { margin: 0px 16px 16px 16px; background-color: rgb(253, 253, 231); border: 2px solid rgb(4, 4, 118); @@ -35,7 +35,9 @@ form#jury_but { min-width: var(--sco-content-min-width); max-width: var(--sco-content-max-width); } - +div.jury_but_box_title { + margin-bottom: 10px; +} .but_annee { margin-left: 32px; display: inline-grid; diff --git a/app/views/notes.py b/app/views/notes.py index 1ef706934a..abdfdfaf1d 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -42,12 +42,17 @@ from flask_login import current_user from app import db from app import models from app.auth.models import User -from app.but import apc_edit_ue -from app.but import jury_but, jury_but_validation_auto +from app.but import ( + apc_edit_ue, + cursus_but, + jury_edit_manual, + jury_but, + jury_but_pv, + jury_but_validation_auto, + jury_but_view, +) from app.but.forms import jury_but_forms -from app.but import jury_but_pv -from app.but import jury_but_view -from app.but import jury_edit_manual + from app.comp import jury, res_sem from app.comp.res_compat import NotesTableCompat @@ -2368,7 +2373,10 @@ def formsemestre_validation_but( page_title=f"Validation BUT S{formsemestre.semestre_id}", formsemestre_id=formsemestre_id, etudid=etudid, - cssstyles=("css/jury_but.css",), + cssstyles=[ + "css/jury_but.css", + "css/cursus_but.css", + ], javascripts=("js/jury_but.js",), ), """
      @@ -2469,7 +2477,7 @@ def formsemestre_validation_but( {warning}
      - + """ ) @@ -2546,6 +2554,19 @@ def formsemestre_validation_but(
      """ ) + # Affichage cursus BUT + but_cursus = cursus_but.EtudCursusBUT(etud, deca.formsemestre.formation) + H += [ + """
      +
      Niveaux de compétences enregistrés :
      + """, + render_template( + "but/cursus_etud.j2", + cursus=but_cursus, + scu=scu, + ), + "
      ", + ] H.append( render_template( "but/documentation_codes_jury.j2", From 4dc2b414020f132811f6cc7e412d0b7ccdd5c522 Mon Sep 17 00:00:00 2001 From: iziram Date: Fri, 23 Jun 2023 16:12:36 +0200 Subject: [PATCH 074/101] Assiduites : Finalisation Page Liste --- app/api/assiduites.py | 4 +- app/api/justificatifs.py | 8 +- app/scodoc/sco_archives_justificatifs.py | 4 +- app/static/css/assiduites.css | 17 +- app/static/icons/remove_circle.svg | 1 + app/static/icons/trash.svg | 1 + .../assiduites/pages/ajout_justificatif.j2 | 3 +- app/templates/assiduites/pages/calendrier.j2 | 3 +- app/templates/assiduites/widgets/alert.j2 | 2 +- app/templates/assiduites/widgets/prompt.j2 | 8 +- .../assiduites/widgets/tableau_assi.j2 | 162 +++++++- .../assiduites/widgets/tableau_base.j2 | 146 +++++-- .../assiduites/widgets/tableau_justi.j2 | 361 +++++++++++++++++- 13 files changed, 655 insertions(+), 65 deletions(-) create mode 100644 app/static/icons/remove_circle.svg create mode 100644 app/static/icons/trash.svg diff --git a/app/api/assiduites.py b/app/api/assiduites.py index 044931b68d..3c5699c3b6 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -599,7 +599,7 @@ def assiduite_edit(assiduite_id: int): moduleimpl: ModuleImpl = None if moduleimpl_id is not False: - if moduleimpl_id is not None: + if moduleimpl_id is not None and moduleimpl_id != "": moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first() if moduleimpl is None: errors.append("param 'moduleimpl_id': invalide") @@ -611,7 +611,7 @@ def assiduite_edit(assiduite_id: int): else: assiduite_unique.moduleimpl_id = moduleimpl_id else: - assiduite_unique.moduleimpl_id = moduleimpl_id + assiduite_unique.moduleimpl_id = None # Cas 3 : desc desc = data.get("desc", False) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index e2cfe61ebd..d1b7862d31 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -272,9 +272,6 @@ def justif_edit(justif_id: int): 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: @@ -283,13 +280,14 @@ def justif_edit(justif_id: int): 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 + if fin <= deb: + errors.append("param 'dates' : Date de début après date de fin") + justificatif_unique.date_debut = deb justificatif_unique.date_fin = fin diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py index 3cad18e96e..576b39473e 100644 --- a/app/scodoc/sco_archives_justificatifs.py +++ b/app/scodoc/sco_archives_justificatifs.py @@ -54,8 +54,8 @@ class Trace: 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}") + if traced[0] is not 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)) diff --git a/app/static/css/assiduites.css b/app/static/css/assiduites.css index 56cef447c7..45b5bcc204 100644 --- a/app/static/css/assiduites.css +++ b/app/static/css/assiduites.css @@ -517,14 +517,25 @@ background-image: url(../icons/filter.svg); } +[name='destroyFile'] { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + background-image: url(../icons/trash.svg); +} + +[name='destroyFile']:checked { + background-image: url(../icons/remove_circle.svg); +} + .icon { display: block; width: 24px; height: 24px; - outline: none; - border: none; + outline: none !important; + border: none !important; cursor: pointer; - margin: 0 2px; + margin: 0 2px !important; } .icon:focus { diff --git a/app/static/icons/remove_circle.svg b/app/static/icons/remove_circle.svg new file mode 100644 index 0000000000..e0c6e0d7b9 --- /dev/null +++ b/app/static/icons/remove_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/icons/trash.svg b/app/static/icons/trash.svg new file mode 100644 index 0000000000..f8aa78561e --- /dev/null +++ b/app/static/icons/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/assiduites/pages/ajout_justificatif.j2 b/app/templates/assiduites/pages/ajout_justificatif.j2 index e10efcaf8b..5c001790f7 100644 --- a/app/templates/assiduites/pages/ajout_justificatif.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif.j2 @@ -159,6 +159,7 @@ $.when( requests ).done(() => { + loadAll(); }) } @@ -171,7 +172,6 @@ let couverture = null; createJustificatif(justificatif, (data) => { - console.log(data); if (Object.keys(data.errors).length > 0) { console.error(data.errors); } @@ -179,7 +179,6 @@ couverture = data.success[0].couverture justif_id = data.success[0].justif_id; importFiles(justif_id); - loadAll(); return; } }) diff --git a/app/templates/assiduites/pages/calendrier.j2 b/app/templates/assiduites/pages/calendrier.j2 index cf1ce4618b..73a3cbf425 100644 --- a/app/templates/assiduites/pages/calendrier.j2 +++ b/app/templates/assiduites/pages/calendrier.j2 @@ -246,7 +246,7 @@ day.textContent = `${dayOfWeek} ${dayOfMonth}`; - if (!nonWorkdays.includes(dayOfWeek.toLowerCase())) { + if (!nonWorkdays.includes(dayOfWeek.toLowerCase()) && dayAssiduities.length > 0) { const cache = document.createElement('div') cache.classList.add('dayline'); cache.appendChild( @@ -304,7 +304,6 @@ let dates = getDaysBetweenDates(bornes.deb, bornes.fin); let datesByMonth = organizeByMonth(dates); const justifs = getEtudJustificatifs(bornes.deb, bornes.fin); - console.log(justifs) let assiduitiesByDay = organizeAssiduitiesByDay(datesByMonth, data, justifs); generateCalendar(assiduitiesByDay, nonwork); }); diff --git a/app/templates/assiduites/widgets/alert.j2 b/app/templates/assiduites/widgets/alert.j2 index 55d82aa87c..d0ce3add8f 100644 --- a/app/templates/assiduites/widgets/alert.j2 +++ b/app/templates/assiduites/widgets/alert.j2 @@ -24,7 +24,7 @@ /* Hidden by default */ position: fixed; /* Stay in place */ - z-index: 750; + z-index: 850; /* Sit on top */ padding-top: 100px; /* Location of the box */ diff --git a/app/templates/assiduites/widgets/prompt.j2 b/app/templates/assiduites/widgets/prompt.j2 index 8ef15c16b7..58c2784ec5 100644 --- a/app/templates/assiduites/widgets/prompt.j2 +++ b/app/templates/assiduites/widgets/prompt.j2 @@ -26,7 +26,7 @@ /* Stay in place */ z-index: 750; /* Sit on top */ - padding-top: 100px; + padding-top: 3vh; /* Location of the box */ left: 0; top: 0; @@ -181,8 +181,10 @@ succBtn.classList.add("btnPrompt") succBtn.textContent = "Valider" succBtn.addEventListener('click', () => { - success(); - closePromptModal(); + const retour = success(); + if (retour == null || retour == false || retour == undefined) { + closePromptModal(); + } }) const cancelBtn = document.createElement('button') cancelBtn.classList.add("btnPrompt") diff --git a/app/templates/assiduites/widgets/tableau_assi.j2 b/app/templates/assiduites/widgets/tableau_assi.j2 index 2f995b2ce5..52114f24d3 100644 --- a/app/templates/assiduites/widgets/tableau_assi.j2 +++ b/app/templates/assiduites/widgets/tableau_assi.j2 @@ -39,6 +39,10 @@
      + + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_base.j2 b/app/templates/assiduites/widgets/tableau_base.j2 index 6e7520b215..5121cb9acc 100644 --- a/app/templates/assiduites/widgets/tableau_base.j2 +++ b/app/templates/assiduites/widgets/tableau_base.j2 @@ -11,6 +11,7 @@ const itemsPerPage = 10; const contextMenu = document.getElementById("contextMenu"); const editOption = document.getElementById("editOption"); + const detailOption = document.getElementById("detailOption"); const deleteOption = document.getElementById("deleteOption"); let selectedRow; @@ -21,14 +22,32 @@ editOption.addEventListener("click", () => { if (selectedRow) { - // Code pour éditer la ligne sélectionnée - console.debug("Éditer :", selectedRow); + const type = selectedRow.getAttribute('type'); + const obj_id = selectedRow.getAttribute('obj_id'); + + if (type == "assiduite") { + editionAssiduites(obj_id); + } else { + editionJustificatifs(obj_id); + } + } + }); + + detailOption.addEventListener("click", () => { + if (selectedRow) { + const type = selectedRow.getAttribute('type'); + const obj_id = selectedRow.getAttribute('obj_id'); + + if (type == "assiduite") { + detailAssiduites(obj_id); + } else { + detailJustificatifs(obj_id); + } } }); 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") { @@ -112,33 +131,70 @@ function renderPaginationButtons(array, assi = true) { const totalPages = Math.ceil(array.length / itemsPerPage); - - if (assi) { - paginationContainerAssiduites.innerHTML = "" - } else { - paginationContainerJustificatifs.innerHTML = "" - } - - if (totalPages == 1) { + if (totalPages <= 1) { return; } - 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; + if (assi) { + paginationContainerAssiduites.innerHTML = "" + paginationContainerAssiduites.querySelector('#paginationAssi')?.addEventListener('change', (e) => { + currentPageAssiduites = e.target.value; + renderTableAssiduites(currentPageAssiduites, array); + }) + + paginationContainerAssiduites.querySelector('.pagination_moins').addEventListener('click', () => { + if (currentPageAssiduites > 1) { + currentPageAssiduites--; + paginationContainerAssiduites.querySelector('#paginationAssi').value = currentPageAssiduites renderTableAssiduites(currentPageAssiduites, array); - }); - paginationContainerAssiduites.appendChild(paginationButton); + } + }) + + paginationContainerAssiduites.querySelector('.pagination_plus').addEventListener('click', () => { + if (currentPageAssiduites < totalPages) { + currentPageAssiduites++; + paginationContainerAssiduites.querySelector('#paginationAssi').value = currentPageAssiduites + renderTableAssiduites(currentPageAssiduites, array); + } + }) + } else { + paginationContainerJustificatifs.innerHTML = "" + paginationContainerJustificatifs.querySelector('#paginationJusti')?.addEventListener('change', (e) => { + currentPageJustificatifs = e.target.value; + renderTableJustificatifs(currentPageJustificatifs, array); + }) + + paginationContainerJustificatifs.querySelector('.pagination_moins').addEventListener('click', () => { + if (currentPageJustificatifs > 1) { + currentPageJustificatifs--; + paginationContainerJustificatifs.querySelector('#paginationJusti').value = currentPageAssiduites + renderTableJustificatifs(currentPageJustificatifs, array); + } + }) + + paginationContainerJustificatifs.querySelector('.pagination_plus').addEventListener('click', () => { + if (currentPageJustificatifs < totalPages) { + currentPageJustificatifs++; + paginationContainerJustificatifs.querySelector('#paginationJusti').value = currentPageAssiduites + renderTableJustificatifs(currentPageJustificatifs, array); + } + }) + } + + + + + + + for (let i = 1; i <= totalPages; i++) { + const paginationButton = document.createElement("option"); + paginationButton.textContent = i; + paginationButton.value = i; + + if (assi) { + paginationContainerAssiduites.querySelector('#paginationAssi').appendChild(paginationButton) } else { - paginationButton.addEventListener("click", () => { - currentPageJustificatifs = i; - renderTableAssiduites(currentPageJustificatifs, array); - }); - paginationContainerJustificatifs.appendChild(paginationButton); + paginationContainerJustificatifs.querySelector('#paginationJusti').appendChild(paginationButton) } } updateActivePaginationButton(assi); @@ -571,7 +627,13 @@ } } - + function openContext(e) { + e.preventDefault(); + selectedRow = e.target.parentElement; + contextMenu.style.top = `${e.clientY - contextMenu.offsetHeight}px`; + contextMenu.style.left = `${e.clientX}px`; + contextMenu.style.display = "block"; + } @@ -608,7 +670,7 @@ .context-menu { display: none; - position: absolute; + position: fixed; list-style-type: none; padding: 10px 0; background-color: #f9f9f9; @@ -721,4 +783,34 @@ padding: 0; margin: 0; } + + .obj-title { + text-decoration: underline #bbb; + font-weight: bold; + } + + .obj-part { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 33%; + padding: 5px; + border: 1px solid #bbb; + } + + .obj-dates, + .obj-mod, + .obj-rest { + display: flex; + justify-content: space-evenly; + margin: 2px; + } + + .liste_pagination { + display: flex; + justify-content: space-evenly; + align-items: center; + gap: 5px; + } \ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_justi.j2 b/app/templates/assiduites/widgets/tableau_justi.j2 index 27dd12e2a5..c1808640ec 100644 --- a/app/templates/assiduites/widgets/tableau_justi.j2 +++ b/app/templates/assiduites/widgets/tableau_justi.j2 @@ -93,19 +93,360 @@ row.appendChild(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); - }); - + row.addEventListener("contextmenu", openContext); tableBodyJustificatifs.appendChild(row); }); updateActivePaginationButton(false); } + function detailJustificatifs(justi_id) { + const path = getUrl() + `/api/justificatif/${justi_id}`; + async_get( + path, + (data) => { + const user = getUserFromId(data.user_id); + const date_debut = moment.tz(data.date_debut, TIMEZONE).format("DD/MM/YYYY HH:mm"); + const date_fin = moment.tz(data.date_fin, TIMEZONE).format("DD/MM/YYYY HH:mm"); + const entry_date = moment.tz(data.entry_date, TIMEZONE).format("DD/MM/YYYY HH:mm"); - \ No newline at end of file + const etat = data.etat.capitalize(); + const desc = data.raison == null ? "" : data.raison; + const id = data.justif_id; + const fichier = data.fichier != null ? "Oui" : "Non"; + let filenames = [] + if (fichier) { + sync_get(path + "/list", (data2) => { + filenames = data2; + }) + } + + const html = ` +
      +
      +
      + Date de début + ${date_debut} +
      +
      + Date de fin + ${date_fin} +
      +
      + Date de saisie + ${entry_date} +
      +
      + +
      +
      + Raison + ${desc} +
      +
      + Etat + ${etat} +
      +
      + Créer par + ${user} +
      +
      +
      +
      + Fichier(s) +
      +
      +
      + Identifiant du justificatif + ${id} +
      +
      +
      + + ` + + const el = document.createElement('div'); + el.innerHTML = html; + + const fichContent = el.querySelector('#fich-content'); + + filenames.forEach((name) => { + const a = document.createElement('a'); + a.textContent = name + a.classList.add("fich-file") + + a.onclick = () => { downloadFile(id, name) }; + + fichContent.appendChild(a); + }) + + openAlertModal("Détails", el.firstElementChild, null, "green") + } + ) + } + + function downloadFile(id, name) { + const path = getUrl() + `/api/justificatif/${id}/export/${name}`; + + fetch(path, { + method: "POST" + + }) + // This returns a promise inside of which we are checking for errors from the server. + // The catch promise at the end of the call does not getting called when the server returns an error. + // More information about the error catching can be found here: https://www.tjvantoll.com/2015/09/13/fetch-and-errors/. + .then((result) => { + if (!result.ok) { + throw Error(result.statusText); + } + + // We are reading the *Content-Disposition* header for getting the original filename given from the server + const header = result.headers.get('Content-Disposition'); + const parts = header.split(';'); + filename = parts[1].split('=')[1].replaceAll("\"", ""); + + return result.blob(); + }) + // We use the download property for triggering the download of the file from our browser. + // More information about the following code can be found here: https://stackoverflow.com/questions/32545632/how-can-i-download-a-file-using-window-fetch. + // The filename from the first promise is used as name of the file. + .then((blob) => { + if (blob != null) { + var url = window.URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + } + }) + // The catch is getting called only for client-side errors. + // For example the throw in the first then-promise, which is the error that came from the server. + .catch((err) => { + console.log(err); + }); + } + + function editionJustificatifs(justif_id) { + const path = getUrl() + `/api/justificatif/${justif_id}`; + async_get( + path, + (data) => { + const html = ` +
      +
      +
      + Date de début + +
      +
      + Date de fin + +
      +
      +
      +
      + Etat du justificatif + +
      +
      + +
      +
      + Raison + +
      +
      +
      +
      +
      +
      + Importer un fichier + +
      +
      +
      + ` + + const desc = data.raison + const fichier = data.fichier != null ? "Oui" : "Non"; + + + const el = document.createElement('div') + el.innerHTML = html; + const assiEdit = el.firstElementChild; + + assiEdit.querySelector('#justi_etat').value = data.etat.toLowerCase(); + assiEdit.querySelector('#justi_raison').value = desc != null ? desc : ""; + + assiEdit.querySelector('#justi_date_debut').value = moment.tz(data.date_debut, TIMEZONE).format("YYYY-MM-DDTHH:MM") + assiEdit.querySelector('#justi_date_fin').value = moment.tz(data.date_fin, TIMEZONE).format("YYYY-MM-DDTHH:MM") + + const fichContent = assiEdit.querySelector('.justi-sect'); + + let filenames = [] + if (data.fichier) { + sync_get(path + "/list", (data2) => { + filenames = data2; + }) + + fichContent.insertAdjacentHTML('beforeend', "Fichier(s)") + } + + + filenames.forEach((name) => { + const a = document.createElement('a'); + a.textContent = name + a.classList.add("fich-file") + + a.onclick = () => { downloadFile(id, name) }; + + const input = document.createElement('input') + input.type = "checkbox" + input.name = "destroyFile"; + input.classList.add('icon') + + const span = document.createElement('span'); + span.classList.add('file-line') + span.appendChild(input) + span.appendChild(a) + + + fichContent.appendChild(span); + }) + + openPromptModal("Modification du justificatif", assiEdit, () => { + const prompt = document.querySelector('.assi-edit'); + + let date_debut = prompt.querySelector('#justi_date_debut').value; + let date_fin = prompt.querySelector('#justi_date_fin').value; + + if (date_debut == "" || date_fin == "") { + openAlertModal("Dates erronées", document.createTextNode('Les dates sont invalides')); + return true + } + date_debut = moment.tz(date_debut, TIMEZONE) + date_fin = moment.tz(date_fin, TIMEZONE) + + if (date_debut >= date_fin) { + openAlertModal("Dates erronées", document.createTextNode('La date de fin doit être après la date de début')); + return true + } + + const edit = { + date_debut: date_debut.format(), + date_fin: date_fin.format(), + raison: prompt.querySelector('#justi_raison').value, + etat: prompt.querySelector('#justi_etat').value, + } + + const toRemoveFiles = [...prompt.querySelectorAll('[name="destroyFile"]:checked')] + + if (toRemoveFiles.length > 0) { + removeFiles(justif_id, toRemoveFiles); + } + + const in_files = prompt.querySelector('#justi_fich'); + + if (in_files.files.length > 0) { + importNewFiles(justif_id, in_files); + } + + fullEditJustificatifs(data.justif_id, edit, () => { + loadAll(); + }) + + + }, () => { }, "green"); + } + ); + } + + function fullEditJustificatifs(justif_id, obj, call = () => { }) { + const path = getUrl() + `/api/justificatif/${justif_id}/edit`; + async_post( + path, + obj, + call, + (data, status) => { + //error + console.error(data, status); + } + ); + } + + function removeFiles(justif_id, files = []) { + const path = getUrl() + `/api/justificatif/${justif_id}/remove`; + files = files.map((el) => { + return el.parentElement.querySelector('a').textContent; + }); + + console.log(justif_id, files); + sync_post( + path, + { + "remove": "list", + "filenames": files, + }, + ); + } + + function importNewFiles(justif_id, in_files) { + const path = getUrl() + `/api/justificatif/${justif_id}/import`; + + const requests = [] + Array.from(in_files.files).forEach((f) => { + const fd = new FormData(); + fd.append('file', f); + requests.push( + $.ajax( + { + url: path, + type: 'POST', + data: fd, + dateType: 'json', + contentType: false, + processData: false, + success: () => { }, + } + ) + ) + + }); + + $.when( + requests + ).done(() => { + }) + + } + + + \ No newline at end of file From 0402eac989942c0ce8d4d038b4a6beca208f240b Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 23 Jun 2023 17:51:17 +0200 Subject: [PATCH 075/101] Fix #582: moy UE fiche etud si dispense. --- app/scodoc/sco_formsemestre_validation.py | 39 +++++++++++++++-------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 15254a0e5e..c087ff2b79 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -512,7 +512,7 @@ def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""): def formsemestre_recap_parcours_table( - Se, + situation_etud_cursus: sco_cursus_dut.SituationEtudCursus, etudid, with_links=False, with_all_columns=True, @@ -550,16 +550,18 @@ def formsemestre_recap_parcours_table( """ ) # titres des UE - H.append("
      " * Se.nb_max_ue) + H.append("" * situation_etud_cursus.nb_max_ue) # if with_links: H.append("") H.append("") num_sem = 0 - for sem in Se.get_semestres(): - is_prev = Se.prev and (Se.prev["formsemestre_id"] == sem["formsemestre_id"]) - is_cur = Se.formsemestre_id == sem["formsemestre_id"] + for sem in situation_etud_cursus.get_semestres(): + is_prev = situation_etud_cursus.prev and ( + situation_etud_cursus.prev["formsemestre_id"] == sem["formsemestre_id"] + ) + is_cur = situation_etud_cursus.formsemestre_id == sem["formsemestre_id"] num_sem += 1 dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid]) @@ -582,7 +584,7 @@ def formsemestre_recap_parcours_table( else: type_sem = "" class_sem = "sem_autre" - if sem["formation_code"] != Se.formation.formation_code: + if sem["formation_code"] != situation_etud_cursus.formation.formation_code: class_sem += " sem_autre_formation" if sem["bul_bgcolor"]: bgcolor = sem["bul_bgcolor"] @@ -646,7 +648,7 @@ def formsemestre_recap_parcours_table( H.append("") H.append(f"""""") # abs # acronymes UEs auxquelles l'étudiant est inscrit (ou capitalisé) - ues = list(nt.etud_ues(etudid)) + ues = list(nt.etud_ues(etudid)) # nb: en BUT, les UE "dispensées" sont incluses cnx = ndb.GetDBConnexion() etud_ue_status = {ue.id: nt.get_etud_ue_status(etudid, ue.id) for ue in ues} if not nt.is_apc: @@ -660,8 +662,10 @@ def formsemestre_recap_parcours_table( for ue in ues: H.append(f"""""") - if len(ues) < Se.nb_max_ue: - H.append(f"""""") + if len(ues) < situation_etud_cursus.nb_max_ue: + H.append( + f"""""" + ) # indique le semestre compensé par celui ci: if decision_sem and decision_sem["compense_formsemestre_id"]: csem = sco_formsemestre.get_formsemestre( @@ -686,7 +690,7 @@ def formsemestre_recap_parcours_table( if not sem["etat"]: # locked lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") default_sem_info += lockicon - if sem["formation_code"] != Se.formation.formation_code: + if sem["formation_code"] != situation_etud_cursus.formation.formation_code: default_sem_info += f"""Autre formation: {sem["formation_code"]}""" H.append( '' @@ -723,14 +727,21 @@ def formsemestre_recap_parcours_table( explanation_ue.append( f"""Capitalisée le {ue_status["event_date"] or "?"}.""" ) - + # Dispense BUT ? + if (etudid, ue.id) in nt.dispense_ues: + moy_ue_txt = "❎" if (ue_status and ue_status["is_capitalized"]) else "⭕" + explanation_ue.append("non inscrit (dispense)") + else: + moy_ue_txt = scu.fmt_note(moy_ue) H.append( f"""""" + }">{moy_ue_txt}""" + ) + if len(ues) < situation_etud_cursus.nb_max_ue: + H.append( + f"""""" ) - if len(ues) < Se.nb_max_ue: - H.append(f"""""") H.append("") if with_links: From fc0a1c285a9ea7911fb343d0ed999f4eac30bbc4 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 25 Jun 2023 11:49:11 +0200 Subject: [PATCH 076/101] =?UTF-8?q?Am=C3=A9liore=20UI=20gestion=20des=20UE?= =?UTF-8?q?=20ant=C3=A9rieures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/jury.py | 25 +- app/models/validations.py | 23 +- app/scodoc/sco_bulletins.py | 2 +- app/scodoc/sco_cursus_dut.py | 2 +- app/scodoc/sco_edit_ue.py | 34 ++- app/scodoc/sco_formsemestre_exterieurs.py | 2 +- app/scodoc/sco_formsemestre_validation.py | 338 +++++++++++----------- app/static/css/jury_delete_manual.css | 4 - app/static/css/scodoc.css | 56 ++-- app/static/js/validate_previous_ue.js | 62 ++-- app/templates/jury/jury_delete_manual.j2 | 28 +- app/views/notes.py | 69 +++-- 12 files changed, 365 insertions(+), 280 deletions(-) diff --git a/app/api/jury.py b/app/api/jury.py index 7de6cdef08..6103d11649 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -10,7 +10,7 @@ from flask import g, url_for from flask_json import as_json -from flask_login import login_required +from flask_login import current_user, login_required import app from app import db, log @@ -29,6 +29,7 @@ from app.models import ( ) from app.scodoc import sco_cache from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import json_error @bp.route("/formsemestre//decisions_jury") @@ -73,7 +74,7 @@ def _news_delete_jury_etud(etud: Identite): ) @login_required @scodoc -@permission_required(Permission.ScoEtudInscrit) +@permission_required(Permission.ScoView) @as_json def validation_ue_delete(etudid: int, validation_id: int): "Efface cette validation" @@ -90,7 +91,7 @@ def validation_ue_delete(etudid: int, validation_id: int): ) @login_required @scodoc -@permission_required(Permission.ScoEtudInscrit) +@permission_required(Permission.ScoView) @as_json def validation_formsemestre_delete(etudid: int, validation_id: int): "Efface cette validation" @@ -106,6 +107,24 @@ def _validation_ue_delete(etudid: int, validation_id: int): validation = ScolarFormSemestreValidation.query.filter_by( id=validation_id, etudid=etudid ).first_or_404() + # Vérification de la permission: + # A le droit de supprimer cette validation: le chef de dept ou quelqu'un ayant + # le droit de saisir des décisions de jury dans le formsemestre concerné s'il y en a un + # (c'est le cas pour les validations de jury, mais pas pour les "antérieures" non + # rattachées à un formsemestre) + if not g.scodoc_dept: # accès API + if not current_user.has_permission(Permission.ScoEtudInscrit): + return json_error(403, "validation_delete: non autorise") + else: + if validation.formsemestre: + if ( + validation.formsemestre.dept_id != g.scodoc_dept_id + ) or not validation.formsemestre.can_edit_jury(): + return json_error(403, "validation_delete: non autorise") + elif not current_user.has_permission(Permission.ScoEtudInscrit): + # Validation non rattachée à un semestre: on doit être chef + return json_error(403, "validation_delete: non autorise") + log(f"validation_ue_delete: etuid={etudid} {validation}") db.session.delete(validation) sco_cache.invalidate_formsemestre_etud(etud) diff --git a/app/models/validations.py b/app/models/validations.py index 7686d78976..10791758a0 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -8,6 +8,8 @@ from app import log from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN from app.models.events import Scolog +from app.scodoc import sco_cache +from app.scodoc import sco_utils as scu class ScolarFormSemestreValidation(db.Model): @@ -70,6 +72,14 @@ class ScolarFormSemestreValidation(db.Model): return f"""décision sur semestre {self.formsemestre.titre_mois()} du { self.event_date.strftime("%d/%m/%Y")}""" + def delete(self): + "Efface cette validation" + log(f"{self.__class__.__name__}.delete({self})") + etud = self.etud + db.session.delete(self) + db.session.commit() + sco_cache.invalidate_formsemestre_etud(etud) + def to_dict(self) -> dict: "as a dict" d = dict(self.__dict__) @@ -79,15 +89,22 @@ class ScolarFormSemestreValidation(db.Model): def html(self, detail=False) -> str: "Affichage html" if self.ue_id is not None: - return f"""Validation de l'UE {self.ue.acronyme} + moyenne = ( + f", moyenne {scu.fmt_note(self.moy_ue)}/20 " + if self.moy_ue is not None + else "" + ) + return f"""Validation + {'externe' if self.is_external else ""} + de l'UE {self.ue.acronyme} {('parcours ' + ", ".join([p.code for p in self.ue.parcours])) + "" if self.ue.parcours else ""} de {self.ue.formation.acronyme} {("émise par " + self.formsemestre.html_link_status()) - if self.formsemestre else ""} - : {self.code} + if self.formsemestre else "externe/antérieure"} + : {self.code}{moyenne} le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")} """ else: diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index c827207b55..d8f62b983e 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -1238,7 +1238,7 @@ def make_menu_autres_operations( "enabled": current_user.has_permission(Permission.ScoImplement), }, { - "title": "Enregistrer une validation d'UE antérieure", + "title": "Gérer les validations d'UEs antérieures", "endpoint": "notes.formsemestre_validate_previous_ue", "args": { "formsemestre_id": formsemestre.id, diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py index 3cf2897fa3..9eab3da6b7 100644 --- a/app/scodoc/sco_cursus_dut.py +++ b/app/scodoc/sco_cursus_dut.py @@ -972,7 +972,7 @@ def do_formsemestre_validate_ue( moy_ue = ue_status["moy"] if ue_status else "" args["moy_ue"] = moy_ue log("formsemestre_validate_ue: create %s" % args) - if code != None: + if code is not None: scolar_formsemestre_validation_create(cnx, args) else: log("formsemestre_validate_ue: code is None, not recording validation") diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index fbe8e2be96..90f01f04ee 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -502,7 +502,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No else: clone_form = "" bonus_div = """
      """ - ue_div = """
      """ + ue_div = """
      """ return ( "\n".join(H) + tf[1] @@ -1375,13 +1375,12 @@ def _ue_table_modules( return "\n".join(H) -def ue_sharing_code(ue_code=None, ue_id=None, hide_ue_id=None): +def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None): """HTML list of UE sharing this code Either ue_code or ue_id may be specified. hide_ue_id spécifie un id à retirer de la liste. """ - ue_code = str(ue_code) - if ue_id: + if ue_id is not None: ue = UniteEns.query.get_or_404(ue_id) if not ue_code: ue_code = ue.ue_code @@ -1400,29 +1399,36 @@ def ue_sharing_code(ue_code=None, ue_id=None, hide_ue_id=None): .filter_by(dept_id=g.scodoc_dept_id) ) - if hide_ue_id: # enlève l'ue de depart + if hide_ue_id is not None: # enlève l'ue de depart q_ues = q_ues.filter(UniteEns.id != hide_ue_id) ues = q_ues.all() + msg = " dans les formations du département " if not ues: - if ue_id: - return ( - f"""Seule UE avec code {ue_code or '-'}""" - ) + if ue_id is not None: + return f"""Seule UE avec code { + ue_code if ue_code is not None else '-'}{msg}""" else: - return f"""Aucune UE avec code {ue_code or '-'}""" + return f"""Aucune UE avec code { + ue_code if ue_code is not None else '-'}{msg}""" H = [] if ue_id: H.append( - f"""Autres UE avec le code {ue_code or '-'}:""" + f"""Pour information, autres UEs avec le code { + ue_code if ue_code is not None else '-'}{msg}:""" ) else: - H.append(f"""UE avec le code {ue_code or '-'}:""") + H.append( + f"""UE avec le code { + ue_code if ue_code is not None else '-'}{msg}:""" + ) H.append("
        ") for ue in ues: H.append( - f"""
      • {ue.acronyme} ({ue.titre}) dans {ue.acronyme} ({ue.titre}) dans + {ue.formation.acronyme} ({ue.formation.titre}), version {ue.formation.version}
      • """ diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index 67f05d625a..de32912f3e 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -517,7 +517,7 @@ def _record_ue_validations_and_coefs( ) assert code is None or (note) # si code validant, il faut une note sco_formsemestre_validation.do_formsemestre_validate_previous_ue( - formsemestre.id, + formsemestre, etud.id, ue.id, note, diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index c087ff2b79..f00542f7bf 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -31,8 +31,9 @@ import time import flask from flask import url_for, flash, g, request -from app.models.etudiants import Identite +import sqlalchemy as sa +from app.models.etudiants import Identite import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app import db, log @@ -1081,62 +1082,44 @@ def formsemestre_validation_suppress_etud(formsemestre_id, etudid): ) # > suppr. decision jury (peut affecter de plusieurs semestres utilisant UE capitalisée) -def formsemestre_validate_previous_ue(formsemestre_id, etudid): +def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite): """Form. saisie UE validée hors ScoDoc (pour étudiants arrivant avec un UE antérieurement validée). """ - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - formation: Formation = Formation.query.get_or_404(sem["formation_id"]) - H = [ - html_sco_header.sco_header( - page_title="Validation UE", - javascripts=["js/validate_previous_ue.js"], - ), - '
      en cours{ass}{ue.acronyme}%s%s{scu.fmt_note(moy_ue)}
      ', - """

      %s: validation d'une UE antérieure

      """ - % etud["nomprenom"], - ( - '
      %s
      ' - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), - ) - ), - f"""

      Utiliser cette page pour enregistrer une UE validée antérieurement, - dans un semestre hors ScoDoc.

      -

      Les UE validées dans ScoDoc sont déjà - automatiquement prises en compte. Cette page n'est utile que pour les étudiants ayant - suivi un début de cursus dans un autre établissement, ou bien dans un semestre géré - sans ScoDoc et qui redouble ce semestre - (ne pas utiliser pour les semestres précédents !). -

      -

      Notez que l'UE est validée, avec enregistrement immédiat de la décision et - l'attribution des ECTS.

      -

      On ne peut prendre en compte ici que les UE du cursus {formation.titre}

      - """, - ] + formation: Formation = formsemestre.formation - # Toutes les UE de cette formation sont présentées (même celles des autres semestres) - ues = formation.ues.order_by(UniteEns.numero) - ue_names = ["Choisir..."] + [f"{ue.acronyme} {ue.titre}" for ue in ues] + # Toutes les UEs non bonus de cette formation sont présentées + # avec indice de semestre <= semestre courant ou NULL + ues = formation.ues.filter( + UniteEns.type != UE_SPORT, + db.or_( + UniteEns.semestre_idx == None, + UniteEns.semestre_idx <= formsemestre.semestre_id, + ), + ).order_by(UniteEns.semestre_idx, UniteEns.numero) + + ue_names = ["Choisir..."] + [ + f"""{('S'+str(ue.semestre_idx)+' : ') if ue.semestre_idx is not None else '' + }{ue.acronyme} {ue.titre} ({ue.ue_code or ""})""" + for ue in ues + ] ue_ids = [""] + [ue.id for ue in ues] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), + form_descr = [ + ("etudid", {"input_type": "hidden"}), + ("formsemestre_id", {"input_type": "hidden"}), ( - ("etudid", {"input_type": "hidden"}), - ("formsemestre_id", {"input_type": "hidden"}), - ( - "ue_id", - { - "input_type": "menu", - "title": "Unité d'Enseignement (UE)", - "allow_null": False, - "allowed_values": ue_ids, - "labels": ue_names, - }, - ), + "ue_id", + { + "input_type": "menu", + "title": "Unité d'Enseignement (UE)", + "allow_null": False, + "allowed_values": ue_ids, + "labels": ue_names, + }, + ), + ] + if not formation.is_apc(): + form_descr.append( ( "semestre_id", { @@ -1147,69 +1130,159 @@ def formsemestre_validate_previous_ue(formsemestre_id, etudid): "allowed_values": [""] + [x for x in range(11)], "labels": ["-"] + list(range(11)), }, - ), - ( - "date", - { - "input_type": "date", - "size": 9, - "explanation": "j/m/a", - "default": time.strftime("%d/%m/%Y"), - }, - ), - ( - "moy_ue", - { - "type": "float", - "allow_null": False, - "min_value": 0, - "max_value": 20, - "title": "Moyenne (/20) obtenue dans cette UE:", - }, - ), + ) + ) + form_descr += [ + ( + "date", + { + "input_type": "date", + "size": 9, + "explanation": "j/m/a", + "default": time.strftime("%d/%m/%Y"), + }, ), - cancelbutton="Annuler", + ( + "moy_ue", + { + "type": "float", + "allow_null": False, + "min_value": 0, + "max_value": 20, + "title": "Moyenne (/20) obtenue dans cette UE:", + }, + ), + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + form_descr, + cancelbutton="Revenir au bulletin", submitlabel="Enregistrer validation d'UE", ) if tf[0] == 0: - X = """ -
      -
      - """ - warn, ue_multiples = check_formation_ues(formation.id) - return "\n".join(H) + tf[1] + X + warn + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect( - scu.NotesURL() - + "/formsemestre_status?formsemestre_id=" - + str(formsemestre_id) - ) - else: - if tf[2]["semestre_id"]: - semestre_id = int(tf[2]["semestre_id"]) - else: - semestre_id = None - do_formsemestre_validate_previous_ue( - formsemestre_id, - etudid, - tf[2]["ue_id"], - tf[2]["moy_ue"], - tf[2]["date"], - semestre_id=semestre_id, - ) - flash("Validation d'UE enregistrée") + return f""" + {html_sco_header.sco_header( + page_title="Validation UE antérieure", + javascripts=["js/validate_previous_ue.js"], + cssstyles=["css/jury_delete_manual.css"], + etudid=etud.id, + )} +

      Gestion des validations d'UEs antérieures + de {etud.html_link_fiche()} +

      + +

      Utiliser cette page pour enregistrer une UE validée antérieurement, + dans un semestre hors ScoDoc.

      +

      Les UE validées dans ScoDoc sont déjà + automatiquement prises en compte. Cette page n'est utile que pour les étudiants ayant + suivi un début de cursus dans un autre établissement, ou bien dans un semestre géré + sans ScoDoc et qui redouble ce semestre + (pour les semestres précédents gérés avec ScoDoc, + passer par la page jury normale)). +

      +

      Notez que l'UE est validée (ADM), avec enregistrement immédiat de la décision et + l'attribution des ECTS.

      +

      On ne peut valider ici que les UEs du cursus {formation.titre}

      + + {_get_etud_ue_cap_html(etud, formsemestre)} + +
      +
      + Enregistrer une UE antérieure +
      + {tf[1]} +
      +
      + +
      + {check_formation_ues(formation.id)[0]} + {html_sco_header.sco_footer()} + """ + + dest_url = url_for( + "notes.formsemestre_validate_previous_ue", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre.id, + etudid=etud.id, + ) + if tf[0] == -1: return flask.redirect( url_for( "notes.formsemestre_bulletinetud", scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etudid=etudid, + formsemestre_id=formsemestre.id, + etudid=etud.id, ) ) + if tf[2].get("semestre_id"): + semestre_id = int(tf[2]["semestre_id"]) + else: + semestre_id = None + + do_formsemestre_validate_previous_ue( + formsemestre, + etud.id, + tf[2]["ue_id"], + tf[2]["moy_ue"], + tf[2]["date"], + semestre_id=semestre_id, + ) + flash("Validation d'UE enregistrée") + return flask.redirect(dest_url) + + +def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str: + """HTML listant les validations d'UEs pour cet étudiant dans des formations de même + code que celle du formsemestre indiqué. + """ + validations: list[ScolarFormSemestreValidation] = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) + .join(UniteEns) + .join(Formation) + .filter_by(formation_code=formsemestre.formation.formation_code) + .order_by( + sa.desc(UniteEns.semestre_idx), + UniteEns.acronyme, + sa.desc(ScolarFormSemestreValidation.event_date), + ) + .all() + ) + + if not validations: + return "" + H = [ + f"""
      +
      Validations d'UEs dans cette formation
      +
      Liste de toutes les UEs validées par {etud.html_link_fiche()}, + sur des semestres ou déclarées comme "antérieures" (externes). +
      +
        """ + ] + for validation in validations: + if validation.formsemestre_id is None: + origine = " enregistrée d'un parcours antérieur (hors ScoDoc)" + else: + origine = f", du semestre {formsemestre.html_link_status()}" + if validation.semestre_id is not None: + origine += f" (S{validation.semestre_id})" + H.append( + f""" +
      • {validation.html()} + + + +
      • + """, + ) + H.append("
      ") + return "\n".join(H) def do_formsemestre_validate_previous_ue( - formsemestre_id, + formsemestre: FormSemestre, etudid, ue_id, moy_ue, @@ -1222,21 +1295,20 @@ def do_formsemestre_validate_previous_ue( Si le coefficient est spécifié, modifie le coefficient de cette UE (utile seulement pour les semestres extérieurs). """ - formsemestre = FormSemestre.get_formsemestre(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) ue: UniteEns = UniteEns.query.get_or_404(ue_id) cnx = ndb.GetDBConnexion() if ue_coefficient != None: sco_formsemestre.do_formsemestre_uecoef_edit_or_create( - cnx, formsemestre_id, ue_id, ue_coefficient + cnx, formsemestre.id, ue_id, ue_coefficient ) else: - sco_formsemestre.do_formsemestre_uecoef_delete(cnx, formsemestre_id, ue_id) + sco_formsemestre.do_formsemestre_uecoef_delete(cnx, formsemestre.id, ue_id) sco_cursus_dut.do_formsemestre_validate_ue( cnx, nt, - formsemestre_id, # "importe" cette UE dans le semestre (new 3/2015) + formsemestre.id, # "importe" cette UE dans le semestre (new 3/2015) etudid, ue_id, code, @@ -1274,62 +1346,6 @@ def _invalidate_etud_formation_caches(etudid, formation_id): ) # > modif decision UE (inval tous semestres avec cet etudiant, ok mais conservatif) -def get_etud_ue_cap_html(etudid, formsemestre_id, ue_id): - """Ramene bout de HTML pour pouvoir supprimer une validation de cette UE""" - valids = ndb.SimpleDictFetch( - """SELECT SFV.* - FROM scolar_formsemestre_validation SFV - WHERE ue_id=%(ue_id)s - AND etudid=%(etudid)s""", - {"etudid": etudid, "ue_id": ue_id}, - ) - if not valids: - return "" - H = [ - '
      Validations existantes pour cette UE:
        ' - ] - for valid in valids: - valid["event_date"] = ndb.DateISOtoDMY(valid["event_date"]) - if valid["moy_ue"] != None: - valid["m"] = ", moyenne %(moy_ue)g/20" % valid - else: - valid["m"] = "" - if valid["formsemestre_id"]: - sem = sco_formsemestre.get_formsemestre(valid["formsemestre_id"]) - valid["s"] = ", du semestre %s" % sem["titreannee"] - else: - valid["s"] = " enregistrée d'un parcours antérieur (hors ScoDoc)" - if valid["semestre_id"]: - valid["s"] += " (S%d)" % valid["semestre_id"] - valid["ds"] = formsemestre_id - H.append( - '
      • %(code)s%(m)s%(s)s, le %(event_date)s effacer
      • ' - % valid - ) - H.append("
      ") - return "\n".join(H) - - -def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id): - """Suppress a validation (ue_id, etudid) and redirect to formsemestre""" - log("etud_ue_suppress_validation( %s, %s, %s)" % (etudid, formsemestre_id, ue_id)) - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - "DELETE FROM scolar_formsemestre_validation WHERE etudid=%(etudid)s and ue_id=%(ue_id)s", - {"etudid": etudid, "ue_id": ue_id}, - ) - - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - _invalidate_etud_formation_caches(etudid, sem["formation_id"]) - - return flask.redirect( - scu.NotesURL() - + "/formsemestre_validate_previous_ue?etudid=%s&formsemestre_id=%s" - % (etudid, formsemestre_id) - ) - - def check_formation_ues(formation_id): """Verifie que les UE d'une formation sont chacune utilisée dans un seul semestre_id Si ce n'est pas le cas, c'est probablement (mais pas forcément) une erreur de diff --git a/app/static/css/jury_delete_manual.css b/app/static/css/jury_delete_manual.css index 6fa14f9ea0..d41e4f802b 100644 --- a/app/static/css/jury_delete_manual.css +++ b/app/static/css/jury_delete_manual.css @@ -4,10 +4,6 @@ div.jury_decisions_list div { font-weight: bold; } -div.jury_decisions_list form { - display: inline-block; -} - span.parcours { color:blueviolet; } diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ca1b1f1290..443f47bc8a 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -76,16 +76,16 @@ div.flashes { } div.alert { - /* - position: absolute; - top: 10px; - right: 10px; */ + padding: 16px; + border-radius: 12px; + font-size: 200%; + opacity: 0.8; } div.alert-info { - color: #0019d7; - background-color: #68f36d; - border-color: #0a8d0c; + color: #208d3b; + background-color: #fffd97; + border-color: #208d3b; } div.alert-error { @@ -94,6 +94,9 @@ div.alert-error { border-color: #8d0a17; } +form.inline-form { + display: inline-block; +} div.tab-content { margin-top: 10px; @@ -1112,9 +1115,11 @@ a.discretelink:hover { text-align: center; } +.expl, .help { + max-width: var(--sco-content-max-width); +} .help { font-style: italic; - max-width: 800px; } .help_important { @@ -1122,13 +1127,28 @@ a.discretelink:hover { color: red; } -div.sco_help { +div.sco_box, div.sco_help { margin-top: 12px; margin-bottom: 4px; + margin-left: 0px; padding: 8px; border-radius: 4px; + border: 1px solid grey; + max-width: var(--sco-content-max-width); +} +div.sco_help { font-style: italic; - max-width: 800px; + background-color: rgb(209, 255, 214); +} +div.sco_box_title { + font-size: 120%; + font-weight: bold; + margin-bottom: 8px; +} +.sco_green_bg { + background-color: rgb(155, 218, 155); +} +.sco_lightgreen_bg { background-color: rgb(209, 255, 214); } @@ -2504,13 +2524,7 @@ input.sco_tag_checkbox { } div#ue_list_code { - background-color: rgb(155, 218, 155); - padding: 10px; border: 1px solid blue; - border-radius: 10px; - padding: 10px; - margin-top: 10px; - margin-right: 15px; } ul.notes_module_list { @@ -2596,16 +2610,6 @@ div#ue_list_modules { margin-right: 15px; } -div#ue_list_etud_validations { - background-color: rgb(220, 250, 220); - padding-left: 4px; - padding-bottom: 1px; - margin: 3ex; -} - -div#ue_list_etud_validations span { - font-weight: bold; -} span.ue_share { font-weight: bold; diff --git a/app/static/js/validate_previous_ue.js b/app/static/js/validate_previous_ue.js index a741380b31..22655f498e 100644 --- a/app/static/js/validate_previous_ue.js +++ b/app/static/js/validate_previous_ue.js @@ -1,31 +1,43 @@ // Affiche et met a jour la liste des UE partageant le meme code -$().ready(function () { - update_ue_validations(); - update_ue_list(); - $("#tf_ue_id").bind("change", update_ue_list); - $("#tf_ue_id").bind("change", update_ue_validations); +document.addEventListener("DOMContentLoaded", () => { + update_ue_list(); + $("#tf_ue_id").bind("change", update_ue_list); + + const buttons = document.querySelectorAll(".ue_list_etud_validations button"); + + buttons.forEach((button) => { + button.addEventListener("click", (event) => { + // Handle button click event here + event.preventDefault(); + const etudid = event.target.dataset.etudid; + const v_id = event.target.dataset.v_id; + const validation_type = event.target.dataset.type; + if (confirm("Supprimer cette validation ?")) { + fetch( + `${SCO_URL}/../api/etudiant/${etudid}/jury/${validation_type}/${v_id}/delete`, + { + method: "POST", + } + ).then((response) => { + // Handle the response + if (response.ok) { + location.reload(); + } else { + throw new Error("Request failed"); + } + }); + } + }); + }); }); - function update_ue_list() { - var ue_id = $("#tf_ue_id")[0].value; - if (ue_id) { - var query = "ue_sharing_code?ue_id=" + ue_id; - $.get(query, '', function (data) { - $("#ue_list_code").html(data); - }); - } + var ue_id = $("#tf_ue_id")[0].value; + if (ue_id) { + var query = SCO_URL + "/Notes/ue_sharing_code?ue_id=" + ue_id; + $.get(query, "", function (data) { + $("#ue_list_code").html(data); + }); + } } - -function update_ue_validations() { - var etudid = $("#tf_etudid")[0].value; - var ue_id = $("#tf_ue_id")[0].value; - var formsemestre_id = $("#tf_formsemestre_id")[0].value; - if (ue_id) { - var query = SCO_URL + "/Notes/get_etud_ue_cap_html?ue_id=" + ue_id + "&etudid=" + etudid + "&formsemestre_id=" + formsemestre_id; - $.get(query, '', function (data) { - $("#ue_list_etud_validations").html(data); - }); - } -} \ No newline at end of file diff --git a/app/templates/jury/jury_delete_manual.j2 b/app/templates/jury/jury_delete_manual.j2 index e423501fbf..f1b750a885 100644 --- a/app/templates/jury/jury_delete_manual.j2 +++ b/app/templates/jury/jury_delete_manual.j2 @@ -26,7 +26,10 @@ pages de saisie de jury habituelles).
        {% for v in sem_vals %}
      • {{v.html()|safe}} -
        +
        +
      • {% endfor %}
      @@ -39,7 +42,10 @@ pages de saisie de jury habituelles).
        {% for v in ue_vals %}
      • {{v.html(detail=True)|safe}} -
        +
        + +
      • {% endfor %}
      @@ -52,7 +58,10 @@ pages de saisie de jury habituelles).
        {% for v in rcue_vals %}
      • {{v.html()|safe}} -
        +
        + +
      • {% endfor %}
      @@ -65,7 +74,10 @@ pages de saisie de jury habituelles).
        {% for v in annee_but_vals %}
      • {{v.html()|safe}} -
        +
        + +
      • {% endfor %}
      @@ -78,7 +90,10 @@ pages de saisie de jury habituelles).
        {% for v in autorisations %}
      • {{v.html()|safe}} -
        +
        + +
      • {% endfor %}
      @@ -113,10 +128,11 @@ document.addEventListener('DOMContentLoaded', () => { button.addEventListener('click', (event) => { // Handle button click event here event.preventDefault(); + const etudid = event.target.dataset.etudid; const v_id = event.target.dataset.v_id; const validation_type = event.target.dataset.type; if (confirm("Supprimer cette validation ?")) { - fetch(`${SCO_URL}/../api/etudiant/{{etud.id}}/jury/${validation_type}/${v_id}/delete`, + fetch(`${SCO_URL}/../api/etudiant/${etudid}/jury/${validation_type}/${v_id}/delete`, { method: "POST", }).then(response => { diff --git a/app/views/notes.py b/app/views/notes.py index abdfdfaf1d..3b1772982d 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -56,17 +56,22 @@ from app.but.forms import jury_but_forms from app.comp import jury, res_sem from app.comp.res_compat import NotesTableCompat -from app.models import Formation, ScolarAutorisationInscription, ScolarNews, Scolog +from app.models import ( + Formation, + ScolarFormSemestreValidation, + ScolarAutorisationInscription, + ScolarNews, + Scolog, +) from app.models.but_refcomp import ApcNiveau from app.models.config import ScoDocSiteConfig from app.models.etudiants import Identite -from app.models.formsemestre import FormSemestre +from app.models.formsemestre import FormSemestre, FormSemestreInscription from app.models.formsemestre import FormSemestreUEComputationExpr from app.models.moduleimpls import ModuleImpl from app.models.modules import Module from app.models.ues import DispenseUE, UniteEns from app.scodoc.sco_exceptions import ScoFormationConflict, ScoPermissionDenied -from app.tables import jury_recap from app.views import notes_bp as bp from app.decorators import ( @@ -483,7 +488,21 @@ def ue_set_internal(ue_id): ) -sco_publish("/ue_sharing_code", sco_edit_ue.ue_sharing_code, Permission.ScoView) +@bp.route("/ue_sharing_code") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def ue_sharing_code(): + ue_code = request.args.get("ue_code") + ue_id = request.args.get("ue_id") + hide_ue_id = request.args.get("hide_ue_id") + return sco_edit_ue.ue_sharing_code( + ue_code=ue_code, + ue_id=None if ue_id is None else int(ue_id), + hide_ue_id=None if hide_ue_id is None else int(hide_ue_id), + ) + + sco_publish( "/edit_ue_set_code_apogee", sco_edit_ue.edit_ue_set_code_apogee, @@ -2621,10 +2640,12 @@ def formsemestre_validation_auto_but(formsemestre_id: int = None): ) -@bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"]) +@bp.route( + "/formsemestre_validate_previous_ue//", + methods=["GET", "POST"], +) @scodoc @permission_required(Permission.ScoView) -@scodoc7func def formsemestre_validate_previous_ue(formsemestre_id, etudid=None): "Form. saisie UE validée hors ScoDoc" formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) @@ -2636,9 +2657,15 @@ def formsemestre_validate_previous_ue(formsemestre_id, etudid=None): formsemestre_id=formsemestre_id, ) ) + etud: Identite = ( + Identite.query.filter_by(id=etudid) + .join(FormSemestreInscription) + .filter_by(formsemestre_id=formsemestre_id) + .first_or_404() + ) return sco_formsemestre_validation.formsemestre_validate_previous_ue( - formsemestre_id, etudid + formsemestre, etud ) @@ -2671,34 +2698,6 @@ def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid=None): ) -sco_publish( - "/get_etud_ue_cap_html", - sco_formsemestre_validation.get_etud_ue_cap_html, - Permission.ScoView, -) - - -@bp.route("/etud_ue_suppress_validation") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id): - """Suppress a validation (ue_id, etudid) and redirect to formsemestre""" - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - if not formsemestre.can_edit_jury(): - raise ScoPermissionDenied( - dest_url=url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - - return sco_formsemestre_validation.etud_ue_suppress_validation( - etudid, formsemestre_id, ue_id - ) - - @bp.route("/formsemestre_validation_auto") @scodoc @permission_required(Permission.ScoView) From df289ba5565c6a1b68c09a1b052f2fa8be6ea0b7 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 25 Jun 2023 11:51:58 +0200 Subject: [PATCH 077/101] un detail --- app/scodoc/sco_formsemestre_validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index f00542f7bf..bf7eb46f8b 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -1167,6 +1167,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite javascripts=["js/validate_previous_ue.js"], cssstyles=["css/jury_delete_manual.css"], etudid=etud.id, + formsemestre_id=formsemestre.id, )}

      Gestion des validations d'UEs antérieures de {etud.html_link_fiche()} From e15c4687034367c7ceeaa0e440db2b713228e14c Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 25 Jun 2023 12:29:36 +0200 Subject: [PATCH 078/101] Fix: typo calcul auto jury BUT --- app/but/jury_but.py | 2 +- sco_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 1a5c1a4c66..2e01afa634 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -1334,7 +1334,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): ordre=annee_inferieure, ) .join(Formation) - .filter_by(formation_code=self.rcue.formsemestre_1.formation.code) + .filter_by(formation_code=self.rcue.formsemestre_1.formation.formation_code) .all() ) if len(validations_annee) > 1: diff --git a/sco_version.py b/sco_version.py index bd238565a6..aa0d290876 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.91" +SCOVERSION = "9.4.92" SCONAME = "ScoDoc" From 02168b8032a29d8b9f43dd1273de4f6de28b773a Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 25 Jun 2023 21:05:19 +0200 Subject: [PATCH 079/101] =?UTF-8?q?Ajout=20ADSUP=20au=20transcodage=20Apog?= =?UTF-8?q?=C3=A9e.=20Cosmetic=20flash.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/forms/main/config_apo.py | 4 +++- app/models/config.py | 2 ++ app/static/css/scodoc.css | 15 +++++++++++---- app/views/scodoc.py | 2 +- sco_version.py | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py index b65d6407e0..5db90405ab 100644 --- a/app/forms/main/config_apo.py +++ b/app/forms/main/config_apo.py @@ -65,6 +65,7 @@ class CodesDecisionsForm(FlaskForm): ADJ = _build_code_field("ADJ") ADJR = _build_code_field("ADJR") ADM = _build_code_field("ADM") + ADSUP = _build_code_field("ADSUP") AJ = _build_code_field("AJ") ATB = _build_code_field("ATB") ATJ = _build_code_field("ATJ") @@ -81,7 +82,8 @@ class CodesDecisionsForm(FlaskForm): NOTES_FMT = StringField( label="Format notes exportées", - description="""Format des notes. Par défaut %3.2f (deux chiffres après la virgule)""", + description="""Format des notes. Par défaut + %3.2f (deux chiffres après la virgule)""", validators=[ validators.Length( max=SHORT_STR_LEN, diff --git a/app/models/config.py b/app/models/config.py index b989781cfe..27f86127b4 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -17,6 +17,7 @@ from app.scodoc.codes_cursus import ( ADJ, ADJR, ADM, + ADSUP, AJ, ATB, ATJ, @@ -39,6 +40,7 @@ CODES_SCODOC_TO_APO = { ADJ: "ADM", ADJR: "ADM", ADM: "ADM", + ADSUP: "ADM", AJ: "AJ", ATB: "AJAC", ATJ: "AJAC", diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 443f47bc8a..34bb0462a9 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -65,13 +65,14 @@ div#gtrcontent { } div.flashes { - transition: opacity 0.5s ease; - margin-top: 8px; left: 50%; + margin-top: 8px; + max-width: 800px; position: fixed; + text-align: center; top: 8px; transform: translateX(-50%); - width: auto; + transition: opacity 0.5s ease; z-index: 1000; } @@ -79,7 +80,7 @@ div.alert { padding: 16px; border-radius: 12px; font-size: 200%; - opacity: 0.8; + opacity: 0.9; } div.alert-info { @@ -88,6 +89,12 @@ div.alert-info { border-color: #208d3b; } +div.alert-warning { + color: #ef5c00; + background-color: #fbfb00d4; + border-color: #767676; +} + div.alert-error { color: #ef0020; background-color: #ffff00; diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 7e2c9d1631..b6e83ce17a 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -247,7 +247,7 @@ def config_codes_decisions(): if form.validate_on_submit(): for code in models.config.CODES_SCODOC_TO_APO: ScoDocSiteConfig.set_code_apo(code, getattr(form, code).data) - flash("Codes décisions enregistrés.") + flash("Codes décisions enregistrés") return redirect(url_for("scodoc.index")) elif request.method == "GET": for code in models.config.CODES_SCODOC_TO_APO: diff --git a/sco_version.py b/sco_version.py index aa0d290876..282f517e7f 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.92" +SCOVERSION = "9.4.93" SCONAME = "ScoDoc" From 4231171668897b6fb5756ff374ff53ec1bb8816a Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 27 Jun 2023 18:28:40 +0200 Subject: [PATCH 080/101] Fix: typo validation manuelle --- app/but/jury_edit_manual.py | 5 +---- app/templates/jury/jury_delete_manual.j2 | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/but/jury_edit_manual.py b/app/but/jury_edit_manual.py index 89ac43a20c..d144c125ca 100644 --- a/app/but/jury_edit_manual.py +++ b/app/but/jury_edit_manual.py @@ -9,12 +9,9 @@ Non spécifique au BUT. """ -from flask import flash, render_template -from flask import g, request +from flask import render_template import sqlalchemy as sa -from app import db - from app.models import ( ApcValidationAnnee, ApcValidationRCUE, diff --git a/app/templates/jury/jury_delete_manual.j2 b/app/templates/jury/jury_delete_manual.j2 index f1b750a885..6e993e2160 100644 --- a/app/templates/jury/jury_delete_manual.j2 +++ b/app/templates/jury/jury_delete_manual.j2 @@ -43,7 +43,7 @@ pages de saisie de jury habituelles). {% for v in ue_vals %}
    • {{v.html(detail=True)|safe}}
      -
    • From 9b8e09cdbacef695815cbf61088a70acac8c9fb8 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 27 Jun 2023 18:33:56 +0200 Subject: [PATCH 081/101] =?UTF-8?q?Fix=20temporaire:=20jury=20BUT:=20propo?= =?UTF-8?q?se=20toujours=20le=20code=20ann=C3=A9e=20RED=20en=20mode=20manu?= =?UTF-8?q?el?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/jury_but.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 2e01afa634..bb73af4311 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -200,6 +200,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): # Codes toujours proposés sauf si include_communs est faux: codes_communs = [ sco_codes.RAT, + sco_codes.RED, # TODO temporaire 9.4.93: propose toujours RED sco_codes.ABAN, sco_codes.ABL, sco_codes.ATJ, From 7a42c24fc443d3089493d45bc61f5064bf15c963 Mon Sep 17 00:00:00 2001 From: iziram Date: Wed, 28 Jun 2023 17:15:24 +0200 Subject: [PATCH 082/101] Assiduites : Pages Bilans --- app/api/justificatifs.py | 27 +- app/scodoc/html_sidebar.py | 3 +- app/scodoc/sco_formsemestre_status.py | 15 +- app/scodoc/sco_moduleimpl_status.py | 2 +- app/scodoc/sco_preferences.py | 14 +- app/scodoc/sco_utils.py | 7 + app/templates/assiduites/pages/bilan_dept.j2 | 160 ++++++++ app/templates/assiduites/pages/bilan_etud.j2 | 342 ++++++++++++++++++ .../assiduites/widgets/tableau_assi.j2 | 3 + .../assiduites/widgets/tableau_base.j2 | 22 +- .../assiduites/widgets/tableau_justi.j2 | 17 +- app/templates/sidebar.j2 | 4 +- app/views/assiduites.py | 107 +++++- scodoc.py | 6 - tools/migrate_abs_to_assiduites.py | 24 +- 15 files changed, 697 insertions(+), 56 deletions(-) create mode 100644 app/templates/assiduites/pages/bilan_dept.j2 create mode 100644 app/templates/assiduites/pages/bilan_etud.j2 diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index d1b7862d31..52ddee04eb 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -18,7 +18,7 @@ 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 import Identite, Justificatif, Departement from app.models.assiduites import compute_assiduites_justified from app.scodoc.sco_archives_justificatifs import JustificatifArchiver from app.scodoc.sco_exceptions import ScoValueError @@ -105,6 +105,31 @@ def justificatifs(etudid: int = None, with_query: bool = False): return data_set +@api_web_bp.route("/justificatifs/dept/", defaults={"with_query": False}) +@api_web_bp.route( + "/justificatifs/dept//query", defaults={"with_query": True} +) +@login_required +@scodoc +@as_json +@permission_required(Permission.ScoView) +def justificatifs_dept(dept_id: int = None, with_query : bool = False): + """ """ + dept = Departement.query.get_or_404(dept_id) + etuds = [etud.id for etud in dept.etudiants] + + justificatifs_query = Justificatif.query.filter(Justificatif.etudid.in_(etuds)) + + 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 data_set + + @bp.route("/justificatif//create", methods=["POST"]) @api_web_bp.route("/justificatif//create", methods=["POST"]) @scodoc diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index cb93b15e1d..ad720791a8 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -54,7 +54,7 @@ def sidebar_common():

      Scolarité

      Semestres
      Programmes
      - Absences
      + Assiduités
      """ ] if current_user.has_permission( @@ -138,6 +138,7 @@ def sidebar(etudid: int = None): f"""
    • Calendrier
    • Liste
    • +
    • Bilan
    """ ) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 05167c6640..ea5d292313 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -213,13 +213,14 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: "enabled": True, "helpmsg": "", }, - { - "title": "Vérifier absences aux évaluations", - "endpoint": "notes.formsemestre_check_absences_html", - "args": {"formsemestre_id": formsemestre_id}, - "enabled": True, - "helpmsg": "", - }, + # TODO: Mettre à jour avec module Assiduités + # { + # "title": "Vérifier absences aux évaluations", + # "endpoint": "notes.formsemestre_check_absences_html", + # "args": {"formsemestre_id": formsemestre_id}, + # "enabled": True, + # "helpmsg": "", + # }, { "title": "Lister tous les enseignants", "endpoint": "notes.formsemestre_enseignants_list", diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index fefeacbefb..56cbd4ab7f 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -138,7 +138,7 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str: }, { "title": "Absences ce jour", - "endpoint": "absences.EtatAbsencesDate", + "endpoint": "assiduites.get_etat_abs_date", "args": { "group_ids": group_id, "desc": E["description"], diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index b7099dabbd..b6a064ed72 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -661,7 +661,19 @@ class BasePreferences(object): "labels": ["1/2 J.", "J.", "H."], "allowed_values": ["1/2 J.", "J.", "H."], "title": "Métrique de l'assiduité", - "explanation": "Unité affichée dans la fiche étudiante et le bilan\n(J. = journée, H. = heure)", + "explanation": "Unité utilisée dans la fiche étudiante, le bilan, et dans les calculs (J. = journée, H. = heure)", + "category": "assi", + "only_global": True, + }, + ), + ( + "assi_seuil", + { + "initvalue": 3.0, + "size": 10, + "title": "Seuil d'alerte des absences", + "type": "float", + "explanation": "Nombres d'absences limite avant alerte dans le bilan (utilisation de l'unité métrique ↑ )", "category": "assi", "only_global": True, }, diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 73e0569951..c163806eeb 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -619,6 +619,13 @@ def AbsencesURL(): ] +def AssiduitesURL(): + """URL of Assiduités""" + return url_for("assiduites.index_html", scodoc_dept=g.scodoc_dept)[ + : -len("/index_html") + ] + + def UsersURL(): """URL of Users e.g. https://scodoc.xxx.fr/ScoDoc/DEPT/Scolarite/Users diff --git a/app/templates/assiduites/pages/bilan_dept.j2 b/app/templates/assiduites/pages/bilan_dept.j2 new file mode 100644 index 0000000000..f6afdf610c --- /dev/null +++ b/app/templates/assiduites/pages/bilan_dept.j2 @@ -0,0 +1,160 @@ +{% include "assiduites/widgets/tableau_base.j2" %} + + +
    + +

    Justificatifs en attente (ou modifiés)

    + {% include "assiduites/widgets/tableau_justi.j2" %} +
    + +
    + Année scolaire 2022-2023 Changer année: + +
    + +
    + +
    + + \ No newline at end of file diff --git a/app/templates/assiduites/pages/bilan_etud.j2 b/app/templates/assiduites/pages/bilan_etud.j2 new file mode 100644 index 0000000000..a282c3c5f6 --- /dev/null +++ b/app/templates/assiduites/pages/bilan_etud.j2 @@ -0,0 +1,342 @@ +{% block app_content %} +{% include "assiduites/widgets/tableau_base.j2" %} +
    + +

    Bilan de l'assiduité de {{sco.etud.nomprenom}}

    + + + +
    + +

    Statistiques d'assiduité

    +
    + + + +
    + +
    + +
    +
    + +
    + +

    Assiduités non justifiées (Uniquement les retards et les absences)

    + {% include "assiduites/widgets/tableau_assi.j2" %} + +

    Justificatifs en attente (ou modifiés)

    + {% include "assiduites/widgets/tableau_justi.j2" %} + +
    + +
    +

    Boutons de suppresions (toute suppression est définitive)

    + + +
    + +
    + +
    + +
    +{% endblock app_content %} + + + \ No newline at end of file diff --git a/app/templates/assiduites/widgets/tableau_assi.j2 b/app/templates/assiduites/widgets/tableau_assi.j2 index 52114f24d3..6d1ea2d33c 100644 --- a/app/templates/assiduites/widgets/tableau_assi.j2 +++ b/app/templates/assiduites/widgets/tableau_assi.j2 @@ -59,6 +59,8 @@ assi = filterArray(assi, filterAssiduites.filters) renderTableAssiduites(currentPageAssiduites, assi); renderPaginationButtons(assi); + + try { stats() } catch (_) { } } const moduleimpls = {} @@ -109,6 +111,7 @@ row.appendChild(td) }) + row.addEventListener("contextmenu", openContext); tableBodyAssiduites.appendChild(row); diff --git a/app/templates/assiduites/widgets/tableau_base.j2 b/app/templates/assiduites/widgets/tableau_base.j2 index 5121cb9acc..242d2c0448 100644 --- a/app/templates/assiduites/widgets/tableau_base.j2 +++ b/app/templates/assiduites/widgets/tableau_base.j2 @@ -132,6 +132,11 @@ function renderPaginationButtons(array, assi = true) { const totalPages = Math.ceil(array.length / itemsPerPage); if (totalPages <= 1) { + if (assi) { + paginationContainerAssiduites.innerHTML = "" + } else { + paginationContainerJustificatifs.innerHTML = "" + } return; } @@ -139,14 +144,15 @@ paginationContainerAssiduites.innerHTML = "" paginationContainerAssiduites.querySelector('#paginationAssi')?.addEventListener('change', (e) => { currentPageAssiduites = e.target.value; - renderTableAssiduites(currentPageAssiduites, array); + assiduiteCallBack(array); }) paginationContainerAssiduites.querySelector('.pagination_moins').addEventListener('click', () => { if (currentPageAssiduites > 1) { currentPageAssiduites--; paginationContainerAssiduites.querySelector('#paginationAssi').value = currentPageAssiduites - renderTableAssiduites(currentPageAssiduites, array); + assiduiteCallBack(array); + } }) @@ -154,21 +160,21 @@ if (currentPageAssiduites < totalPages) { currentPageAssiduites++; paginationContainerAssiduites.querySelector('#paginationAssi').value = currentPageAssiduites - renderTableAssiduites(currentPageAssiduites, array); + assiduiteCallBack(array); } }) } else { paginationContainerJustificatifs.innerHTML = "" paginationContainerJustificatifs.querySelector('#paginationJusti')?.addEventListener('change', (e) => { currentPageJustificatifs = e.target.value; - renderTableJustificatifs(currentPageJustificatifs, array); + justificatifCallBack(array); }) paginationContainerJustificatifs.querySelector('.pagination_moins').addEventListener('click', () => { if (currentPageJustificatifs > 1) { currentPageJustificatifs--; paginationContainerJustificatifs.querySelector('#paginationJusti').value = currentPageAssiduites - renderTableJustificatifs(currentPageJustificatifs, array); + justificatifCallBack(array); } }) @@ -176,7 +182,7 @@ if (currentPageJustificatifs < totalPages) { currentPageJustificatifs++; paginationContainerJustificatifs.querySelector('#paginationJusti').value = currentPageAssiduites - renderTableJustificatifs(currentPageJustificatifs, array); + justificatifCallBack(array); } }) } @@ -624,6 +630,8 @@ return "Raison"; case "fichier": return "Fichier"; + case "etudid": + return "Etudiant"; } } @@ -776,7 +784,7 @@ margin-left: 2px !important; } - label { + .filter-body label { display: flex; justify-content: center; align-items: center; diff --git a/app/templates/assiduites/widgets/tableau_justi.j2 b/app/templates/assiduites/widgets/tableau_justi.j2 index c1808640ec..a10041eb8e 100644 --- a/app/templates/assiduites/widgets/tableau_justi.j2 +++ b/app/templates/assiduites/widgets/tableau_justi.j2 @@ -57,6 +57,17 @@ renderPaginationButtons(justi, false); } + + function getEtudiant(id) { + if (id in etuds) { + return etuds[id]; + } + getSingleEtud(id); + + return etuds[id]; + + } + function renderTableJustificatifs(page, justificatifs) { generateTableHead(filterJustificatifs.columns, false) @@ -85,9 +96,13 @@ td.textContent = moment.tz(justificatif[k], TIMEZONE).format(`DD/MM/Y HH:mm`) } else if (k.indexOf('fichier') != -1) { td.textContent = justificatif.fichier ? "Oui" : "Non"; + } else if (k.indexOf('etudid') != -1) { + const e = getEtudiant(justificatif.etudid); + + td.textContent = `${e.prenom.capitalize()} ${e.nom.toUpperCase()}`; } else { - td.textContent = justificatif[k].capitalize() + td.textContent = `${justificatif[k]}`.capitalize() } row.appendChild(td) diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 6a607db793..8b8d559f4c 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -24,7 +24,7 @@

    Scolarité

    Semestres
    Programmes
    - Absences
    + Assiduités
    {% if current_user.has_permission(sco.Permission.ScoUsersAdmin) or current_user.has_permission(sco.Permission.ScoUsersView) @@ -73,6 +73,8 @@ etudid=sco.etud.id) }}">Calendrier
  • Liste
  • +
  • Bilan
  • {% endif %} {# /etud-insidebar #} diff --git a/app/views/assiduites.py b/app/views/assiduites.py index 464b4cc538..8c2fed0c5f 100644 --- a/app/views/assiduites.py +++ b/app/views/assiduites.py @@ -10,7 +10,7 @@ from app.decorators import ( scodoc, permission_required, ) -from app.models import FormSemestre, Identite, ScoDocSiteConfig, Assiduite +from app.models import FormSemestre, Identite, ScoDocSiteConfig, Assiduite, Departement from app.views import assiduites_bp as bp from app.views import ScoData @@ -125,36 +125,49 @@ class HTMLBuilder: @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"], + javascripts=[ + "js/assiduites.js", + "libjs/moment.new.min.js", + "libjs/moment-timezone.js", + ], + cssstyles=[ + "css/assiduites.css", + ], ), """

    Traitement des assiduités

    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). + le semestre concerné (saisie par jour ou saisie différée).

    """, ] H.append( """

    Pour signaler, annuler ou justifier une assiduité pour un seul étudiant, - choisissez d'abord concerné:

    """ + choisissez d'abord le concerné:

    """ ) 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""" -

    Billets d'absence

    - - """ - ) + # if current_user.has_permission( + # Permission.ScoAbsChange + # ) and sco_preferences.get_preference("handle_billets_abs"): + # H.append( + # f""" + #

    Billets d'absence

    + # + # """ + # ) + + H.append( + render_template( + "assiduites/pages/bilan_dept.j2", + dept_id=g.scodoc_dept_id, + annee=scu.annee_scolaire(), + ), + ) H.append(html_sco_header.sco_footer()) return "\n".join(H) @@ -269,6 +282,60 @@ def liste_assiduites_etud(): ).build() +@bp.route("/BilanEtud") +@scodoc +@permission_required(Permission.ScoAbsChange) +def bilan_etud(): + """ + bilan_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="Bilan de l'assiduité étudiante", + init_qtip=True, + javascripts=[ + "js/assiduites.js", + "libjs/moment.new.min.js", + "libjs/moment-timezone.js", + ], + cssstyles=CSSSTYLES + + [ + "css/assiduites.css", + ], + ) + + date_debut: str = f"{scu.annee_scolaire()}-09-01" + date_fin: str = f"{scu.annee_scolaire()+1}-06-30" + + assi_metric = { + "H.": "heure", + "J.": "journee", + "1/2 J.": "demi", + }.get(sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id)) + + return HTMLBuilder( + header, + render_template( + "assiduites/pages/bilan_etud.j2", + sco=ScoData(etud), + date_debut=date_debut, + date_fin=date_fin, + assi_metric=assi_metric, + assi_seuil=_get_seuil(), + ), + ).build() + + @bp.route("/AjoutJustificatifEtud") @scodoc @permission_required(Permission.ScoAbsChange) @@ -549,7 +616,7 @@ def get_etat_abs_date(): etat = scu.EtatAssiduite.inverse().get(assi.etat).name etudiant = { - "nom": f'{etud["nomprenom"]}', + "nom": f'{etud["nomprenom"]}', "etat": etat, } @@ -777,3 +844,7 @@ def _str_to_num(string: str): def get_time(label: str, default: str): return _str_to_num(ScoDocSiteConfig.get(label, default)) + + +def _get_seuil(): + return sco_preferences.get_preference("assi_seuil", dept_id=g.scodoc_dept_id) diff --git a/scodoc.py b/scodoc.py index ee1a8edf88..21a66cbcb8 100755 --- a/scodoc.py +++ b/scodoc.py @@ -655,22 +655,16 @@ def profile(host, port, length, profile_dir): "-m", "--morning", help="Spécifie l'heure de début des cours format `hh:mm`", - default="Heure configurée dans la configuration générale / 08:00 sinon", - 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="Heure configurée dans la configuration générale / 13:00 sinon", - show_default=True, ) @click.option( "-e", "--evening", help="Spécifie l'heure de fin des cours format `hh:mm`", - default="Heure configurée dans la configuration générale / 18:00 sinon", - show_default=True, ) @with_appcontext def migrate_abs_to_assiduites( diff --git a/tools/migrate_abs_to_assiduites.py b/tools/migrate_abs_to_assiduites.py index b64586098b..3860e18aa9 100644 --- a/tools/migrate_abs_to_assiduites.py +++ b/tools/migrate_abs_to_assiduites.py @@ -228,22 +228,22 @@ def migrate_abs_to_assiduites( _glob.DEBUG = debug if morning is None: - _glob.MORNING = ScoDocSiteConfig.get("assi_morning_time", time(8, 0)) - else: - morning: list[str] = morning.split(":") - _glob.MORNING = time(int(morning[0]), int(morning[1])) + morning = ScoDocSiteConfig.get("assi_morning_time", time(8, 0)) + + morning: list[str] = morning.split(":") + _glob.MORNING = time(int(morning[0]), int(morning[1])) if noon is None: - _glob.NOON = ScoDocSiteConfig.get("assi_lunch_time", time(13, 0)) - else: - noon: list[str] = noon.split(":") - _glob.NOON = time(int(noon[0]), int(noon[1])) + noon = ScoDocSiteConfig.get("assi_lunch_time", time(13, 0)) + + noon: list[str] = noon.split(":") + _glob.NOON = time(int(noon[0]), int(noon[1])) if evening is None: - _glob.EVENING = ScoDocSiteConfig.get("assi_afternoon_time", time(18, 0)) - else: - evening: list[str] = evening.split(":") - _glob.EVENING = time(int(evening[0]), int(evening[1])) + evening = ScoDocSiteConfig.get("assi_afternoon_time", time(18, 0)) + + evening: list[str] = evening.split(":") + _glob.EVENING = time(int(evening[0]), int(evening[1])) if dept is None: prof_total = Profiler("MigrationTotal") From 015ec66aec9fa21c58e0c7cbf0b38c722b8681f0 Mon Sep 17 00:00:00 2001 From: IDK Date: Tue, 27 Jun 2023 23:22:32 +0200 Subject: [PATCH 083/101] Nouvelle gestion RCUE --- app/but/rcue.py | 243 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 app/but/rcue.py diff --git a/app/but/rcue.py b/app/but/rcue.py new file mode 100644 index 0000000000..26763468c3 --- /dev/null +++ b/app/but/rcue.py @@ -0,0 +1,243 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Jury BUT: un RCUE, ou Regroupe Cohérent d'UEs +""" +from typing import Union +from flask_sqlalchemy.query import Query + +from app.comp.res_but import ResultatsSemestreBUT +from app.models import ( + ApcNiveau, + ApcValidationRCUE, + Identite, + ScolarFormSemestreValidation, + UniteEns, +) +from app.scodoc import codes_cursus +from app.scodoc.codes_cursus import BUT_CODES_ORDER + + +class RegroupementCoherentUE: + """Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs + de la même année (BUT1,2,3) liées au *même niveau de compétence*. + + La moyenne (10/20) au RCUE déclenche la compensation des UEs. + """ + + def __init__( + self, + etud: Identite, + niveau: ApcNiveau, + res_pair: ResultatsSemestreBUT, + res_impair: ResultatsSemestreBUT, + semestre_id_impair: int, + cur_ues_pair: list[UniteEns], + cur_ues_impair: list[UniteEns], + ): + """ + res_pair, res_impair: résultats des formsemestre de l'année en cours, ou None + cur_ues_pair, cur_ues_impair: ues auxquelles l'étudiant est inscrit cette année + """ + self.etud: Identite = etud + self.niveau: ApcNiveau = niveau + "Le niveau de compétences de ce RCUE" + # Chercher l'UE en cours pour pair, impair + # une UE à laquelle l'étudiant est inscrit (non dispensé) + # dans l'un des formsemestre en cours + ues = [ue for ue in cur_ues_pair if ue.niveau_competence_id == niveau.id] + self.ue_cur_pair = ues[0] if ues else None + "UE paire en cours" + ues = [ue for ue in cur_ues_impair if ue.niveau_competence_id == niveau.id] + self.ue_cur_impair = ues[0] if ues else None + "UE impaire en cours" + + self.validation_ue_cur_pair = ( + ScolarFormSemestreValidation.query.filter_by( + etudid=etud.id, + formsemestre_id=res_pair.formsemestre.id, + ue_id=self.ue_cur_pair, + ).first() + if self.ue_cur_pair + else None + ) + self.validation_ue_cur_impair = ( + ScolarFormSemestreValidation.query.filter_by( + etudid=etud.id, + formsemestre_id=res_impair.formsemestre.id, + ue_id=self.ue_cur_impair, + ).first() + if self.ue_cur_impair + else None + ) + + # Autres validations pour l'UE paire + self.validation_ue_best_pair = _best_autre_ue_validation( + etud.id, + niveau.id, + semestre_id_impair + 1, + self.ue_cur_pair.id if self.ue_cur_pair else None, + ) + self.validation_ue_best_impair = _best_autre_ue_validation( + etud.id, + niveau.id, + semestre_id_impair, + self.ue_cur_impair.id if self.ue_cur_impair else None, + ) + + # Suis-je complet ? + self.complete = (self.ue_cur_pair or self.validation_ue_best_pair) and ( + self.ue_cur_impair or self.validation_ue_best_impair + ) + if not self.complete: + self.moy_rcue = None + + # Stocke les moyennes d'UE + if self.ue_cur_impair: + ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id) + self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée + self.ue_1 = self.ue_cur_impair + elif self.validation_ue_best_impair: + self.moy_ue_1 = self.validation_ue_best_pair.moy_ue + self.ue_1 = self.ue_cur_impair + else: + self.moy_ue_1, self.ue_1 = None, None + self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0 + + if self.ue_cur_pair: + ue_status = res_pair.get_etud_ue_status(etud.id, self.ue_cur_pair.id) + self.moy_ue_2 = ue_status["moy"] if ue_status else None # avec capitalisée + self.ue_2 = self.ue_cur_pair + elif self.validation_ue_best_pair: + self.moy_ue_2 = self.validation_ue_best_pair.moy_ue + self.ue_2 = self.validation_ue_best_pair.ue + else: + self.moy_ue_2, self.ue_2 = None, None + self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0 + + # Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées ou antérieures) + if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None): + # Moyenne RCUE (les pondérations par défaut sont 1.) + self.moy_rcue = ( + self.moy_ue_1 * self.ue_1.coef_rcue + + self.moy_ue_2 * self.ue_2.coef_rcue + ) / (self.ue_1.coef_rcue + self.ue_2.coef_rcue) + else: + self.moy_rcue = None + + def __repr__(self) -> str: + return f"""<{self.__class__.__name__} { + self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) { + self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})>""" + + def __str__(self) -> str: + return f"""RCUE { + self.ue_1.acronyme if self.ue_1 else "?"}({self.moy_ue_1}) + { + self.ue_2.acronyme if self.ue_2 else "?"}({self.moy_ue_2})""" + + def query_validations( + self, + ) -> Query: # list[ApcValidationRCUE] + """Les validations de jury enregistrées pour ce RCUE""" + niveau = self.ue_2.niveau_competence + + return ( + ApcValidationRCUE.query.filter_by( + etudid=self.etud.id, + ) + .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id) + .join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id) + .filter(ApcNiveau.id == niveau.id) + ) + + def other_ue(self, ue: UniteEns) -> UniteEns: + """L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError""" + if ue.id == self.ue_1.id: + return self.ue_2 + elif ue.id == self.ue_2.id: + return self.ue_1 + raise ValueError(f"ue {ue} hors RCUE {self}") + + def est_enregistre(self) -> bool: + """Vrai si ce RCUE, donc le niveau de compétences correspondant + a une décision jury enregistrée + """ + return self.query_validations().count() > 0 + + def est_compensable(self): + """Vrai si ce RCUE est validable (uniquement) par compensation + c'est à dire que sa moyenne est > 10 avec une UE < 10. + Note: si ADM, est_compensable est faux. + """ + return ( + (self.moy_rcue is not None) + and (self.moy_rcue > codes_cursus.BUT_BARRE_RCUE) + and ( + (self.moy_ue_1_val < codes_cursus.NOTES_BARRE_GEN) + or (self.moy_ue_2_val < codes_cursus.NOTES_BARRE_GEN) + ) + ) + + def est_suffisant(self) -> bool: + """Vrai si ce RCUE est > 8""" + return (self.moy_rcue is not None) and ( + self.moy_rcue > codes_cursus.BUT_RCUE_SUFFISANT + ) + + def est_validable(self) -> bool: + """Vrai si ce RCUE satisfait les conditions pour être validé, + c'est à dire que la moyenne des UE qui le constituent soit > 10 + """ + return (self.moy_rcue is not None) and ( + self.moy_rcue > codes_cursus.BUT_BARRE_RCUE + ) + + def code_valide(self) -> Union[ApcValidationRCUE, None]: + "Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None" + validation = self.query_validations().first() + if (validation is not None) and ( + validation.code in codes_cursus.CODES_RCUE_VALIDES + ): + return validation + return None + + +def _best_autre_ue_validation( + etudid: int, niveau_id: int, semestre_id: int, ue_id: int +) -> ScolarFormSemestreValidation: + """La "meilleure" validation validante d'UE pour ce niveau/semestre""" + validations = ( + ScolarFormSemestreValidation.query.filter_by(etudid=etudid) + .join(UniteEns) + .filter_by(semestre_idx=semestre_id + 1) + .join(ApcNiveau) + .filter(ApcNiveau.id == niveau_id) + ) + validations = [v for v in validations if codes_cursus.code_ue_validant(v.code)] + # Elimine l'UE en cours si elle existe + if ue_id is not None: + validations = [v for v in validations if v.ue_id != ue_id] + validations = sorted(validations, key=lambda v: BUT_CODES_ORDER.get(v.code, 0)) + return validations[-1] if validations else None + + +# def compute_ues_by_niveau( +# niveaux: list[ApcNiveau], +# ) -> dict[int, tuple[list[UniteEns], list[UniteEns]]]: +# """UEs à valider cette année pour cet étudiant, selon son parcours. +# Considérer les UEs associées aux niveaux et non celles des formsemestres +# en cours. Notez que même si l'étudiant n'est pas inscrit ("dispensé") à une UE +# dans le formsemestre origine, elle doit apparaitre sur la page jury. +# Return: { niveau_id : ( [ues impair], [ues pair]) } +# """ +# # Les UEs associées à ce niveau, toutes formations confondues +# return { +# niveau.id: ( +# [ue for ue in niveau.ues if ue.semestre_idx % 2], +# [ue for ue in niveau.ues if not (ue.semestre_idx % 2)], +# ) +# for niveau in niveaux +# } From b78f8e19a16fb2ff3bf38ed2cfe3cb061569f214 Mon Sep 17 00:00:00 2001 From: IDK Date: Wed, 28 Jun 2023 21:22:39 +0200 Subject: [PATCH 084/101] WIP: nouveaux RCUEs --- app/but/rcue.py | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/app/but/rcue.py b/app/but/rcue.py index 26763468c3..b7cf2711e5 100644 --- a/app/but/rcue.py +++ b/app/but/rcue.py @@ -42,6 +42,8 @@ class RegroupementCoherentUE: res_pair, res_impair: résultats des formsemestre de l'année en cours, ou None cur_ues_pair, cur_ues_impair: ues auxquelles l'étudiant est inscrit cette année """ + self.semestre_id_impair = semestre_id_impair + self.semestre_id_pair = semestre_id_impair + 1 self.etud: Identite = etud self.niveau: ApcNiveau = niveau "Le niveau de compétences de ce RCUE" @@ -59,7 +61,7 @@ class RegroupementCoherentUE: ScolarFormSemestreValidation.query.filter_by( etudid=etud.id, formsemestre_id=res_pair.formsemestre.id, - ue_id=self.ue_cur_pair, + ue_id=self.ue_cur_pair.id, ).first() if self.ue_cur_pair else None @@ -68,27 +70,27 @@ class RegroupementCoherentUE: ScolarFormSemestreValidation.query.filter_by( etudid=etud.id, formsemestre_id=res_impair.formsemestre.id, - ue_id=self.ue_cur_impair, + ue_id=self.ue_cur_impair.id, ).first() if self.ue_cur_impair else None ) # Autres validations pour l'UE paire - self.validation_ue_best_pair = _best_autre_ue_validation( + self.validation_ue_best_pair = best_autre_ue_validation( etud.id, niveau.id, semestre_id_impair + 1, - self.ue_cur_pair.id if self.ue_cur_pair else None, + res_pair.formsemestre.id if (res_pair and self.ue_cur_pair) else None, ) - self.validation_ue_best_impair = _best_autre_ue_validation( + self.validation_ue_best_impair = best_autre_ue_validation( etud.id, niveau.id, semestre_id_impair, - self.ue_cur_impair.id if self.ue_cur_impair else None, + res_impair.formsemestre.id if (res_impair and self.ue_cur_impair) else None, ) - # Suis-je complet ? + # Suis-je complet ? (= en cours ou validé sur les deux moitiés) self.complete = (self.ue_cur_pair or self.validation_ue_best_pair) and ( self.ue_cur_impair or self.validation_ue_best_impair ) @@ -96,21 +98,31 @@ class RegroupementCoherentUE: self.moy_rcue = None # Stocke les moyennes d'UE + self.res_impair = None + "résultats formsemestre de l'UE si elle est courante, None sinon" + self.ue_status_impair = None if self.ue_cur_impair: ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id) self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée self.ue_1 = self.ue_cur_impair + self.res_impair = res_impair + self.ue_status_impair = ue_status elif self.validation_ue_best_impair: - self.moy_ue_1 = self.validation_ue_best_pair.moy_ue - self.ue_1 = self.ue_cur_impair + self.moy_ue_1 = self.validation_ue_best_impair.moy_ue + self.ue_1 = self.validation_ue_best_impair.ue else: self.moy_ue_1, self.ue_1 = None, None self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0 + self.res_pair = None + "résultats formsemestre de l'UE si elle est courante, None sinon" + self.ue_status_pair = None if self.ue_cur_pair: ue_status = res_pair.get_etud_ue_status(etud.id, self.ue_cur_pair.id) self.moy_ue_2 = ue_status["moy"] if ue_status else None # avec capitalisée self.ue_2 = self.ue_cur_pair + self.res_pair = res_pair + self.ue_status_pair = ue_status elif self.validation_ue_best_pair: self.moy_ue_2 = self.validation_ue_best_pair.moy_ue self.ue_2 = self.validation_ue_best_pair.ue @@ -142,15 +154,13 @@ class RegroupementCoherentUE: self, ) -> Query: # list[ApcValidationRCUE] """Les validations de jury enregistrées pour ce RCUE""" - niveau = self.ue_2.niveau_competence - return ( ApcValidationRCUE.query.filter_by( etudid=self.etud.id, ) .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id) .join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id) - .filter(ApcNiveau.id == niveau.id) + .filter(ApcNiveau.id == self.niveau.id) ) def other_ue(self, ue: UniteEns) -> UniteEns: @@ -205,21 +215,21 @@ class RegroupementCoherentUE: return None -def _best_autre_ue_validation( - etudid: int, niveau_id: int, semestre_id: int, ue_id: int +def best_autre_ue_validation( + etudid: int, niveau_id: int, semestre_id: int, formsemestre_id: int ) -> ScolarFormSemestreValidation: """La "meilleure" validation validante d'UE pour ce niveau/semestre""" validations = ( ScolarFormSemestreValidation.query.filter_by(etudid=etudid) .join(UniteEns) - .filter_by(semestre_idx=semestre_id + 1) + .filter_by(semestre_idx=semestre_id) .join(ApcNiveau) .filter(ApcNiveau.id == niveau_id) ) validations = [v for v in validations if codes_cursus.code_ue_validant(v.code)] # Elimine l'UE en cours si elle existe - if ue_id is not None: - validations = [v for v in validations if v.ue_id != ue_id] + if formsemestre_id is not None: + validations = [v for v in validations if v.formsemestre_id != formsemestre_id] validations = sorted(validations, key=lambda v: BUT_CODES_ORDER.get(v.code, 0)) return validations[-1] if validations else None From b4c10146a956393e60e2b88c482fd573592726d7 Mon Sep 17 00:00:00 2001 From: IDK Date: Wed, 28 Jun 2023 21:25:01 +0200 Subject: [PATCH 085/101] Adapte les tests unitaires jury BUT --- tests/unit/yaml_setup_but.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/yaml_setup_but.py b/tests/unit/yaml_setup_but.py index 6532cce1e8..606fa14d18 100644 --- a/tests/unit/yaml_setup_but.py +++ b/tests/unit/yaml_setup_but.py @@ -247,7 +247,7 @@ def but_compare_decisions_annee(deca: DecisionsProposeesAnnee, deca_att: dict): but_check_decisions_ues(deca.decisions_ues, deca_att["decisions_ues"]) if "nb_rcues_annee" in deca_att: - assert deca_att["nb_rcues_annee"] == len(deca.rcues_annee) + assert deca_att["nb_rcues_annee"] == len(deca.get_decisions_rcues_annee()) if "decisions_rcues" in deca_att: but_check_decisions_rcues( @@ -282,14 +282,12 @@ def check_deca_fields(formsemestre: FormSemestre, etud: Identite = None): .filter(UniteEns.semestre_idx == formsemestre.semestre_id) .all() ) + assert len(ues) == len(deca.decisions_rcue_by_niveau.values()) if formsemestre.semestre_id % 2: assert deca.formsemestre_impair == formsemestre - assert ues == deca.ues_impair else: assert deca.formsemestre_pair == formsemestre - assert ues == deca.ues_pair assert deca.inscription_etat == scu.INSCRIT - assert deca.inscription_etat_impair == scu.INSCRIT assert (deca.parcour is None) or ( deca.parcour.id in {p.id for p in formsemestre.parcours} ) From d38f233c21bad7948d6d443635d5cde330ff94dc Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 28 Jun 2023 21:25:38 +0200 Subject: [PATCH 086/101] WIP: nouvelles gestion jury BUT. --- app/but/cursus_but.py | 1 - app/but/jury_but.py | 897 ++++++++---------- app/but/jury_but_pv.py | 2 +- app/but/jury_but_results.py | 6 +- app/but/jury_but_view.py | 39 +- app/models/but_validations.py | 140 +-- app/models/events.py | 7 +- app/models/validations.py | 2 +- app/scodoc/sco_formsemestre_edit.py | 8 +- app/scodoc/sco_utils.py | 2 +- app/static/js/scodoc.js | 491 +++++----- app/tables/jury_recap.py | 13 +- app/templates/but/documentation_codes_jury.j2 | 12 + app/templates/pn/ue_infos.j2 | 1 + app/views/notes.py | 48 +- sco_version.py | 2 +- 16 files changed, 749 insertions(+), 922 deletions(-) diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 5b5b6f29c2..5eda000dca 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -36,7 +36,6 @@ from app.models import Scolog, ScolarAutorisationInscription from app.models.but_validations import ( ApcValidationAnnee, ApcValidationRCUE, - RegroupementCoherentUE, ) from app.models.etudiants import Identite from app.models.formations import Formation diff --git a/app/but/jury_but.py b/app/but/jury_but.py index bb73af4311..d4e7dd8cf2 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -25,8 +25,8 @@ Utilisation: - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE, ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années) - Si RCUE validé, on déclenche d'éventuelles validations: - ("La validation des deux UE du niveau d’une compétence emporte la validation - de l’ensemble des UE du niveau inférieur de cette même compétence.") + ("La validation des deux UE du niveau d'une compétence emporte la validation + de l'ensemble des UE du niveau inférieur de cette même compétence.") Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`. @@ -60,7 +60,6 @@ DecisionsProposeesUE: décisions de jury sur une UE du BUT """ from datetime import datetime import html -from operator import attrgetter import re from typing import Union @@ -71,6 +70,7 @@ from app import db from app import log from app.but import cursus_but from app.but.cursus_but import EtudCursusBUT +from app.but.rcue import RegroupementCoherentUE from app.comp.res_but import ResultatsSemestreBUT from app.comp import res_sem @@ -83,7 +83,6 @@ from app.models import Scolog, ScolarAutorisationInscription from app.models.but_validations import ( ApcValidationAnnee, ApcValidationRCUE, - RegroupementCoherentUE, ) from app.models.etudiants import Identite from app.models.formations import Formation @@ -186,10 +185,10 @@ class DecisionsProposeesAnnee(DecisionsProposees): """Décisions de jury sur une année (ETP) du BUT Le texte: - La poursuite d'études dans un semestre pair d’une même année est de droit - pour tout étudiant. La poursuite d’études dans un semestre impair est - possible si et seulement si l’étudiant a obtenu : - - la moyenne à plus de la moitié des regroupements cohérents d’UE; + La poursuite d'études dans un semestre pair d'une même année est de droit + pour tout étudiant. La poursuite d'études dans un semestre impair est + possible si et seulement si l'étudiant a obtenu : + - la moyenne à plus de la moitié des regroupements cohérents d'UE; - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE. La poursuite d'études dans le semestre 5 nécessite de plus la validation de toutes les UE des semestres 1 et 2 dans les conditions de validation @@ -220,129 +219,102 @@ class DecisionsProposeesAnnee(DecisionsProposees): raise ScoNoReferentielCompetences(formation=formsemestre.formation) super().__init__(etud=etud) self.formsemestre = formsemestre - "le formsemestre utilisé pour construire ce deca" - self.formsemestre_id = formsemestre.id - "l'id du formsemestre utilisé pour construire ce deca" - formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre) - assert ( - (formsemestre_pair is None) - or (formsemestre_impair is None) - or ( - ((formsemestre_pair.semestre_id - formsemestre_impair.semestre_id) == 1) - and ( - formsemestre_pair.formation.referentiel_competence_id - == formsemestre_impair.formation.referentiel_competence_id - ) - ) - ) - # Si les années scolaires sont distinctes, on est "à cheval" - self.a_cheval = ( - formsemestre_impair - and formsemestre_pair - and formsemestre_impair.annee_scolaire() - != formsemestre_pair.annee_scolaire() - ) - "vrai si on groupe deux semestres d'années scolaires différentes" + "le formsemestre d'origine, utilisé pour construire ce deca" # Si on part d'un semestre IMPAIR, il n'y aura pas de décision année proposée # (mais on pourra évidemment valider des UE et même des RCUE) self.jury_annuel: bool = formsemestre.semestre_id in (2, 4, 6) "vrai si jury de fin d'année scolaire (sem. pair, propose code annuel)" - - self.formsemestre_impair = formsemestre_impair - "le 1er semestre du groupement (S1, S3, S5)" - self.formsemestre_pair = formsemestre_pair - "le second formsemestre (S2, S4, S6), de la même année scolaire ou d'une précédente" - formsemestre_last = formsemestre_pair or formsemestre_impair - "le formsemestre le plus avancé (en indice de semestre) dans le groupement" - - self.annee_but = (formsemestre_last.semestre_id + 1) // 2 + self.annee_but = (formsemestre.semestre_id + 1) // 2 "le rang de l'année dans le BUT: 1, 2, 3" assert self.annee_but in (1, 2, 3) - self.autorisations_recorded = False - "vrai si on a enregistré l'autorisation de passage" - self.rcues_annee = [] - """RCUEs de l'année - (peuvent concerner l'année scolaire antérieur pour les redoublants - avec UE capitalisées) - """ - self.inscription_etat = etud.inscription_etat(formsemestre_last.id) - "état de l'inscription dans le semestre le plus avancé (pair si année complète)" - self.inscription_etat_pair = ( - etud.inscription_etat(formsemestre_pair.id) - if formsemestre_pair is not None - else None + # ---- inscription et parcours + inscription = formsemestre.etuds_inscriptions.get(etud.id) + if inscription is None: + raise ValueError("Etudiant non inscrit au semestre") + self.inscription_etat = inscription.etat + "état de l'inscription dans le semestre origine" + self.parcour = inscription.parcour + "Le parcours considéré, qui est celui de l'étudiant dans le formsemestre origine" + self.formsemestre_impair, self.formsemestre_pair = self.comp_formsemestres( + formsemestre ) - self.inscription_etat_impair = ( - etud.inscription_etat(formsemestre_impair.id) - if formsemestre_impair is not None - else None - ) - - if self.formsemestre_impair is not None: - self.validation = ( - ApcValidationAnnee.query.filter_by( - etudid=self.etud.id, - ordre=self.annee_but, - ) - .join(Formation) - .filter_by(formation_code=self.formsemestre.formation.formation_code) - .first() - ) - else: - self.validation = None - if self.validation is not None: - self.code_valide = self.validation.code - self.parcour = None - "Le parcours considéré (celui du semestre pair, ou à défaut impair)" - if self.formsemestre_pair is not None: - self.res_pair: ResultatsSemestreBUT = res_sem.load_formsemestre_results( - self.formsemestre_pair - ) - else: - self.res_pair = None - if self.formsemestre_impair is not None: - self.res_impair: ResultatsSemestreBUT = res_sem.load_formsemestre_results( - self.formsemestre_impair - ) - else: - self.res_impair = None - - self.ues_impair, self.ues_pair = self.compute_ues_annee() # pylint: disable=all - self.decisions_ues = { - ue.id: DecisionsProposeesUE( - etud, formsemestre_impair, ue, self.inscription_etat_impair - ) - for ue in self.ues_impair - } - "{ue_id : DecisionsProposeesUE} pour toutes les UE de l'année" - self.decisions_ues.update( - { - ue.id: DecisionsProposeesUE( - etud, formsemestre_pair, ue, self.inscription_etat_pair - ) - for ue in self.ues_pair - } - ) - self.rcues_annee = self.compute_rcues_annee() - - formation = ( - self.formsemestre_impair.formation + # ---- résultats et UEs en cours cette année: + self.res_impair: ResultatsSemestreBUT = ( + res_sem.load_formsemestre_results(self.formsemestre_impair) if self.formsemestre_impair - else self.formsemestre_pair.formation + else None ) - ( - parcours, - niveaux_by_parcours, - ) = formation.referentiel_competence.get_niveaux_by_parcours( - self.annee_but, [self.parcour] if self.parcour else None + self.res_pair: ResultatsSemestreBUT = ( + res_sem.load_formsemestre_results(self.formsemestre_pair) + if self.formsemestre_pair + else None + ) + self.cur_ues_impair = ( + list_ue_parcour_etud( + self.formsemestre_impair, self.etud, self.parcour, self.res_impair + ) + if self.formsemestre_impair + else [] + ) + self.cur_ues_pair = ( + list_ue_parcour_etud( + self.formsemestre_pair, self.etud, self.parcour, self.res_pair + ) + if self.formsemestre_pair + else [] + ) + # ---- Niveaux et RCUEs + niveaux_by_parcours = ( + formsemestre.formation.referentiel_competence.get_niveaux_by_parcours( + self.annee_but, [self.parcour] if self.parcour else None + )[1] ) self.niveaux_competences = niveaux_by_parcours["TC"] + ( niveaux_by_parcours[self.parcour.id] if self.parcour else [] ) - """liste non triée des niveaux de compétences associés à cette année pour cet étudiant. + """Les niveaux à valider pour cet étudiant dans cette année, compte tenu de son parcours. + Liste non triée des niveaux de compétences associés à cette année pour cet étudiant. = niveaux du tronc commun + niveau du parcours de l'étudiant. """ - self.decisions_rcue_by_niveau = self.compute_decisions_niveaux() + self.rcue_by_niveau = self._compute_rcues_annee() + """RCUEs de l'année + (peuvent être construits avec des UEs validées antérieurement: redoublants + avec UEs capitalisées, validation "antérieures") + """ + # ---- Décision année et autorisation + self.autorisations_recorded = False + "vrai si on a enregistré l'autorisation de passage" + self.validation = ( + ApcValidationAnnee.query.filter_by( + etudid=self.etud.id, + ordre=self.annee_but, + ) + .join(Formation) + .filter_by(formation_code=self.formsemestre.formation.formation_code) + .first() + ) + "Validation actuellement enregistrée pour cette année BUT" + self.code_valide = self.validation.code if self.validation is not None else None + "Le code jury annuel enregistré, ou None" + + # ---- Décisions d'UEs + self.decisions_ues = { + rcue.ue_1.id: DecisionsProposeesUE( + etud, self.formsemestre_impair, rcue, False, self.inscription_etat + ) + for rcue in self.rcue_by_niveau.values() + if rcue.ue_1 + } + self.decisions_ues.update( + { + rcue.ue_2.id: DecisionsProposeesUE( + etud, self.formsemestre_pair, rcue, True, self.inscription_etat + ) + for rcue in self.rcue_by_niveau.values() + if rcue.ue_2 + } + ) + self.decisions_rcue_by_niveau = self._compute_decisions_niveaux() "les décisions rcue associées aux niveau_id" self.dec_rcue_by_ue = self._dec_rcue_by_ue() "{ ue_id : DecisionsProposeesRCUE } pour toutes les UE associées à un niveau" @@ -371,12 +343,12 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0) "Vrai si peut passer dans l'année BUT suivante: plus de la moitié validables et tous > 8" explanation = "" - # Cas particulier du passage en BUT 3: nécessité d’avoir validé toutes les UEs du BUT 1. + # Cas particulier du passage en BUT 3: nécessité d'avoir validé toutes les UEs du BUT 1. if self.passage_de_droit and self.annee_but == 2: inscription = formsemestre.etuds_inscriptions.get(etud.id) if inscription: ues_but1_non_validees = cursus_but.etud_ues_de_but1_non_validees( - etud, formation, inscription.parcour + etud, self.formsemestre.formation, self.parcour ) self.passage_de_droit = not ues_but1_non_validees explanation += ( @@ -434,7 +406,8 @@ class DecisionsProposeesAnnee(DecisionsProposees): sco_codes.ADJ, sco_codes.PASD, # voir #488 (discutable, conventions locales) ] + self.codes - explanation += f""" et {self.nb_rcues_under_8} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8""" + explanation += f""" et {self.nb_rcues_under_8 + } niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8""" # Si l'un des semestres est extérieur, propose ADM if ( @@ -487,7 +460,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): text += "" text += f""" -
  • RCUEs: {html.escape(str(self.rcues_annee))}
  • +
  • RCUEs: {html.escape(str(self.rcue_by_niveau))}
  • nb_competences: {getattr(self, "nb_competences", "-")}
  • nb_validables: {getattr(self, "nb_validables", "-")}
  • codes: {self.codes}
  • @@ -509,177 +482,107 @@ class DecisionsProposeesAnnee(DecisionsProposees): def comp_formsemestres( self, formsemestre: FormSemestre ) -> tuple[FormSemestre, FormSemestre]: - """Les deux formsemestres du niveau auquel appartient formsemestre. - Complète le niveau avec le formsemestre antérieur le plus récent. - L'"autre" formsemestre peut ainsi appartenir à l'année scolaire - antérieure (redoublants). + """Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF) + du niveau auquel appartient formsemestre. + -> S_impair, S_pair + + Si l'origine est impair, S_impair est l'origine et S_pair est None + Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur + suivi par cet étudiant (ou None). """ if not formsemestre.formation.is_apc(): # garde fou return None, None - if formsemestre.semestre_id % 2 == 0: - other_semestre_id = formsemestre.semestre_id - 1 - else: - other_semestre_id = formsemestre.semestre_id + 1 - other_formsemestre = None + if formsemestre.semestre_id % 2: + idx_autre = formsemestre.semestre_id + 1 + else: + idx_autre = formsemestre.semestre_id - 1 + + # Cherche l'autre semestre de la même année scolaire: + autre_formsemestre = None for inscr in self.etud.formsemestre_inscriptions: if ( + (inscr.etat == scu.INSCRIT) + and # Même spécialité BUT (tolère ainsi des variantes de formation) ( inscr.formsemestre.formation.referentiel_competence == formsemestre.formation.referentiel_competence ) # L'autre semestre - and (inscr.formsemestre.semestre_id == other_semestre_id) - # Antérieur - and inscr.formsemestre.date_debut < formsemestre.date_debut - # Et plus le récent possible - and ( - (other_formsemestre is None) - or (other_formsemestre.date_debut < inscr.formsemestre.date_debut) - ) + and (inscr.formsemestre.semestre_id == idx_autre) + # de la même année scolaire + and inscr.formsemestre.annee_scolaire() == formsemestre.annee_scolaire() ): - other_formsemestre = inscr.formsemestre - if formsemestre.semestre_id % 2 == 0: - return other_formsemestre, formsemestre - return formsemestre, other_formsemestre + autre_formsemestre = inscr.formsemestre + break + # autre_formsemestre peut être None + if formsemestre.semestre_id % 2: + return formsemestre, autre_formsemestre + else: + return autre_formsemestre, formsemestre - def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]: - """UEs à valider cette année pour cet étudiant, selon son parcours. - Affecte self.parcour suivant l'inscription de l'étudiant et - ramène [ listes des UE du semestre impair, liste des UE du semestre pair ]. - """ - ues_sems = [] - for formsemestre, res in ( - (self.formsemestre_impair, self.res_impair), - (self.formsemestre_pair, self.res_pair), - ): - if (formsemestre is None) or (not formsemestre.formation.is_apc()): - ues = [] - else: - parcour, ues = list_ue_parcour_etud(formsemestre, self.etud, res) - if parcour is not None: - self.parcour = parcour - ues_sems.append(ues) - return ues_sems + def get_decisions_rcues_annee(self) -> list["DecisionsProposeesRCUE"]: + "Liste des DecisionsProposeesRCUE de l'année, tirée par numéro d'UE" + return self.decisions_rcue_by_niveau.values() - def check_ues_ready_jury(self) -> list[str]: - """Vérifie que les toutes les UEs (hors bonus) de l'année sont - bien associées à des niveaux de compétences. - Renvoie liste vide si ok, sinon liste de message explicatifs - """ - messages = [] - for ue in self.ues_impair + self.ues_pair: - if ue.niveau_competence is None: - messages.append( - f"UE {ue.acronyme} non associée à un niveau de compétence" - ) - if ue.semestre_idx is None: - messages.append( - f"UE {ue.acronyme} n'a pas d'indice de semestre dans la formation" - ) - return messages + def _compute_rcues_annee(self) -> dict[int, RegroupementCoherentUE]: + "calcule tous les RCUEs: { niveau_id : rcue }" + semestre_id_impair = ((self.formsemestre.semestre_id - 1) // 2) * 2 + 1 + return { + niveau.id: RegroupementCoherentUE( + self.etud, + niveau, + self.res_pair, + self.res_impair, + semestre_id_impair, + self.cur_ues_pair, + self.cur_ues_impair, + ) + for niveau in self.niveaux_competences + } - def compute_rcues_annee(self) -> list[RegroupementCoherentUE]: - """Liste des regroupements d'UE à considérer cette année. - On peut avoir un RCUE à cheval sur plusieurs années (redoublants avec UE capitalisées). - Si on n'a pas les deux semestres, aucun RCUE. - """ - if self.formsemestre_pair is None or self.formsemestre_impair is None: - return [] - rcues_annee = [] - ues_impair_sans_rcue = {ue.id for ue in self.ues_impair} - for ue_pair in self.ues_pair: - rcue = None - for ue_impair in self.ues_impair: - if self.a_cheval: - # l'UE paire DOIT être capitalisée pour être utilisée - if ( - self.decisions_ues[ue_pair.id].code_valide - not in CODES_UE_CAPITALISANTS - ): - continue # ignore cette UE antérieure non capitalisée - # et l'UE impaire doit être actuellement meilleure que - # celle éventuellement capitalisée - if ( - self.decisions_ues[ue_impair.id].ue_status - and self.decisions_ues[ue_impair.id].ue_status["is_capitalized"] - ): - continue # ignore cette UE car capitalisée et actuelle moins bonne - if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id: - rcue = RegroupementCoherentUE( - self.etud, - self.formsemestre_impair, - self.decisions_ues[ue_impair.id], - self.formsemestre_pair, - self.decisions_ues[ue_pair.id], - self.inscription_etat, - ) - ues_impair_sans_rcue.discard(ue_impair.id) - break - # if rcue is None and not self.a_cheval: - # raise NoRCUEError(deca=self, ue=ue_pair) - if rcue is not None: - rcues_annee.append(rcue) - # Si jury annuel (pas à cheval), on doit avoir tous les RCUEs: - # if len(ues_impair_sans_rcue) > 0 and not self.a_cheval: - # ue = UniteEns.query.get(ues_impair_sans_rcue.pop()) - # raise NoRCUEError(deca=self, ue=ue) - return rcues_annee - - def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]: + def _compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]: """Pour chaque niveau de compétence de cette année, construit - le DecisionsProposeesRCUE, ou None s'il n'y en a pas - (ne devrait pas arriver car compute_rcues_annee vérifie déjà cela). - + le DecisionsProposeesRCUE à partir du rcue déjà calculé. Appelé à la construction du deca, donc avant décisions manuelles. Return: { niveau_id : DecisionsProposeesRCUE } """ - # Retrouve le RCUE associé à chaque niveau - rc_niveaux = [] - for niveau in self.niveaux_competences: - rcue = None - for rc in self.rcues_annee: - if rc.ue_1.niveau_competence_id == niveau.id: - rcue = rc - break - if rcue is not None: - dec_rcue = DecisionsProposeesRCUE(self, rcue, self.inscription_etat) - rc_niveaux.append((dec_rcue, niveau.id)) - # prévient les UE concernées :-) - self.decisions_ues[dec_rcue.rcue.ue_1.id].set_rcue(dec_rcue.rcue) - self.decisions_ues[dec_rcue.rcue.ue_2.id].set_rcue(dec_rcue.rcue) # Ordonne par numéro d'UE - rc_niveaux.sort(key=lambda x: x[0].rcue.ue_1.numero) - decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux} - return decisions_rcue_by_niveau + niv_rcue = sorted( + self.rcue_by_niveau.items(), + key=lambda x: x[1].ue_1.numero + if x[1].ue_1 + else x[1].ue_2.numero + if x[1].ue_2 + else 0, + ) + return { + niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat) + for (niveau_id, rcue) in niv_rcue + } def _dec_rcue_by_ue(self) -> dict[int, "DecisionsProposeesRCUE"]: """construit dict { ue_id : DecisionsProposeesRCUE } à partir de self.decisions_rcue_by_niveau""" d = {} for dec_rcue in self.decisions_rcue_by_niveau.values(): - d[dec_rcue.rcue.ue_1.id] = dec_rcue - d[dec_rcue.rcue.ue_2.id] = dec_rcue + if dec_rcue.rcue.ue_1: + d[dec_rcue.rcue.ue_1.id] = dec_rcue + if dec_rcue.rcue.ue_2: + d[dec_rcue.rcue.ue_2.id] = dec_rcue return d - def formsemestre_ects(self) -> float: - "ECTS validés dans le formsemestre de départ du deca" - ues = self.ues_impair if self.formsemestre.semestre_id % 2 else self.ues_pair - return sum( - [ - self.decisions_ues[ue.id].ects_acquis() - for ue in ues - if ue.id in self.decisions_ues - ] - ) + def ects_annee(self) -> float: + "ECTS validés dans l'année BUT courante" + return sum([dec_ue.ects_acquis() for dec_ue in self.decisions_ues.values()]) def next_semestre_ids(self, code: str) -> set[int]: """Les indices des semestres dans lequels l'étudiant est autorisé à poursuivre après le semestre courant. """ - # La poursuite d'études dans un semestre pair d’une même année + # La poursuite d'études dans un semestre pair d'une même année # est de droit pour tout étudiant. # Pas de redoublements directs de S_impair vers S_impair # (pourront être traités manuellement) @@ -687,9 +590,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.formsemestre.semestre_id % 2 ) and self.formsemestre.semestre_id < sco_codes.CursusBUT.NB_SEM: return {self.formsemestre.semestre_id + 1} - # La poursuite d’études dans un semestre impair est possible si - # et seulement si l’étudiant a obtenu : - # - la moyenne à plus de la moitié des regroupements cohérents d’UE ; + # La poursuite d'études dans un semestre impair est possible si + # et seulement si l'étudiant a obtenu : + # - la moyenne à plus de la moitié des regroupements cohérents d'UE ; # - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE. # # La condition a paru trop stricte à de nombreux collègues. @@ -914,20 +817,20 @@ class DecisionsProposeesAnnee(DecisionsProposees): pour cette année: décisions d'UE, de RCUE, d'année, et autorisations d'inscription émises. Efface même si étudiant DEM ou DEF. - Si à cheval ou only_one_sem, n'efface que les décisions UE et les + Si only_one_sem, n'efface que les décisions UE et les autorisations de passage du semestre d'origine du deca. Dans tous les cas, efface les validations de l'année en cours. (commite la session.) """ - if only_one_sem or self.a_cheval: + if only_one_sem: # N'efface que les autorisations venant de ce semestre, # et les validations de ses UEs ScolarAutorisationInscription.delete_autorisation_etud( - self.etud.id, self.formsemestre_id + self.etud.id, self.formsemestre.id ) for dec_ue in self.decisions_ues.values(): - if dec_ue.formsemestre.id == self.formsemestre_id: + if dec_ue.formsemestre.id == self.formsemestre.id: dec_ue.erase() else: for dec_ue in self.decisions_ues.values(): @@ -968,7 +871,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): # et autres UEs (en cas de changement d'architecture de formation depuis le jury ?) # for validation in ScolarFormSemestreValidation.query.filter_by( - etudid=self.etud.id, formsemestre_id=self.formsemestre_id + etudid=self.etud.id, formsemestre_id=self.formsemestre.id ): db.session.delete(validation) @@ -1026,14 +929,17 @@ class DecisionsProposeesAnnee(DecisionsProposees): for dec_rcue in self.decisions_rcue_by_niveau.values(): if dec_rcue.code_valide in CODES_RCUE_VALIDES: for ue in (dec_rcue.rcue.ue_1, dec_rcue.rcue.ue_2): - dec_ue = self.decisions_ues.get(ue.id) - if dec_ue: - if dec_ue.code_valide not in CODES_UE_VALIDES: + if ue: + dec_ue = self.decisions_ues.get(ue.id) + if dec_ue: + if dec_ue.code_valide not in CODES_UE_VALIDES: + messages.append( + f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !" + ) + else: messages.append( - f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !" + f"L'UE {ue.acronyme} n'a pas décision (???)" ) - else: - messages.append(f"L'UE {ue.acronyme} n'a pas décision (???)") return messages def valide_diplome(self) -> bool: @@ -1042,26 +948,25 @@ class DecisionsProposeesAnnee(DecisionsProposees): def list_ue_parcour_etud( - formsemestre: FormSemestre, etud: Identite, res: ResultatsSemestreBUT -) -> tuple[ApcParcours, list[UniteEns]]: - """Parcour dans lequel l'étudiant est inscrit, - et liste des UEs à valider pour ce semestre (sans les UE "dispensées") - """ - if res.etuds_parcour_id[etud.id] is None: - parcour = None + formsemestre: FormSemestre, + etud: Identite, + parcour: ApcParcours, + res: ResultatsSemestreBUT, +) -> list[UniteEns]: + """Liste des UEs suivies ce semestre (sans les UE "dispensées")""" + + if parcour is None: # pas de parcour: prend toutes les UEs (non bonus) ues = [ue for ue in res.etud_ues(etud.id) if ue.type == UE_STANDARD] ues.sort(key=lambda u: u.numero) else: - parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id]) ues = ( formsemestre.formation.query_ues_parcour(parcour) .filter(UniteEns.semestre_idx == formsemestre.semestre_id) .order_by(UniteEns.numero) .all() ) - ues = [ue for ue in ues if (etud.id, ue.id) not in res.dispense_ues] - return parcour, ues + return [ue for ue in ues if (etud.id, ue.id) not in res.dispense_ues] class DecisionsProposeesRCUE(DecisionsProposees): @@ -1117,8 +1022,11 @@ class DecisionsProposeesRCUE(DecisionsProposees): self.codes.insert(0, sco_codes.AJ) # Si au moins l'un des semestres est extérieur, propose ADM au cas où if ( - dec_prop_annee.formsemestre_impair.modalite == "EXT" - or dec_prop_annee.formsemestre_pair.modalite == "EXT" + dec_prop_annee.formsemestre_impair + and dec_prop_annee.formsemestre_impair.modalite == "EXT" + ) or ( + dec_prop_annee.formsemestre_pair + and dec_prop_annee.formsemestre_pair.modalite == "EXT" ): self.codes.insert(0, sco_codes.ADM) # S'il y a une décision enregistrée: si elle est plus favorable que celle que l'on @@ -1148,11 +1056,13 @@ class DecisionsProposeesRCUE(DecisionsProposees): """ if self.rcue is None: return False # pas de RCUE a enregistrer + if not (self.rcue.ue_1 and self.rcue.ue_2): + return False # on n'a pas les deux UEs if self.inscription_etat != scu.INSCRIT: return False if code and not code in self.codes: raise ScoValueError( - f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" + f"code RCUE invalide pour {self.rcue}: {html.escape(code)}" ) if code == self.code_valide: self.recorded = True @@ -1166,7 +1076,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): else: self.validation = ApcValidationRCUE( etudid=self.etud.id, - formsemestre_id=self.rcue.formsemestre_2.id, + formsemestre_id=self.deca.formsemestre.id, # origine ue1_id=self.rcue.ue_1.id, ue2_id=self.rcue.ue_2.id, parcours_id=parcours_id, @@ -1190,7 +1100,9 @@ class DecisionsProposeesRCUE(DecisionsProposees): if dec_ue and dec_ue.code_valide not in CODES_UE_VALIDES: log(f"rcue.record: force ADJR sur {dec_ue}") flash( - f"""UEs du RCUE "{dec_ue.ue.niveau_competence.competence.titre}" passées en ADJR""" + f"""UEs du RCUE "{ + dec_ue.ue.niveau_competence.competence.titre + }" passées en ADJR""" ) dec_ue.record(sco_codes.ADJR) @@ -1198,13 +1110,13 @@ class DecisionsProposeesRCUE(DecisionsProposees): if code in CODES_RCUE_VALIDES: self.valide_niveau_inferieur() - if self.rcue.formsemestre_1 is not None: + if self.rcue.res_impair is not None: sco_cache.invalidate_formsemestre( - formsemestre_id=self.rcue.formsemestre_1.id + formsemestre_id=self.rcue.res_impair.formsemestre.id ) - if self.rcue.formsemestre_2 is not None: + if self.rcue.res_pair is not None: sco_cache.invalidate_formsemestre( - formsemestre_id=self.rcue.formsemestre_2.id + formsemestre_id=self.rcue.res_pair.formsemestre.id ) self.code_valide = code # mise à jour état self.recorded = True @@ -1236,13 +1148,13 @@ class DecisionsProposeesRCUE(DecisionsProposees): def valide_niveau_inferieur(self) -> None: """Appelé juste après la validation d'un RCUE. - *La validation des deux UE du niveau d’une compétence emporte la validation de - l’ensemble des UEs du niveau inférieur de cette même compétence.* + *La validation des deux UE du niveau d'une compétence emporte la validation de + l'ensemble des UEs du niveau inférieur de cette même compétence.* """ - if not self.rcue or not self.rcue.ue_1 or not self.rcue.ue_1.niveau_competence: + if not self.rcue: return - competence: ApcCompetence = self.rcue.ue_1.niveau_competence.competence - ordre_inferieur = self.rcue.ue_1.niveau_competence.ordre - 1 + competence: ApcCompetence = self.rcue.niveau.competence + ordre_inferieur = self.rcue.niveau.ordre - 1 if ordre_inferieur < 1: return # pas de niveau inferieur @@ -1259,43 +1171,14 @@ class DecisionsProposeesRCUE(DecisionsProposees): if [v for v in validations_rcue if code_rcue_validant(v.code)]: return # déjà validé - # --- Validations des UEs - ues, ue1, ue2 = self._get_ues_inferieures(competence, ordre_inferieur) - # Pour chaque UE inférieure non validée, valide: - for ue in ues: - validations_ue = ScolarFormSemestreValidation.query.filter_by( - etudid=self.etud.id, ue_id=ue.id - ).all() - if [ - validation - for validation in validations_ue - if sco_codes.code_ue_validant(validation.code) - ]: - continue # on a déjà une validation - # aucune validation validante - validation_ue = validations_ue[0] if validations_ue else None - if validation_ue: - # Modifie validation existante - validation_ue.code = sco_codes.ADSUP - validation_ue.event_date = datetime.now() - if validation_ue.formsemestre_id is not None: - sco_cache.invalidate_formsemestre( - formsemestre_id=validation_ue.formsemestre_id - ) - log(f"updating {validation_ue}") - else: - # Ajoute une validation, - # pas de formsemestre ni de note car pas une capitalisation - validation_ue = ScolarFormSemestreValidation( - etudid=self.etud.id, - code=sco_codes.ADSUP, - ue_id=ue.id, - is_external=True, # pas rattachée à un formsemestre - ) - log(f"recording {validation_ue}") - db.session.add(validation_ue) - - # Valide le RCUE inférieur + # --- Validations des UEs du niveau inférieur + self.valide_ue_inferieures( + self.rcue.semestre_id_impair, ordre_inferieur, competence + ) + self.valide_ue_inferieures( + self.rcue.semestre_id_pair, ordre_inferieur, competence + ) + # --- Valide le RCUE inférieur if validations_rcue: # Met à jour validation existante validation_rcue = validations_rcue[0] @@ -1308,20 +1191,93 @@ class DecisionsProposeesRCUE(DecisionsProposees): sco_cache.invalidate_formsemestre( formsemestre_id=validation_rcue.formsemestre_id ) - elif ue1 and ue2: + else: # Crée nouvelle validation - validation_rcue = ApcValidationRCUE( - etudid=self.etud.id, ue1_id=ue1.id, ue2_id=ue2.id, code=sco_codes.ADSUP + ue1 = self._get_ue_inferieure( + self.rcue.semestre_id_impair, ordre_inferieur, competence ) - db.session.add(validation_rcue) - db.session.commit() - log(f"recording {validation_rcue}") + ue2 = self._get_ue_inferieure( + self.rcue.semestre_id_pair, ordre_inferieur, competence + ) + if ue1 and ue2: + validation_rcue = ApcValidationRCUE( + etudid=self.etud.id, + ue1_id=ue1.id, + ue2_id=ue2.id, + code=sco_codes.ADSUP, + ) + db.session.add(validation_rcue) + db.session.commit() + log(f"recording {validation_rcue}") + self.valide_annee_inferieure() + def _get_ue_inferieure( + self, semestre_id: int, ordre_inferieur: int, competence: ApcCompetence + ) -> UniteEns: + "L'UE de la formation associée au semestre indiqué diu niveau de compétence" + return ( + UniteEns.query.filter_by( + formation_id=self.deca.formsemestre.formation_id, + semestre_idx=semestre_id, + ) + .join(ApcNiveau) + .filter_by(ordre=ordre_inferieur) + .join(ApcCompetence) + .filter_by(id=competence.id) + ).first() + + def valide_ue_inferieures( + self, semestre_id: int, ordre_inferieur: int, competence: ApcCompetence + ): + """Au besoin, enregistre une validation d'UE ADSUP pour le niveau de compétence + semestre_id : l'indice du semestre concerné (le pair ou l'impair) + """ + # Les validations d'UE impaires existantes pour ce niveau inférieur ? + validations_ues: list[ScolarFormSemestreValidation] = ( + ScolarFormSemestreValidation.query.filter_by(etudid=self.etud.id) + .join(UniteEns) + .filter_by(semestre_idx=semestre_id) + .join(ApcNiveau) + .filter_by(ordre=ordre_inferieur) + .join(ApcCompetence) + .filter_by(id=competence.id) + ).all() + validations_ues_validantes = [ + validation + for validation in validations_ues + if sco_codes.code_ue_validant(validation.code) + ] + if not validations_ues_validantes: + # Il faut créer une validation d'UE + # cherche l'UE de notre formation associée à ce niveau + # et warning si il n'y en a pas + ue = self._get_ue_inferieure(semestre_id, ordre_inferieur, competence) + if not ue: + # programme incomplet ou mal paramétré + flash( + f"""Impossible de valider l'UE inférieure du niveau { + ordre_inferieur + } de la compétence {competence.titre} + car elle n'existe pas dans la formation + """, + "warning", + ) + log("valide_ue_inferieures: UE manquante dans la formation") + else: + validation_ue = ScolarFormSemestreValidation( + etudid=self.etud.id, + code=sco_codes.ADSUP, + ue_id=ue.id, + is_external=True, # pas rattachée à un formsemestre + ) + db.session.add(validation_ue) + log(f"recording {validation_ue}") + def valide_annee_inferieure(self) -> None: """Si tous les RCUEs de l'année inférieure sont validés, la valide""" # Indice de l'année inférieure: - annee_courante = self.rcue.ue_1.niveau_competence.annee # "BUT2" + annee_courante = self.rcue.niveau.annee # "BUT2" if not re.match(r"^BUT\d$", annee_courante): log("Warning: valide_annee_inferieure invalid annee_courante") return @@ -1335,7 +1291,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): ordre=annee_inferieure, ) .join(Formation) - .filter_by(formation_code=self.rcue.formsemestre_1.formation.formation_code) + .filter_by(formation_code=self.deca.formsemestre.formation.formation_code) .all() ) if len(validations_annee) > 1: @@ -1352,7 +1308,7 @@ class DecisionsProposeesRCUE(DecisionsProposees): # Liste des niveaux à valider: # ici on sort l'artillerie lourde cursus: EtudCursusBUT = EtudCursusBUT( - self.etud, self.rcue.formsemestre_1.formation + self.etud, self.deca.formsemestre.formation ) niveaux_a_valider = cursus.niveaux_by_annee[annee_inferieure] # Pour chaque niveau, cherche validation RCUE @@ -1375,54 +1331,14 @@ class DecisionsProposeesRCUE(DecisionsProposees): etudid=self.etud.id, ordre=annee_inferieure, code=sco_codes.ADSUP, - formation_id=self.rcue.formsemestre_1.formation_id, - # met cette validation sur l'année scolaire actuelle, pas la précédente (??) - annee_scolaire=self.rcue.formsemestre_1.annee_scolaire(), + formation_id=self.deca.formsemestre.formation_id, + # met cette validation sur l'année scolaire actuelle, pas la précédente + annee_scolaire=self.deca.formsemestre.annee_scolaire(), ) log(f"recording {validation_annee}") db.session.add(validation_annee) db.session.commit() - def _get_ues_inferieures( - self, competence: ApcCompetence, ordre_inferieur: int - ) -> tuple[list[UniteEns], UniteEns, UniteEns]: - """Les UEs de cette formation associées au niveau de compétence inférieur ? - Note: on ne cherche que dans la formation courante, pas les UEs de - même code d'autres formations. - """ - formation: Formation = self.rcue.formsemestre_1.formation - ues: list[UniteEns] = ( - UniteEns.query.filter_by(formation_id=formation.id) - .filter(UniteEns.semestre_idx != None) - .join(ApcNiveau) - .filter_by(ordre=ordre_inferieur) - .join(ApcCompetence) - .filter_by(id=competence.id) - .all() - ) - log(f"valide_niveau_inferieur: {competence} UEs inférieures: {ues}") - if len(ues) != 2: # on n'a pas 2 UE associées au niveau inférieur ! - flash( - "Impossible de valider le niveau de compétence inférieur: pas 2 UEs associées'", - "warning", - ) - return [], None, None - ues_impaires = [ue for ue in ues if ue.semestre_idx % 2] - if len(ues_impaires) != 1: - flash( - "Impossible de valider le niveau de compétence inférieur: pas d'UE impaire associée" - ) - return [], None, None - ue1 = ues_impaires[0] - ues_paires = [ue for ue in ues if not ue.semestre_idx % 2] - if len(ues_paires) != 1: - flash( - "Impossible de valider le niveau de compétence inférieur: pas d'UE paire associée" - ) - return [], None, None - ue2 = ues_paires[0] - return ues, ue1, ue2 - class DecisionsProposeesUE(DecisionsProposees): """Décisions de jury sur une UE du BUT @@ -1437,6 +1353,10 @@ class DecisionsProposeesUE(DecisionsProposees): sinon: ADJ, AJ et proposer toujours: RAT, DEF, ABAN, ADJR, DEM, UEBSL (codes_communs) + + + Le DecisionsProposeesUE peut concerner une UE du formsemestre, ou une validation + antérieure non éditable. """ # Codes toujours proposés sauf si include_communs est faux: @@ -1454,51 +1374,59 @@ class DecisionsProposeesUE(DecisionsProposees): self, etud: Identite, formsemestre: FormSemestre, - ue: UniteEns, + rcue: RegroupementCoherentUE = None, + paire: bool = False, inscription_etat: str = scu.INSCRIT, ): - # Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non) + self.paire = paire + self.ue: UniteEns = rcue.ue_2 if paire else rcue.ue_1 + self.inscription_etat = inscription_etat + # Une UE peut être validée plusieurs fois en cas de redoublement + # (qu'elle soit capitalisée ou non) # mais ici on a restreint au formsemestre donc une seule (prend la première) validation = ScolarFormSemestreValidation.query.filter_by( - etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id + etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=self.ue.id << XXX ).first() super().__init__( etud=etud, code_valide=validation.code if validation is not None else None, ) self.validation = validation + "validation dans le formsemestre courant" self.formsemestre = formsemestre - self.ue: UniteEns = ue - self.rcue: RegroupementCoherentUE = None + self.rcue: RegroupementCoherentUE = rcue "Le rcue auquel est rattaché cette UE, ou None" - self.inscription_etat = inscription_etat - "inscription: I, DEM, DEF dans le semestre de cette UE" + # Editable ou pas ? + # si ue courante, éditable. + self.editable = ( + (self.rcue.ue_cur_pair is not None) + if paire + else (self.rcue.ue_cur_impair is not None) + ) + res: ResultatsSemestreBUT = ( + self.rcue.res_pair if paire else self.rcue.res_impair + ) self.moy_ue = np.NaN self.moy_ue_with_cap = np.NaN self.ue_status = {} - if ue.type == sco_codes.UE_SPORT: - self.explanation = "UE bonus, pas de décision de jury" + if self.ue.type != sco_codes.UE_STANDARD: + self.explanation = "UE non standard, pas de décision de jury BUT" self.codes = [] # aucun code proposé return - if inscription_etat != scu.INSCRIT: + + if res and res.get_etud_etat(etud.id) != scu.INSCRIT: self.validation = None # cache toute validation - self.explanation = "non incrit (dem. ou déf.)" + self.explanation = "non inscrit (dem. ou déf.)" self.codes = [ - sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF + sco_codes.DEM + if res.get_etud_etat(etud.id) == scu.DEMISSION + else sco_codes.DEF ] return # Moyenne de l'UE ? - res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) - # Safety checks: - if not ue.id in res.etud_moy_ue: - self.explanation = "UE sans résultat" - return - if not etud.id in res.etud_moy_ue[ue.id]: - self.explanation = "Étudiant sans résultat dans cette UE" - return - ue_status = res.get_etud_ue_status(etud.id, ue.id) + ue_status = self.rcue.ue_status_pair if paire else self.rcue.ue_status_impair self.moy_ue = ue_status["cur_moy_ue"] self.moy_ue_with_cap = ue_status["moy"] self.ue_status = ue_status @@ -1508,11 +1436,6 @@ class DecisionsProposeesUE(DecisionsProposees): return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide } codes={self.codes} explanation={self.explanation}>""" - def set_rcue(self, rcue: RegroupementCoherentUE): - """Rattache cette UE à un RCUE. Cela peut modifier les codes - proposés par compute_codes() (si compensation)""" - self.rcue = rcue - def compute_codes(self): """Calcul des .codes attribuables et de l'explanation associée""" if self.inscription_etat != scu.INSCRIT: @@ -1607,91 +1530,91 @@ class DecisionsProposeesUE(DecisionsProposees): return 0.0 -class BUTCursusEtud: # WIP TODO - """Validation du cursus d'un étudiant""" +# class BUTCursusEtud: # WIP TODO +# """Validation du cursus d'un étudiant""" - def __init__(self, formsemestre: FormSemestre, etud: Identite): - if formsemestre.formation.referentiel_competence is None: - raise ScoNoReferentielCompetences(formation=formsemestre.formation) - assert len(etud.formsemestre_inscriptions) > 0 - self.formsemestre = formsemestre - self.etud = etud - # - # La dernière inscription en date va donner le parcours (donc les compétences à valider) - self.last_inscription = sorted( - etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut") - )[-1] +# def __init__(self, formsemestre: FormSemestre, etud: Identite): +# if formsemestre.formation.referentiel_competence is None: +# raise ScoNoReferentielCompetences(formation=formsemestre.formation) +# assert len(etud.formsemestre_inscriptions) > 0 +# self.formsemestre = formsemestre +# self.etud = etud +# # +# # La dernière inscription en date va donner le parcours (donc les compétences à valider) +# self.last_inscription = sorted( +# etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut") +# )[-1] - def est_diplomable(self) -> bool: - """Vrai si toutes les compétences sont validables""" - return all( - self.competence_validable(competence) - for competence in self.competences_du_parcours() - ) +# def est_diplomable(self) -> bool: +# """Vrai si toutes les compétences sont validables""" +# return all( +# self.competence_validable(competence) +# for competence in self.competences_du_parcours() +# ) - def est_annee_validee(self, ordre: int) -> bool: - """Vrai si l'année BUT ordre est validée""" - # On cherche les validations d'annee avec le même - # code formation que nous. - return ( - ApcValidationAnnee.query.filter_by( - etudid=self.etud.id, - ordre=ordre, - ) - .join(Formation) - .filter( - Formation.formation_code == self.formsemestre.formation.formation_code - ) - .count() - > 0 - ) +# def est_annee_validee(self, ordre: int) -> bool: +# """Vrai si l'année BUT ordre est validée""" +# # On cherche les validations d'annee avec le même +# # code formation que nous. +# return ( +# ApcValidationAnnee.query.filter_by( +# etudid=self.etud.id, +# ordre=ordre, +# ) +# .join(Formation) +# .filter( +# Formation.formation_code == self.formsemestre.formation.formation_code +# ) +# .count() +# > 0 +# ) - def est_diplome(self) -> bool: - """Vrai si BUT déjà validé""" - # vrai si la troisième année est validée - return self.est_annee_validee(3) +# def est_diplome(self) -> bool: +# """Vrai si BUT déjà validé""" +# # vrai si la troisième année est validée +# return self.est_annee_validee(3) - def competences_du_parcours(self) -> list[ApcCompetence]: - """Construit liste des compétences du parcours, qui doivent être - validées pour obtenir le diplôme. - Le parcours est celui de la dernière inscription. - """ - parcour = self.last_inscription.parcour - query = self.formsemestre.formation.formation.query_competences_parcour(parcour) - if query is None: - return [] - return query.all() +# def competences_du_parcours(self) -> list[ApcCompetence]: +# """Construit liste des compétences du parcours, qui doivent être +# validées pour obtenir le diplôme. +# Le parcours est celui de la dernière inscription. +# """ +# parcour = self.last_inscription.parcour +# query = self.formsemestre.formation.formation.query_competences_parcour(parcour) +# if query is None: +# return [] +# return query.all() - def competence_validee(self, competence: ApcCompetence) -> bool: - """Vrai si la compétence est validée, c'est à dire que tous ses - niveaux sont validés (ApcValidationRCUE). - """ - # XXX A REVOIR - validations = ( - ApcValidationRCUE.query.filter_by(etudid=self.etud.id) - .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) - .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id) - .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id) - ) +# def competence_validee(self, competence: ApcCompetence) -> bool: +# """Vrai si la compétence est validée, c'est à dire que tous ses +# niveaux sont validés (ApcValidationRCUE). +# """ +# # XXX A REVOIR +# validations = ( +# ApcValidationRCUE.query.filter_by(etudid=self.etud.id) +# .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id) +# .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id) +# .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id) +# ) - def competence_validable(self, competence: ApcCompetence): - """Vrai si la compétence est "validable" automatiquement, c'est à dire - que les conditions de notes sont satisfaites pour l'acquisition de - son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision. +# def competence_validable(self, competence: ApcCompetence): +# """Vrai si la compétence est "validable" automatiquement, c'est à dire +# que les conditions de notes sont satisfaites pour l'acquisition de +# son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision. - En vertu de la règle "La validation des deux UE du niveau d’une compétence - emporte la validation de l'ensemble des UE du niveau inférieur de cette - même compétence.", - il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit. - """ - pass +# En vertu de la règle "La validation des deux UE du niveau d'une compétence +# emporte la validation de l'ensemble des UE du niveau inférieur de cette +# même compétence.", +# il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit. +# """ +# pass - def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]: - """La liste des UE à valider si on valide ce niveau. - Ne liste que les UE qui ne sont pas déjà acquises. +# def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]: +# """La liste des UE à valider si on valide ce niveau. +# Ne liste que les UE qui ne sont pas déjà acquises. - Selon la règle donnée par l'arrêté BUT: - * La validation des deux UE du niveau d’une compétence emporte la validation de - l'ensemble des UE du niveau inférieur de cette même compétence. - """ - pass +# Selon la règle donnée par l'arrêté BUT: +# * La validation des deux UE du niveau d'une compétence emporte la validation de +# l'ensemble des UE du niveau inférieur de cette même compétence. +# """ +# pass diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py index e3119b2764..29630af045 100644 --- a/app/but/jury_but_pv.py +++ b/app/but/jury_but_pv.py @@ -153,7 +153,7 @@ def pvjury_table_but( etudid=etud.id, ), "cursus": _descr_cursus_but(etud), - "ects": f"{deca.formsemestre_ects():g}", + "ects": f"{deca.ects_annee():g}", "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-", "niveaux": deca.descr_niveaux_validation(line_sep=line_sep) if deca diff --git a/app/but/jury_but_results.py b/app/but/jury_but_results.py index 00aa649ad8..089b2e772a 100644 --- a/app/but/jury_but_results.py +++ b/app/but/jury_but_results.py @@ -48,9 +48,9 @@ def _get_jury_but_etud_result( # --- Les RCUEs rcue_list = [] if deca: - for rcue in deca.rcues_annee: - dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id) - if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau + for dec_rcue in deca.get_decisions_rcues_annee(): + rcue = dec_rcue.rcue + if rcue.complete: # n'exporte que les RCUEs complets dec_ue1 = deca.decisions_ues[rcue.ue_1.id] dec_ue2 = deca.decisions_ues[rcue.ue_2.id] rcue_dict = { diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 4af88cae57..cfb778152b 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -93,35 +93,25 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
    RCUE
    """ ) - for niveau in deca.niveaux_competences: + for dec_rcue in deca.get_decisions_rcues_annee(): + rcue = dec_rcue.rcue + niveau = rcue.niveau H.append( f"""
    {niveau.competence.titre}
    """ ) - dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None - ues = [ - ue - for ue in deca.ues_impair - if ue.niveau_competence and ue.niveau_competence.id == niveau.id - ] - ue_impair = ues[0] if ues else None - ues = [ - ue - for ue in deca.ues_pair - if ue.niveau_competence and ue.niveau_competence.id == niveau.id - ] - ue_pair = ues[0] if ues else None + ue_impair, ue_pair = rcue.ue_1, rcue.ue_2 # Les UEs à afficher, - # qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant + # qui ues_ro = [ ( ue_impair, - (deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id), + rcue.ue_cur_impair is None, ), ( ue_pair, - deca.a_cheval and deca.formsemestre_id != deca.formsemestre_pair.id, + rcue.ue_cur_pair is None, ), ] # Ordonne selon les dates des 2 semestres considérés: @@ -155,12 +145,13 @@ def _gen_but_select( code_valide: str, disabled: bool = False, klass: str = "", - data: dict = {}, + data: dict = None, code_valide_label: str = "", ) -> str: "Le menu html select avec les codes" # if disabled: # mauvaise idée car le disabled est traité en JS # return f"""
    {code_valide}
    """ + data = data or {} options_htm = "\n".join( [ f"""