WIP: fonctions d'import de semestres monomodules

This commit is contained in:
ilona 2024-12-21 22:21:10 +01:00
parent bb99cd8aa6
commit 6db0aa36cd
8 changed files with 540 additions and 20 deletions

View File

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

View File

@ -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
]

View File

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

View File

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

View File

@ -0,0 +1,48 @@
{% 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é.
</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>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% 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>
{% endblock %}

View File

@ -740,6 +740,16 @@ def index_html():
</li>
</ul>
</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>
</li>
</ul>
</div>
"""
)

View File

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