# -*- 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/", 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/", 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/") @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/") @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//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 )