diff --git a/app/forms/config_logos.py b/app/forms/config_logos.py new file mode 100644 index 00000000..d01cc5cc --- /dev/null +++ b/app/forms/config_logos.py @@ -0,0 +1,451 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2023 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 +# +############################################################################## + +""" +Formulaires configuration logos + +Contrib @jmp, dec 21 +""" + +from flask import flash, url_for, redirect, render_template +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed +from wtforms import SubmitField, FormField, validators, FieldList +from wtforms import ValidationError +from wtforms.fields.simple import StringField, HiddenField + +from app.models import Departement +from app.scodoc import sco_logos, html_sco_header +from app.scodoc import sco_utils as scu + +from app.scodoc.sco_config_actions import LogoInsert +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_logos import find_logo + + +JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [] + +CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS + +# class ItemForm(FlaskForm): +# """Unused Generic class to document common behavior for classes +# * ScoConfigurationForm +# * DeptForm +# * LogoForm +# Some or all of these implements: +# * Composite design pattern (ScoConfigurationForm and DeptForm) +# - a FieldList(FormField(ItemForm)) +# - FieldListItem are created by browsing the model +# - index dictionnary to provide direct access to a SubItemForm +# - the direct access method (get_form) +# * have some information added to be displayed +# - information are collected from a model object +# Common methods: +# * build(model) (not for LogoForm who has no child) +# for each child: +# * create en entry in the FieldList for each subitem found +# * update self.index +# * fill_in additional information into the form +# * recursively calls build for each chid +# some spécific information may be added after standard processing +# (typically header/footer description) +# * preview(data) +# check the data from a post and build a list of operations that has to be done. +# for a two phase process: +# * phase 1 (list all opérations) +# * phase 2 (may be confirmation and execure) +# - if no op found: return to the form with a message 'Aucune modification trouvée' +# - only one operation found: execute and go to main page +# - more than 1 operation found. asked form confirmation (and execution if confirmed) +# +# Someday we'll have time to refactor as abstract classes but Abstract FieldList makes this +# a bit complicated +# """ + +# Terminology: +# dept_id : identifies a dept in modele (= list_logos()). None designates globals logos +# dept_key : identifies a dept in this form only (..index[dept_key], and fields 'dept_key'). +# 'GLOBAL' designates globals logos (we need a string value to set up HiddenField +GLOBAL = "_" + + +def dept_id_to_key(dept_id): + if dept_id is None: + return GLOBAL + return dept_id + + +def dept_key_to_id(dept_key): + if dept_key == GLOBAL: + return None + return dept_key + + +def logo_name_validator(message=None): + def validate_logo_name(form, field): + name = field.data if field.data else "" + if "." in name: + raise ValidationError(message) + if not scu.is_valid_filename(name): + raise ValidationError(message) + + return validate_logo_name + + +class AddLogoForm(FlaskForm): + """Formulaire permettant l'ajout d'un logo (dans un département)""" + + from app.scodoc.sco_config_actions import LogoInsert + + dept_key = HiddenField() + name = StringField( + label="Nom", + validators=[ + logo_name_validator("Nom de logo invalide (alphanumérique, _)"), + validators.Length( + max=20, message="Un nom ne doit pas dépasser 20 caractères" + ), + validators.DataRequired("Nom de logo requis (alphanumériques ou '-')"), + ], + ) + upload = FileField( + label="Sélectionner l'image", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {', '.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", + ), + validators.DataRequired("Fichier image manquant"), + ], + ) + do_insert = SubmitField("ajouter une image") + + def __init__(self, *args, **kwargs): + kwargs["meta"] = {"csrf": False} + super().__init__(*args, **kwargs) + + def id(self): + return f"id=add_{self.dept_key.data}" + + def validate_name(self, name): + dept_id = dept_key_to_id(self.dept_key.data) + if dept_id == GLOBAL: + dept_id = None + if find_logo(logoname=name.data, dept_id=dept_id, strict=True) is not None: + raise validators.ValidationError("Un logo de même nom existe déjà") + + def select_action(self): + if self.data["do_insert"]: + if self.validate(): + return LogoInsert.build_action(self.data) + return None + + def opened(self): + if self.do_insert.data: + if self.name.errors: + return "open" + if self.upload.errors: + return "open" + return "" + + +class LogoForm(FlaskForm): + """Embed both presentation of a logo (cf. template file configuration.j2) + and all its data and UI action (change, delete)""" + + dept_key = HiddenField() + logo_id = HiddenField() + upload = FileField( + label="Remplacer l'image", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {', '.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", + ) + ], + ) + do_delete = SubmitField("Supprimer") + do_rename = SubmitField("Renommer") + new_name = StringField( + label="Nom", + validators=[ + logo_name_validator("Nom de logo invalide (alphanumérique, _)"), + validators.Length( + max=20, message="Un nom ne doit pas dépasser 20 caractères" + ), + validators.DataRequired("Nom de logo requis (alphanumériques ou '-')"), + ], + ) + + def __init__(self, *args, **kwargs): + kwargs["meta"] = {"csrf": False} + super().__init__(*args, **kwargs) + logo = find_logo( + logoname=self.logo_id.data, dept_id=dept_key_to_id(self.dept_key.data) + ) + if logo is None: + raise ScoValueError("logo introuvable") + self.logo = logo.select() + self.description = None + self.titre = None + self.can_delete = True + if self.dept_key.data == GLOBAL: + if self.logo_id.data == "header": + self.can_delete = False + self.description = "" + self.titre = "Logo en-tête" + if self.logo_id.data == "footer": + self.can_delete = False + self.titre = "Logo pied de page" + self.description = "" + else: + if self.logo_id.data == "header": + self.description = "Se substitue au header défini au niveau global" + self.titre = "Logo en-tête" + if self.logo_id.data == "footer": + self.description = "Se substitue au footer défini au niveau global" + self.titre = "Logo pied de page" + + def id(self): + idstring = f"{self.dept_key.data}_{self.logo_id.data}" + return f"id={idstring}" + + def select_action(self): + from app.scodoc.sco_config_actions import LogoRename + from app.scodoc.sco_config_actions import LogoUpdate + from app.scodoc.sco_config_actions import LogoDelete + + if self.do_delete.data and self.can_delete: + return LogoDelete.build_action(self.data) + if self.upload.data and self.validate(): + return LogoUpdate.build_action(self.data) + if self.do_rename.data and self.validate(): + return LogoRename.build_action(self.data) + return None + + def opened(self): + if self.upload.data and self.upload.errors: + return "open" + if self.new_name.data and self.new_name.errors: + return "open" + return "" + + +class DeptForm(FlaskForm): + dept_key = HiddenField() + dept_name = HiddenField() + add_logo = FormField(AddLogoForm) + logos = FieldList(FormField(LogoForm)) + + def __init__(self, *args, **kwargs): + kwargs["meta"] = {"csrf": False} + super().__init__(*args, **kwargs) + + def id(self): + return f"id=DEPT_{self.dept_key.data}" + + def is_local(self): + if self.dept_key.data == GLOBAL: + return None + return True + + def select_action(self): + action = self.add_logo.form.select_action() + if action: + return action + for logo_entry in self.logos.entries: + logo_form = logo_entry.form + action = logo_form.select_action() + if action: + return action + return None + + def get_form(self, logoname=None): + """Retourne le formulaire associé à un logo. None si pas trouvé""" + if logoname is None: # recherche de département + return self + return self.index.get(logoname, None) + + def opened(self): + if self.add_logo.opened(): + return "open" + for logo_form in self.logos: + if logo_form.opened(): + return "open" + return "" + + def count(self): + compte = len(self.logos.entries) + if compte == 0: + return "vide" + elif compte == 1: + return "1 élément" + else: + return f"{compte} éléments" + + +def _make_dept_id_name(): + """Cette section assure que tous les départements sont traités (y compris ceux qu'ont pas de logo au départ) + et détermine l'ordre d'affichage des DeptForm (GLOBAL d'abord, puis par ordre alpha de nom de département) + -> [ (None, None), (dept_id, dept_name)... ]""" + depts = [(None, GLOBAL)] + for dept in ( + Departement.query.filter_by(visible=True).order_by(Departement.acronym).all() + ): + depts.append((dept.id, dept.acronym)) + return depts + + +def _ordered_logos(modele): + """sort logoname alphabetically but header and footer moved at start. (since there is no space in logoname)""" + + def sort(name): + if name == "header": + return " 0" + if name == "footer": + return " 1" + return name + + order = sorted(modele.keys(), key=sort) + return order + + +def _make_dept_data(dept_id, dept_name, modele): + dept_key = dept_id_to_key(dept_id) + data = { + "dept_key": dept_key, + "dept_name": dept_name, + "add_logo": {"dept_key": dept_key}, + } + logos = [] + if modele is not None: + for name in _ordered_logos(modele): + logos.append({"dept_key": dept_key, "logo_id": name}) + data["logos"] = logos + return data + + +def _make_depts_data(modele): + data = [] + for dept_id, dept_name in _make_dept_id_name(): + data.append( + _make_dept_data( + dept_id=dept_id, dept_name=dept_name, modele=modele.get(dept_id, None) + ) + ) + return data + + +def _make_data(modele): + data = { + "depts": _make_depts_data(modele=modele), + } + return data + + +class LogosConfigurationForm(FlaskForm): + "Panneau de configuration des logos" + depts = FieldList(FormField(DeptForm)) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # def _set_global_logos_infos(self): + # "specific processing for globals items" + # global_header = self.get_form(logoname="header") + # global_header.description = ( + # "image placée en haut de certains documents documents PDF." + # ) + # global_header.titre = "Logo en-tête" + # global_header.can_delete = False + # global_footer = self.get_form(logoname="footer") + # global_footer.description = ( + # "image placée en pied de page de certains documents documents PDF." + # ) + # global_footer.titre = "Logo pied de page" + # global_footer.can_delete = False + + # def _build_dept(self, dept_id, dept_name, modele): + # dept_key = dept_id or GLOBAL + # data = {"dept_key": dept_key} + # entry = self.depts.append_entry(data) + # entry.form.build(dept_name, modele.get(dept_id, {})) + # self.index[str(dept_key)] = entry.form + + # def build(self, modele): + # "Build the Form hierachy (DeptForm, LogoForm) and add extra data (from modele)" + # # if entries already initialized (POST). keep subforms + # self.index = {} + # # create entries in FieldList (one entry per dept + # for dept_id, dept_name in self.dept_id_name: + # self._build_dept(dept_id=dept_id, dept_name=dept_name, modele=modele) + # self._set_global_logos_infos() + + def get_form(self, dept_key=GLOBAL, logoname=None): + """Retourne un formulaire: + * pour un département (get_form(dept_id)) ou à un logo (get_form(dept_id, logname)) + * propre à un département (get_form(dept_id, logoname) ou global (get_form(logoname)) + retourne None si le formulaire cherché ne peut être trouvé + """ + dept_form = self.index.get(dept_key, None) + if dept_form is None: # département non trouvé + return None + return dept_form.get_form(logoname) + + def select_action(self): + for dept_entry in self.depts: + dept_form = dept_entry.form + action = dept_form.select_action() + if action: + return action + return None + + +def config_logos(): + "Page de configuration des logos" + # nb: le contrôle d'accès (SuperAdmin) doit être fait dans la vue + form = LogosConfigurationForm( + data=_make_data( + modele=sco_logos.list_logos(), + ) + ) + if form.is_submitted(): + action = form.select_action() + if action: + action.execute() + flash(action.message) + return redirect(url_for("scodoc.configure_logos")) + else: + if not form.validate(): + scu.flash_errors(form) + + return render_template( + "config_logos.j2", + scodoc_dept=None, + title="Configuration ScoDoc", + form=form, + ) diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index 5c86d9ff..e3993f3d 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -1243,7 +1243,7 @@ class BasePreferences(object): { "initvalue": 0, "title": "Afficher toutes les évaluations sur les bulletins", - "explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives; n'affecte pas le calcul des moyennes)", + "explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives)", "input_type": "boolcheckbox", "category": "bul", "labels": ["non", "oui"], @@ -2069,7 +2069,10 @@ class BasePreferences(object): self.load() H = [ - html_sco_header.sco_header(page_title="Préférences"), + html_sco_header.sco_header( + page_title="Préférences", + javascripts=["js/detail_summary_persistence.js"], + ), f"

