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_logos.py b/app/scodoc/sco_logos.py index e29b5183..0737e7f0 100644 --- a/app/scodoc/sco_logos.py +++ b/app/scodoc/sco_logos.py @@ -34,30 +34,188 @@ SCODOC_LOGOS_DIR /opt/scodoc-data/config/logos """ import imghdr import os +import re +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 = "_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 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: + [GLOBAL][name] pour les logos globaux + [dept_id][name] pour les logos propres à un département (attention id numérique du dept) + """ + inventory = {GLOBAL: _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.acronym] = _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 "" + self.suffix = None + self.dimensions = None + 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)] + ) + self.filepath = None + self.filename = None + + 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 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.dimensions = img.size + return self + return None + + def get_url(self): + """Retourne l'URL permettant d'obtenir l'image du logo""" + return url_for( + "scodoc.get_logo", + scodoc_dept=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", + scodoc_dept=self.scodoc_dept_id, + name=self.logoname, + global_if_not_found=False, + ) def guess_image_type(stream) -> str: @@ -68,28 +226,3 @@ def guess_image_type(stream) -> str: if not fmt: return None 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 diff --git a/app/scodoc/sco_pdf.py b/app/scodoc/sco_pdf.py index 77e6f4e1..54ed29b2 100755 --- a/app/scodoc/sco_pdf.py +++ b/app/scodoc/sco_pdf.py @@ -60,11 +60,8 @@ 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_logos import find_logo +from app.scodoc.sco_utils import CONFIG from app import log from app.scodoc.sco_exceptions import ScoGenError, ScoValueError import sco_version @@ -219,20 +216,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_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 5860ef07..548820a3 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -228,7 +228,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/templates/configuration.html b/app/templates/configuration.html index 6dcf1c51..88f1fd81 100644 --- a/app/templates/configuration.html +++ b/app/templates/configuration.html @@ -36,12 +36,12 @@ diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 1722aca2..47d405cb 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -30,6 +30,8 @@ Module main: page d'accueil, avec liste des départements Emmanuel Viennet, 2021 """ +import io + from app.auth.models import User import os @@ -38,7 +40,7 @@ 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 @@ -65,6 +67,8 @@ from app.scodoc.sco_exceptions import AccessDenied 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") @@ -240,13 +244,9 @@ def configuration(): 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") - ) + sco_logos.write_logo(stream=form.logo_header.data, name="header") if form.logo_footer.data: - sco_logos.store_image( - form.logo_footer.data, os.path.join(scu.SCODOC_LOGOS_DIR, "logo_footer") - ) + sco_logos.write_logo(stream=form.logo_footer.data, name="footer") app.clear_scodoc_cache() flash(f"Configuration enregistrée") return redirect(url_for("scodoc.index")) @@ -259,29 +259,74 @@ def configuration(): ) -def _return_logo(logo_type="header", scodoc_dept=""): +SMALL_SIZE = (300, 300) + + +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}") + logo = sco_logos.find_logo(name, dept_id, strict) + 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 send_file(logo.filepath, mimetype=f"image/{suffix}") 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"], + ) + + +# @bp.route("/ScoDoc/logo_header") +# @bp.route("/ScoDoc//logo_header") +# def logo_header(scodoc_dept=""): +# "Image logo header" +# return _return_logo(name="header", scodoc_dept=scodoc_dept) + + +# @bp.route("/ScoDoc/logo_footer") +# @bp.route("/ScoDoc//logo_footer") +# def logo_footer(scodoc_dept=""): +# "Image logo footer" +# return _return_logo(name="footer", scodoc_dept=scodoc_dept) # essais