# -*- 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 import flask 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 ScoPermissionDenied, 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 ) @bp.route("/formsemestre_flip_lock/", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) # acces vérifié dans la vue def formsemestre_flip_lock(formsemestre_id: int): "Changement de l'état de verrouillage du semestre. Si GET, dialogue de confirmation." formsemestre = FormSemestre.get_formsemestre(formsemestre_id) dest_url = url_for( "notes.formsemestre_status", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ) if not formsemestre.can_be_edited_by(allow_locked=True): raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url) if request.method == "GET": msg = "verrouillage" if formsemestre.etat else "déverrouillage" return scu.confirm_dialog( f"

Confirmer le {msg} du semestre ?

", help_msg="""Les notes d'un semestre verrouillé ne peuvent plus être modifiées. Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment (par son responsable ou un administrateur).
Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié. """, dest_url="", cancel_url=dest_url, parameters={"formsemestre_id": formsemestre_id}, ) formsemestre.flip_lock() db.session.commit() return flask.redirect(dest_url) @bp.route("/formsemestres_unlock", defaults={"unlock": True}, methods=["GET", "POST"]) @bp.route("/formsemestres_lock", defaults={"unlock": False}, methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) # acces vérifié dans la vue def formsemestres_lock(unlock: bool = False): "Lock formsemestres (or unlock if unlock is True). If GET, asks for confirmation." dest_url = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept, showsemtable=1) try: formsemestre_ids = request.args.getlist("formsemestre_ids", type=int) except ValueError as exc: raise ScoValueError("argument formsemestre_ids invalide") from exc if not formsemestre_ids: raise ScoValueError("aucun semestre sélectionné") formsemestres = [ FormSemestre.get_formsemestre(formsemestre_id) for formsemestre_id in formsemestre_ids ] for formsemestre in formsemestres: if not formsemestre.can_be_edited_by(allow_locked=True): raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url) if request.method == "GET": return scu.confirm_dialog( f"

Confirmer le {'dé' if unlock else ''}verrouillage des semestres ?

", help_msg=f"""
Les semestres suivants seront modifiés:
  • {'
  • '.join([ s.html_link_status() for s in formsemestres ])}
Les notes d'un semestre verrouillé ne peuvent plus être modifiées. Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment (par son responsable ou un administrateur).
Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié.
""", dest_url="", cancel_url=dest_url, parameters={"formsemestre_ids": formsemestre_ids}, ) for formsemestre in formsemestres: formsemestre.etat = unlock db.session.add(formsemestre) db.session.commit() if unlock: flash(f"{len(formsemestres)} semestres déverrouillés") else: flash(f"{len(formsemestres)} semestres verrouillés") return redirect(dest_url) @bp.route( "/formsemestres_enable_publish", defaults={"enable": True}, methods=["GET", "POST"] ) @bp.route( "/formsemestres_disable_publish", defaults={"enable": False}, methods=["GET", "POST"], ) @scodoc @permission_required(Permission.ScoView) # acces vérifié dans la vue def formsemestres_enable_publish(enable: bool = False): """Change état publication bulletins sur portail. Peut affecter un (formsemestre_id) ou plusieurs (formsemestre_ids) semestres. """ arg_name = ( "formsemestre_ids" if "formsemestre_ids" in request.args else "formsemestre_id" ) try: formsemestre_ids = request.args.getlist(arg_name, type=int) except ValueError as exc: raise ScoValueError("argument formsemestre_ids invalide") from exc if not formsemestre_ids: raise ScoValueError("aucun semestre sélectionné") formsemestres = [ FormSemestre.get_formsemestre(formsemestre_id) for formsemestre_id in formsemestre_ids ] dest_url = ( url_for("scolar.index_html", scodoc_dept=g.scodoc_dept, showsemtable=1) if "formsemestre_ids" in request.args else url_for( "notes.formsemestre_status", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestres[0].id, ) ) for formsemestre in formsemestres: if not formsemestre.can_be_edited_by(allow_locked=True): raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url) if request.method == "GET": return scu.confirm_dialog( f"

Confirmer la {'' if enable else 'non'} publication des bulletins ?

", help_msg="""Il est parfois utile de désactiver la diffusion des bulletins sur la passerelle, par exemple pendant la tenue d'un jury ou avant harmonisation des notes.
Ce réglage n'a d'effet que si votre établissement a interfacé ScoDoc avec une passerelle étudiant. """, dest_url="", cancel_url=dest_url, parameters={"formsemestre_ids": formsemestre_ids}, ) for formsemestre in formsemestres: formsemestre.bul_hide_xml = not enable db.session.add(formsemestre) log( f"formsemestres_enable_publish: {formsemestre} -> {formsemestre.bul_hide_xml}" ) db.session.commit() s = "s" if len(formsemestres) > 1 else "" if enable: flash(f"{len(formsemestres)} semestre{s} publié{s}") else: flash(f"{len(formsemestres)} semestre{s} non publié{s}") return flask.redirect(dest_url)