This commit is contained in:
Emmanuel Viennet 2025-01-07 13:24:57 +01:00
commit d6efe13c8c
16 changed files with 831 additions and 35 deletions

View File

@ -823,7 +823,7 @@ def formsemestre_get_description(formsemestre_id: int):
@as_json @as_json
def formsemestre_edit_description(formsemestre_id: int): def formsemestre_edit_description(formsemestre_id: int):
"""Modifie description externe du formsemestre. """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 formsemestre_id : l'id du formsemestre
SAMPLES SAMPLES

View File

@ -911,7 +911,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
""" """
) )
if formsemestres: if not formsemestres:
H.append( H.append(
f""" f"""
<li><a class="stdlink" href="{ <li><a class="stdlink" href="{

View File

@ -14,9 +14,11 @@ from wtforms import (
TextAreaField, TextAreaField,
SubmitField, 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.forms import ScoDocForm
from app.formsemestre.import_from_descr import describe_field
from app.models import FORMSEMESTRE_DISPOSITIFS from app.models import FORMSEMESTRE_DISPOSITIFS
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -46,44 +48,50 @@ class FormSemestreDescriptionForm(ScoDocForm):
description = TextAreaField( description = TextAreaField(
"Description", "Description",
validators=[Optional()], validators=[Optional()],
description="""texte libre : informations description=describe_field("descr_description"),
sur le contenu, les objectifs, les modalités d'évaluation, etc.""",
) )
horaire = StringField( horaire = StringField(
"Horaire", validators=[Optional()], description="ex: les lundis 9h-12h" "Horaire", validators=[Optional()], description=describe_field("descr_horaire")
) )
date_debut_inscriptions = DateDMYField( date_debut_inscriptions = DateDMYField(
"Date de début des inscriptions", "Date de début des inscriptions",
description="""date d'ouverture des inscriptions description=describe_field("descr_date_debut_inscriptions"),
(laisser vide pour autoriser tout le temps)""",
render_kw={ render_kw={
"id": "date_debut_inscriptions", "id": "date_debut_inscriptions",
}, },
) )
date_fin_inscriptions = DateDMYField( date_fin_inscriptions = DateDMYField(
"Date de fin des inscriptions", "Date de fin des inscriptions",
description=describe_field("descr_date_fin_inscriptions"),
render_kw={ render_kw={
"id": "date_fin_inscriptions", "id": "date_fin_inscriptions",
}, },
) )
image = FileField( image = FileField(
"Image", validators=[Optional()], description="Image illustrant cette formation" "Image", validators=[Optional()], description=describe_field("descr_image")
) )
campus = StringField( 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 = SelectField(
"Dispositif", "Dispositif",
choices=FORMSEMESTRE_DISPOSITIFS.items(), choices=FORMSEMESTRE_DISPOSITIFS.items(),
coerce=int, coerce=int,
description="modalité de formation", description=describe_field("descr_dispositif"),
validators=[AnyOf(FORMSEMESTRE_DISPOSITIFS.keys())], validators=[AnyOf(FORMSEMESTRE_DISPOSITIFS.keys())],
) )
dispositif_descr = TextAreaField(
"Description du dispositif",
validators=[Optional()],
description=describe_field("descr_dispositif_descr"),
)
modalites_mcc = TextAreaField( modalites_mcc = TextAreaField(
"Modalités de contrôle des connaissances", "Modalités de contrôle des connaissances",
validators=[Optional()], validators=[Optional()],
description="texte libre", description=describe_field("descr_modalites_mcc"),
) )
photo_ens = FileField( photo_ens = FileField(
"Photo de l'enseignant(e)", "Photo de l'enseignant(e)",
@ -94,13 +102,12 @@ class FormSemestreDescriptionForm(ScoDocForm):
"Public visé", validators=[Optional()], description="ex: débutants" "Public visé", validators=[Optional()], description="ex: débutants"
) )
prerequis = TextAreaField( prerequis = TextAreaField(
"Prérequis", validators=[Optional()], description="texte libre" "Prérequis", validators=[Optional()], description="texte libre. HTML autorisé."
) )
responsable = StringField( responsable = StringField(
"Responsable", "Responsable",
validators=[Optional()], validators=[Optional()],
description="""nom de l'enseignant de la formation, ou personne description=describe_field("descr_responsable"),
chargée de l'organisation du semestre.""",
) )
wip = BooleanField( wip = BooleanField(
@ -110,3 +117,26 @@ class FormSemestreDescriptionForm(ScoDocForm):
submit = SubmitField("Enregistrer") submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) 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"),
],
)
image_archive_file = FileField(
"Fichier zip avec les images",
validators=[
FileAllowed(["zip"], "Fichier .zip uniquement"),
],
)
create_formation = BooleanField(
"Créer les programmes de formations s'ils n'existent pas", default=True
)
submit = SubmitField("Importer et créer les formations")
cancel = SubmitField("Annuler")

View File

@ -0,0 +1,382 @@
# -*- 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, url_for
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.codes_cursus import CodesCursus
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=True, default=1
),
FieldDescr(
"formation_commentaire",
"commentaire à usage interne",
optional=True,
default="",
),
)
# --- 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 (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",
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 (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",
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):
if value is None or value == "":
if not field.optional:
raise ValueError(f"champs {field.key} requis")
return field.default
match field.type:
case "str":
return str(value).strip()
case "int":
return int(value)
case "image":
return str(value).strip() # image path
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):
try:
return datetime.date.fromisoformat(value)
except ValueError:
# try datetime
return datetime.datetime.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.datetime.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=2):
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}",
dest_label="Reprendre",
dest_url=url_for(
"notes.formsemestres_import_from_description",
scodoc_dept=g.scodoc_dept,
),
) 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.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(
{
"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"],
"coefficient": 1.0,
}
)
return formation
def create_formsemestre_from_description(
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 = (
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)
db.session.flush()
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]
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,
"formsemestre_id": formsemestre.id,
"responsable_id": user.id,
}
)
# --- FormSemestreDescription
args = {
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)
#
db.session.commit()
return formsemestre
def create_formsemestres_from_description(
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, images=images
)
for data in infos
]

