ScoDoc/app/views/notes_formsemestre.py

478 lines
18 KiB
Python

# -*- 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
#
##############################################################################
"""
Vues "modernes" des formsemestres
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
import PIL
from app import db, log
from app.decorators import (
scodoc,
permission_required,
)
from app.formations import formation_io, formation_versions
from app.forms.formsemestre import (
change_formation,
edit_modimpls_codes_apo,
edit_description,
)
from app.formsemestre import import_from_descr
from app.models import (
Formation,
FormSemestre,
FormSemestreDescription,
FORMSEMESTRE_DISPOSITIFS,
ScoDocSiteConfig,
)
from app.scodoc import (
sco_edt_cal,
sco_groups_view,
)
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
from app.views import notes_bp as bp
from app.views import ScoData
@bp.route(
"/formsemestre_change_formation/<int:formsemestre_id>", methods=["GET", "POST"]
)
@scodoc
@permission_required(Permission.EditFormSemestre)
def formsemestre_change_formation(formsemestre_id: int):
"""Propose de changer un formsemestre de formation.
Cette opération est bien sûr impossible... sauf si les deux formations sont identiques.
Par exemple, on vient de créer une formation, et on a oublié d'y associé un formsemestre
existant.
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
formation_dict = formation_io.formation_export_dict(
formsemestre.formation, export_external_ues=True, ue_reference_style="acronyme"
)
formations = [
formation
for formation in Formation.query.filter_by(
dept_id=formsemestre.dept_id, acronyme=formsemestre.formation.acronyme
)
if formation.id != formsemestre.formation.id
and formation_versions.formations_are_equals(
formation, formation2_dict=formation_dict
)
]
form = change_formation.gen_formsemestre_change_formation_form(formations)
if request.method == "POST" and form.validate:
if not form.cancel.data:
new_formation_id = form.radio_but.data
if new_formation_id is None: # pas de choix radio
flash("Pas de formation sélectionnée !")
return render_template(
"formsemestre/change_formation.j2",
form=form,
formations=formations,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
)
else:
new_formation: Formation = Formation.query.filter_by(
dept_id=g.scodoc_dept_id, formation_id=new_formation_id
).first_or_404()
formation_versions.formsemestre_change_formation(
formsemestre, new_formation
)
flash("Formation du semestre modifiée")
return redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
# GET
return render_template(
"formsemestre/change_formation.j2",
form=form,
formations=formations,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
)
@bp.route(
"/formsemestre_edit_modimpls_codes/<int:formsemestre_id>", methods=["GET", "POST"]
)
@scodoc
@permission_required(Permission.EditFormSemestre)
def formsemestre_edit_modimpls_codes(formsemestre_id: int):
"""Edition des codes Apogée et EDT"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
form = edit_modimpls_codes_apo.EditModimplsCodesForm(formsemestre)
if request.method == "POST" and form.validate:
if not form.cancel.data:
# record codes
for modimpl in formsemestre.modimpls_sorted:
field_apo = getattr(form, f"modimpl_apo_{modimpl.id}")
field_edt = getattr(form, f"modimpl_edt_{modimpl.id}")
if field_apo and field_edt:
modimpl.code_apogee = field_apo.data.strip() or None
modimpl.edt_id = field_edt.data.strip() or None
log(f"setting codes for {modimpl}: apo={field_apo} edt={field_edt}")
db.session.add(modimpl)
db.session.commit()
flash("Codes enregistrés")
return redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
# GET
for modimpl in formsemestre.modimpls_sorted:
field_apo = getattr(form, f"modimpl_apo_{modimpl.id}")
field_edt = getattr(form, f"modimpl_edt_{modimpl.id}")
field_apo.data = modimpl.code_apogee or ""
field_edt.data = modimpl.edt_id or ""
return render_template(
"formsemestre/edit_modimpls_codes.j2",
form=form,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
)
@bp.route("/formsemestre/edt/<int:formsemestre_id>")
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_edt(formsemestre_id: int):
"""Expérimental: affiche emploi du temps du semestre"""
current_date = request.args.get("current_date")
show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
view = request.args.get("view", "week")
views_names = {"day": "Jour", "month": "Mois", "week": "Semaine"}
if view not in views_names:
raise ScoValueError("valeur invalide pour le paramètre view")
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
cfg = ScoDocSiteConfig.query.filter_by(name="assi_morning_time").first()
hour_start = cfg.value.split(":")[0].lstrip(" 0") if cfg else "7"
cfg = ScoDocSiteConfig.query.filter_by(name="assi_afternoon_time").first()
hour_end = cfg.value.split(":")[0].lstrip(" 0") if cfg else "18"
group_ids = request.args.getlist("group_ids", int)
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids=group_ids,
formsemestre_id=formsemestre_id,
empty_list_select_all=False,
)
return render_template(
"formsemestre/edt.j2",
current_date=current_date,
formsemestre=formsemestre,
hour_start=hour_start,
hour_end=hour_end,
form_groups_choice=sco_groups_view.form_groups_choice(
groups_infos,
submit_on_change=True,
default_deselect_others=False,
with_deselect_butt=True,
),
groups_query_args=groups_infos.groups_query_args,
sco=ScoData(formsemestre=formsemestre),
show_modules_titles=show_modules_titles,
title=f"EDT S{formsemestre.semestre_id} {formsemestre.titre_formation()}",
view=view,
views_names=views_names,
)
@bp.route("/formsemestre/edt_help_config/<int:formsemestre_id>")
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_edt_help_config(formsemestre_id: int):
"""Page d'aide à la configuration de l'extraction emplois du temps
Affiche les identifiants extraits de l'ics et ceux de ScoDoc.
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
edt2group = sco_edt_cal.formsemestre_retreive_groups_from_edt_id(formsemestre)
events_sco, edt_groups_ids = sco_edt_cal.load_and_convert_ics(formsemestre)
return render_template(
"formsemestre/edt_help_config.j2",
formsemestre=formsemestre,
edt2group=edt2group,
edt_groups_ids=edt_groups_ids,
events_sco=events_sco,
sco=ScoData(formsemestre=formsemestre),
ScoDocSiteConfig=ScoDocSiteConfig,
title="Aide configuration EDT",
)
@bp.route(
"/formsemestre_description/<int:formsemestre_id>/edit", methods=["GET", "POST"]
)
@scodoc
@permission_required(Permission.EditFormSemestre)
def edit_formsemestre_description(formsemestre_id: int):
"Edition de la description d'un formsemestre"
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if not formsemestre.description:
formsemestre.description = FormSemestreDescription()
db.session.add(formsemestre)
db.session.commit()
formsemestre_description = formsemestre.description
form = edit_description.FormSemestreDescriptionForm(obj=formsemestre_description)
ok = True
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(
url_for(
"notes.formsemestre_editwithmodules",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
)
# Vérification valeur dispositif
if form.dispositif.data not in FORMSEMESTRE_DISPOSITIFS:
flash("Dispositif inconnu", "danger")
ok = False
# Vérification dates inscriptions
if form.date_debut_inscriptions.data:
try:
date_debut_inscriptions_dt = datetime.datetime.strptime(
form.date_debut_inscriptions.data, scu.DATE_FMT
)
except ValueError:
flash("Date de début des inscriptions invalide", "danger")
form.set_error("date début invalide", form.date_debut_inscriptions)
ok = False
else:
date_debut_inscriptions_dt = None
if form.date_fin_inscriptions.data:
try:
date_fin_inscriptions_dt = datetime.datetime.strptime(
form.date_fin_inscriptions.data, scu.DATE_FMT
)
except ValueError:
flash("Date de fin des inscriptions invalide", "danger")
form.set_error("date fin invalide", form.date_fin_inscriptions)
ok = False
else:
date_fin_inscriptions_dt = None
if ok:
# dates converties
form.date_debut_inscriptions.data = date_debut_inscriptions_dt
form.date_fin_inscriptions.data = date_fin_inscriptions_dt
# Affecte tous les champs sauf les images:
form_image = form.image
del form.image
form_photo_ens = form.photo_ens
del form.photo_ens
form.populate_obj(formsemestre_description)
# Affecte les images:
for field, form_field in (
("image", form_image),
("photo_ens", form_photo_ens),
):
if form_field.data:
image_data = form_field.data.read()
max_length = current_app.config.get("MAX_CONTENT_LENGTH")
if max_length and len(image_data) > max_length:
flash(
f"Image trop grande ({field}), max {max_length} octets",
"danger",
)
return redirect(
url_for(
"notes.edit_formsemestre_description",
formsemestre_id=formsemestre.id,
scodoc_dept=g.scodoc_dept,
)
)
try:
_ = PIL.Image.open(io.BytesIO(image_data))
except PIL.UnidentifiedImageError:
flash(
f"Image invalide ({field}), doit être une image",
"danger",
)
return redirect(
url_for(
"notes.edit_formsemestre_description",
formsemestre_id=formsemestre.id,
scodoc_dept=g.scodoc_dept,
)
)
setattr(formsemestre_description, field, image_data)
db.session.commit()
flash("Description enregistrée", "success")
return redirect(
url_for(
"notes.formsemestre_status",
formsemestre_id=formsemestre.id,
scodoc_dept=g.scodoc_dept,
)
)
return render_template(
"formsemestre/edit_description.j2",
form=form,
formsemestre=formsemestre,
formsemestre_description=formsemestre_description,
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
)