forked from ScoDoc/ScoDoc
Fix: url photo inconnue
This commit is contained in:
parent
9a9319ae04
commit
6f35e18a63
|
@ -1,411 +1,411 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 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@gmail.com
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
"""(Nouvelle) (Nouvelle) gestion des photos d'etudiants
|
||||
|
||||
Les images sont stockées dans .../var/scodoc/photos
|
||||
L'attribut "photo_filename" de la table identite donne le nom du fichier image,
|
||||
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:
|
||||
- jusqu'à novembre 2009, les images étaient stockées dans Zope (ZODB).
|
||||
- jusqu'à v1908, stockées dans .../static/photos (et donc accessibles sans authentification).
|
||||
- support for legacy ZODB removed in v1909.
|
||||
|
||||
"""
|
||||
import datetime
|
||||
import glob
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
import requests
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import PIL
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from flask import abort, request, g, has_request_context
|
||||
from flask.helpers import make_response, url_for
|
||||
|
||||
from app import log
|
||||
from app import db
|
||||
from app.models import Identite
|
||||
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
|
||||
from config import Config
|
||||
|
||||
# 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")
|
||||
UNKNOWN_IMAGE_PATH = os.path.join(ICONS_DIR, "unknown.jpg")
|
||||
UNKNOWN_IMAGE_URL = "get_photo_image?etudid=" # with empty etudid => unknown face image
|
||||
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
|
||||
H90 = ".h90" # suffix for reduced size images
|
||||
|
||||
|
||||
def photo_portal_url(etud):
|
||||
"""Returns external URL to retreive photo on portal,
|
||||
or None if no portal configured"""
|
||||
photo_url = sco_portal_apogee.get_photo_url()
|
||||
if photo_url and etud["code_nip"]:
|
||||
return photo_url + "?nip=" + etud["code_nip"]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_etud_photo_url(etudid, size="small"):
|
||||
return (
|
||||
url_for(
|
||||
"scolar.get_photo_image",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etudid,
|
||||
size=size,
|
||||
)
|
||||
if has_request_context()
|
||||
else ""
|
||||
)
|
||||
|
||||
|
||||
def etud_photo_url(etud: dict, size="small", fast=False) -> str:
|
||||
"""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.
|
||||
|
||||
"""
|
||||
photo_url = get_etud_photo_url(etud["etudid"], size=size)
|
||||
if fast:
|
||||
return photo_url
|
||||
path = photo_pathname(etud["photo_filename"], size=size)
|
||||
if not path:
|
||||
# Portail ?
|
||||
ext_url = photo_portal_url(etud)
|
||||
if not ext_url:
|
||||
# fallback: Photo "unknown"
|
||||
photo_url = scu.ScoURL() + "/" + UNKNOWN_IMAGE_URL
|
||||
else:
|
||||
# essaie de copier la photo du portail
|
||||
new_path, _ = copy_portal_photo_to_fs(etud)
|
||||
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:
|
||||
photo_url = ext_url
|
||||
else:
|
||||
photo_url = UNKNOWN_IMAGE_URL
|
||||
return photo_url
|
||||
|
||||
|
||||
def get_photo_image(etudid=None, size="small"):
|
||||
"""Returns photo image (HTTP response)
|
||||
If not etudid, use "unknown" image
|
||||
"""
|
||||
if not etudid:
|
||||
filename = UNKNOWN_IMAGE_PATH
|
||||
else:
|
||||
etud = Identite.query.get_or_404(etudid)
|
||||
filename = photo_pathname(etud.photo_filename, size=size)
|
||||
if not filename:
|
||||
filename = UNKNOWN_IMAGE_PATH
|
||||
return _http_jpeg_file(filename)
|
||||
|
||||
|
||||
def _http_jpeg_file(filename):
|
||||
"""returns an image as a Flask response"""
|
||||
st = os.stat(filename)
|
||||
last_modified = st.st_mtime # float timestamp
|
||||
file_size = st.st_size
|
||||
header = request.headers.get("If-Modified-Since")
|
||||
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:
|
||||
mod_since = None
|
||||
if (mod_since is not None) and last_modified <= mod_since:
|
||||
return "", 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
|
||||
|
||||
|
||||
def etud_photo_is_local(etud: dict, size="small"):
|
||||
return photo_pathname(etud["photo_filename"], size=size)
|
||||
|
||||
|
||||
def etud_photo_html(etud: dict = None, etudid=None, title=None, size="small"):
|
||||
"""HTML img tag for the photo, either in small size (h90)
|
||||
or original size (size=="orig")
|
||||
"""
|
||||
if not etud:
|
||||
if etudid:
|
||||
etuds = sco_etud.get_etud_info(filled=True, etudid=etudid)
|
||||
if not etuds:
|
||||
return abort(404, "etudiant inconnu")
|
||||
etud = etuds[0]
|
||||
else:
|
||||
raise ValueError("etud_photo_html: either etud or etudid must be specified")
|
||||
photo_url = etud_photo_url(etud, size=size)
|
||||
nom = etud.get("nomprenom", etud["nom_disp"])
|
||||
if title is None:
|
||||
title = nom
|
||||
if not etud_photo_is_local(etud):
|
||||
fallback = (
|
||||
"""onerror='this.onerror = null; this.src="%s"'""" % UNKNOWN_IMAGE_URL
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def etud_photo_orig_html(etud=None, etudid=None, title=None):
|
||||
"""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.
|
||||
"""
|
||||
return etud_photo_html(etud=etud, etudid=etudid, title=title, size="orig")
|
||||
|
||||
|
||||
def photo_pathname(photo_filename: str, size="orig"):
|
||||
"""Returns full path of image file if etud has a photo (in the filesystem),
|
||||
or False.
|
||||
Do not distinguish the cases: no photo, or file missing.
|
||||
Argument: photo_filename (Identite attribute)
|
||||
Resultat: False or str
|
||||
"""
|
||||
if size == "small":
|
||||
version = H90
|
||||
elif size == "orig":
|
||||
version = ""
|
||||
else:
|
||||
raise ValueError("invalid size parameter for photo")
|
||||
if not photo_filename:
|
||||
return False
|
||||
path = os.path.join(PHOTO_DIR, photo_filename) + version + IMAGE_EXT
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def store_photo(etud: dict, data, filename: str) -> tuple[bool, str]:
|
||||
"""Store image for this etud.
|
||||
If there is an existing photo, it is erased and replaced.
|
||||
data is a bytes string with image raw data.
|
||||
|
||||
Update database to store filename.
|
||||
|
||||
Returns (status, err_msg)
|
||||
"""
|
||||
# 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["etudid"], data)
|
||||
except (OSError, PIL.UnidentifiedImageError) as exc:
|
||||
raise ScoValueError(
|
||||
msg="Fichier d'image '{filename}' invalide ou format non supporté"
|
||||
) from exc
|
||||
|
||||
# update database:
|
||||
etud["photo_filename"] = saved_filename
|
||||
etud["foto"] = None
|
||||
|
||||
cnx = ndb.GetDBConnexion()
|
||||
sco_etud.identite_edit_nocheck(cnx, etud)
|
||||
cnx.commit()
|
||||
#
|
||||
logdb(cnx, method="changePhoto", msg=saved_filename, etudid=etud["etudid"])
|
||||
#
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def suppress_photo(etud: Identite) -> None:
|
||||
"""Suppress a photo"""
|
||||
log(f"suppress_photo {etud}")
|
||||
rel_path = photo_pathname(etud.photo_filename)
|
||||
# 1- remove ref. from database
|
||||
etud.photo_filename = None
|
||||
db.session.add(etud)
|
||||
|
||||
# 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:
|
||||
log(f"removing file {filename}")
|
||||
os.remove(filename)
|
||||
db.session.commit()
|
||||
# 3- log
|
||||
cnx = ndb.GetDBConnexion()
|
||||
logdb(cnx, method="changePhoto", msg="suppression", etudid=etud.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal functions
|
||||
|
||||
|
||||
def save_image(etudid, data):
|
||||
"""data is a bytes string.
|
||||
Save image in JPEG in 2 sizes (original and h90).
|
||||
Returns filename (relative to PHOTO_DIR), without extension
|
||||
"""
|
||||
data_file = io.BytesIO()
|
||||
data_file.write(data)
|
||||
data_file.seek(0)
|
||||
img = PILImage.open(data_file)
|
||||
filename = get_new_filename(etudid)
|
||||
path = os.path.join(PHOTO_DIR, filename)
|
||||
log("saving %dx%d jpeg to %s" % (img.size[0], img.size[1], path))
|
||||
img = img.convert("RGB")
|
||||
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
|
||||
W = int((img.size[0] * H) / img.size[1])
|
||||
img.thumbnail((W, H), PILImage.ANTIALIAS)
|
||||
return img
|
||||
|
||||
|
||||
def get_new_filename(etudid):
|
||||
"""Constructs a random filename to store a new image.
|
||||
The path is constructed as: Fxx/etudid
|
||||
"""
|
||||
dept = g.scodoc_dept
|
||||
return find_new_dir() + dept + "_" + str(etudid)
|
||||
|
||||
|
||||
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
|
||||
log(f"creating directory {path}")
|
||||
os.mkdir(path)
|
||||
return d + "/"
|
||||
|
||||
|
||||
def copy_portal_photo_to_fs(etud: dict):
|
||||
"""Copy the photo from portal (distant website) to local fs.
|
||||
Returns rel. path or None if copy failed, with a diagnostic message
|
||||
"""
|
||||
if "nomprenom" not in etud:
|
||||
sco_etud.format_etud_ident(etud)
|
||||
url = photo_portal_url(etud)
|
||||
if not url:
|
||||
return None, f"""{etud['nomprenom']}: pas de code NIP"""
|
||||
portal_timeout = sco_preferences.get_preference("portal_timeout")
|
||||
error_message = None
|
||||
try:
|
||||
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:
|
||||
error_message = "unknown requests error"
|
||||
if error_message is not None:
|
||||
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}""",
|
||||
)
|
||||
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}"""
|
||||
|
||||
data = r.content # image bytes
|
||||
try:
|
||||
status, error_message = store_photo(etud, data, "(inconnue)")
|
||||
except Exception:
|
||||
status = False
|
||||
error_message = "Erreur chargement photo du portail"
|
||||
log("copy_portal_photo_to_fs: failure (exception in store_photo)!")
|
||||
if status:
|
||||
log(f"copy_portal_photo_to_fs: copied {url}")
|
||||
return (
|
||||
photo_pathname(etud["photo_filename"]),
|
||||
f"{etud['nomprenom']}: photo chargée",
|
||||
)
|
||||
else:
|
||||
return None, f"{etud['nomprenom']}: <b>{error_message}</b>"
|
||||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2022 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@gmail.com
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
"""(Nouvelle) (Nouvelle) gestion des photos d'etudiants
|
||||
|
||||
Les images sont stockées dans .../var/scodoc/photos
|
||||
L'attribut "photo_filename" de la table identite donne le nom du fichier image,
|
||||
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:
|
||||
- jusqu'à novembre 2009, les images étaient stockées dans Zope (ZODB).
|
||||
- jusqu'à v1908, stockées dans .../static/photos (et donc accessibles sans authentification).
|
||||
- support for legacy ZODB removed in v1909.
|
||||
|
||||
"""
|
||||
import datetime
|
||||
import glob
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
import requests
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import PIL
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from flask import abort, request, g, has_request_context
|
||||
from flask.helpers import make_response, url_for
|
||||
|
||||
from app import log
|
||||
from app import db
|
||||
from app.models import Identite
|
||||
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
|
||||
from config import Config
|
||||
|
||||
# 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")
|
||||
UNKNOWN_IMAGE_PATH = os.path.join(ICONS_DIR, "unknown.jpg")
|
||||
UNKNOWN_IMAGE_URL = "get_photo_image?etudid=" # with empty etudid => unknown face image
|
||||
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
|
||||
H90 = ".h90" # suffix for reduced size images
|
||||
|
||||
|
||||
def photo_portal_url(etud):
|
||||
"""Returns external URL to retreive photo on portal,
|
||||
or None if no portal configured"""
|
||||
photo_url = sco_portal_apogee.get_photo_url()
|
||||
if photo_url and etud["code_nip"]:
|
||||
return photo_url + "?nip=" + etud["code_nip"]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_etud_photo_url(etudid, size="small"):
|
||||
return (
|
||||
url_for(
|
||||
"scolar.get_photo_image",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etudid,
|
||||
size=size,
|
||||
)
|
||||
if has_request_context()
|
||||
else ""
|
||||
)
|
||||
|
||||
|
||||
def etud_photo_url(etud: dict, size="small", fast=False) -> str:
|
||||
"""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.
|
||||
|
||||
"""
|
||||
photo_url = get_etud_photo_url(etud["etudid"], size=size)
|
||||
if fast:
|
||||
return photo_url
|
||||
path = photo_pathname(etud["photo_filename"], size=size)
|
||||
if not path:
|
||||
# Portail ?
|
||||
ext_url = photo_portal_url(etud)
|
||||
if not ext_url:
|
||||
# fallback: Photo "unknown"
|
||||
photo_url = scu.ScoURL() + "/" + UNKNOWN_IMAGE_URL
|
||||
else:
|
||||
# essaie de copier la photo du portail
|
||||
new_path, _ = copy_portal_photo_to_fs(etud)
|
||||
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:
|
||||
photo_url = ext_url
|
||||
else:
|
||||
photo_url = scu.ScoURL() + "/" + UNKNOWN_IMAGE_URL
|
||||
return photo_url
|
||||
|
||||
|
||||
def get_photo_image(etudid=None, size="small"):
|
||||
"""Returns photo image (HTTP response)
|
||||
If not etudid, use "unknown" image
|
||||
"""
|
||||
if not etudid:
|
||||
filename = UNKNOWN_IMAGE_PATH
|
||||
else:
|
||||
etud = Identite.query.get_or_404(etudid)
|
||||
filename = photo_pathname(etud.photo_filename, size=size)
|
||||
if not filename:
|
||||
filename = UNKNOWN_IMAGE_PATH
|
||||
return _http_jpeg_file(filename)
|
||||
|
||||
|
||||
def _http_jpeg_file(filename):
|
||||
"""returns an image as a Flask response"""
|
||||
st = os.stat(filename)
|
||||
last_modified = st.st_mtime # float timestamp
|
||||
file_size = st.st_size
|
||||
header = request.headers.get("If-Modified-Since")
|
||||
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:
|
||||
mod_since = None
|
||||
if (mod_since is not None) and last_modified <= mod_since:
|
||||
return "", 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
|
||||
|
||||
|
||||
def etud_photo_is_local(etud: dict, size="small"):
|
||||
return photo_pathname(etud["photo_filename"], size=size)
|
||||
|
||||
|
||||
def etud_photo_html(etud: dict = None, etudid=None, title=None, size="small"):
|
||||
"""HTML img tag for the photo, either in small size (h90)
|
||||
or original size (size=="orig")
|
||||
"""
|
||||
if not etud:
|
||||
if etudid:
|
||||
etuds = sco_etud.get_etud_info(filled=True, etudid=etudid)
|
||||
if not etuds:
|
||||
return abort(404, "etudiant inconnu")
|
||||
etud = etuds[0]
|
||||
else:
|
||||
raise ValueError("etud_photo_html: either etud or etudid must be specified")
|
||||
photo_url = etud_photo_url(etud, size=size)
|
||||
nom = etud.get("nomprenom", etud["nom_disp"])
|
||||
if title is None:
|
||||
title = nom
|
||||
if not etud_photo_is_local(etud):
|
||||
fallback = (
|
||||
"""onerror='this.onerror = null; this.src="%s"'""" % UNKNOWN_IMAGE_URL
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def etud_photo_orig_html(etud=None, etudid=None, title=None):
|
||||
"""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.
|
||||
"""
|
||||
return etud_photo_html(etud=etud, etudid=etudid, title=title, size="orig")
|
||||
|
||||
|
||||
def photo_pathname(photo_filename: str, size="orig"):
|
||||
"""Returns full path of image file if etud has a photo (in the filesystem),
|
||||
or False.
|
||||
Do not distinguish the cases: no photo, or file missing.
|
||||
Argument: photo_filename (Identite attribute)
|
||||
Resultat: False or str
|
||||
"""
|
||||
if size == "small":
|
||||
version = H90
|
||||
elif size == "orig":
|
||||
version = ""
|
||||
else:
|
||||
raise ValueError("invalid size parameter for photo")
|
||||
if not photo_filename:
|
||||
return False
|
||||
path = os.path.join(PHOTO_DIR, photo_filename) + version + IMAGE_EXT
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def store_photo(etud: dict, data, filename: str) -> tuple[bool, str]:
|
||||
"""Store image for this etud.
|
||||
If there is an existing photo, it is erased and replaced.
|
||||
data is a bytes string with image raw data.
|
||||
|
||||
Update database to store filename.
|
||||
|
||||
Returns (status, err_msg)
|
||||
"""
|
||||
# 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["etudid"], data)
|
||||
except (OSError, PIL.UnidentifiedImageError) as exc:
|
||||
raise ScoValueError(
|
||||
msg="Fichier d'image '{filename}' invalide ou format non supporté"
|
||||
) from exc
|
||||
|
||||
# update database:
|
||||
etud["photo_filename"] = saved_filename
|
||||
etud["foto"] = None
|
||||
|
||||
cnx = ndb.GetDBConnexion()
|
||||
sco_etud.identite_edit_nocheck(cnx, etud)
|
||||
cnx.commit()
|
||||
#
|
||||
logdb(cnx, method="changePhoto", msg=saved_filename, etudid=etud["etudid"])
|
||||
#
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def suppress_photo(etud: Identite) -> None:
|
||||
"""Suppress a photo"""
|
||||
log(f"suppress_photo {etud}")
|
||||
rel_path = photo_pathname(etud.photo_filename)
|
||||
# 1- remove ref. from database
|
||||
etud.photo_filename = None
|
||||
db.session.add(etud)
|
||||
|
||||
# 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:
|
||||
log(f"removing file {filename}")
|
||||
os.remove(filename)
|
||||
db.session.commit()
|
||||
# 3- log
|
||||
cnx = ndb.GetDBConnexion()
|
||||
logdb(cnx, method="changePhoto", msg="suppression", etudid=etud.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal functions
|
||||
|
||||
|
||||
def save_image(etudid, data):
|
||||
"""data is a bytes string.
|
||||
Save image in JPEG in 2 sizes (original and h90).
|
||||
Returns filename (relative to PHOTO_DIR), without extension
|
||||
"""
|
||||
data_file = io.BytesIO()
|
||||
data_file.write(data)
|
||||
data_file.seek(0)
|
||||
img = PILImage.open(data_file)
|
||||
filename = get_new_filename(etudid)
|
||||
path = os.path.join(PHOTO_DIR, filename)
|
||||
log("saving %dx%d jpeg to %s" % (img.size[0], img.size[1], path))
|
||||
img = img.convert("RGB")
|
||||
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
|
||||
W = int((img.size[0] * H) / img.size[1])
|
||||
img.thumbnail((W, H), PILImage.ANTIALIAS)
|
||||
return img
|
||||
|
||||
|
||||
def get_new_filename(etudid):
|
||||
"""Constructs a random filename to store a new image.
|
||||
The path is constructed as: Fxx/etudid
|
||||
"""
|
||||
dept = g.scodoc_dept
|
||||
return find_new_dir() + dept + "_" + str(etudid)
|
||||
|
||||
|
||||
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
|
||||
log(f"creating directory {path}")
|
||||
os.mkdir(path)
|
||||
return d + "/"
|
||||
|
||||
|
||||
def copy_portal_photo_to_fs(etud: dict):
|
||||
"""Copy the photo from portal (distant website) to local fs.
|
||||
Returns rel. path or None if copy failed, with a diagnostic message
|
||||
"""
|
||||
if "nomprenom" not in etud:
|
||||
sco_etud.format_etud_ident(etud)
|
||||
url = photo_portal_url(etud)
|
||||
if not url:
|
||||
return None, f"""{etud['nomprenom']}: pas de code NIP"""
|
||||
portal_timeout = sco_preferences.get_preference("portal_timeout")
|
||||
error_message = None
|
||||
try:
|
||||
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:
|
||||
error_message = "unknown requests error"
|
||||
if error_message is not None:
|
||||
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}""",
|
||||
)
|
||||
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}"""
|
||||
|
||||
data = r.content # image bytes
|
||||
try:
|
||||
status, error_message = store_photo(etud, data, "(inconnue)")
|
||||
except Exception:
|
||||
status = False
|
||||
error_message = "Erreur chargement photo du portail"
|
||||
log("copy_portal_photo_to_fs: failure (exception in store_photo)!")
|
||||
if status:
|
||||
log(f"copy_portal_photo_to_fs: copied {url}")
|
||||
return (
|
||||
photo_pathname(etud["photo_filename"]),
|
||||
f"{etud['nomprenom']}: photo chargée",
|
||||
)
|
||||
else:
|
||||
return None, f"{etud['nomprenom']}: <b>{error_message}</b>"
|
||||
|
|
Loading…
Reference in New Issue