View File

@ -381,12 +381,14 @@ class Matiere(ScoDocModel):
@classmethod @classmethod
def create_from_dict(cls, data: dict) -> "Matiere": def create_from_dict(cls, data: dict) -> "Matiere":
"""Create matière from dict. Log, news, cache. """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. Commit session.
""" """
# check ue # check ue
if data.get("ue_id") is None: if data.get("ue_id") is None:
raise ScoValueError("UE id missing") if data.get("ue") is None:
raise ScoValueError("UE missing")
else: # check ue_id
_ = UniteEns.get_ue(data["ue_id"]) _ = UniteEns.get_ue(data["ue_id"])
mat = super().create_from_dict(data) mat = super().create_from_dict(data)
@ -394,6 +396,7 @@ class Matiere(ScoDocModel):
db.session.refresh(mat) db.session.refresh(mat)
# news # news
formation = mat.ue.formation formation = mat.ue.formation
log(f"Matiere.create_from_dict: created {mat} from {data}")
ScolarNews.add( ScolarNews.add(
typ=ScolarNews.NEWS_FORM, typ=ScolarNews.NEWS_FORM,
obj=formation.id, obj=formation.id,

View File

@ -39,8 +39,12 @@ class FormSemestreDescription(models.ScoDocModel):
dispositif = db.Column(db.Integer, nullable=False, default=0, server_default="0") dispositif = db.Column(db.Integer, nullable=False, default=0, server_default="0")
"0 présentiel, 1 online, 2 hybride" "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="") 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_ens = db.Column(db.LargeBinary(), nullable=True)
"photo de l'enseignant(e)" "photo de l'enseignant(e)"
public = db.Column(db.Text(), nullable=False, default="", server_default="") public = db.Column(db.Text(), nullable=False, default="", server_default="")

View File

@ -77,7 +77,9 @@ class ModuleImpl(ScoDocModel):
# check required args # check required args
for required_arg in ("formsemestre_id", "module_id", "responsable_id"): for required_arg in ("formsemestre_id", "module_id", "responsable_id"):
if required_arg not in data: 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"]) _ = FormSemestre.get_formsemestre(data["formsemestre_id"])
_ = Module.get_instance(data["module_id"]) _ = Module.get_instance(data["module_id"])
if not db.session.get(User, data["responsable_id"]): if not db.session.get(User, data["responsable_id"]):

View File

@ -210,14 +210,27 @@ class Module(models.ScoDocModel):
from app.models.formations import Formation from app.models.formations import Formation
# check required arguments # check required arguments
for required_arg in ("code", "formation_id", "ue_id"): if "code" not in data:
if required_arg not in data: raise ScoValueError("Module.create_from_dict: missing 'code' argument")
raise ScoValueError(f"missing argument: {required_arg}")
if not data["code"]: if not data["code"]:
raise ScoValueError("module code must be non empty") raise ScoValueError(
# Check formation "Module.create_from_dict: module code must be non empty"
formation = Formation.get_formation(data["formation_id"]) )
# 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"]) ue = UniteEns.get_ue(data["ue_id"])
# Check formation
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 # refuse de créer un module APC avec semestres semestre du module != semestre de l'UE
if formation.is_apc(): if formation.is_apc():
if int(data.get("semestre_id", 1)) != ue.semestre_idx: if int(data.get("semestre_id", 1)) != ue.semestre_idx:

View File

@ -55,17 +55,17 @@ def index_html(showcodes=0, showsemtable=0, export_table_formsemestres=False):
current_formsemestres = ( current_formsemestres = (
FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=True) FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=True)
.filter(FormSemestre.modalite != "EXT") .filter(FormSemestre.modalite != "EXT")
.order_by(desc(FormSemestre.date_debut)) .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
) )
locked_formsemestres = ( locked_formsemestres = (
FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=False) FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=False)
.filter(FormSemestre.modalite != "EXT") .filter(FormSemestre.modalite != "EXT")
.order_by(desc(FormSemestre.date_debut)) .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
) )
formsemestres = ( formsemestres = (
FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id) FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id)
.filter(FormSemestre.modalite != "EXT") .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 if showsemtable: # table de tous les formsemestres
table = _sem_table_gt( table = _sem_table_gt(

View File

@ -470,9 +470,11 @@ def excel_simple_table(
lines: list[list[str]] = None, lines: list[list[str]] = None,
sheet_name: str = "feuille", sheet_name: str = "feuille",
titles_styles=None, 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) ws = ScoExcelSheet(sheet_name)
if titles is None: if titles is None:
titles = [] titles = []
@ -510,7 +512,11 @@ def excel_simple_table(
def excel_bytes_to_list(bytes_content) -> tuple[list, list[list]]: 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: try:
filelike = io.BytesIO(bytes_content) filelike = io.BytesIO(bytes_content)
return _excel_to_list(filelike) return _excel_to_list(filelike)
@ -522,8 +528,31 @@ def excel_bytes_to_list(bytes_content) -> tuple[list, list[list]]:
) from exc ) 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[1:]]
def excel_file_to_list(filelike) -> tuple[list, list[list]]: 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: try:
return _excel_to_list(filelike) return _excel_to_list(filelike)
except Exception as exc: 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]]: 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) workbook = _open_workbook(filelike)
diag = [] # liste de chaines pour former message d'erreur diag = [] # liste de chaines pour former message d'erreur
if len(workbook.sheetnames) < 1: if len(workbook.sheetnames) < 1:

