ScoDoc/app/scodoc/sco_photos.py

413 lines
14 KiB
Python
Raw Permalink Normal View History

2020-09-26 16:19:37 +02:00
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
2023-12-31 23:04:06 +01:00
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
2020-09-26 16:19:37 +02:00
#
# 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@gmail.com
#
##############################################################################
"""(Nouvelle) (Nouvelle) gestion des photos d'etudiants
Les images sont stockées dans .../var/scodoc/photos
2023-12-31 23:04:06 +01:00
L'attribut "photo_filename" de la table identite donne le nom du fichier image,
2020-09-26 16:19:37 +02:00
sans extension (e.g. "F44/RT_EID31545").
Toutes les images sont converties en jpg, et stockées dans photo_filename.jpg en taille originale.
Elles sont aussi réduites en 90 pixels de hauteur, et stockées dans photo_filename.h90.jpg
Les images sont servies par ScoDoc, via la méthode getphotofile?etudid=xxx
## Historique:
2023-12-31 23:04:06 +01:00
- jusqu'à novembre 2009, les images étaient stockées dans Zope (ZODB).
2020-09-26 16:19:37 +02:00
- jusqu'à v1908, stockées dans .../static/photos (et donc accessibles sans authentification).
- support for legacy ZODB removed in v1909.
"""
2021-08-21 15:17:14 +02:00
import datetime
import glob
2021-07-26 15:23:07 +02:00
import io
2020-09-26 16:19:37 +02:00
import os
import random
2021-08-21 15:17:14 +02:00
import requests
import time
2022-09-15 10:06:44 +02:00
import traceback
2021-07-11 22:56:22 +02:00
import PIL
2021-08-21 15:17:14 +02:00
from PIL import Image as PILImage
2020-09-26 16:19:37 +02:00
2022-09-05 12:13:13 +02:00
from flask import abort, request, g, has_request_context
2021-12-20 22:53:09 +01:00
from flask.helpers import make_response, url_for
2021-12-20 22:53:09 +01:00
from app import log
from app import db
from app.models import Identite, Scolog
from app.scodoc import sco_etud
from app.scodoc import sco_portal_apogee
from app.scodoc import sco_preferences
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.scolog import logdb
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
2021-12-20 22:53:09 +01:00
from config import Config
2020-09-26 16:19:37 +02:00
2021-12-20 22:53:09 +01:00
# Full paths on server's filesystem. Something like "/opt/scodoc-data/photos"
PHOTO_DIR = os.path.join(Config.SCODOC_VAR_DIR, "photos")
ICONS_DIR = os.path.join(Config.SCODOC_DIR, "app", "static", "icons")
2020-09-26 16:19:37 +02:00
UNKNOWN_IMAGE_PATH = os.path.join(ICONS_DIR, "unknown.jpg")
2020-09-26 16:19:37 +02:00
IMAGE_EXT = ".jpg"
JPG_QUALITY = 0.92
REDUCED_HEIGHT = 90 # pixels
MAX_FILE_SIZE = 4 * 1024 * 1024 # max allowed size for uploaded image, in bytes
2020-09-26 16:19:37 +02:00
H90 = ".h90" # suffix for reduced size images
def unknown_image_url() -> str:
"URL for 'unkwown' face image"
return url_for("scolar.get_photo_image", scodoc_dept=g.scodoc_dept, etudid="")
def photo_portal_url(code_nip: str):
2020-09-26 16:19:37 +02:00
"""Returns external URL to retreive photo on portal,
or None if no portal configured"""
2021-08-20 10:51:42 +02:00
photo_url = sco_portal_apogee.get_photo_url()
if photo_url and code_nip:
return photo_url + "?nip=" + code_nip
2020-09-26 16:19:37 +02:00
else:
return None
2020-12-12 18:05:28 +01:00
2021-12-05 20:21:51 +01:00
def get_etud_photo_url(etudid, size="small"):
2022-08-08 10:34:13 +02:00
return (
url_for(
"scolar.get_photo_image",
scodoc_dept=g.scodoc_dept,
etudid=etudid,
size=size,
)
if has_request_context()
else ""
2021-12-05 20:21:51 +01:00
)
2021-12-20 22:53:09 +01:00
def etud_photo_url(etud: dict, size="small", fast=False) -> str:
2020-09-26 16:19:37 +02:00
"""url to the image of the student, in "small" size or "orig" size.
If ScoDoc doesn't have an image and a portal is configured, link to it.
2021-12-20 22:53:09 +01:00
2020-09-26 16:19:37 +02:00
"""
2021-12-05 20:21:51 +01:00
photo_url = get_etud_photo_url(etud["etudid"], size=size)
if fast:
return photo_url
2021-12-20 22:53:09 +01:00
path = photo_pathname(etud["photo_filename"], size=size)
2020-09-26 16:19:37 +02:00
if not path:
# Portail ?
ext_url = photo_portal_url(etud["code_nip"])
2020-09-26 16:19:37 +02:00
if not ext_url:
# fallback: Photo "unknown"
photo_url = unknown_image_url()
2020-09-26 16:19:37 +02:00
else:
# essaie de copier la photo du portail
new_path, _ = copy_portal_photo_to_fs(etud["etudid"])
2020-09-26 16:19:37 +02:00
if not new_path:
# copy failed, can we use external url ?
# nb: rarement utile, car le portail est rarement accessible sans authentification
if scu.CONFIG.PUBLISH_PORTAL_PHOTO_URL:
2020-09-26 16:19:37 +02:00
photo_url = ext_url
else:
photo_url = unknown_image_url()
2020-09-26 16:19:37 +02:00
return photo_url
def get_photo_image(etudid=None, size="small"):
2020-09-26 16:19:37 +02:00
"""Returns photo image (HTTP response)
If not etudid, use "unknown" image
"""
if not etudid:
filename = UNKNOWN_IMAGE_PATH
else:
etud = Identite.get_etud(etudid)
2021-12-20 22:53:09 +01:00
filename = photo_pathname(etud.photo_filename, size=size)
2020-09-26 16:19:37 +02:00
if not filename:
filename = UNKNOWN_IMAGE_PATH
2023-04-17 15:44:55 +02:00
r = build_image_response(filename)
return r
2020-09-26 16:19:37 +02:00
2023-04-17 15:44:55 +02:00
def build_image_response(filename):
"""returns an image as a Flask response"""
2020-09-26 16:19:37 +02:00
st = os.stat(filename)
last_modified = st.st_mtime # float timestamp
file_size = st.st_size
header = request.headers.get("If-Modified-Since")
2020-09-26 16:19:37 +02:00
if header is not None:
header = header.split(";")[0]
# Some proxies seem to send invalid date strings for this
# header. If the date string is not valid, we ignore it
# rather than raise an error to be generally consistent
# with common servers such as Apache (which can usually
# understand the screwy date string as a lucky side effect
# of the way they parse it).
try:
dt = datetime.datetime.strptime(header, "%a, %d %b %Y %H:%M:%S GMT")
mod_since = dt.timestamp()
except ValueError:
2020-09-26 16:19:37 +02:00
mod_since = None
if (mod_since is not None) and last_modified <= mod_since:
return make_response(b"", 304) # not modified
#
last_modified_str = time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)
)
response = make_response(open(filename, mode="rb").read())
response.headers["Content-Type"] = "image/jpeg"
response.headers["Last-Modified"] = last_modified_str
response.headers["Cache-Control"] = "max-age=3600"
response.headers["Content-Length"] = str(file_size)
return response
2020-09-26 16:19:37 +02:00
def etud_photo_is_local(photo_filename: str, size="small"):
return photo_pathname(photo_filename, size=size)
2020-09-26 16:19:37 +02:00
def etud_photo_html(etud: dict = None, etudid=None, title=None, size="small") -> str:
2020-12-12 18:05:28 +01:00
"""HTML img tag for the photo, either in small size (h90)
2020-09-26 16:19:37 +02:00
or original size (size=="orig")
"""
if not etud:
if etudid:
2022-09-05 12:13:13 +02:00
etuds = sco_etud.get_etud_info(filled=True, etudid=etudid)
if not etuds:
return abort(404, "etudiant inconnu")
etud = etuds[0]
2020-09-26 16:19:37 +02:00
else:
abort(404, "etud_photo_html: either etud or etudid must be specified")
photo_url = etud_photo_url(etud, size=size)
2020-09-26 16:19:37 +02:00
nom = etud.get("nomprenom", etud["nom_disp"])
if title is None:
title = nom
if not etud_photo_is_local(etud["photo_filename"]):
2020-09-26 16:19:37 +02:00
fallback = (
f"""onerror='this.onerror = null; this.src="{unknown_image_url()}"'"""
2020-09-26 16:19:37 +02:00
)
else:
fallback = ""
if size == "small":
height_attr = 'height="%s"' % REDUCED_HEIGHT
else:
height_attr = ""
return '<img src="%s" alt="photo %s" title="%s" border="0" %s %s />' % (
photo_url,
nom,
title,
height_attr,
fallback,
)
2020-12-12 18:05:28 +01:00
def etud_photo_orig_html(etud=None, etudid=None, title=None) -> str:
2020-09-26 16:19:37 +02:00
"""HTML img tag for the photo, in full size.
Full-size images are always stored locally in the filesystem.
They are the original uploaded images, converted in jpeg.
"""
2021-09-24 12:10:53 +02:00
return etud_photo_html(etud=etud, etudid=etudid, title=title, size="orig")
2020-12-12 18:05:28 +01:00
2020-09-26 16:19:37 +02:00
2021-12-20 22:53:09 +01:00
def photo_pathname(photo_filename: str, size="orig"):
"""Returns full path of image file if etud has a photo (in the filesystem),
or False.
2020-09-26 16:19:37 +02:00
Do not distinguish the cases: no photo, or file missing.
2021-12-20 22:53:09 +01:00
Argument: photo_filename (Identite attribute)
Resultat: False or str
2020-09-26 16:19:37 +02:00
"""
if size == "small":
version = H90
elif size == "orig":
version = ""
else:
abort(404, "invalid size parameter for photo")
2021-12-20 22:53:09 +01:00
if not photo_filename:
2020-09-26 16:19:37 +02:00
return False
2021-12-20 22:53:09 +01:00
path = os.path.join(PHOTO_DIR, photo_filename) + version + IMAGE_EXT
2020-09-26 16:19:37 +02:00
if os.path.exists(path):
return path
else:
return False
def store_photo(etud: Identite, data, filename: str) -> tuple[bool, str]:
2020-09-26 16:19:37 +02:00
"""Store image for this etud.
If there is an existing photo, it is erased and replaced.
2021-07-26 15:23:07 +02:00
data is a bytes string with image raw data.
2020-09-26 16:19:37 +02:00
Update database to store filename.
Returns (status, err_msg)
2020-09-26 16:19:37 +02:00
"""
# basic checks
filesize = len(data)
if filesize < 10 or filesize > MAX_FILE_SIZE:
return False, f"Fichier image '{filename}' de taille invalide ! ({filesize})"
try:
saved_filename = save_image(etud, data)
except (OSError, PIL.UnidentifiedImageError) as exc:
raise ScoValueError(
msg="Fichier d'image '{filename}' invalide ou format non supporté"
) from exc
2020-09-26 16:19:37 +02:00
# update database:
etud.photo_filename = saved_filename
db.session.add(etud)
Scolog.logdb(method="changePhoto", msg=saved_filename, etudid=etud.id)
db.session.commit()
2020-09-26 16:19:37 +02:00
#
return True, "ok"
2020-09-26 16:19:37 +02:00
2021-12-20 22:53:09 +01:00
def suppress_photo(etud: Identite) -> None:
2020-09-26 16:19:37 +02:00
"""Suppress a photo"""
2022-09-05 12:13:13 +02:00
log(f"suppress_photo {etud}")
2021-12-20 22:53:09 +01:00
rel_path = photo_pathname(etud.photo_filename)
2020-09-26 16:19:37 +02:00
# 1- remove ref. from database
2021-12-20 22:53:09 +01:00
etud.photo_filename = None
db.session.add(etud)
2020-09-26 16:19:37 +02:00
# 2- erase images files
if rel_path:
# remove extension and glob
rel_path = rel_path[: -len(IMAGE_EXT)]
filenames = glob.glob(rel_path + "*" + IMAGE_EXT)
for filename in filenames:
2022-09-05 12:13:13 +02:00
log(f"removing file {filename}")
2020-09-26 16:19:37 +02:00
os.remove(filename)
2021-12-20 22:53:09 +01:00
db.session.commit()
2020-09-26 16:19:37 +02:00
# 3- log
2021-12-20 22:53:09 +01:00
cnx = ndb.GetDBConnexion()
logdb(cnx, method="changePhoto", msg="suppression", etudid=etud.id)
2020-09-26 16:19:37 +02:00
# ---------------------------------------------------------------------------
# Internal functions
2020-12-12 18:05:28 +01:00
def save_image(etud: Identite, data: bytes):
"""data is a bytes string.
2020-09-26 16:19:37 +02:00
Save image in JPEG in 2 sizes (original and h90).
Returns filename (relative to PHOTO_DIR), without extension
"""
2021-07-26 15:23:07 +02:00
data_file = io.BytesIO()
2020-09-26 16:19:37 +02:00
data_file.write(data)
data_file.seek(0)
img = PILImage.open(data_file)
filename = get_new_filename(etud)
2020-12-12 18:05:28 +01:00
path = os.path.join(PHOTO_DIR, filename)
2020-09-26 16:19:37 +02:00
log("saving %dx%d jpeg to %s" % (img.size[0], img.size[1], path))
img = img.convert("RGB")
2020-09-26 16:19:37 +02:00
img.save(path + IMAGE_EXT, format="JPEG", quality=92)
# resize:
img = scale_height(img)
log("saving %dx%d jpeg to %s.h90" % (img.size[0], img.size[1], filename))
img.save(path + H90 + IMAGE_EXT, format="JPEG", quality=92)
return filename
def scale_height(img, W=None, H=REDUCED_HEIGHT):
if W is None:
# keep aspect
2021-07-09 19:50:40 +02:00
W = int((img.size[0] * H) / img.size[1])
2023-08-11 15:14:16 +02:00
img.thumbnail((W, H), PILImage.LANCZOS)
2020-09-26 16:19:37 +02:00
return img
def get_new_filename(etud: Identite):
2020-09-26 16:19:37 +02:00
"""Constructs a random filename to store a new image.
The path is constructed as: Fxx/etudid
"""
dept = etud.departement.acronym
return find_new_dir() + dept + "_" + str(etud.id)
2020-09-26 16:19:37 +02:00
def find_new_dir():
"""select randomly a new subdirectory to store a new file.
We define 100 subdirectories named from F00 to F99.
Returns a path relative to the PHOTO_DIR.
"""
d = "F" + "%02d" % random.randint(0, 99)
path = os.path.join(PHOTO_DIR, d)
if not os.path.exists(path):
# ensure photos directory exists
if not os.path.exists(PHOTO_DIR):
os.mkdir(PHOTO_DIR)
# create subdirectory
2022-09-05 12:13:13 +02:00
log(f"creating directory {path}")
2020-09-26 16:19:37 +02:00
os.mkdir(path)
return d + "/"
def copy_portal_photo_to_fs(etudid: int):
2020-09-26 16:19:37 +02:00
"""Copy the photo from portal (distant website) to local fs.
Returns rel. path or None if copy failed, with a diagnostic message
2020-09-26 16:19:37 +02:00
"""
etud: Identite = Identite.query.get_or_404(etudid)
url = photo_portal_url(etud.code_nip)
2020-09-26 16:19:37 +02:00
if not url:
return None, f"""{etud.nomprenom}: pas de code NIP"""
portal_timeout = sco_preferences.get_preference("portal_timeout")
error_message = None
2020-09-26 16:19:37 +02:00
try:
2021-08-21 15:17:14 +02:00
r = requests.get(url, timeout=portal_timeout)
except requests.ConnectionError:
error_message = "ConnectionError"
except requests.Timeout:
error_message = "Timeout"
except requests.TooManyRedirects:
error_message = "TooManyRedirects"
except requests.RequestException:
2022-09-15 10:06:44 +02:00
error_message = "unknown requests error"
if error_message is not None:
2022-09-15 10:06:44 +02:00
log("sco_photos: download failed")
log(traceback.format_exc())
log(f"copy_portal_photo_to_fs: {error_message}")
return (
None,
f"""{etud.nomprenom}: erreur chargement de {url}\n{error_message}""",
)
2021-08-21 15:17:14 +02:00
if r.status_code != 200:
log(f"copy_portal_photo_to_fs: download failed {r.status_code }")
return None, f"""{etud.nomprenom}: erreur chargement de {url}"""
2021-08-21 15:17:14 +02:00
data = r.content # image bytes
2020-09-26 16:19:37 +02:00
try:
status, error_message = store_photo(etud, data, "(inconnue)")
except Exception:
status = False
error_message = "Erreur chargement photo du portail"
2020-09-26 16:19:37 +02:00
log("copy_portal_photo_to_fs: failure (exception in store_photo)!")
if status:
log(f"copy_portal_photo_to_fs: copied {url}")
2021-12-20 22:53:09 +01:00
return (
photo_pathname(etud.photo_filename),
f"{etud.nomprenom}: photo chargée",
2021-12-20 22:53:09 +01:00
)
2020-09-26 16:19:37 +02:00
else:
return None, f"{etud.nomprenom}: <b>{error_message}</b>"