From 94347657f6d6e1272c0afe88991dd856f7083b0f Mon Sep 17 00:00:00 2001
From: iziram <matthias.hartmann@iziram.fr>
Date: Mon, 17 Apr 2023 15:43:58 +0200
Subject: [PATCH] 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 000000000..0e61d3856
--- /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 2fd0f9a2e..697361b66 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 ac9e681c2..da9214bfa 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 000000000..ac38e0682
--- /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 000000000..10b4e7851
--- /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
-- 
GitLab