View File

@ -0,0 +1,59 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{% block app_content %}
<h2 class="formsemestre">Importation / création de semestres de formation monomodules</h2>
<div class="scobox help explanation"> <p style="color: red">Fonction réservé à
la création de semestres ne comprenant qu'un seul module (dans une UE unique),
décrits dans un fichier excel. </p>
<p>L'opération se déroule en plusieurs étapes:
</p>
<ol>
<li style="margin-bottom:8px;"> 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.
</li>
<li style="margin-bottom:8px;">Vous ajoutez une ligne par semestre/formation
à créer, avec votre logiciel tableur préféré. En option, les colonnes images donnent
les noms complets (avec chemin) d'un fichier dans l'archive zip associée.
</li>
<li style="margin-bottom:32px;">Revenez sur cette page et chargez le fichier dans ScoDoc.
</li>
</ol>
</div>
<div class="scobox">
<div class="scobox-title">Étape 1: exporter fichier Excel à charger</div>
<ul>
<li><a class="stdlink" href="{{
url_for('notes.formsemestres_import_from_description_sample', scodoc_dept=g.scodoc_dept)
}}">Obtenir la feuille excel à remplir</a>
</li>
</ul>
</div>
<div class="scobox">
<div class="scobox-title">Étape 2: charger le fichier Excel rempli</div>
<div class="row">
<div class="col-md-8">
{{ wtf.quick_form(form) }}
</div>
</div>
</div>
<div class="scobox help explanation">
<div class="scobox-title">Description des champs du fichier excel</div>
<ul>
{% for field, descr in fields_description.items() %}
<li><tt>{{field}}</tt>&nbsp;: <span>{{descr}}</span></li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "sco_page.j2" %}
{% import 'wtf.j2' as wtf %}
{% block app_content %}
<h2 class="formsemestre">Semestres créés</h2>
<div class="scobox">
<div class="scobox-title">Les semestres suivants ont été créés:</div>
<ul>
{% for formsemestre in formsemestres %}
<li>{{ formsemestre.html_link_status() | safe }}
</li>
{% endfor %}
</ul>
</div>
<div>
<a class="stdlink" href="{{
url_for('scolar.index_html', scodoc_dept=g.scodoc_dept)}}">Accueil semestres</a>
</div>
{% endblock %}

