diff --git a/app/pe/pe_tools.py b/app/pe/pe_tools.py index 46e706ee..99adbedd 100644 --- a/app/pe/pe_tools.py +++ b/app/pe/pe_tools.py @@ -44,6 +44,7 @@ import unicodedata import app.scodoc.sco_utils as scu from app import log +from app.scodoc.sco_logos import find_logo PE_DEBUG = 0 @@ -201,11 +202,11 @@ def add_pe_stuff_to_zip(zipfile, ziproot): add_local_file_to_zip(zipfile, ziproot, pathname, "avis/" + filename) # Logos: (add to logos/ directory in zip) - logos_names = ["logo_header.jpg", "logo_footer.jpg"] - for f in logos_names: - logo = os.path.join(scu.SCODOC_LOGOS_DIR, f) - if os.path.isfile(logo): - add_local_file_to_zip(zipfile, ziproot, logo, "avis/logos/" + f) + logos_names = ["header", "footer"] + for name in logos_names: + logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id) + if logo is not None: + add_local_file_to_zip(zipfile, ziproot, logo, "avis/logos/" + logo.filename) # ---------------------------------------------------------------------------------------- diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py index 81df5b1e..b28e9db5 100644 --- a/app/scodoc/sco_bulletins_pdf.py +++ b/app/scodoc/sco_bulletins_pdf.py @@ -51,23 +51,24 @@ Chaque semestre peut si nécessaire utiliser un type de bulletin différent. """ import io -import os import re import time import traceback +from pydoc import html from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate -from flask import g, url_for, request +from flask import g, request import app.scodoc.sco_utils as scu -from app import log +from app import log, ScoValueError from app.scodoc import sco_cache from app.scodoc import sco_formsemestre from app.scodoc import sco_pdf from app.scodoc import sco_preferences from app.scodoc import sco_etud import sco_version +from app.scodoc.sco_logos import find_logo def pdfassemblebulletins( @@ -110,6 +111,17 @@ def pdfassemblebulletins( return data +def replacement_function(match): + balise = match.group(1) + name = match.group(3) + logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id) + if logo is not None: + return r'' % (match.group(2), logo.filepath, match.group(4)) + raise ScoValueError( + 'balise "%s": logo "%s" introuvable' % (html.escape(balise), html.escape(name)) + ) + + def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"): """Process a field given in preferences, returns - if format = 'pdf': a list of Platypus objects @@ -141,24 +153,18 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"): return text # --- PDF format: # handle logos: - image_dir = scu.SCODOC_LOGOS_DIR + "/logos_" + g.scodoc_dept + "/" - if not os.path.exists(image_dir): - image_dir = scu.SCODOC_LOGOS_DIR + "/" # use global logos - if not os.path.exists(image_dir): - log(f"Warning: missing global logo directory ({image_dir})") - image_dir = None - text = re.sub( r"<(\s*)logo(.*?)src\s*=\s*(.*?)>", r"<\1logo\2\3>", text ) # remove forbidden src attribute - if image_dir is not None: - text = re.sub( - r'<\s*logo(.*?)name\s*=\s*"(\w*?)"(.*?)/?>', - r'' % image_dir, - text, - ) - # nota: le match sur \w*? donne le nom du logo et interdit les .. et autres - # tentatives d'acceder à d'autres fichiers ! + text = re.sub( + r'(<\s*logo(.*?)name\s*=\s*"(\w*?)"(.*?)/?>)', + replacement_function, + text, + ) + # nota: le match sur \w*? donne le nom du logo et interdit les .. et autres + # tentatives d'acceder à d'autres fichiers ! + # la protection contre des noms malveillants est aussi assurée par l'utilisation de + # secure_filename dans la classe Logo # log('field: %s' % (text)) return sco_pdf.makeParas(text, style, suppress_empty=suppress_empty_pars) diff --git a/app/scodoc/sco_config_actions.py b/app/scodoc/sco_config_actions.py new file mode 100644 index 00000000..bf633a26 --- /dev/null +++ b/app/scodoc/sco_config_actions.py @@ -0,0 +1,181 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2021 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 +# +############################################################################## + +""" +Module main: page d'accueil, avec liste des départements + +Emmanuel Viennet, 2021 +""" +from app.models import ScoDocSiteConfig +from app.scodoc.sco_logos import write_logo, find_logo, delete_logo +import app +from flask import current_app + + +class Action: + """Base class for all classes describing an action from from config form.""" + + def __init__(self, message, parameters): + self.message = message + self.parameters = parameters + + @staticmethod + def build_action(parameters, stream=None): + """Check (from parameters) if some action has to be done and + then return list of action (or else return empty list).""" + raise NotImplementedError + + def display(self): + """return a str describing the action to be done""" + return self.message.format_map(self.parameters) + + def execute(self): + """Executes the action""" + raise NotImplementedError + + +GLOBAL = "_" + + +class LogoUpdate(Action): + """Action: change a logo + dept_id: dept_id or '_', + logo_id: logo_id, + upload: image file replacement + """ + + def __init__(self, parameters): + super().__init__( + f"Modification du logo {parameters['logo_id']} pour le département {parameters['dept_id']}", + parameters, + ) + + @staticmethod + def build_action(parameters): + dept_id = parameters["dept_key"] + if dept_id == GLOBAL: + dept_id = None + parameters["dept_id"] = dept_id + if parameters["upload"] is not None: + return LogoUpdate(parameters) + return None + + def execute(self): + current_app.logger.info(self.message) + write_logo( + stream=self.parameters["upload"], + dept_id=self.parameters["dept_id"], + name=self.parameters["logo_id"], + ) + + +class LogoDelete(Action): + """Action: Delete an existing logo + dept_id: dept_id or '_', + logo_id: logo_id + """ + + def __init__(self, parameters): + super().__init__( + f"Suppression du logo {parameters['logo_id']} pour le département {parameters['dept_id']}.", + parameters, + ) + + @staticmethod + def build_action(parameters): + parameters["dept_id"] = parameters["dept_key"] + if parameters["dept_key"] == GLOBAL: + parameters["dept_id"] = None + if parameters["do_delete"]: + return LogoDelete(parameters) + return None + + def execute(self): + current_app.logger.info(self.message) + delete_logo(name=self.parameters["logo_id"], dept_id=self.parameters["dept_id"]) + + +class LogoInsert(Action): + """Action: add a new logo + dept_key: dept_id or '_', + logo_id: logo_id, + upload: image file replacement + """ + + def __init__(self, parameters): + super().__init__( + f"Ajout du logo {parameters['name']} pour le département {parameters['dept_key']} ({parameters['upload']}).", + parameters, + ) + + @staticmethod + def build_action(parameters): + if parameters["dept_key"] == GLOBAL: + parameters["dept_id"] = None + if parameters["upload"] and parameters["name"]: + logo = find_logo( + logoname=parameters["name"], dept_id=parameters["dept_key"] + ) + if logo is None: + return LogoInsert(parameters) + return None + + def execute(self): + dept_id = self.parameters["dept_key"] + if dept_id == GLOBAL: + dept_id = None + current_app.logger.info(self.message) + write_logo( + stream=self.parameters["upload"], + name=self.parameters["name"], + dept_id=dept_id, + ) + + +class BonusSportUpdate(Action): + """Action: Change bonus_sport_function_name. + bonus_sport_function_name: the new value""" + + def __init__(self, parameters): + super().__init__( + f"Changement du calcul de bonus sport pour ({parameters['bonus_sport_func_name']}).", + parameters, + ) + + @staticmethod + def build_action(parameters): + if ( + parameters["bonus_sport_func_name"] + != ScoDocSiteConfig.get_bonus_sport_func_name() + ): + return [BonusSportUpdate(parameters)] + return [] + + def execute(self): + current_app.logger.info(self.message) + ScoDocSiteConfig.set_bonus_sport_func(self.parameters["bonus_sport_func_name"]) + app.clear_scodoc_cache() diff --git a/app/scodoc/sco_config_form.py b/app/scodoc/sco_config_form.py new file mode 100644 index 00000000..f7ff47d7 --- /dev/null +++ b/app/scodoc/sco_config_form.py @@ -0,0 +1,402 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2021 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 +# +############################################################################## + +""" +Module main: page d'accueil, avec liste des départements + +Emmanuel Viennet, 2021 +""" +import re + +from flask import flash, url_for, redirect, render_template +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed +from wtforms import SelectField, SubmitField, FormField, validators, FieldList +from wtforms.fields.simple import BooleanField, StringField, HiddenField + +from app import AccessDenied +from app.models import Departement +from app.models import ScoDocSiteConfig +from app.scodoc import sco_logos, html_sco_header +from app.scodoc import sco_utils as scu +from app.scodoc.sco_config_actions import ( + LogoDelete, + LogoUpdate, + LogoInsert, + BonusSportUpdate, +) + +from flask_login import current_user + +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 + + +class AddLogoForm(FlaskForm): + """Formulaire permettant l'ajout d'un logo (dans un département)""" + + dept_key = HiddenField() + name = StringField( + label="Nom", + validators=[ + validators.regexp( + r"^[a-zA-Z0-9-]*$", + re.IGNORECASE, + "Ne doit comporter que lettres, chiffres ou -", + ), + 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 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) 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 + + +class LogoForm(FlaskForm): + """Embed both presentation of a logo (cf. template file configuration.html) + 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 l'image") + + def __init__(self, *args, **kwargs): + kwargs["meta"] = {"csrf": False} + super().__init__(*args, **kwargs) + self.logo = find_logo( + logoname=self.logo_id.data, dept_id=dept_key_to_id(self.dept_key.data) + ).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 select_action(self): + 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) + return None + + +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 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 _make_dept_id_name(): + """Cette section assute 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(bonus_sport, modele): + data = { + "bonus_sport_func_name": bonus_sport, + "depts": _make_depts_data(modele=modele), + } + return data + + +class ScoDocConfigurationForm(FlaskForm): + "Panneau de configuration général" + bonus_sport_func_name = SelectField( + label="Fonction de calcul des bonus sport&culture", + choices=[ + (x, x if x else "Aucune") + for x in ScoDocSiteConfig.get_bonus_sport_func_names() + ], + ) + 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): + if ( + self.data["bonus_sport_func_name"] + != ScoDocSiteConfig.get_bonus_sport_func_name() + ): + return BonusSportUpdate(self.data) + for dept_entry in self.depts: + dept_form = dept_entry.form + action = dept_form.select_action() + if action: + return action + return None + + +def configuration(): + """Panneau de configuration général""" + auth_name = str(current_user) + if not current_user.is_administrator(): + raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) + form = ScoDocConfigurationForm( + data=_make_data( + bonus_sport=ScoDocSiteConfig.get_bonus_sport_func_name(), + 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.configuration", + ) + ) + return render_template( + "configuration.html", + scodoc_dept=None, + title="Configuration ScoDoc", + form=form, + ) diff --git a/app/scodoc/sco_logos.py b/app/scodoc/sco_logos.py index e29b5183..8aa88c53 100644 --- a/app/scodoc/sco_logos.py +++ b/app/scodoc/sco_logos.py @@ -32,32 +32,247 @@ avec `ext` membre de LOGOS_IMAGES_ALLOWED_TYPES (= jpg, png) SCODOC_LOGOS_DIR /opt/scodoc-data/config/logos """ +import glob import imghdr import os +import re +import shutil +from pathlib import Path -from flask import abort, current_app +from flask import abort, current_app, url_for +from werkzeug.utils import secure_filename +from app import Departement, ScoValueError from app.scodoc import sco_utils as scu +from PIL import Image as PILImage + +GLOBAL = "_" # category for server level logos -def get_logo_filename(logo_type: str, scodoc_dept: str) -> str: - """return full filename for this logo, or "" if not found - an existing file with extension. - logo_type: "header" or "footer" - scodoc-dept: acronym +def find_logo(logoname, dept_id=None, strict=False, prefix=scu.LOGO_FILE_PREFIX): """ - # Search logos in dept specific dir (/opt/scodoc-data/config/logos/logos_), - # then in config dir /opt/scodoc-data/config/logos/ - for image_dir in ( - scu.SCODOC_LOGOS_DIR + "/logos_" + scodoc_dept, - scu.SCODOC_LOGOS_DIR, # global logos - ): - for suffix in scu.LOGOS_IMAGES_ALLOWED_TYPES: - filename = os.path.join(image_dir, f"logo_{logo_type}.{suffix}") - if os.path.isfile(filename) and os.access(filename, os.R_OK): - return filename + "Recherche un logo 'name' existant. + Deux strategies: + si strict: + reherche uniquement dans le département puis si non trouvé au niveau global + sinon + On recherche en local au dept d'abord puis si pas trouvé recherche globale + quelque soit la stratégie, retourne None si pas trouvé + :param logoname: le nom recherche + :param dept_id: l'id du département dans lequel se fait la recherche (None si global) + :param strict: stratégie de recherche (strict = False => dept ou global) + :param prefix: le prefix utilisé (parmi scu.LOGO_FILE_PREFIX / scu.BACKGROUND_FILE_PREFIX) + :return: un objet Logo désignant le fichier image trouvé (ou None) + """ + logo = Logo(logoname, dept_id, prefix).select() + if logo is None and not strict: + logo = Logo(logoname=logoname, dept_id=None, prefix=prefix).select() + return logo - return "" + +def delete_logo(name, dept_id=None): + """Delete all files matching logo (dept_id, name) (including all allowed extensions) + Args: + name: The name of the logo + dept_id: the dept_id (if local). Use None to destroy globals logos + """ + logo = find_logo(logoname=name, dept_id=dept_id) + while logo is not None: + os.unlink(logo.select().filepath) + logo = find_logo(logoname=name, dept_id=dept_id) + + +def write_logo(stream, name, dept_id=None): + """Crée le fichier logo sur le serveur. + Le suffixe du fichier (parmi LOGO_IMAGES_ALLOWED_TYPES) est déduit du contenu du stream""" + Logo(logoname=name, dept_id=dept_id).create(stream) + + +def list_logos(): + """Crée l'inventaire de tous les logos existants. + L'inventaire se présente comme un dictionnaire de dictionnaire de Logo: + [None][name] pour les logos globaux + [dept_id][name] pour les logos propres à un département (attention id numérique du dept) + Les départements sans logos sont absents du résultat + """ + inventory = {None: _list_dept_logos()} # logos globaux (header / footer) + for dept in Departement.query.filter_by(visible=True).all(): + logos_dept = _list_dept_logos(dept_id=dept.id) + if logos_dept: + inventory[dept.id] = _list_dept_logos(dept.id) + return inventory + + +def _list_dept_logos(dept_id=None, prefix=scu.LOGO_FILE_PREFIX): + """nventorie toutes les images existantes pour un niveau (GLOBAL ou un département). + retourne un dictionnaire de Logo [logoname] -> Logo + les noms des fichiers concernés doivent être de la forme: /. + : répertoire de recherche (déduit du dept_id) + : le prefix (LOGO_FILE_PREFIX pour les logos) + : un des suffixes autorisés + :param dept_id: l'id du departement concerné (si None -> global) + :param prefix: le préfixe utilisé + :return: le résultat de la recherche ou None si aucune image trouvée + """ + allowed_ext = "|".join(scu.LOGOS_IMAGES_ALLOWED_TYPES) + filename_parser = re.compile(f"{prefix}([^.]*).({allowed_ext})") + logos = {} + path_dir = Path(scu.SCODOC_LOGOS_DIR) + if dept_id: + path_dir = Path( + os.path.sep.join( + [scu.SCODOC_LOGOS_DIR, scu.LOGOS_DIR_PREFIX + str(dept_id)] + ) + ) + if path_dir.exists(): + for entry in path_dir.iterdir(): + if os.access(path_dir.joinpath(entry).absolute(), os.R_OK): + result = filename_parser.match(entry.name) + if result: + logoname = result.group(1) + logos[logoname] = Logo(logoname=logoname, dept_id=dept_id).select() + return logos if len(logos.keys()) > 0 else None + + +class Logo: + """Responsable des opérations (select, create), du calcul des chemins et url + ainsi que de la récupération des informations sur un logp. + Usage: + logo existant: Logo(, , ...).select() (retourne None si fichier non trouvé) + logo en création: Logo(, , ...).create(stream) + Les attributs filename, filepath, get_url() ne devraient pas être utilisés avant les opérations + select ou save (le format n'est pas encore connu à ce moement là) + """ + + def __init__(self, logoname, dept_id=None, prefix=scu.LOGO_FILE_PREFIX): + """Initialisation des noms et département des logos. + if prefix = None on recherche simplement une image 'logoname.*' + Le format est renseigné au moment de la lecture (select) ou de la création (create) de l'objet + """ + self.logoname = secure_filename(logoname) + self.scodoc_dept_id = dept_id + self.prefix = prefix or "" + if self.scodoc_dept_id: + self.dirpath = os.path.sep.join( + [ + scu.SCODOC_LOGOS_DIR, + scu.LOGOS_DIR_PREFIX + secure_filename(str(dept_id)), + ] + ) + else: + self.dirpath = scu.SCODOC_LOGOS_DIR + self.basepath = os.path.sep.join( + [self.dirpath, self.prefix + secure_filename(self.logoname)] + ) + # next attributes are computer by the select function + self.suffix = "Not inited: call the select or create function before access" + self.filepath = "Not inited: call the select or create function before access" + self.filename = "Not inited: call the select or create function before access" + self.size = "Not inited: call the select or create function before access" + self.aspect_ratio = ( + "Not inited: call the select or create function before access" + ) + self.density = "Not inited: call the select or create function before access" + self.mm = "Not inited: call the select or create function before access" + + def _set_format(self, fmt): + self.suffix = fmt + self.filepath = self.basepath + "." + fmt + self.filename = self.logoname + "." + fmt + + def _ensure_directory_exists(self): + "create enclosing directory if necessary" + if not Path(self.dirpath).exists(): + current_app.logger.info(f"sco_logos creating directory %s", self.dirpath) + os.mkdir(self.dirpath) + + def create(self, stream): + img_type = guess_image_type(stream) + if img_type not in scu.LOGOS_IMAGES_ALLOWED_TYPES: + abort(400, "type d'image invalide") + self._set_format(img_type) + self._ensure_directory_exists() + filename = self.basepath + "." + self.suffix + with open(filename, "wb") as f: + f.write(stream.read()) + current_app.logger.info(f"sco_logos.store_image %s", self.filename) + # erase other formats if they exists + for suffix in set(scu.LOGOS_IMAGES_ALLOWED_TYPES) - set([img_type]): + try: + os.unlink(self.basepath + "." + suffix) + except IOError: + pass + + def _read_info(self, img): + """computes some properties from the real image + aspect_ratio assumes that x_density and y_density are equals + """ + x_size, y_size = img.size + self.density = img.info.get("dpi", None) + unit = 1 + if self.density is None: # no dpi found try jfif infos + self.density = img.info.get("jfif_density", None) + unit = img.info.get("jfif_unit", 0) # 0 = no unit ; 1 = inch ; 2 = mm + if self.density is not None: + x_density, y_density = self.density + if unit != 0: + unit2mm = [0, 1 / 0.254, 0.1][unit] + x_mm = round(x_size * unit2mm / x_density, 2) + y_mm = round(y_size * unit2mm / y_density, 2) + self.mm = (x_mm, y_mm) + else: + self.mm = None + else: + self.mm = None + + self.size = (x_size, y_size) + self.aspect_ratio = round(float(x_size) / y_size, 2) + + def select(self): + """ + Récupération des données pour un logo existant + il doit exister un et un seul fichier image parmi de suffixe/types autorisés + (sinon on prend le premier trouvé) + cette opération permet d'affiner le format d'un logo de format inconnu + """ + for suffix in scu.LOGOS_IMAGES_ALLOWED_TYPES: + path = Path(self.basepath + "." + suffix) + if path.exists(): + self._set_format(suffix) + with open(self.filepath, "rb") as f: + img = PILImage.open(f) + self._read_info(img) + return self + return None + + def get_url(self): + """Retourne l'URL permettant d'obtenir l'image du logo""" + return url_for( + "scodoc.get_logo", + dept_id=self.scodoc_dept_id, + name=self.logoname, + global_if_not_found=False, + ) + + def get_url_small(self): + """Retourne l'URL permettant d'obtenir l'image du logo sous forme de miniature""" + return url_for( + "scodoc.get_logo_small", + dept_id=self.scodoc_dept_id, + name=self.logoname, + global_if_not_found=False, + ) + + def get_usage(self): + if self.mm is None: + return f'' + else: + return f'' + + def last_modified(self): + path = Path(self.filepath) + dt = path.stat().st_mtime + return path.stat().st_mtime def guess_image_type(stream) -> str: @@ -70,26 +285,33 @@ def guess_image_type(stream) -> str: return fmt if fmt != "jpeg" else "jpg" -def _ensure_directory_exists(filename): - "create enclosing directory if necessary" - directory = os.path.split(filename)[0] - if not os.path.exists(directory): - current_app.logger.info(f"sco_logos creating directory %s", directory) - os.mkdir(directory) - - -def store_image(stream, basename): - img_type = guess_image_type(stream) - if img_type not in scu.LOGOS_IMAGES_ALLOWED_TYPES: - abort(400, "type d'image invalide") - filename = basename + "." + img_type - _ensure_directory_exists(filename) - with open(filename, "wb") as f: - f.write(stream.read()) - current_app.logger.info(f"sco_logos.store_image %s", filename) - # erase other formats if they exists - for extension in set(scu.LOGOS_IMAGES_ALLOWED_TYPES) - set([img_type]): - try: - os.unlink(basename + "." + extension) - except IOError: - pass +def make_logo_local(logoname, dept_name): + depts = Departement.query.filter_by(acronym=dept_name).all() + if len(depts) == 0: + print(f"no dept {dept_name} found. aborting") + return + if len(depts) > 1: + print(f"several depts {dept_name} found. aborting") + return + dept = depts[0] + print(f"Move logo {logoname}' from global to {dept.acronym}") + old_path_wild = f"/opt/scodoc-data/config/logos/logo_{logoname}.*" + new_dir = f"/opt/scodoc-data/config/logos/logos_{dept.id}" + logos = glob.glob(old_path_wild) + # checks that there is non local already present + for logo in logos: + filename = os.path.split(logo)[1] + new_name = os.path.sep.join([new_dir, filename]) + if os.path.exists(new_name): + print("local version of global logo already exists. aborting") + return + # create new__dir if necessary + if not os.path.exists(new_dir): + print(f"- create {new_dir} directory") + os.mkdir(new_dir) + # move global logo (all suffixes) to local dir note: pre existent file (logo_XXX.*) in local dir does not + # prevent operation if there is no conflict with moved files + # At this point everything is ok so we can do files manipulation + for logo in logos: + shutil.move(logo, new_dir) + # print(f"moved {n_moves}/{n} etuds") diff --git a/app/scodoc/sco_pdf.py b/app/scodoc/sco_pdf.py index 77e6f4e1..85ead167 100755 --- a/app/scodoc/sco_pdf.py +++ b/app/scodoc/sco_pdf.py @@ -60,11 +60,7 @@ from reportlab.lib.pagesizes import letter, A4, landscape from flask import g import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ( - CONFIG, - SCODOC_LOGOS_DIR, - LOGOS_IMAGES_ALLOWED_TYPES, -) +from app.scodoc.sco_utils import CONFIG from app import log from app.scodoc.sco_exceptions import ScoGenError, ScoValueError import sco_version @@ -196,6 +192,10 @@ class ScolarsPageTemplate(PageTemplate): preferences=None, # dictionnary with preferences, required ): """Initialise our page template.""" + from app.scodoc.sco_logos import ( + find_logo, + ) # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf + self.preferences = preferences self.pagesbookmarks = pagesbookmarks self.pdfmeta_author = author @@ -219,20 +219,16 @@ class ScolarsPageTemplate(PageTemplate): ) PageTemplate.__init__(self, "ScolarsPageTemplate", [content]) self.logo = None - # XXX COPIED from sco_pvpdf, to be refactored (no time now) - # Search background in dept specific dir, then in global config dir - for image_dir in ( - SCODOC_LOGOS_DIR + "/logos_" + g.scodoc_dept + "/", - SCODOC_LOGOS_DIR + "/", # global logos - ): - for suffix in LOGOS_IMAGES_ALLOWED_TYPES: - fn = image_dir + "/bul_pdf_background" + "." + suffix - if not self.background_image_filename and os.path.exists(fn): - self.background_image_filename = fn - # Also try to use PV background - fn = image_dir + "/letter_background" + "." + suffix - if not self.background_image_filename and os.path.exists(fn): - self.background_image_filename = fn + logo = find_logo( + logoname="bul_pdf_background", dept_id=g.scodoc_dept_id, prefix=None + ) + if logo is None: + # Also try to use PV background + logo = find_logo( + logoname="letter_background", dept_id=g.scodoc_dept_id, prefix=None + ) + if logo is not None: + self.background_image_filename = logo.filepath def beforeDrawPage(self, canvas, doc): """Draws (optional) background, logo and contribution message on each page. diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index d83ca14f..7e08d355 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -2017,10 +2017,10 @@ class BasePreferences(object): H = [ html_sco_header.sco_header(page_title="Préférences"), "

