Merge pull request 'livrable_logos' (#200) from jmplace/ScoDoc-Lille:livrable_logos into PNBUT

Reviewed-on: #200
This commit is contained in:
Emmanuel Viennet 2021-12-12 17:00:52 +01:00
commit 10941b6ef4
30 changed files with 1616 additions and 253 deletions

View File

@ -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)
# ----------------------------------------------------------------------------------------

View File

@ -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'<img %s src="%s"%s/>' % (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'<img\1src="%s/logo_\2.jpg"\3/>' % 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)

View File

@ -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()

View File

@ -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,
)

View File

@ -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_<dept>),
# 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: <rep>/<prefix><name>.<suffixe>
<rep> : répertoire de recherche (déduit du dept_id)
<prefix>: le prefix (LOGO_FILE_PREFIX pour les logos)
<suffix>: 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(<name>, <dept_id>, ...).select() (retourne None si fichier non trouvé)
logo en création: Logo(<name>, <dept_id>, ...).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'<logo name="{self.logoname}" width="?? mm" height="?? mm">'
else:
return f'<logo name="{self.logoname}" width="{self.mm[0]}mm"">'
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")

View File

@ -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.

View File

@ -2017,10 +2017,10 @@ class BasePreferences(object):
H = [
html_sco_header.sco_header(page_title="Préférences"),
"<h2>Préférences globales pour %s</h2>" % scu.ScoURL(),
f"""<p><a href="{url_for("scolar.config_logos", scodoc_dept=g.scodoc_dept)
}">modification des logos du département (pour documents pdf)</a></p>"""
if current_user.is_administrator()
else "",
# f"""<p><a href="{url_for("scolar.config_logos", scodoc_dept=g.scodoc_dept)
# }">modification des logos du département (pour documents pdf)</a></p>"""
# if current_user.is_administrator()
# else "",
"""<p class="help">Ces paramètres s'appliquent par défaut à tous les semestres, sauf si ceux-ci définissent des valeurs spécifiques.</p>
<p class="msg">Attention: cliquez sur "Enregistrer les modifications" en bas de page pour appliquer vos changements !</p>
""",

View File

@ -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."""

View File

@ -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<name>.<suffix> (fichier global) ou
# SCODOC_LOGO_DIR/LOGOS_DIR_PREFIX<dept_id>/LOGO_FILE_PREFIX<name>.<suffix> (fichier départemental)
# ----- Les outils distribués
SCO_TOOLS_DIR = os.path.join(Config.SCODOC_DIR, "tools")

View File

@ -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;

View File

@ -0,0 +1,6 @@
function submit_form() {
$("#configuration_form").submit();
}
$(function () {
})

View File

@ -0,0 +1,81 @@
{% macro render_field(field) %}
<div>
<span class="wtf-field">{{ field.label }} :</span>
<span class="wtf-field">{{ field()|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</span>
</div>
{% endmacro %}
{% macro render_logo(logo_form, titre=None) %}
{% if titre %}
<tr>
<td colspan="2">
<h3>{{ titre }}</h3>
<hr/>
</td>
</tr>
{% endif %}
<tr>
<td style="padding-right: 20px; vertical-align: top;">
<p class="help">{{ logo_form.form.description }} Image actuelle:</p>
<div class="img-container"><img src="{{ logo_form.logo.get_url_small() }}"
alt="pas de logo chargé" /></div>
</td>
<td style="vertical-align: top;">
{{ logo_form.form.dept_id() }}
{{ logo_form.form.logo_id() }}
Nom: {{ logo_form.form.logo.logoname }}<br/>
{# {{ logo_form.form.description }}<br/>#}
Format: {{ logo_form.logo.suffix }}<br/>
Taille en px: {{ logo_form.logo.size }}<br/>
{% if logo_form.logo.mm %}
Taile en mm: {{ logo_form.logo.mm }}<br/>
{% endif %}
Aspect ratio: {{ logo_form.logo.aspect_ratio }}<br/>
<hr/>
Usage: {{ logo_form.logo.get_usage() }}
<hr/>
<span class="wtf-field">{{ render_field(logo_form.upload) }}</span>
{% if logo_form.can_delete %}
{{ render_field(logo_form.do_delete) }}
{% endif %}
</td>
</tr>
{% endmacro %}
{#{% block app_content %}#}
{% if scodoc_dept %}
<h1>Logos du département {{ scodoc_dept }}</h1>
{% else %}
<h1>Configuration générale</h1>
{% endif %}
<form class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{% if not scodoc_dept %}
<div class="sco_help">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):</div>
{{ render_field(form.bonus_sport_func_name)}}
<div class="configuration_logo">
<table>
{{ render_logo(form.header, 'Logo en-tête') }}
{{ render_logo(form.footer, 'Logo pied de page') }}
</table>
</div>
{% endif %}
<!-- <div class="sco_help">Les paramètres ci-dessous peuvent être changés dans chaque département
(paramétrage).<br />On indique ici les valeurs initiales par défaut:
</div> -->
<div class="sco-submit">{{ form.submit() }}</div>
</form>
{#{% endblock %}#}

View File

@ -1,10 +1,12 @@
{% extends 'base.html' %}
{% import 'bootstrap/wtf.html' as wtf %}
{% macro render_field(field) %}
{% macro render_field(field, with_label=True) %}
<div>
<span class="wtf-field">{{ field.label }} :</span>
<span class="wtf-field">{{ field()|safe }}
{% if with_label %}
<span class="wtf-field">{{ field.label }} :</span>
{% endif %}
<span class="wtf-field">{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
@ -16,38 +18,102 @@
</div>
{% endmacro %}
{% macro render_add_logo(add_logo_form) %}
<div class="logo-add">
<h3>Ajouter un logo</h3>
{{ 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") }}
</div>
{% endmacro %}
{% macro render_logo(dept_form, logo_form) %}
<div class="logo-edit">
{{ logo_form.hidden_tag() }}
{% if logo_form.titre %}
<tr class="logo-edit">
<td colspan="3" class="titre">
<div class="nom"><h3>{{ logo_form.titre }}</h3></div>
<div class="description">{{ logo_form.description or "" }}</div>
</td>
</tr>
{% else %}
<tr class="logo-edit">
<td colspan="3" class="titre">
<span class="nom"><h3>Logo personalisé: {{ logo_form.logo_id.data }}</h3></span>
<span class="description">{{ logo_form.description or "" }}</span>
</td>
</tr>
{% endif %}
<tr>
<td style="padding-right: 20px; ">
<div class="img-container">
<img src="{{ logo_form.logo.get_url_small() }}" alt="pas de logo chargé" /></div>
</td><td class="img-data">
<h3>{{ logo_form.logo.logoname }} (Format: {{ logo_form.logo.suffix }})</h3>
Taille: {{ logo_form.logo.size }} px
{% if logo_form.logo.mm %} &nbsp; / &nbsp; {{ logo_form.logo.mm }} mm {% endif %}<br/>
Aspect ratio: {{ logo_form.logo.aspect_ratio }}<br/>
Usage: <span style="font-family: system-ui">{{ logo_form.logo.get_usage() }}</span>
</td><td class=""img-action">
<p>Modifier l'image</p>
<span class="wtf-field">{{ render_field(logo_form.upload, False, onchange="submit_form()") }}</span>
{% if logo_form.can_delete %}
<p>Supprimer l'image</p>
{{ render_field(logo_form.do_delete, False, onSubmit="submit_form()") }}
{% endif %}
</td>
</tr>
</div>
{% endmacro %}
{% macro render_logos(dept_form) %}
<table>
{% for logo_entry in dept_form.logos.entries %}
{% set logo_form = logo_entry.form %}
{{ render_logo(dept_form, logo_form) }}
{% else %}
<p class="logo-edit"><h3>Aucun logo défini en propre à ce département</h3></p>
{% endfor %}
</table>
{% endmacro %}
{% block app_content %}
{% if scodoc_dept %}
<h1>Logos du département {{ scodoc_dept }}</h1>
{% else %}
<h1>Configuration générale {{ scodoc_dept }}</h1>
{% endif %}
<script src="/ScoDoc/static/jQuery/jquery.js"></script>
<script src="/ScoDoc/static/js/configuration.js"></script>
<form class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
<form id="configuration_form" class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{% if not scodoc_dept %}
<div class="sco_help">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):</div>
{{ render_field(form.bonus_sport_func_name)}}
{% endif %}
<div class="configuration_logo">
<h3>Logo en-tête</h3>
<p class="help">image placée en haut de certains documents documents PDF. Image actuelle:</p>
<div class="img-container"><img src="{{ url_for('scodoc.logo_header', scodoc_dept=scodoc_dept) }}"
alt="pas de logo chargé" /></div>
{{ render_field(form.logo_header) }}
<h3>Logo pied de page</h3>
<p class="help">image placée en pied de page de certains documents documents PDF. Image actuelle:</p>
<div class="img-container"><img src="{{ url_for('scodoc.logo_footer', scodoc_dept=g.scodoc_dept) }}"
alt="pas de logo chargé" /></div>
{{ render_field(form.logo_footer) }}
<h1>Configuration générale</h1>
<div class="sco_help">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):</div>
{{ render_field(form.bonus_sport_func_name, onChange="submit_form()")}}
<h1>Bibliothèque de logos</h1>
{% for dept_entry in form.depts.entries %}
{% set dept_form = dept_entry.form %}
{{ dept_entry.form.hidden_tag() }}
{% if dept_entry.form.is_local() %}
<div class="departement">
<h2>Département {{ dept_form.dept_name.data }}</h2>
<h3>Logos locaux</h3>
<div class="sco_help">Les paramètres donnés sont spécifiques à ce département.<br/>
Les logos du département se substituent aux logos de même nom définis globalement:</div>
</div>
{% else %}
<div class="departement">
<h2>Logos généraux</h2>
<div class="sco_help">Les images de cette section sont utilisé pour tous les départements,
mais peuvent être redéfinies localement au niveau de chaque département
(il suffit de définir un logo local de même nom)</div>
</div>
{% endif %}
{{ render_logos(dept_form) }}
{{ render_add_logo(dept_form.add_logo.form) }}
{% endfor %}
</div>
<!-- <div class="sco_help">Les paramètres ci-dessous peuvent être changés dans chaque département
(paramétrage).<br />On indique ici les valeurs initiales par défaut:
</div> -->
<div class="sco-submit">{{ form.submit() }}</div>
</form>
{% endblock %}

View File

@ -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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
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/<scodoc_dept>/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/<name>/small", defaults={"dept_id": None})
@bp.route("/ScoDoc/<int:dept_id>/logos/<name>/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/<scodoc_dept>/logo_footer")
def logo_footer(scodoc_dept=""):
"Image logo footer"
return _return_logo(logo_type="footer", scodoc_dept=scodoc_dept)
@bp.route(
"/ScoDoc/logos/<name>", defaults={"dept_id": None}
) # if dept not specified, take global logo
@bp.route("/ScoDoc/<int:dept_id>/logos/<name>")
@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

View File

@ -174,7 +174,7 @@ class DeptLogosConfigurationForm(FlaskForm):
validators=[
FileAllowed(
scu.LOGOS_IMAGES_ALLOWED_TYPES,
f"n'accepte que les fichiers image <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
f"n'accepte que les fichiers image <tt>{','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}</tt>",
)
],
)
@ -185,7 +185,7 @@ class DeptLogosConfigurationForm(FlaskForm):
validators=[
FileAllowed(
scu.LOGOS_IMAGES_ALLOWED_TYPES,
f"n'accepte que les fichiers image <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
f"n'accepte que les fichiers image <tt>{','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}</tt>",
)
],
)
@ -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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
# )
# ],
# )
#
# 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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
# )
# ],
# )
#
# 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,
# )
# --------------------------------------------------------------------

View File

@ -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"))

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

244
tests/unit/test_logos.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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 ]

View File

@ -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")