From 6db0aa36cda2fc65585861ef5e5010a5a462e382 Mon Sep 17 00:00:00 2001
From: ilona
Date: Sat, 21 Dec 2024 22:21:10 +0100
Subject: [PATCH 1/7] WIP: fonctions d'import de semestres monomodules
---
app/forms/formsemestre/edit_description.py | 52 ++-
app/formsemestre/import_from_descr.py | 337 ++++++++++++++++++
app/models/formsemestre_descr.py | 6 +-
app/scodoc/sco_excel.py | 42 ++-
.../formsemestre/import_from_description.j2 | 48 +++
.../import_from_description_result.j2 | 17 +
app/views/notes.py | 10 +
app/views/notes_formsemestre.py | 48 +++
8 files changed, 540 insertions(+), 20 deletions(-)
create mode 100644 app/formsemestre/import_from_descr.py
create mode 100644 app/templates/formsemestre/import_from_description.j2
create mode 100644 app/templates/formsemestre/import_from_description_result.j2
diff --git a/app/forms/formsemestre/edit_description.py b/app/forms/formsemestre/edit_description.py
index e0b49b2a..ea9519b8 100644
--- a/app/forms/formsemestre/edit_description.py
+++ b/app/forms/formsemestre/edit_description.py
@@ -14,9 +14,11 @@ from wtforms import (
TextAreaField,
SubmitField,
)
-from wtforms.validators import AnyOf, Optional
+from flask_wtf.file import FileAllowed
+from wtforms.validators import AnyOf, Optional, DataRequired
from app.forms import ScoDocForm
+from app.formsemestre.import_from_descr import describe_field
from app.models import FORMSEMESTRE_DISPOSITIFS
from app.scodoc import sco_utils as scu
@@ -46,44 +48,50 @@ class FormSemestreDescriptionForm(ScoDocForm):
description = TextAreaField(
"Description",
validators=[Optional()],
- description="""texte libre : informations
- sur le contenu, les objectifs, les modalités d'évaluation, etc.""",
+ description=describe_field("descr_description"),
)
horaire = StringField(
- "Horaire", validators=[Optional()], description="ex: les lundis 9h-12h"
+ "Horaire", validators=[Optional()], description=describe_field("descr_horaire")
)
date_debut_inscriptions = DateDMYField(
"Date de début des inscriptions",
- description="""date d'ouverture des inscriptions
- (laisser vide pour autoriser tout le temps)""",
+ description=describe_field("descr_date_debut_inscriptions"),
render_kw={
"id": "date_debut_inscriptions",
},
)
date_fin_inscriptions = DateDMYField(
"Date de fin des inscriptions",
+ description=describe_field("descr_date_fin_inscriptions"),
render_kw={
"id": "date_fin_inscriptions",
},
)
image = FileField(
- "Image", validators=[Optional()], description="Image illustrant cette formation"
+ "Image", validators=[Optional()], description=describe_field("descr_image")
)
campus = StringField(
- "Campus", validators=[Optional()], description="ex: Villetaneuse"
+ "Campus", validators=[Optional()], description=describe_field("descr_campus")
+ )
+ salle = StringField(
+ "Salle", validators=[Optional()], description=describe_field("descr_salle")
)
- salle = StringField("Salle", validators=[Optional()], description="ex: salle 123")
dispositif = SelectField(
"Dispositif",
choices=FORMSEMESTRE_DISPOSITIFS.items(),
coerce=int,
- description="modalité de formation",
+ description=describe_field("descr_dispositif"),
validators=[AnyOf(FORMSEMESTRE_DISPOSITIFS.keys())],
)
+ dispositif_descr = TextAreaField(
+ "Description du dispositif",
+ validators=[Optional()],
+ description=describe_field("descr_dispositif_descr"),
+ )
modalites_mcc = TextAreaField(
"Modalités de contrôle des connaissances",
validators=[Optional()],
- description="texte libre",
+ description=describe_field("descr_modalites_mcc"),
)
photo_ens = FileField(
"Photo de l'enseignant(e)",
@@ -94,13 +102,12 @@ class FormSemestreDescriptionForm(ScoDocForm):
"Public visé", validators=[Optional()], description="ex: débutants"
)
prerequis = TextAreaField(
- "Prérequis", validators=[Optional()], description="texte libre"
+ "Prérequis", validators=[Optional()], description="texte libre. HTML autorisé."
)
responsable = StringField(
"Responsable",
validators=[Optional()],
- description="""nom de l'enseignant de la formation, ou personne
- chargée de l'organisation du semestre.""",
+ description=describe_field("descr_responsable"),
)
wip = BooleanField(
@@ -110,3 +117,20 @@ class FormSemestreDescriptionForm(ScoDocForm):
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
+
+
+class FormSemestresImportFromDescrForm(ScoDocForm):
+ """Formulaire import excel semestres"""
+
+ fichier = FileField(
+ "Fichier à importer",
+ validators=[
+ DataRequired(),
+ FileAllowed(["xlsx"], "Fichier .xlsx uniquement"),
+ ],
+ )
+ create_formation = BooleanField(
+ "Créer les programmes de formations si ils n'existent pas"
+ )
+ 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
new file mode 100644
index 00000000..b2827672
--- /dev/null
+++ b/app/formsemestre/import_from_descr.py
@@ -0,0 +1,337 @@
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# ScoDoc
+#
+# Copyright (c) 1999 - 2024 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
+#
+##############################################################################
+
+"""
+Importation directe de formsemestres depuis un fichier excel
+donnant leurs paramètres et description.
+(2024 pour EL)
+
+- Formation: indiquée par ("dept", "formation_acronyme", "formation_titre", "formation_version")
+- Modules: ces formsemestres ne prennent que le premier module de la formation
+à laquelle ils sont rattachés.
+
+Les champs sont définis ci-dessous, pour chaque objet:
+ Formation, FormSemestre, FormSemestreDescription.
+
+"""
+
+from collections import namedtuple
+import datetime
+
+from flask import g
+from flask_login import current_user
+
+from app import db, log
+from app.models import (
+ Formation,
+ FormSemestre,
+ FormSemestreDescription,
+ Matiere,
+ Module,
+ ModuleImpl,
+ UniteEns,
+)
+from app.scodoc import sco_excel
+from app.scodoc import sco_utils as scu
+from app.scodoc.sco_exceptions import ScoValueError
+
+# Définition des champs
+FieldDescr = namedtuple(
+ "DescrField",
+ ("key", "description", "optional", "default", "type", "allow_html"),
+ defaults=(None, "", True, "", "str", False),
+)
+
+# --- Formation
+FORMATION_FIELDS = (
+ FieldDescr("formation_acronyme", "acronyme de la formation", optional=False),
+ FieldDescr("formation_titre", "titre de la formation", optional=False),
+ FieldDescr("formation_version", "version de la formation", optional=False),
+)
+# --- FormSemestre
+FORMSEMESTRE_FIELDS = (
+ FieldDescr(
+ "semestre_id",
+ "indice du semestre dans la formation",
+ optional=False,
+ type="int",
+ ),
+ FieldDescr("titre", "titre du semestre (si vide, sera déduit de la formation)"),
+ FieldDescr(
+ "capacite_accueil",
+ "capacité d'accueil (nombre ou vide)",
+ type="int",
+ default=None,
+ ),
+ FieldDescr(
+ "date_debut", "date début des cours du semestre", type="date", optional=False
+ ),
+ FieldDescr(
+ "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("modalite", "modalité de formation: 'FI', 'FAP', 'FC'", default="FI"),
+ FieldDescr(
+ "elt_sem_apo",
+ "code(s) Apogée élement semestre, eg 'VRTW1' ou 'V2INCS4,V2INLS4'",
+ ),
+ FieldDescr("elt_annee_apo", "code(s) Apogée élement année"),
+ FieldDescr("elt_passage_apo", "code(s) Apogée élement passage"),
+)
+# --- Description externe (FormSemestreDescription)
+# --- champs préfixés par "descr_"
+FORMSEMESTRE_DESCR_FIELDS = (
+ FieldDescr(
+ "descr_description",
+ """description du cours: informations sur le contenu, les objectifs, les modalités d'évaluation, etc.""",
+ allow_html=True,
+ ),
+ FieldDescr(
+ "descr_horaire",
+ "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).",
+ type="datetime",
+ ),
+ FieldDescr(
+ "descr_date_fin_inscriptions", "Date de fin des inscriptions", type="datetime"
+ ),
+ FieldDescr(
+ "descr_wip",
+ "work in progress: si vrai, affichera juste le titre du semestre",
+ 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_dispositif",
+ "modalité de formation: 0 présentiel, 1 online, 2 hybride.",
+ type="int",
+ default=0,
+ ),
+ FieldDescr(
+ "descr_dispositif_descr", "décrit modalités de formation", allow_html=True
+ ),
+ FieldDescr(
+ "descr_modalites_mcc",
+ "modalités de contrôle des connaissances",
+ allow_html=True,
+ ),
+ FieldDescr(
+ "descr_photo_ens",
+ "photo de l'enseignant(e) ou autre illustration",
+ 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.",
+ allow_html=True,
+ ),
+)
+
+ALL_FIELDS = FORMATION_FIELDS + FORMSEMESTRE_FIELDS + FORMSEMESTRE_DESCR_FIELDS
+
+FIELDS_BY_KEY = {}
+
+
+def describe_field(key: str) -> str:
+ """texte aide décrivant ce champ"""
+ 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 "")
+
+
+def generate_sample():
+ """Generate excel xlsx for import"""
+ titles = [fs.key for fs in ALL_FIELDS]
+ comments = [fs.description for fs in ALL_FIELDS]
+
+ style = sco_excel.excel_make_style(bold=True)
+ titles_styles = [style] * len(titles)
+ return sco_excel.excel_simple_table(
+ titles=titles,
+ titles_styles=titles_styles,
+ sheet_name="Import semestres",
+ comments=comments,
+ )
+
+
+def check_and_convert(value, field: FieldDescr):
+ match field.type:
+ case "str":
+ return str(value).strip()
+ case "int":
+ return int(value)
+ case "image":
+ if value:
+ raise NotImplementedError("image import from Excel not implemented")
+ case "bool":
+ return scu.to_bool(value)
+ case "date":
+ if isinstance(value, datetime.date):
+ return value
+ if isinstance(value, datetime.datetime):
+ return value.date
+ if isinstance(value, str):
+ return datetime.date.fromisoformat(value)
+ raise ValueError(f"invalid date for {field.key}")
+ case "datetime":
+ if isinstance(value, datetime.datetime):
+ return value
+ if isinstance(value, str):
+ return datetime.date.fromisoformat(value)
+ raise ValueError(f"invalid datetime for {field.key}")
+ raise NotImplementedError(f"unimplemented type {field.type} for field {field.key}")
+
+
+def read_excel(datafile) -> list[dict]:
+ "lecture fichier excel import formsemestres"
+ exceldata = datafile.read()
+ diag, rows = sco_excel.excel_bytes_to_dict(exceldata)
+ # check and convert types
+ for line_num, row in enumerate(rows, start=1):
+ for field in ALL_FIELDS:
+ if field.key not in row:
+ if field.optional:
+ row[field.key] = field.default
+ else:
+ raise ScoValueError(
+ f"Ligne {line_num}, colonne {field.key}: valeur requise"
+ )
+ else:
+ try:
+ row[field.key] = check_and_convert(row[field.key], field)
+ except ValueError as exc:
+ raise ScoValueError(
+ f"Ligne {line_num}, colonne {field.key}: {exc.args}"
+ ) from exc
+ log(diag) # XXX
+ return rows
+
+
+def _create_formation_and_modimpl(data) -> Formation:
+ """Create a new formation, with a UE and module"""
+ args = {field.key: data[field.key] for field in FORMATION_FIELDS}
+ args["dept_id"] = g.scodoc_dept_id
+ formation = Formation.create_from_dict(args)
+ ue = UniteEns.create_from_dict(
+ {
+ "formation": formation,
+ "acronyme": f"UE {data['formation_acronyme']}",
+ "titre": data["formation_titre"],
+ }
+ )
+ matiere = Matiere.create_from_dict(
+ {
+ "ue": ue,
+ "titre": data["formation_titre"],
+ }
+ )
+ module = Module.create_from_dict(
+ {
+ "ue": ue,
+ "formation": formation,
+ "matiere": matiere,
+ "titre": data["formation_titre"],
+ "abbrev": data["formation_titre"],
+ "code": data["formation_acronyme"],
+ }
+ )
+ return formation
+
+
+def create_formsemestre_from_description(
+ data: dict, create_formation=False
+) -> FormSemestre:
+ """Create from fields in data.
+ - Search formation: if needed and create_formation, create it;
+ - Create formsemestre
+ - Create formsemestre description
+ """
+ created = [] # list of created objects XXX unused
+ user = current_user # resp. semestre et module
+ formation = (
+ db.session.query(Formation)
+ .filter_by(
+ dept_id=g.scodoc_dept_id,
+ acronyme=data["formation_acronyme"],
+ titre=data["formation_titre"],
+ version=data["formation_version"],
+ )
+ .first()
+ )
+ if not formation:
+ if not create_formation:
+ raise ScoValueError("formation inexistante dans ce département")
+ formation = _create_formation_and_modimpl(data)
+ created.append(formation)
+ # Détermine le module à placer dans le formsemestre
+ module = formation.modules.first()
+ if not module:
+ raise ScoValueError(
+ f"La formation {formation.get_titre_version()} n'a aucun module"
+ )
+ # --- FormSemestre
+ args = {field.key: data[field.key] for field in FORMSEMESTRE_FIELDS}
+ args["dept_id"] = g.scodoc_dept_id
+ args["formation_id"] = formation.id
+ args["responsables"] = [user]
+ formsemestre = FormSemestre.create_formsemestre(data)
+ modimpl = ModuleImpl.create_from_dict(
+ {
+ "module_id": module.id,
+ "formsemestre_id": formsemestre.id,
+ "responsable_id": user.id,
+ }
+ )
+ # --- FormSemestreDescription
+ args = {
+ field.key[6:] if field.key.startswith("descr_") else field.key: data[field.key]
+ for field in FORMSEMESTRE_DESCR_FIELDS
+ }
+ args["formsemestre_id"] = formsemestre.id
+ formsemestre_descr = FormSemestreDescription.create_from_dict(args)
+ #
+ db.session.commit()
+ return formsemestre
+
+
+def create_formsemestres_from_description(
+ infos: list[dict], create_formation=False
+) -> list[FormSemestre]:
+ "Creation de tous les semestres mono-modules"
+ return [
+ create_formsemestre_from_description(data, create_formation=create_formation)
+ for data in infos
+ ]
diff --git a/app/models/formsemestre_descr.py b/app/models/formsemestre_descr.py
index 1e754c52..cff56185 100644
--- a/app/models/formsemestre_descr.py
+++ b/app/models/formsemestre_descr.py
@@ -39,8 +39,12 @@ class FormSemestreDescription(models.ScoDocModel):
dispositif = db.Column(db.Integer, nullable=False, default=0, server_default="0")
"0 présentiel, 1 online, 2 hybride"
+ dispositif_descr = db.Column(
+ db.Text(), nullable=False, default="", server_default=""
+ )
+ "décrit modalités de formation. html autorisé."
modalites_mcc = db.Column(db.Text(), nullable=False, default="", server_default="")
- "modalités de contrôle des connaissances"
+ "modalités de contrôle des connaissances (texte libre, html autorisé)"
photo_ens = db.Column(db.LargeBinary(), nullable=True)
"photo de l'enseignant(e)"
public = db.Column(db.Text(), nullable=False, default="", server_default="")
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index acd4293c..d1e78de8 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -470,9 +470,11 @@ def excel_simple_table(
lines: list[list[str]] = None,
sheet_name: str = "feuille",
titles_styles=None,
- comments=None,
+ comments: list[str] | None = None,
):
- """Export simple type 'CSV': 1ere ligne en gras, le reste tel quel."""
+ """Export simple type 'CSV': 1ere ligne en gras, le reste tel quel.
+ comments (optionnel) donne des commentaires à placer sur les cellules de titre.
+ """
ws = ScoExcelSheet(sheet_name)
if titles is None:
titles = []
@@ -510,7 +512,11 @@ def excel_simple_table(
def excel_bytes_to_list(bytes_content) -> tuple[list, list[list]]:
- "Lecture d'un flux xlsx"
+ """Lecture d'un flux xlsx.
+ returns:
+ - diag : a list of strings (error messages aimed at helping the user)
+ - a list of lists: the spreadsheet cells
+ """
try:
filelike = io.BytesIO(bytes_content)
return _excel_to_list(filelike)
@@ -522,8 +528,31 @@ def excel_bytes_to_list(bytes_content) -> tuple[list, list[list]]:
) from exc
+def excel_bytes_to_dict(
+ bytes_content, force_lowercase_keys=True
+) -> tuple[list[str], list[dict]]:
+ """Lecture d'un flux xlsx et conversion en dict,
+ les clés étant données par les titres sur la première ligne.
+ returns:
+ - diag : a list of strings (error messages aimed at helping the user)
+ - a list of dict: the spreadsheet cells
+ """
+ diag, rows = excel_bytes_to_list(bytes_content)
+ if len(rows) < 1:
+ raise ScoValueError("Fichier excel vide")
+ if force_lowercase_keys:
+ keys = [k.strip().lower() for k in rows[0]]
+ else:
+ keys = [k.strip() for k in rows[0]]
+ return diag, [dict(zip(keys, row)) for row in rows]
+
+
def excel_file_to_list(filelike) -> tuple[list, list[list]]:
- "Lecture d'un flux xlsx"
+ """Lecture d'un flux xlsx
+ returns:
+ - diag : a list of strings (error messages aimed at helping the user)
+ - a list of lists: the spreadsheet cells
+ """
try:
return _excel_to_list(filelike)
except Exception as exc:
@@ -554,7 +583,10 @@ def _open_workbook(filelike, dump_debug=False) -> Workbook:
def _excel_to_list(filelike) -> tuple[list, list[list]]:
- """returns list of list"""
+ """returns:
+ - diag : a list of strings (error messages aimed at helping the user)
+ - a list of lists: the spreadsheet cells
+ """
workbook = _open_workbook(filelike)
diag = [] # liste de chaines pour former message d'erreur
if len(workbook.sheetnames) < 1:
diff --git a/app/templates/formsemestre/import_from_description.j2 b/app/templates/formsemestre/import_from_description.j2
new file mode 100644
index 00000000..3a464b3a
--- /dev/null
+++ b/app/templates/formsemestre/import_from_description.j2
@@ -0,0 +1,48 @@
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+{% block app_content %}
+
+
+ Fonction réservé à
+la création de semestres ne comprenant qu'un seul module (dans une UE unique),
+décrits dans un fichier excel.
+
+
L'opération se déroule en plusieurs étapes:
+
+
+ - Dans un premier temps, vous téléchargez une
+ feuille Excel donnant les titres des colonnes. Certaines colonnes sont
+ optionnelles et peuvent être ignorés. Si vous ajoutez d'autres colonnes,
+ elles seront ignorées.
+
+
+ - Vous ajoutez une ligne par semestre/formation
+ à créer, avec votre logiciel tableur préféré.
+
+
+ - Revenez sur cette page et chargez le fichier dans ScoDoc.
+
+
+
+
+
+
Étape 1: exporter fichier Excel à charger
+
+
+
+
+
Étape 2: charger le fichier Excel rempli
+
+
+
+ {{ wtf.quick_form(form) }}
+
+
+
+{% endblock %}
diff --git a/app/templates/formsemestre/import_from_description_result.j2 b/app/templates/formsemestre/import_from_description_result.j2
new file mode 100644
index 00000000..66b09d93
--- /dev/null
+++ b/app/templates/formsemestre/import_from_description_result.j2
@@ -0,0 +1,17 @@
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+{% block app_content %}
+
+
+
+
Les semestres suivants ont été créés:
+
+ {% for formsemestre in formsemestres %}
+ - {{ formsemestre.html_link_status() | safe }}
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/app/views/notes.py b/app/views/notes.py
index b317169e..538b0c8f 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -740,6 +740,16 @@ def index_html():
+
+
+
Opérations avancées réservées aux connaisseuses
+
+
"""
)
diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py
index 4a62df44..5b3e9d3f 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -47,6 +47,7 @@ from app.forms.formsemestre import (
edit_modimpls_codes_apo,
edit_description,
)
+from app.formsemestre import import_from_descr
from app.models import (
Formation,
FormSemestre,
@@ -354,3 +355,50 @@ def edit_formsemestre_description(formsemestre_id: int):
sco=ScoData(formsemestre=formsemestre),
title="Modif. description semestre",
)
+
+
+@bp.route("/formsemestres/import_from_descr", methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.EditFormSemestre)
+@permission_required(Permission.EditFormation)
+def formsemestres_import_from_description():
+ """Import de formation/formsemestre à partir d'un excel.
+ Un seul module est créé. Utilisé pour EL.
+ """
+ form = edit_description.FormSemestresImportFromDescrForm()
+ if form.validate_on_submit():
+ if form.cancel.data: # cancel button
+ return redirect(
+ url_for(
+ "notes.index_html",
+ scodoc_dept=g.scodoc_dept,
+ )
+ )
+ datafile = request.files[form.fichier.name]
+ create_formation = form.create_formation
+ infos = import_from_descr.read_excel(datafile)
+ formsemestres = import_from_descr.create_formsemestres_from_description(
+ infos, create_formation=create_formation
+ )
+ current_app.logger.info(
+ f"formsemestres_import_from_description: {len(formsemestres)} semestres créés"
+ )
+ flash(f"Importation et création de {len(formsemestres)} semestres")
+ return render_template("formsemestre/import_from_description_result.j2")
+
+ return render_template(
+ "formsemestre/import_from_description.j2",
+ title="Importation de semestres de formations monomodules",
+ form=form,
+ )
+
+
+@bp.route("/formsemestres/import_from_descr_sample", methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.ScoView)
+def formsemestres_import_from_description_sample():
+ "Renvoie fichier excel à remplir"
+ xls = import_from_descr.generate_sample()
+ return scu.send_file(
+ xls, "ImportSemestres", scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE
+ )
From f4b995c9d234c6918f3e848bd3ff902fdefb7d21 Mon Sep 17 00:00:00 2001
From: ilona
Date: Mon, 30 Dec 2024 19:34:47 +0100
Subject: [PATCH 2/7] WIP: fonctions d'import de semestres monomodules, suite
---
app/formations/edit_ue.py | 2 +-
app/formsemestre/import_from_descr.py | 62 +++++++++++++++----
app/models/formations.py | 9 ++-
app/models/moduleimpls.py | 4 +-
app/models/modules.py | 25 ++++++--
app/scodoc/sco_excel.py | 2 +-
.../formsemestre/import_from_description.j2 | 10 +++
.../import_from_description_result.j2 | 5 ++
app/views/notes_formsemestre.py | 16 ++++-
.../bc85a55e63e1_add_dispositif_descr.py | 33 ++++++++++
10 files changed, 143 insertions(+), 25 deletions(-)
create mode 100644 migrations/versions/bc85a55e63e1_add_dispositif_descr.py
diff --git a/app/formations/edit_ue.py b/app/formations/edit_ue.py
index 83a484aa..eb37a617 100644
--- a/app/formations/edit_ue.py
+++ b/app/formations/edit_ue.py
@@ -911,7 +911,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
"""
)
- if formsemestres:
+ if not formsemestres:
H.append(
f"""
list[dict]:
exceldata = datafile.read()
diag, rows = sco_excel.excel_bytes_to_dict(exceldata)
# check and convert types
- for line_num, row in enumerate(rows, start=1):
+ for line_num, row in enumerate(rows, start=2):
for field in ALL_FIELDS:
if field.key not in row:
if field.optional:
@@ -234,7 +252,12 @@ def read_excel(datafile) -> list[dict]:
row[field.key] = check_and_convert(row[field.key], field)
except ValueError as exc:
raise ScoValueError(
- f"Ligne {line_num}, colonne {field.key}: {exc.args}"
+ f"Ligne {line_num}, colonne {field.key}: {exc.args}",
+ dest_label="Reprendre",
+ dest_url=url_for(
+ "notes.formsemestres_import_from_description",
+ scodoc_dept=g.scodoc_dept,
+ ),
) from exc
log(diag) # XXX
return rows
@@ -242,8 +265,16 @@ def read_excel(datafile) -> list[dict]:
def _create_formation_and_modimpl(data) -> Formation:
"""Create a new formation, with a UE and module"""
- args = {field.key: data[field.key] for field in FORMATION_FIELDS}
+ args = {
+ field.key.removeprefix("formation_"): data[field.key]
+ for field in FORMATION_FIELDS
+ }
args["dept_id"] = g.scodoc_dept_id
+ # add some required fields:
+ if "titre_officiel" not in args:
+ args["titre_officiel"] = args["titre"]
+ if not args.get("type_parcours"):
+ args["type_parcours"] = CodesCursus.Mono
formation = Formation.create_from_dict(args)
ue = UniteEns.create_from_dict(
{
@@ -266,6 +297,7 @@ def _create_formation_and_modimpl(data) -> Formation:
"titre": data["formation_titre"],
"abbrev": data["formation_titre"],
"code": data["formation_acronyme"],
+ "coefficient": 1.0,
}
)
return formation
@@ -295,6 +327,7 @@ def create_formsemestre_from_description(
if not create_formation:
raise ScoValueError("formation inexistante dans ce département")
formation = _create_formation_and_modimpl(data)
+ db.session.flush()
created.append(formation)
# Détermine le module à placer dans le formsemestre
module = formation.modules.first()
@@ -307,7 +340,9 @@ def create_formsemestre_from_description(
args["dept_id"] = g.scodoc_dept_id
args["formation_id"] = formation.id
args["responsables"] = [user]
- formsemestre = FormSemestre.create_formsemestre(data)
+ if not args.get("titre"):
+ args["titre"] = formation.titre or formation.titre_officiel
+ formsemestre = FormSemestre.create_formsemestre(args)
modimpl = ModuleImpl.create_from_dict(
{
"module_id": module.id,
@@ -317,9 +352,11 @@ def create_formsemestre_from_description(
)
# --- FormSemestreDescription
args = {
- field.key[6:] if field.key.startswith("descr_") else field.key: data[field.key]
+ field.key.removeprefix("descr_"): data[field.key]
for field in FORMSEMESTRE_DESCR_FIELDS
}
+ args["image"] = args["image"] or None
+ args["photo_ens"] = args["photo_ens"] or None
args["formsemestre_id"] = formsemestre.id
formsemestre_descr = FormSemestreDescription.create_from_dict(args)
#
@@ -328,9 +365,12 @@ def create_formsemestre_from_description(
def create_formsemestres_from_description(
- infos: list[dict], create_formation=False
+ infos: list[dict], create_formation: bool = False
) -> 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)
for data in infos
diff --git a/app/models/formations.py b/app/models/formations.py
index 559b6fe7..9ed7cd0a 100644
--- a/app/models/formations.py
+++ b/app/models/formations.py
@@ -381,19 +381,22 @@ class Matiere(ScoDocModel):
@classmethod
def create_from_dict(cls, data: dict) -> "Matiere":
"""Create matière from dict. Log, news, cache.
- data must include ue_id, a valid UE id.
+ data must include ue_id, a valid UE id, or ue.
Commit session.
"""
# check ue
if data.get("ue_id") is None:
- raise ScoValueError("UE id missing")
- _ = UniteEns.get_ue(data["ue_id"])
+ if data.get("ue") is None:
+ raise ScoValueError("UE missing")
+ else: # check ue_id
+ _ = UniteEns.get_ue(data["ue_id"])
mat = super().create_from_dict(data)
db.session.commit()
db.session.refresh(mat)
# news
formation = mat.ue.formation
+ log(f"Matiere.create_from_dict: created {mat} from {data}")
ScolarNews.add(
typ=ScolarNews.NEWS_FORM,
obj=formation.id,
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index a3ce27a7..ffe7db11 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -77,7 +77,9 @@ class ModuleImpl(ScoDocModel):
# check required args
for required_arg in ("formsemestre_id", "module_id", "responsable_id"):
if required_arg not in data:
- raise ScoValueError(f"missing argument: {required_arg}")
+ raise ScoValueError(
+ f"ModuleImpl.create_from_dict: missing argument: {required_arg}"
+ )
_ = FormSemestre.get_formsemestre(data["formsemestre_id"])
_ = Module.get_instance(data["module_id"])
if not db.session.get(User, data["responsable_id"]):
diff --git a/app/models/modules.py b/app/models/modules.py
index 2df6a8b2..7c7a3820 100644
--- a/app/models/modules.py
+++ b/app/models/modules.py
@@ -210,14 +210,27 @@ class Module(models.ScoDocModel):
from app.models.formations import Formation
# check required arguments
- for required_arg in ("code", "formation_id", "ue_id"):
- if required_arg not in data:
- raise ScoValueError(f"missing argument: {required_arg}")
+ if "code" not in data:
+ raise ScoValueError("Module.create_from_dict: missing 'code' argument")
if not data["code"]:
- raise ScoValueError("module code must be non empty")
+ raise ScoValueError(
+ "Module.create_from_dict: module code must be non empty"
+ )
+ # Check ue
+ if data.get("ue_id") is None:
+ ue = data.get("ue")
+ if ue is None or not isinstance(ue, UniteEns):
+ raise ScoValueError("Module.create_from_dict: UE missing")
+ else: # check ue_id
+ ue = UniteEns.get_ue(data["ue_id"])
# Check formation
- formation = Formation.get_formation(data["formation_id"])
- ue = UniteEns.get_ue(data["ue_id"])
+ if data.get("formation_id") is None:
+ formation = data.get("formation")
+ if formation is None or not isinstance(formation, Formation):
+ raise ScoValueError("Module.create_from_dict: formation missing")
+ else: # check ue_id
+ formation = UniteEns.get_ue(data["ue_id"])
+ # formation = Formation.get_formation(data["formation_id"])
# refuse de créer un module APC avec semestres semestre du module != semestre de l'UE
if formation.is_apc():
if int(data.get("semestre_id", 1)) != ue.semestre_idx:
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index d1e78de8..c9da7336 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -544,7 +544,7 @@ def excel_bytes_to_dict(
keys = [k.strip().lower() for k in rows[0]]
else:
keys = [k.strip() for k in rows[0]]
- return diag, [dict(zip(keys, row)) for row in rows]
+ return diag, [dict(zip(keys, row)) for row in rows[1:]]
def excel_file_to_list(filelike) -> tuple[list, list[list]]:
diff --git a/app/templates/formsemestre/import_from_description.j2 b/app/templates/formsemestre/import_from_description.j2
index 3a464b3a..65f93aef 100644
--- a/app/templates/formsemestre/import_from_description.j2
+++ b/app/templates/formsemestre/import_from_description.j2
@@ -45,4 +45,14 @@ décrits dans un fichier excel.
+
+
+
+
Description des champs du fichier excel
+
+ {% for field, descr in fields_description.items() %}
+ - {{field}} : {{descr}}
+ {% endfor %}
+
+
{% endblock %}
diff --git a/app/templates/formsemestre/import_from_description_result.j2 b/app/templates/formsemestre/import_from_description_result.j2
index 66b09d93..e264f6fa 100644
--- a/app/templates/formsemestre/import_from_description_result.j2
+++ b/app/templates/formsemestre/import_from_description_result.j2
@@ -14,4 +14,9 @@
+
+
{% endblock %}
diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py
index 5b3e9d3f..deb7b3af 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -375,8 +375,13 @@ def formsemestres_import_from_description():
)
)
datafile = request.files[form.fichier.name]
- create_formation = form.create_formation
+ create_formation = form.create_formation.data
infos = import_from_descr.read_excel(datafile)
+ 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
)
@@ -384,12 +389,19 @@ def formsemestres_import_from_description():
f"formsemestres_import_from_description: {len(formsemestres)} semestres créés"
)
flash(f"Importation et création de {len(formsemestres)} semestres")
- return render_template("formsemestre/import_from_description_result.j2")
+ return render_template(
+ "formsemestre/import_from_description_result.j2",
+ formsemestres=formsemestres,
+ )
return render_template(
"formsemestre/import_from_description.j2",
title="Importation de semestres de formations monomodules",
form=form,
+ fields_description={
+ key: import_from_descr.describe_field(key)
+ for key in sorted(import_from_descr.FIELDS_BY_KEY)
+ },
)
diff --git a/migrations/versions/bc85a55e63e1_add_dispositif_descr.py b/migrations/versions/bc85a55e63e1_add_dispositif_descr.py
new file mode 100644
index 00000000..0853f699
--- /dev/null
+++ b/migrations/versions/bc85a55e63e1_add_dispositif_descr.py
@@ -0,0 +1,33 @@
+"""add dispositif_descr
+
+Revision ID: bc85a55e63e1
+Revises: bcd959a23aea
+Create Date: 2024-12-30 18:32:55.024694
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "bc85a55e63e1"
+down_revision = "bcd959a23aea"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ with op.batch_alter_table(
+ "notes_formsemestre_description", schema=None
+ ) as batch_op:
+ batch_op.add_column(
+ sa.Column("dispositif_descr", sa.Text(), server_default="", nullable=False)
+ )
+
+
+def downgrade():
+ with op.batch_alter_table(
+ "notes_formsemestre_description", schema=None
+ ) as batch_op:
+ batch_op.drop_column("dispositif_descr")
From df422ad1d353ec02db5560c6eab70ba0a28e73f7 Mon Sep 17 00:00:00 2001
From: ilona
Date: Tue, 31 Dec 2024 13:34:36 +0100
Subject: [PATCH 3/7] Liste formsemestres accueil: trie aussi par titres
---
app/scodoc/sco_dept.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py
index e033a89a..e81bf450 100644
--- a/app/scodoc/sco_dept.py
+++ b/app/scodoc/sco_dept.py
@@ -55,17 +55,17 @@ def index_html(showcodes=0, showsemtable=0, export_table_formsemestres=False):
current_formsemestres = (
FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=True)
.filter(FormSemestre.modalite != "EXT")
- .order_by(desc(FormSemestre.date_debut))
+ .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
)
locked_formsemestres = (
FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=False)
.filter(FormSemestre.modalite != "EXT")
- .order_by(desc(FormSemestre.date_debut))
+ .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
)
formsemestres = (
FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id)
.filter(FormSemestre.modalite != "EXT")
- .order_by(desc(FormSemestre.date_debut))
+ .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
)
if showsemtable: # table de tous les formsemestres
table = _sem_table_gt(
From fd00e2f55d6d4a28fa8039e7cc3db3ae9e7b3c68 Mon Sep 17 00:00:00 2001
From: ilona
Date: Tue, 31 Dec 2024 20:32:13 +0100
Subject: [PATCH 4/7] 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.
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 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)
From ea6307ac9223ac503c1fe129c5028aed5c03e74b Mon Sep 17 00:00:00 2001
From: ilona
Date: Tue, 31 Dec 2024 21:24:09 +0100
Subject: [PATCH 5/7] WIP: fonctions d'import de semestres monomodules: import
des images
---
app/views/notes_formsemestre.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py
index ecacefbc..54fa6efa 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -448,7 +448,6 @@ 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] = (
@@ -457,7 +456,7 @@ def _load_images_refs(infos: list[dict], images: dict):
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",
+ f'Image "{info[key]}" 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,
From c279fcd0503aad6ad64cf7a3a237dc12219105c5 Mon Sep 17 00:00:00 2001
From: ilona
Date: Fri, 3 Jan 2025 17:13:33 +0100
Subject: [PATCH 6/7] New command line: user-edit
---
scodoc.py | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 85 insertions(+)
diff --git a/scodoc.py b/scodoc.py
index 562c67fb..82bea3cd 100755
--- a/scodoc.py
+++ b/scodoc.py
@@ -430,6 +430,91 @@ def user_change_login(user_name, new_user_name):
user.change_user_name(new_user_name)
+@app.cli.command()
+@click.argument("username")
+@click.option(
+ "-d",
+ "--deactivate",
+ "deactivate",
+ is_flag=True,
+ help="désactive ce compte",
+)
+@click.option(
+ "-a",
+ "--activate",
+ "activate",
+ is_flag=True,
+ help="(ré)active ce compte",
+)
+@click.option("-c", "--cas-id", "cas_id")
+@click.option(
+ "--allow-cas-login",
+ "allow_cas_login",
+ is_flag=True,
+ help="autorise login via CAS",
+)
+@click.option(
+ "--disable-cas-login",
+ "disable_cas_login",
+ is_flag=True,
+ help="interdit login via CAS",
+)
+@click.option(
+ "--allow-scodoc-login",
+ "allow_scodoc_login",
+ is_flag=True,
+ help="autorise login via ScoDoc",
+)
+@click.option(
+ "--disable-scodoc-login",
+ "disable_scodoc_login",
+ is_flag=True,
+ help="interdit login via ScoDoc",
+)
+@click.option(
+ "-v",
+ "--verbose",
+ "verbose",
+ is_flag=True,
+ help="verbose: affiche l'état après modif.",
+)
+def user_edit(
+ username,
+ cas_id: str = None,
+ allow_cas_login=None,
+ allow_scodoc_login=None,
+ disable_cas_login=None,
+ disable_scodoc_login=None,
+ activate=None,
+ deactivate=None,
+ verbose=False,
+):
+ """Add or remove a role to the given user in the given dept"""
+ user: User = User.query.filter_by(user_name=username).first()
+ if not user:
+ sys.stderr.write(f"user_role: user {username} does not exists\n")
+ return 1
+ if cas_id:
+ user.cas_id = cas_id
+ if allow_cas_login:
+ user.cas_allow_login = True
+ if disable_cas_login:
+ user.cas_allow_login = False
+ if allow_scodoc_login:
+ user.cas_allow_scodoc_login = True
+ if disable_scodoc_login:
+ user.cas_allow_scodoc_login = False
+ if activate:
+ user.active = True
+ if deactivate:
+ user.active = False
+ db.session.add(user)
+ db.session.commit()
+ if verbose:
+ for k, v in sorted(user.to_dict().items()):
+ print(f"{k} : {v}")
+
+
def abort_if_false(ctx, param, value):
if not value:
ctx.abort()
From ebff6f61b284c9829f9ce1ffd7848473a1d7324e Mon Sep 17 00:00:00 2001
From: ilona
Date: Tue, 7 Jan 2025 13:22:43 +0100
Subject: [PATCH 7/7] cosmetic
---
app/api/formsemestres.py | 2 +-
app/views/notes.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py
index 4d91bc4f..af73d6f2 100644
--- a/app/api/formsemestres.py
+++ b/app/api/formsemestres.py
@@ -823,7 +823,7 @@ def formsemestre_get_description(formsemestre_id: int):
@as_json
def formsemestre_edit_description(formsemestre_id: int):
"""Modifie description externe du formsemestre.
- Les images peuvent êtres passées dans el json, encodées en base64.
+ Les images peuvent êtres passées dans le json, encodées en base64.
formsemestre_id : l'id du formsemestre
SAMPLES
diff --git a/app/views/notes.py b/app/views/notes.py
index 538b0c8f..eedb4c67 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -746,7 +746,7 @@ def index_html():