Préférences globales pour %s

" % scu.ScoURL(), - f"""

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

""" - if current_user.is_administrator() - else "", + # f"""

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

""" + # if current_user.is_administrator() + # else "", """

Ces paramètres s'appliquent par défaut à tous les semestres, sauf si ceux-ci définissent des valeurs spécifiques.

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

""", diff --git a/app/scodoc/sco_pvpdf.py b/app/scodoc/sco_pvpdf.py index 41dce9ba..b8664c51 100644 --- a/app/scodoc/sco_pvpdf.py +++ b/app/scodoc/sco_pvpdf.py @@ -52,6 +52,7 @@ from app.scodoc import sco_pdf from app.scodoc import sco_preferences from app.scodoc import sco_etud import sco_version +from app.scodoc.sco_logos import find_logo from app.scodoc.sco_pdf import PDFLOCK from app.scodoc.sco_pdf import SU @@ -201,33 +202,36 @@ class CourrierIndividuelTemplate(PageTemplate): self.logo_footer = None self.logo_header = None # Search logos in dept specific dir, then in global scu.CONFIG dir - for image_dir in ( - scu.SCODOC_LOGOS_DIR + "/logos_" + g.scodoc_dept, - scu.SCODOC_LOGOS_DIR, # global logos - ): - for suffix in scu.LOGOS_IMAGES_ALLOWED_TYPES: - if template_name == "PVJuryTemplate": - fn = image_dir + "/pvjury_background" + "." + suffix - else: - fn = image_dir + "/letter_background" + "." + suffix - if not self.background_image_filename and os.path.exists(fn): - self.background_image_filename = fn + if template_name == "PVJuryTemplate": + background = find_logo( + logoname="pvjury_background", + dept_id=g.scodoc_dept_id, + prefix="", + ) + else: + background = find_logo( + logoname="letter_background", + dept_id=g.scodoc_dept_id, + prefix="", + ) + if not self.background_image_filename and background is not None: + self.background_image_filename = background.filepath - fn = image_dir + "/logo_footer" + "." + suffix - if not self.logo_footer and os.path.exists(fn): - self.logo_footer = Image( - fn, - height=LOGO_FOOTER_HEIGHT, - width=LOGO_FOOTER_WIDTH, - ) + footer = find_logo(logoname="footer", dept_id=g.scodoc_dept_id) + if footer is not None: + self.logo_footer = Image( + footer.filepath, + height=LOGO_FOOTER_HEIGHT, + width=LOGO_FOOTER_WIDTH, + ) - fn = image_dir + "/logo_header" + "." + suffix - if not self.logo_header and os.path.exists(fn): - self.logo_header = Image( - fn, - height=LOGO_HEADER_HEIGHT, - width=LOGO_HEADER_WIDTH, - ) + header = find_logo(logoname="header", dept_id=g.scodoc_dept_id) + if header is not None: + self.logo_header = Image( + header.filepath, + height=LOGO_HEADER_HEIGHT, + width=LOGO_HEADER_WIDTH, + ) def beforeDrawPage(self, canvas, doc): """Draws a logo and an contribution message on each page.""" diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 861e9487..c023c738 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -283,7 +283,12 @@ if not os.path.exists(SCO_TMP_DIR) and os.path.exists(Config.SCODOC_VAR_DIR): # ----- Les logos: /opt/scodoc-data/config/logos SCODOC_LOGOS_DIR = os.path.join(SCODOC_CFG_DIR, "logos") LOGOS_IMAGES_ALLOWED_TYPES = ("jpg", "jpeg", "png") # remind that PIL does not read pdf +LOGOS_DIR_PREFIX = "logos_" +LOGO_FILE_PREFIX = "logo_" +# forme générale des noms des fichiers logos/background: +# SCODOC_LOGO_DIR/LOGO_FILE_PREFIX. (fichier global) ou +# SCODOC_LOGO_DIR/LOGOS_DIR_PREFIX/LOGO_FILE_PREFIX. (fichier départemental) # ----- Les outils distribués SCO_TOOLS_DIR = os.path.join(Config.SCODOC_DIR, "tools") diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index f41cb4e3..ac075128 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -867,6 +867,9 @@ div.sco_help { span.wtf-field ul.errors li { color: red; +} +.configuration_logo div.img { + } .configuration_logo div.img-container { width: 256px; @@ -874,6 +877,20 @@ span.wtf-field ul.errors li { .configuration_logo div.img-container img { max-width: 100%; } +.configuration_logo div.img-data { + vertical-align: top; +} +.configuration_logo logo-edit titre { + background-color:lightblue; +} +.configuration_logo logo-edit nom { + float: left; + vertical-align: baseline; +} +.configuration_logo logo-edit description { + float:right; + vertical-align:baseline; +} p.indent { padding-left: 2em; diff --git a/app/static/js/configuration.js b/app/static/js/configuration.js new file mode 100644 index 00000000..b537d572 --- /dev/null +++ b/app/static/js/configuration.js @@ -0,0 +1,6 @@ +function submit_form() { + $("#configuration_form").submit(); +} + +$(function () { +}) \ No newline at end of file diff --git a/app/templates/config_dept.html b/app/templates/config_dept.html new file mode 100644 index 00000000..c602c5f3 --- /dev/null +++ b/app/templates/config_dept.html @@ -0,0 +1,81 @@ +{% macro render_field(field) %} +
+ {{ field.label }} : + {{ field()|safe }} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+
+{% endmacro %} + +{% macro render_logo(logo_form, titre=None) %} + {% if titre %} + + +

{{ titre }}

+
+ + + {% endif %} + + +

{{ logo_form.form.description }} Image actuelle:

+
pas de logo chargé
+ + + {{ logo_form.form.dept_id() }} + {{ logo_form.form.logo_id() }} + Nom: {{ logo_form.form.logo.logoname }}
+{# {{ logo_form.form.description }}
#} + Format: {{ logo_form.logo.suffix }}
+ Taille en px: {{ logo_form.logo.size }}
+ {% if logo_form.logo.mm %} + Taile en mm: {{ logo_form.logo.mm }}
+ {% endif %} + Aspect ratio: {{ logo_form.logo.aspect_ratio }}
+
+ Usage: {{ logo_form.logo.get_usage() }} +
+ {{ render_field(logo_form.upload) }} + {% if logo_form.can_delete %} + {{ render_field(logo_form.do_delete) }} + {% endif %} + + +{% endmacro %} + + +{#{% block app_content %}#} + +{% if scodoc_dept %} +

Logos du département {{ scodoc_dept }}

+{% else %} +

Configuration générale

+{% endif %} + +
+ {{ form.hidden_tag() }} + {% if not scodoc_dept %} +
Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):
+ {{ render_field(form.bonus_sport_func_name)}} + + {% endif %} + + + +
{{ form.submit() }}
+
+{#{% endblock %}#} \ No newline at end of file diff --git a/app/templates/configuration.html b/app/templates/configuration.html index 6dcf1c51..b874d48d 100644 --- a/app/templates/configuration.html +++ b/app/templates/configuration.html @@ -1,10 +1,12 @@ {% extends 'base.html' %} {% import 'bootstrap/wtf.html' as wtf %} -{% macro render_field(field) %} +{% macro render_field(field, with_label=True) %}
- {{ field.label }} : - {{ field()|safe }} + {% if with_label %} + {{ field.label }} : + {% endif %} + {{ field(**kwargs)|safe }} {% if field.errors %}
    {% for error in field.errors %} @@ -16,38 +18,102 @@
{% endmacro %} +{% macro render_add_logo(add_logo_form) %} +
+

Ajouter un logo

+ {{ add_logo_form.hidden_tag() }} + {{ render_field(add_logo_form.name) }} + {{ render_field(add_logo_form.upload) }} + {{ render_field(add_logo_form.do_insert, False, onSubmit="submit_form") }} +
+{% endmacro %} + +{% macro render_logo(dept_form, logo_form) %} +
+ {{ logo_form.hidden_tag() }} + {% if logo_form.titre %} + + +

{{ logo_form.titre }}

+
{{ logo_form.description or "" }}
+ + + {% else %} + + +

Logo personalisé: {{ logo_form.logo_id.data }}

+ {{ logo_form.description or "" }} + + + {% endif %} + + +
+ pas de logo chargé
+ +

{{ logo_form.logo.logoname }} (Format: {{ logo_form.logo.suffix }})

+ Taille: {{ logo_form.logo.size }} px + {% if logo_form.logo.mm %}   /   {{ logo_form.logo.mm }} mm {% endif %}
+ Aspect ratio: {{ logo_form.logo.aspect_ratio }}
+ Usage: {{ logo_form.logo.get_usage() }} + +

Modifier l'image

+ {{ render_field(logo_form.upload, False, onchange="submit_form()") }} + {% if logo_form.can_delete %} +

Supprimer l'image

+ {{ render_field(logo_form.do_delete, False, onSubmit="submit_form()") }} + {% endif %} + + +
+{% endmacro %} + +{% macro render_logos(dept_form) %} + + {% for logo_entry in dept_form.logos.entries %} + {% set logo_form = logo_entry.form %} + {{ render_logo(dept_form, logo_form) }} + {% else %} +

Aucun logo défini en propre à ce département

+ {% endfor %} +
+{% endmacro %} + {% block app_content %} -{% if scodoc_dept %} -

Logos du département {{ scodoc_dept }}

-{% else %} -

Configuration générale {{ scodoc_dept }}

-{% endif %} + + -
+ {{ form.hidden_tag() }} - {% if not scodoc_dept %} -
Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):
- - {{ render_field(form.bonus_sport_func_name)}} - {% endif %} - - -
{{ form.submit() }}
{% endblock %} \ No newline at end of file diff --git a/app/views/scodoc.py b/app/views/scodoc.py index c8ea5aad..301dfbb4 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -30,6 +30,11 @@ Module main: page d'accueil, avec liste des départements Emmanuel Viennet, 2021 """ +import datetime +import io + +import wtforms.validators + from app.auth.models import User import os @@ -38,13 +43,13 @@ from flask import abort, flash, url_for, redirect, render_template, send_file from flask import request from flask.app import Flask import flask_login -from flask_login.utils import login_required +from flask_login.utils import login_required, current_user from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed from werkzeug.exceptions import BadRequest, NotFound -from wtforms import SelectField, SubmitField +from wtforms import SelectField, SubmitField, FormField, validators, Form, FieldList from wtforms.fields import IntegerField -from wtforms.fields.simple import BooleanField, StringField, TextAreaField +from wtforms.fields.simple import BooleanField, StringField, TextAreaField, HiddenField from wtforms.validators import ValidationError, DataRequired, Email, EqualTo import app @@ -52,7 +57,7 @@ from app.models import Departement, Identite from app.models import FormSemestre, FormsemestreInscription from app.models import ScoDocSiteConfig import sco_version -from app.scodoc import sco_logos +from app.scodoc import sco_logos, sco_config_form from app.scodoc import sco_find_etud from app.scodoc import sco_utils as scu from app.decorators import ( @@ -60,11 +65,16 @@ from app.decorators import ( scodoc7func, scodoc, permission_required_compat_scodoc7, + permission_required, ) +from app.scodoc.sco_config_form import configuration from app.scodoc.sco_exceptions import AccessDenied +from app.scodoc.sco_logos import find_logo from app.scodoc.sco_permissions import Permission from app.views import scodoc_bp as bp +from PIL import Image as PILImage + @bp.route("/") @bp.route("/ScoDoc") @@ -173,43 +183,6 @@ def about(scodoc_dept=None): # ---- CONFIGURATION - -class ScoDocConfigurationForm(FlaskForm): - "Panneau de configuration général" - - bonus_sport_func_name = SelectField( - label="Fonction de calcul des bonus sport&culture", - choices=[ - (x, x if x else "Aucune") - for x in ScoDocSiteConfig.get_bonus_sport_func_names() - ], - ) - - logo_header = FileField( - label="Modifier l'image:", - description="logo placé en haut des documents PDF", - validators=[ - FileAllowed( - scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", - ) - ], - ) - - logo_footer = FileField( - label="Modifier l'image:", - description="logo placé en pied des documents PDF", - validators=[ - FileAllowed( - scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", - ) - ], - ) - - submit = SubmitField("Enregistrer") - - # Notes pour variables config: (valeurs par défaut des paramètres de département) # Chaines simples # SCOLAR_FONT = "Helvetica" @@ -231,55 +204,74 @@ class ScoDocConfigurationForm(FlaskForm): @bp.route("/ScoDoc/configuration", methods=["GET", "POST"]) @admin_required def configuration(): - "Panneau de configuration général" - form = ScoDocConfigurationForm( - bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func_name(), - ) - if form.validate_on_submit(): - ScoDocSiteConfig.set_bonus_sport_func(form.bonus_sport_func_name.data) - if form.logo_header.data: - sco_logos.store_image( - form.logo_header.data, os.path.join(scu.SCODOC_LOGOS_DIR, "logo_header") - ) - if form.logo_footer.data: - sco_logos.store_image( - form.logo_footer.data, os.path.join(scu.SCODOC_LOGOS_DIR, "logo_footer") - ) - app.clear_scodoc_cache() - flash(f"Configuration enregistrée") - return redirect(url_for("scodoc.index")) - - return render_template( - "configuration.html", - title="Configuration ScoDoc", - form=form, - scodoc_dept=None, - ) + auth_name = str(current_user) + if not current_user.is_administrator(): + raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) + return sco_config_form.configuration() -def _return_logo(logo_type="header", scodoc_dept=""): +SMALL_SIZE = (200, 200) + + +def _return_logo(name="header", dept_id="", small=False, strict: bool = True): # stockée dans /opt/scodoc-data/config/logos donc servie manuellement ici - filename = sco_logos.get_logo_filename(logo_type, scodoc_dept) - if filename: - extension = os.path.splitext(filename)[1] - return send_file(filename, mimetype=f"image/{extension}") + # from app.scodoc.sco_photos import _http_jpeg_file + + logo = sco_logos.find_logo(name, dept_id, strict).select() + if logo is not None: + suffix = logo.suffix + if small: + with PILImage.open(logo.filepath) as im: + im.thumbnail(SMALL_SIZE) + stream = io.BytesIO() + # on garde le même format (on pourrait plus simplement générer systématiquement du JPEG) + fmt = { # adapt suffix to be compliant with PIL save format + "PNG": "PNG", + "JPG": "JPEG", + "JPEG": "JPEG", + }[suffix.upper()] + im.save(stream, fmt) + stream.seek(0) + return send_file(stream, mimetype=f"image/{fmt}") + else: + # return _http_jpeg_file(logo.filepath) + # ... replaces ... + return send_file( + logo.filepath, + mimetype=f"image/{suffix}", + last_modified=datetime.datetime.now(), + ) else: - return "" + abort(404) -@bp.route("/ScoDoc/logo_header") -@bp.route("/ScoDoc//logo_header") -def logo_header(scodoc_dept=""): - "Image logo header" - # "/opt/scodoc-data/config/logos/logo_header") - return _return_logo(logo_type="header", scodoc_dept=scodoc_dept) +# small version (copy/paste from get_logo +@bp.route("/ScoDoc/logos//small", defaults={"dept_id": None}) +@bp.route("/ScoDoc//logos//small") +@admin_required +def get_logo_small(name: str, dept_id: int): + strict = request.args.get("strict", "False") + return _return_logo( + name, + dept_id=dept_id, + small=True, + strict=strict.upper() not in ["0", "FALSE"], + ) -@bp.route("/ScoDoc/logo_footer") -@bp.route("/ScoDoc//logo_footer") -def logo_footer(scodoc_dept=""): - "Image logo footer" - return _return_logo(logo_type="footer", scodoc_dept=scodoc_dept) +@bp.route( + "/ScoDoc/logos/", defaults={"dept_id": None} +) # if dept not specified, take global logo +@bp.route("/ScoDoc//logos/") +@admin_required +def get_logo(name: str, dept_id: int): + strict = request.args.get("strict", "False") + return _return_logo( + name, + dept_id=dept_id, + small=False, + strict=strict.upper() not in ["0", "FALSE"], + ) # essais diff --git a/app/views/scolar.py b/app/views/scolar.py index c708495a..ebfd4d1a 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -174,7 +174,7 @@ class DeptLogosConfigurationForm(FlaskForm): validators=[ FileAllowed( scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", ) ], ) @@ -185,7 +185,7 @@ class DeptLogosConfigurationForm(FlaskForm): validators=[ FileAllowed( scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", ) ], ) @@ -193,36 +193,96 @@ class DeptLogosConfigurationForm(FlaskForm): submit = SubmitField("Enregistrer") -@bp.route("/config_logos", methods=["GET", "POST"]) -@permission_required(Permission.ScoChangePreferences) -def config_logos(scodoc_dept): - "Panneau de configuration général" - form = DeptLogosConfigurationForm() - if form.validate_on_submit(): - if form.logo_header.data: - sco_logos.store_image( - form.logo_header.data, - os.path.join( - scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" - ), - ) - if form.logo_footer.data: - sco_logos.store_image( - form.logo_footer.data, - os.path.join( - scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" - ), - ) - app.clear_scodoc_cache() - flash(f"Logos enregistrés") - return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# @bp.route("/config_logos", methods=["GET", "POST"]) +# @permission_required(Permission.ScoChangePreferences) +# def config_logos(scodoc_dept): +# "Panneau de configuration général" +# form = DeptLogosConfigurationForm() +# if form.validate_on_submit(): +# if form.logo_header.data: +# sco_logos.store_image( +# form.logo_header.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" +# ), +# ) +# if form.logo_footer.data: +# sco_logos.store_image( +# form.logo_footer.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" +# ), +# ) +# app.clear_scodoc_cache() +# flash(f"Logos enregistrés") +# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# +# return render_template( +# "configuration.html", +# title="Configuration Logos du département", +# form=form, +# scodoc_dept=scodoc_dept, +# ) +# +# +# class DeptLogosConfigurationForm(FlaskForm): +# "Panneau de configuration logos dept" +# +# logo_header = FileField( +# label="Modifier l'image:", +# description="logo placé en haut des documents PDF", +# validators=[ +# FileAllowed( +# scu.LOGOS_IMAGES_ALLOWED_TYPES, +# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", +# ) +# ], +# ) +# +# logo_footer = FileField( +# label="Modifier l'image:", +# description="logo placé en pied des documents PDF", +# validators=[ +# FileAllowed( +# scu.LOGOS_IMAGES_ALLOWED_TYPES, +# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", +# ) +# ], +# ) +# +# submit = SubmitField("Enregistrer") - return render_template( - "configuration.html", - title="Configuration Logos du département", - form=form, - scodoc_dept=scodoc_dept, - ) + +# @bp.route("/config_logos", methods=["GET", "POST"]) +# @permission_required(Permission.ScoChangePreferences) +# def config_logos(scodoc_dept): +# "Panneau de configuration général" +# form = DeptLogosConfigurationForm() +# if form.validate_on_submit(): +# if form.logo_header.data: +# sco_logos.store_image( +# form.logo_header.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" +# ), +# ) +# if form.logo_footer.data: +# sco_logos.store_image( +# form.logo_footer.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" +# ), +# ) +# app.clear_scodoc_cache() +# flash(f"Logos enregistrés") +# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# +# return render_template( +# "configuration.html", +# title="Configuration Logos du département", +# form=form, +# scodoc_dept=scodoc_dept, +# ) # -------------------------------------------------------------------- diff --git a/scodoc.py b/scodoc.py index cfea19d7..3204c766 100755 --- a/scodoc.py +++ b/scodoc.py @@ -22,6 +22,7 @@ from app import models from app.auth.models import User, Role, UserRole from app.models import ScoPreference +from app.scodoc.sco_logos import make_logo_local from app.models import Formation, UniteEns, Module from app.models import FormSemestre, FormsemestreInscription from app.models import ModuleImpl, ModuleImplInscription @@ -340,6 +341,28 @@ def migrate_scodoc7_dept_archives(dept: str): # migrate-scodoc7-dept-archives tools.migrate_scodoc7_dept_archives(dept) +@app.cli.command() +@click.argument("dept", default="") +@with_appcontext +def migrate_scodoc7_dept_logos(dept: str = ""): # migrate-scodoc7-dept-logos + """Post-migration: renomme les logos en fonction des id / dept de ScoDoc 9""" + tools.migrate_scodoc7_dept_logos(dept) + + +@app.cli.command() +@click.argument("logo", default=None) +@click.argument("dept", default=None) +@with_appcontext +def localize_logo(logo: str = None, dept: str = None): # migrate-scodoc7-dept-logos + """Make local to a dept a global logo (both logo and dept names are mandatory)""" + if logo in ["header", "footer"]: + print( + f"Can't make logo '{logo}' local: add a local version throught configuration form instead" + ) + return + make_logo_local(logoname=logo, dept_name=dept) + + @app.cli.command() @click.argument("formsemestre_id", type=click.INT) @click.argument("xlsfile", type=click.File("rb")) diff --git a/tests/ressources/test_logos/logo_A.jpg b/tests/ressources/test_logos/logo_A.jpg new file mode 100644 index 00000000..1da64b66 Binary files /dev/null and b/tests/ressources/test_logos/logo_A.jpg differ diff --git a/tests/ressources/test_logos/logo_A1.jpg b/tests/ressources/test_logos/logo_A1.jpg new file mode 100644 index 00000000..66ecf45f Binary files /dev/null and b/tests/ressources/test_logos/logo_A1.jpg differ diff --git a/tests/ressources/test_logos/logo_C.jpg b/tests/ressources/test_logos/logo_C.jpg new file mode 100644 index 00000000..523201bc Binary files /dev/null and b/tests/ressources/test_logos/logo_C.jpg differ diff --git a/tests/ressources/test_logos/logo_D.png b/tests/ressources/test_logos/logo_D.png new file mode 100644 index 00000000..d7a73011 Binary files /dev/null and b/tests/ressources/test_logos/logo_D.png differ diff --git a/tests/ressources/test_logos/logo_E.jpg b/tests/ressources/test_logos/logo_E.jpg new file mode 100644 index 00000000..64c60165 Binary files /dev/null and b/tests/ressources/test_logos/logo_E.jpg differ diff --git a/tests/ressources/test_logos/logo_F.jpeg b/tests/ressources/test_logos/logo_F.jpeg new file mode 100644 index 00000000..20728130 Binary files /dev/null and b/tests/ressources/test_logos/logo_F.jpeg differ diff --git a/tests/ressources/test_logos/logos_1/logo_A.jpg b/tests/ressources/test_logos/logos_1/logo_A.jpg new file mode 100644 index 00000000..6d183db1 Binary files /dev/null and b/tests/ressources/test_logos/logos_1/logo_A.jpg differ diff --git a/tests/ressources/test_logos/logos_1/logo_B.jpg b/tests/ressources/test_logos/logos_1/logo_B.jpg new file mode 100644 index 00000000..7c1708f1 Binary files /dev/null and b/tests/ressources/test_logos/logos_1/logo_B.jpg differ diff --git a/tests/ressources/test_logos/logos_2/logo_A.jpg b/tests/ressources/test_logos/logos_2/logo_A.jpg new file mode 100644 index 00000000..130269a6 Binary files /dev/null and b/tests/ressources/test_logos/logos_2/logo_A.jpg differ diff --git a/tests/ressources/test_logos/logos_2/logo_A1.jpg b/tests/ressources/test_logos/logos_2/logo_A1.jpg new file mode 100644 index 00000000..ee9da2ec Binary files /dev/null and b/tests/ressources/test_logos/logos_2/logo_A1.jpg differ diff --git a/tests/unit/test_logos.py b/tests/unit/test_logos.py new file mode 100644 index 00000000..b5cf4238 --- /dev/null +++ b/tests/unit/test_logos.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- + +"""Test Logos + + +Utiliser comme: + pytest tests/unit/test_logos.py + +""" +from pathlib import Path +from shutil import copytree, copy, rmtree + +import pytest as pytest +from _pytest.python_api import approx + +import app +from app import db +from app.models import Departement +import app.scodoc.sco_utils as scu +from app.scodoc.sco_logos import ( + find_logo, + Logo, + list_logos, + GLOBAL, + write_logo, + delete_logo, +) + +RESOURCES_DIR = "/opt/scodoc/tests/ressources/test_logos" + + +@pytest.fixture +def create_dept(test_client): + """Crée 2 départements: + return departements object + """ + dept1 = Departement(acronym="RT") + dept2 = Departement(acronym="INFO") + dept3 = Departement(acronym="GEA") + db.session.add(dept1) + db.session.add(dept2) + db.session.add(dept3) + db.session.commit() + yield dept1, dept2, dept3 + db.session.delete(dept1) + db.session.delete(dept2) + db.session.delete(dept3) + db.session.commit() + + +@pytest.fixture +def create_logos(create_dept): + """Crée les logos: + ...logos --+-- logo_A.jpg + +-- logo_C.jpg + +-- logo_D.png + +-- logo_E.jpg + +-- logo_F.jpeg + +-- logos_{d1} --+-- logo_A.jpg + | +-- logo_B.jpg + +-- logos_{d2} --+-- logo_A.jpg + + """ + dept1, dept2, dept3 = create_dept + d1 = dept1.id + d2 = dept2.id + d3 = dept3.id + FILE_LIST = ["logo_A.jpg", "logo_C.jpg", "logo_D.png", "logo_E.jpg", "logo_F.jpeg"] + for fn in FILE_LIST: + from_path = Path(RESOURCES_DIR).joinpath(fn) + to_path = Path(scu.SCODOC_LOGOS_DIR).joinpath(fn) + copy(from_path.absolute(), to_path.absolute()) + copytree( + f"{RESOURCES_DIR}/logos_1", + f"{scu.SCODOC_LOGOS_DIR}/logos_{d1}", + ) + copytree( + f"{RESOURCES_DIR}/logos_2", + f"{scu.SCODOC_LOGOS_DIR}/logos_{d2}", + ) + yield None + rmtree(f"{scu.SCODOC_LOGOS_DIR}/logos_{d1}") + rmtree(f"{scu.SCODOC_LOGOS_DIR}/logos_{d2}") + # rm files + for fn in FILE_LIST: + to_path = Path(scu.SCODOC_LOGOS_DIR).joinpath(fn) + to_path.unlink() + + +def test_select_global_only(create_logos): + C_logo = app.scodoc.sco_logos.find_logo(logoname="C") + assert C_logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logo_C.jpg" + + +def test_select_local_only(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + B_logo = app.scodoc.sco_logos.find_logo(logoname="B", dept_id=dept1.id) + assert B_logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logos_{dept1.id}/logo_B.jpg" + + +def test_select_local_override_global(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + A1_logo = app.scodoc.sco_logos.find_logo(logoname="A", dept_id=dept1.id) + assert A1_logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logos_{dept1.id}/logo_A.jpg" + + +def test_select_global_with_strict(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + A_logo = app.scodoc.sco_logos.find_logo(logoname="A", dept_id=dept1.id, strict=True) + assert A_logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logos_{dept1.id}/logo_A.jpg" + + +def test_looks_for_non_existant_should_give_none(create_dept, create_logos): + # search for a local non-existant logo returns None + dept1, dept2, dept3 = create_dept + no_logo = app.scodoc.sco_logos.find_logo(logoname="Z", dept_id=dept1.id) + assert no_logo is None + + +def test_looks_localy_for_a_global_should_give_none(create_dept, create_logos): + # search for a local non-existant logo returns None + dept1, dept2, dept3 = create_dept + no_logo = app.scodoc.sco_logos.find_logo( + logoname="C", dept_id=dept1.id, strict=True + ) + assert no_logo is None + + +def test_get_jpg_data(create_dept, create_logos): + logo = find_logo("A", dept_id=None) + assert logo is not None + logo.select() + assert logo.logoname == "A" + assert logo.suffix == "jpg" + assert logo.filename == "A.jpg" + assert logo.size == (224, 131) + assert logo.mm == approx((9.38, 5.49), 0.1) + + +def test_get_png_without_data(create_dept, create_logos): + logo = find_logo("D", dept_id=None) + assert logo is not None + logo.select() + assert logo.logoname == "D" + assert logo.suffix == "png" + assert logo.filename == "D.png" + assert logo.size == (140, 131) + assert logo.density is None + assert logo.mm is None + + +def test_delete_unique_global_jpg_logo(create_dept, create_logos): + from_path = Path(RESOURCES_DIR).joinpath("logo_A.jpg") + to_path = Path(scu.SCODOC_LOGOS_DIR).joinpath("logo_W.jpg") + copy(from_path.absolute(), to_path.absolute()) + assert to_path.exists() + delete_logo(name="W") + assert not to_path.exists() + + +def test_delete_unique_local_jpg_logo(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + from_path = Path(RESOURCES_DIR).joinpath("logo_A.jpg") + to_path = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_W.jpg") + copy(from_path.absolute(), to_path.absolute()) + assert to_path.exists() + delete_logo(name="W", dept_id=dept1.id) + assert not to_path.exists() + + +def test_delete_multiple_local_jpg_logo(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + from_path_A = Path(RESOURCES_DIR).joinpath("logo_A.jpg") + to_path_A = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_V.jpg") + from_path_B = Path(RESOURCES_DIR).joinpath("logo_D.png") + to_path_B = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_V.png") + copy(from_path_A.absolute(), to_path_A.absolute()) + copy(from_path_B.absolute(), to_path_B.absolute()) + assert to_path_A.exists() + assert to_path_B.exists() + delete_logo(name="V", dept_id=dept1.id) + assert not to_path_A.exists() + assert not to_path_B.exists() + + +def test_create_global_jpg_logo(create_dept, create_logos): + path = Path(f"{RESOURCES_DIR}/logo_C.jpg") + stream = path.open("rb") + logo_path = Path(scu.SCODOC_LOGOS_DIR).joinpath("logo_X.jpg") + assert not logo_path.exists() + write_logo(stream, name="X") # create global logo + assert logo_path.exists() + logo_path.unlink(missing_ok=True) + + +def test_create_locale_jpg_logo(create_dept, create_logos): + dept1, dept2, dept3 = create_dept + path = Path(f"{RESOURCES_DIR}/logo_C.jpg") + stream = path.open("rb") + logo_path = Path(scu.SCODOC_LOGOS_DIR).joinpath(f"logos_{dept1.id}", "logo_Y.jpg") + assert not logo_path.exists() + write_logo(stream, name="Y", dept_id=dept1.id) # create global logo + assert logo_path.exists() + logo_path.unlink(missing_ok=True) + + +def test_create_jpg_instead_of_png_logo(create_dept, create_logos): + # action + logo = Logo("D") # create global logo (replace logo_D.png) + path = Path(f"{RESOURCES_DIR}/logo_C.jpg") + stream = path.open("rb") + logo.create(stream) + # test + created = Path(f"{scu.SCODOC_LOGOS_DIR}/logo_D.jpg") + removed = Path(f"{scu.SCODOC_LOGOS_DIR}/logo_D.png") + # file system check + assert created.exists() + assert not removed.exists() + # logo check + logo = find_logo("D") + assert logo is not None + assert logo.filepath == f"{scu.SCODOC_LOGOS_DIR}/logo_D.jpg" # created.absolute() + # restore initial state + original = Path(f"{RESOURCES_DIR}/logo_D.png") + copy(original, removed) + created.unlink(missing_ok=True) + + +def test_list_logo(create_dept, create_logos): + # test only existence of copied logos. We assumes that they are OK + dept1, dept2, dept3 = create_dept + logos = list_logos() + assert set(logos.keys()) == {dept1.id, dept2.id, None} + assert {"A", "C", "D", "E", "F", "header", "footer"}.issubset( + set(logos[None].keys()) + ) + rt = logos.get(dept1.id, None) + assert rt is not None + assert {"A", "B"}.issubset(set(rt.keys())) + info = logos.get(dept2.id, None) + assert info is not None + assert {"A"}.issubset(set(rt.keys())) + gea = logos.get(dept3.id, None) + assert gea is None diff --git a/tools/__init__.py b/tools/__init__.py index 7ed85cae..ac9e681c 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -7,3 +7,4 @@ from tools.import_scodoc7_user_db import import_scodoc7_user_db from tools.import_scodoc7_dept import import_scodoc7_dept from tools.migrate_scodoc7_archives import migrate_scodoc7_dept_archives +from tools.migrate_scodoc7_logos import migrate_scodoc7_dept_logos diff --git a/tools/migrate_from_scodoc7.sh b/tools/migrate_from_scodoc7.sh index 59c0cee8..7d4132ca 100755 --- a/tools/migrate_from_scodoc7.sh +++ b/tools/migrate_from_scodoc7.sh @@ -274,6 +274,9 @@ done # ----- Post-Migration: renomme archives en fonction des nouveaux ids su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask migrate-scodoc7-dept-archives)" "$SCODOC_USER" || die "Erreur de la post-migration des archives" +# ----- Post-Migration: renomme logos en fonction des nouveaux ids +su -c "(cd $SCODOC_DIR && source venv/bin/activate && flask migrate-scodoc7-dept-logos)" || die "Erreur de la post-migration des logos" + # --- Si migration "en place", désactive ScoDoc 7 if [ "$INPLACE" == 1 ] diff --git a/tools/migrate_scodoc7_logos.py b/tools/migrate_scodoc7_logos.py new file mode 100644 index 00000000..57c7775f --- /dev/null +++ b/tools/migrate_scodoc7_logos.py @@ -0,0 +1,53 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +import glob +import os +import shutil + +from app.models import Departement + + +def migrate_scodoc7_dept_logos(dept_name=""): + if dept_name: + depts = Departement.query.filter_by(acronym=dept_name) + else: + depts = Departement.query + n_dir = 0 + n_moves = 0 + n_depts = 0 + purged_candidates = [] # directory that maybe purged at the end + for dept in depts: + logos_dir7 = f"/opt/scodoc-data/config/logos/logos_{dept.acronym}" + logos_dir9 = f"/opt/scodoc-data/config/logos/logos_{dept.id}" + if os.path.exists(logos_dir7): + print(f"Migrating {dept.acronym} logos...") + purged_candidates.append(logos_dir7) + n_depts += 1 + if not os.path.exists(logos_dir9): + # print(f"renaming {logos_dir7} to {logos_dir9}") + shutil.move(logos_dir7, logos_dir9) + n_dir += 1 + else: + # print(f"merging {logos_dir7} with {logos_dir9}") + for logo in glob.glob(f"{logos_dir7}/*"): + # print(f"\tmoving {logo}") + fn = os.path.split(logo)[1] + if not os.path.exists(os.path.sep.join([logos_dir9, fn])): + shutil.move(logo, logos_dir9) + n_moves += 1 + n_purged = 0 + for candidate in purged_candidates: + if len(os.listdir(candidate)) == 0: + os.rmdir(candidate) + n_purged += 1 + print(f"{n_depts} department(s) scanned") + if n_dir: + print(f"{n_dir} directory(ies) moved") + if n_moves: + print(f"{n_moves} file(s) moved") + if n_purged: + print(f"{n_purged} scodoc7 logo dir(s) removed") + if n_dir + n_moves + n_purged == 0: + print("nothing done") + # print(f"moved {n_moves}/{n} etuds")