From fd00e2f55d6d4a28fa8039e7cc3db3ae9e7b3c68 Mon Sep 17 00:00:00 2001
From: ilona <ilona@scodoc.org>
Date: Tue, 31 Dec 2024 20:32:13 +0100
Subject: [PATCH] WIP: fonctions d'import de semestres monomodules: import des
 images

---
 app/forms/formsemestre/edit_description.py    |  8 ++-
 app/formsemestre/import_from_descr.py         | 43 +++++++------
 .../formsemestre/import_from_description.j2   |  3 +-
 app/views/notes_formsemestre.py               | 64 ++++++++++++++++++-
 4 files changed, 96 insertions(+), 22 deletions(-)

diff --git a/app/forms/formsemestre/edit_description.py b/app/forms/formsemestre/edit_description.py
index ea9519b8..d10cd807 100644
--- a/app/forms/formsemestre/edit_description.py
+++ b/app/forms/formsemestre/edit_description.py
@@ -129,8 +129,14 @@ class FormSemestresImportFromDescrForm(ScoDocForm):
             FileAllowed(["xlsx"], "Fichier .xlsx uniquement"),
         ],
     )
+    image_archive_file = FileField(
+        "Fichier zip avec les images",
+        validators=[
+            FileAllowed(["zip"], "Fichier .zip uniquement"),
+        ],
+    )
     create_formation = BooleanField(
-        "Créer les programmes de formations si ils n'existent pas"
+        "Créer les programmes de formations s'ils n'existent pas", default=True
     )
     submit = SubmitField("Importer et créer les formations")
     cancel = SubmitField("Annuler")
