forked from ScoDoc/ScoDoc
Merge branch 'el-odf' of https://scodoc.org/git/viennet/ScoDoc
This commit is contained in:
commit
d6efe13c8c
@ -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
|
||||
|
@ -911,7 +911,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
|
||||
|
||||
"""
|
||||
)
|
||||
if formsemestres:
|
||||
if not formsemestres:
|
||||
H.append(
|
||||
f"""
|
||||
<li><a class="stdlink" href="{
|
||||
|
@ -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,26 @@ 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"),
|
||||
],
|
||||
)
|
||||
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")
|
||||
|
382
app/formsemestre/import_from_descr.py
Normal file
382
app/formsemestre/import_from_descr.py
Normal 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
|
||||
]
|
@ -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,
|
||||
|
@ -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="")
|
||||
|
@ -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"]):
|
||||
|
@ -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:
|
||||
|
@ -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(
|
||||
|
@ -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[1:]]
|
||||
|
||||
|
||||
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:
|
||||
|
59
app/templates/formsemestre/import_from_description.j2
Normal file
59
app/templates/formsemestre/import_from_description.j2
Normal 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> : <span>{{descr}}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
22
app/templates/formsemestre/import_from_description_result.j2
Normal file
22
app/templates/formsemestre/import_from_description_result.j2
Normal 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 %}
|
@ -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> (expérimental)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
@ -47,6 +48,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 +356,122 @@ 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]
|
||||
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
|
||||
)
|
||||
|
33
migrations/versions/bc85a55e63e1_add_dispositif_descr.py
Normal file
33
migrations/versions/bc85a55e63e1_add_dispositif_descr.py
Normal 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")
|
85
scodoc.py
85
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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user