View File

@ -740,6 +740,16 @@ def index_html():
</li> </li>
</ul> </ul>
</div> </div>
<div class="scobox">
<div class="scobox-title">Opérations avancées réservées aux connaisseuses</div>
<ul class="sco-links">
<li><a class="stdlink" href="{
url_for('notes.formsemestres_import_from_description', scodoc_dept=g.scodoc_dept)
}">Importer des sessions monomodules</a> (expérimental)
</li>
</ul>
</div>
""" """
) )

View File

@ -31,6 +31,7 @@ Emmanuel Viennet, 2023
import datetime import datetime
import io import io
import zipfile
from flask import flash, redirect, render_template, url_for from flask import flash, redirect, render_template, url_for
from flask import current_app, g, request from flask import current_app, g, request
@ -47,6 +48,7 @@ from app.forms.formsemestre import (
edit_modimpls_codes_apo, edit_modimpls_codes_apo,
edit_description, edit_description,
) )
from app.formsemestre import import_from_descr
from app.models import ( from app.models import (
Formation, Formation,
FormSemestre, FormSemestre,
@ -354,3 +356,122 @@ def edit_formsemestre_description(formsemestre_id: int):
sco=ScoData(formsemestre=formsemestre), sco=ScoData(formsemestre=formsemestre),
title="Modif. description semestre", 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]
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, images=images
)
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",
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)
},
)
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.
"""
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 "{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,
),
dest_label="Reprendre",
)
info[key] = images[info[key]]
@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
)

View File

@ -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")

View File

@ -430,6 +430,91 @@ def user_change_login(user_name, new_user_name):
user.change_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): def abort_if_false(ctx, param, value):
if not value: if not value:
ctx.abort() ctx.abort()