Préférences globales pour {scu.ScoURL()}

", # f"""

modification des logos du département (pour documents pdf)

""" @@ -2210,10 +2213,14 @@ class SemPreferences: ) # a bug ! sem = sco_formsemestre.get_formsemestre(self.formsemestre_id) H = [ - html_sco_header.html_sem_header("Préférences du semestre"), + html_sco_header.html_sem_header( + "Préférences du semestre", + javascripts=["js/detail_summary_persistence.js"], + ), """

Les paramètres définis ici ne s'appliqueront qu'à ce semestre.

Attention: cliquez sur "Enregistrer les modifications" en bas de page pour appliquer vos changements !

+ pour reinitialisés l'état de toutes +les balises (fermées par défaut sauf si attribut open déjà activé dans le code source de la page) + +*/ + +const ID_ATTRIBUTE = "ds_id" + +function genere_id(detail, idnum) { + let id = "ds_" + idnum; + if (detail.getAttribute("id")) { + id = "#" + detail.getAttribute("id"); + } + detail.setAttribute(ID_ATTRIBUTE, id); + return id; +} + +// remise à l'état initial. doit être exécuté dès le chargement de la page pour que l'état 'open' +// des balises soit celui indiqué par le serveur (et donc indépendant du localstorage) +function reset_detail(detail, id) { + let opened = detail.getAttribute("open"); + if (opened) { + detail.setAttribute("open", true); + localStorage.setItem(id, true); + } else { + detail.removeAttribute("open"); + localStorage.setItem(id, false); + } +} + +function restore_detail(detail, id) { + let status = localStorage.getItem(id); + if (status == "true") { + detail.setAttribute("open", true); + } else { + detail.removeAttribute("open"); + } +} + +function add_listener(detail) { + detail.addEventListener('toggle', (e) => { + let id = e.target.getAttribute(ID_ATTRIBUTE); + let ante = e.target.getAttribute("open"); + if (ante == null) { + localStorage.setItem(id, false); + } else { + localStorage.setItem(id, true); + } + e.stopPropagation(); + }) +} + +function reset_ds() { + let idnum = 0; + keepDetails = true; + details = document.querySelectorAll("details") + details.forEach(function (detail) { + let id = genere_id(detail, idnum); + console.log("Processing " + id) + if (keepDetails) { + restore_detail(detail, id); + } else { + reset_detail(detail, id); + } + add_listener(detail); + idnum++; + }); +} + +window.addEventListener('load', function() { + console.log("details/summary persistence ON"); + reset_ds(); +}) diff --git a/app/templates/config_logos.j2 b/app/templates/config_logos.j2 index 03808c1b..918c1f9a 100644 --- a/app/templates/config_logos.j2 +++ b/app/templates/config_logos.j2 @@ -20,7 +20,7 @@ {% endmacro %} {% macro render_add_logo(add_logo_form) %} -
+

Ajouter un logo

@@ -33,7 +33,7 @@ {% endmacro %} {% macro render_logo(dept_form, logo_form) %} -
+
{{ logo_form.hidden_tag() }} {% if logo_form.titre %} @@ -95,6 +95,7 @@ +
{{ form.hidden_tag() }} @@ -104,7 +105,7 @@ {% for dept_entry in form.depts.entries %} {% set dept_form = dept_entry.form %} {{ dept_entry.form.hidden_tag() }} -
+
{% if dept_entry.form.is_local() %}