369 lines
14 KiB
Python
369 lines
14 KiB
Python
# -*- mode: python -*-
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##############################################################################
|
|
#
|
|
# Gestion scolarite IUT
|
|
#
|
|
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
|
#
|
|
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
|
#
|
|
##############################################################################
|
|
|
|
"""Gestion des images logos (nouveau ScoDoc 9.1)
|
|
|
|
Les logos sont `logo_header.<ext>` et `logo_footer.<ext>`
|
|
avec `ext` membre de LOGOS_IMAGES_ALLOWED_TYPES (= jpg, png)
|
|
|
|
SCODOC_LOGOS_DIR /opt/scodoc-data/config/logos
|
|
"""
|
|
import glob
|
|
import os
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from flask import current_app, url_for
|
|
from PIL import Image as PILImage
|
|
import puremagic
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from app import log
|
|
from app.models import Departement
|
|
from app.scodoc import sco_utils as scu
|
|
from app.scodoc.sco_exceptions import ScoValueError
|
|
|
|
GLOBAL = "_" # category for server level logos
|
|
|
|
|
|
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 logo.
|
|
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)
|
|
if not self.logoname:
|
|
self.logoname = "*** *** nom de logo invalide *** à changer ! *** ***"
|
|
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 computed by the select function
|
|
self.suffix = (
|
|
"Not initialized: call the select or create function before access"
|
|
)
|
|
self.filepath = (
|
|
"Not initialized: call the select or create function before access"
|
|
)
|
|
self.filename = (
|
|
"Not initialized: call the select or create function before access"
|
|
)
|
|
self.size = "Not initialized: call the select or create function before access"
|
|
self.aspect_ratio = (
|
|
"Not initialized: call the select or create function before access"
|
|
)
|
|
self.density = (
|
|
"Not initialized: call the select or create function before access"
|
|
)
|
|
self.mm = "Not initialized: call the select or create function before access"
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Logo(logoname='{self.logoname}', filename='{self.filename}')"
|
|
|
|
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("sco_logos creating directory %s", self.dirpath)
|
|
os.mkdir(self.dirpath)
|
|
|
|
def create(self, stream):
|
|
"enregistre logo"
|
|
try:
|
|
img_type = guess_image_type(stream)
|
|
except ValueError as exc:
|
|
raise ScoValueError("fichier logo invalide") from exc
|
|
if img_type not in scu.LOGOS_IMAGES_ALLOWED_TYPES:
|
|
raise ScoValueError(f"type d'image invalide ({img_type})")
|
|
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("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 and x_density != 0 and y_density != 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 rename(self, new_name):
|
|
"""Change le nom (pas le département)
|
|
Les éléments non utiles ne sont pas recalculés (car rechargés lors des accès ultérieurs)
|
|
"""
|
|
old_path = Path(self.filepath)
|
|
self.logoname = secure_filename(new_name)
|
|
if not self.logoname:
|
|
self.logoname = "*** *** nom de logo invalide *** à changer ! *** ***"
|
|
else:
|
|
new_path = os.path.sep.join(
|
|
[self.dirpath, self.prefix + self.logoname + "." + self.suffix]
|
|
)
|
|
old_path.rename(new_path)
|
|
|
|
def html(self) -> str:
|
|
"élément HTML img affichant ce logo"
|
|
return f"""<img class="sco_logo" src="{self.get_url()}" alt="Logo {self.logoname}">"""
|
|
|
|
|
|
def find_logo(
|
|
logoname: str,
|
|
dept_id: int | None = None,
|
|
strict: bool = False,
|
|
prefix: str = scu.LOGO_FILE_PREFIX,
|
|
) -> Logo | None:
|
|
"""
|
|
"Recherche un logo 'name' existant.
|
|
Deux strategies:
|
|
si strict:
|
|
recherche 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
|
|
|
|
|
|
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 rename_logo(old_name, new_name, dept_id):
|
|
logo = find_logo(old_name, dept_id, True)
|
|
logo.rename(new_name)
|
|
|
|
|
|
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):
|
|
"""Inventorie 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)
|
|
# parse filename 'logo_<logoname>.<ext> . be carefull: logoname may include '.'
|
|
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)[
|
|
:-1
|
|
] # retreive logoname from filename (less final dot)
|
|
logos[logoname] = Logo(logoname=logoname, dept_id=dept_id).select()
|
|
return logos if len(logos.keys()) > 0 else None
|
|
|
|
|
|
def guess_image_type(stream) -> str:
|
|
"guess image type from header in stream"
|
|
ext = puremagic.from_stream(stream)
|
|
if not ext or not ext.startswith("."):
|
|
return None
|
|
fmt = ext[1:] # remove leading .
|
|
if fmt == "jfif":
|
|
fmt = "jpg"
|
|
return fmt if fmt != "jpeg" else "jpg"
|
|
|
|
|
|
def make_logo_local(logoname, dept_name):
|
|
depts = Departement.query.filter_by(acronym=dept_name).all()
|
|
if len(depts) == 0:
|
|
log(f"no dept {dept_name} found. aborting")
|
|
return
|
|
if len(depts) > 1:
|
|
log(f"several depts {dept_name} found. aborting")
|
|
return
|
|
dept = depts[0]
|
|
log(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):
|
|
log("local version of global logo already exists. aborting")
|
|
return
|
|
# create new__dir if necessary
|
|
if not os.path.exists(new_dir):
|
|
log(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)
|
|
# log(f"moved {n_moves}/{n} etuds")
|