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 %} +

Importation / création de semestres de formation monomodules

+ +

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: +

+
    +
  1. 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. +
  2. + +
  3. Vous ajoutez une ligne par semestre/formation + à créer, avec votre logiciel tableur préféré. +
  4. + +
  5. Revenez sur cette page et chargez le fichier dans ScoDoc. +
  6. +
+
+ +
+
É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 %} +

Semestres créés

+ +
+
Les semestres suivants ont été créés:
+ +
+ +{% 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 @@ +
    + Accueil semestres +
    + {% 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():