From fd00e2f55d6d4a28fa8039e7cc3db3ae9e7b3c68 Mon Sep 17 00:00:00 2001
From: ilona
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 ea9519b80..d10cd8075 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 938cdc062..07cdf0c3b 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 65f93aefa..b89aab5f5 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.
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.
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 deb7b3af6..ecacefbc1 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)