diff --git a/app/formsemestre/import_from_descr.py b/app/formsemestre/import_from_descr.py
index 938cdc06..07cdf0c3 100644
--- a/app/formsemestre/import_from_descr.py
+++ b/app/formsemestre/import_from_descr.py
@@ -91,7 +91,7 @@ FORMSEMESTRE_FIELDS = (
     FieldDescr("titre", "titre du semestre (si vide, sera déduit de la formation)"),
     FieldDescr(
         "capacite_accueil",
-        "capacité d'accueil (nombre ou vide)",
+        "capacité d'accueil (nombre ou vide).",
         type="int",
         default=None,
     ),
@@ -102,7 +102,7 @@ FORMSEMESTRE_FIELDS = (
         "date_fin", "date fin des cours du semestre", type="date", optional=False
     ),
     FieldDescr("edt_id", "identifiant emplois du temps (optionnel)"),
-    FieldDescr("etat", "déverrouillage", type="bool", default=True),
+    FieldDescr("etat", "déverrouillage.", type="bool", default=True),
     FieldDescr("modalite", "modalité de formation: 'FI', 'FAP', 'FC'", default="FI"),
     FieldDescr(
         "elt_sem_apo",
@@ -121,15 +121,15 @@ FORMSEMESTRE_DESCR_FIELDS = (
     ),
     FieldDescr(
         "descr_horaire",
-        "indication sur l'horaire, texte libre, ex.: les lundis 9h-12h.",
+        "indication sur l'horaire, texte libre, ex.: les lundis 9h-12h",
     ),
     FieldDescr(
         "descr_date_debut_inscriptions",
-        "Date d'ouverture des inscriptions (laisser vide pour autoriser tout le temps).",
+        "Date d'ouverture des inscriptions (laisser vide pour autoriser tout le temps)",
         type="datetime",
     ),
     FieldDescr(
-        "descr_date_fin_inscriptions", "Date de fin des inscriptions", type="datetime"
+        "descr_date_fin_inscriptions", "Date de fin des inscriptions.", type="datetime"
     ),
     FieldDescr(
         "descr_wip",
@@ -137,12 +137,16 @@ FORMSEMESTRE_DESCR_FIELDS = (
         type="bool",
         default=False,
     ),
-    FieldDescr("descr_image", "image illustrant cette formation.", type="image"),
-    FieldDescr("descr_campus", "campus, par ex. Villetaneuse."),
-    FieldDescr("descr_salle", "salle"),
+    FieldDescr(
+        "descr_image",
+        "image illustrant cette formation (en excel, nom du fichier dans le zip associé)",
+        type="image",
+    ),
+    FieldDescr("descr_campus", "campus, par ex. Villetaneuse"),
+    FieldDescr("descr_salle", "salle."),
     FieldDescr(
         "descr_dispositif",
-        "modalité de formation: 0 présentiel, 1 online, 2 hybride.",
+        "modalité de formation: 0 présentiel, 1 online, 2 hybride",
         type="int",
         default=0,
     ),
@@ -151,19 +155,19 @@ FORMSEMESTRE_DESCR_FIELDS = (
     ),
     FieldDescr(
         "descr_modalites_mcc",
-        "modalités de contrôle des connaissances",
+        "modalités de contrôle des connaissances.",
         allow_html=True,
     ),
     FieldDescr(
         "descr_photo_ens",
-        "photo de l'enseignant(e) ou autre illustration",
+        "photo de l'enseignant(e) ou autre illustration (en excel, nom du fichier dans le zip associé)",
         type="image",
     ),
     FieldDescr("descr_public", "public visé"),
     FieldDescr("descr_prerequis", "prérequis", allow_html=True),
     FieldDescr(
         "descr_responsable",
-        "responsable du cours ou personne chargée de l'organisation du semestre.",
+        "responsable du cours ou personne chargée de l'organisation du semestre",
         allow_html=True,
     ),
 )
@@ -178,7 +182,7 @@ def describe_field(key: str) -> str:
     if not FIELDS_BY_KEY:
         FIELDS_BY_KEY.update({field.key: field for field in ALL_FIELDS})
     field = FIELDS_BY_KEY[key]
-    return field.description + (" HTML autorisé." if field.allow_html else "")
+    return field.description + (" HTML autorisé" if field.allow_html else "")
 
 
 def generate_sample():
@@ -207,9 +211,7 @@ def check_and_convert(value, field: FieldDescr):
         case "int":
             return int(value)
         case "image":
-            return None  # XXX ignore
-            if value:  # WIP
-                raise NotImplementedError("image import from Excel not implemented")
+            return str(value).strip()  # image path
         case "bool":
             return scu.to_bool(value)
         case "date":
@@ -304,13 +306,14 @@ def _create_formation_and_modimpl(data) -> Formation:
 
 
 def create_formsemestre_from_description(
-    data: dict, create_formation=False
+    data: dict, create_formation=False, images: dict | None = None
 ) -> FormSemestre:
     """Create from fields in data.
     - Search formation: if needed and create_formation, create it;
     - Create formsemestre
     - Create formsemestre description
     """
+    images = images or {}
     created = []  # list of created objects XXX unused
     user = current_user  # resp. semestre et module
     formation = (
@@ -365,13 +368,15 @@ def create_formsemestre_from_description(
 
 
 def create_formsemestres_from_description(
-    infos: list[dict], create_formation: bool = False
+    infos: list[dict], create_formation: bool = False, images: dict | None = None
 ) -> list[FormSemestre]:
     "Creation de tous les semestres mono-modules"
     log(
         f"create_formsemestres_from_description: {len(infos)} items, create_formation={create_formation}"
     )
     return [
-        create_formsemestre_from_description(data, create_formation=create_formation)
+        create_formsemestre_from_description(
+            data, create_formation=create_formation, images=images
+        )
         for data in infos
     ]
diff --git a/app/templates/formsemestre/import_from_description.j2 b/app/templates/formsemestre/import_from_description.j2
index 65f93aef..b89aab5f 100644
--- a/app/templates/formsemestre/import_from_description.j2
+++ b/app/templates/formsemestre/import_from_description.j2
@@ -18,7 +18,8 @@ décrits dans un fichier excel. </p>
     </li>
 
     <li style="margin-bottom:8px;">Vous ajoutez une ligne par semestre/formation
-    à créer, avec votre logiciel tableur préféré.
+    à créer, avec votre logiciel tableur préféré. En option, les colonnes images donnent
+    les noms complets (avec chemin) d'un fichier dans l'archive zip associée.
     </li>
 
     <li style="margin-bottom:32px;">Revenez sur cette page et chargez le fichier dans ScoDoc.
diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py
index deb7b3af..ecacefbc 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -31,6 +31,7 @@ Emmanuel Viennet, 2023
 
 import datetime
 import io
+import zipfile
 
 from flask import flash, redirect, render_template, url_for
 from flask import current_app, g, request
@@ -375,15 +376,18 @@ def formsemestres_import_from_description():
                 )
             )
         datafile = request.files[form.fichier.name]
+        image_archive_file = request.files[form.image_archive_file.name]
         create_formation = form.create_formation.data
         infos = import_from_descr.read_excel(datafile)
+        images = _extract_images_from_zip(image_archive_file)
+        _load_images_refs(infos, images)
         for linenum, info in enumerate(infos, start=1):
             info["formation_commentaire"] = (
                 info.get("formation_commentaire")
                 or f"importé de {request.files[form.fichier.name].filename}, ligne {linenum}"
             )
         formsemestres = import_from_descr.create_formsemestres_from_description(
-            infos, create_formation=create_formation
+            infos, create_formation=create_formation, images=images
         )
         current_app.logger.info(
             f"formsemestres_import_from_description: {len(formsemestres)} semestres créés"
@@ -405,6 +409,64 @@ def formsemestres_import_from_description():
     )
 
 
+def _extract_images_from_zip(image_archive_file) -> dict[str, bytes]:
+    """Read archive file, and build dict: { path : image_data }
+    check that image_data is a valid image.
+    """
+    # Image suffixes supported by PIL
+    exts = PIL.Image.registered_extensions()
+    supported_extensions = tuple(ex for ex, f in exts.items() if f in PIL.Image.OPEN)
+
+    images = {}
+    with zipfile.ZipFile(image_archive_file) as archive:
+        for file_info in archive.infolist():
+            if file_info.is_dir() or file_info.filename.startswith("__"):
+                continue
+            if not file_info.filename.lower().endswith(supported_extensions):
+                continue  # ignore non image files
+            with archive.open(file_info) as file:
+                image_data = file.read()
+                try:
+                    _ = PIL.Image.open(io.BytesIO(image_data))
+                    images[file_info.filename] = image_data
+                except PIL.UnidentifiedImageError as exc:
+                    current_app.logger.warning(
+                        f"Invalid image in archive: {file_info.filename}"
+                    )
+                    raise ScoValueError(
+                        f"Image invalide dans l'archive: {file_info.filename}",
+                        dest_url=url_for(
+                            "notes.formsemestres_import_from_description",
+                            scodoc_dept=g.scodoc_dept,
+                        ),
+                        dest_label="Reprendre",
+                    ) from exc
+    return images
+
+
+def _load_images_refs(infos: list[dict], images: dict):
+    """Check if all referenced images in excel (infos)
+    are present in the zip archive (images) and put them in the infos dicts.
+    """
+    breakpoint()
+    for linenum, info in enumerate(infos, start=1):
+        for key in ("descr_image", "descr_photo_ens"):
+            info[key] = (
+                info[key].strip() if isinstance(info[key], str) else None
+            ) or None
+            if info[key]:
+                if info[key] not in images:
+                    raise ScoValueError(
+                        f"Image référencée en ligne {linenum}, colonne {key} non trouvée dans le zip",
+                        dest_url=url_for(
+                            "notes.formsemestres_import_from_description",
+                            scodoc_dept=g.scodoc_dept,
+                        ),
+                        dest_label="Reprendre",
+                    )
+                info[key] = images[info[key]]
+
+
 @bp.route("/formsemestres/import_from_descr_sample", methods=["GET", "POST"])
 @scodoc
 @permission_required(Permission.ScoView)
-- 
GitLab