361 lines
13 KiB
Python
361 lines
13 KiB
Python
|
# -*- mode: python -*-
|
||
|
# -*- coding: utf-8 -*-
|
||
|
|
||
|
##############################################################################
|
||
|
#
|
||
|
# Gestion scolarite IUT
|
||
|
#
|
||
|
# Copyright (c) 1999 - 2020 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 os
|
||
|
import time
|
||
|
import datetime
|
||
|
import random
|
||
|
import urllib2
|
||
|
import traceback
|
||
|
from PIL import Image as PILImage
|
||
|
from cStringIO import StringIO
|
||
|
import glob
|
||
|
|
||
|
from sco_utils import *
|
||
|
from notes_log import log
|
||
|
from notesdb import *
|
||
|
import scolars
|
||
|
import sco_portal_apogee
|
||
|
from scolog import logdb
|
||
|
|
||
|
# Full paths on server's filesystem. Something like "/opt/scodoc/var/scodoc/photos"
|
||
|
PHOTO_DIR = os.path.join(os.environ["INSTANCE_HOME"], "var", "scodoc", "photos")
|
||
|
ICONS_DIR = os.path.join(SCO_SRCDIR, "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 = 1024 * 1024 # max allowed size for uploaded image, in bytes
|
||
|
H90 = ".h90" # suffix for reduced size images
|
||
|
|
||
|
|
||
|
def photo_portal_url(context, etud):
|
||
|
"""Returns external URL to retreive photo on portal,
|
||
|
or None if no portal configured"""
|
||
|
photo_url = sco_portal_apogee.get_photo_url(context)
|
||
|
if photo_url and etud["code_nip"]:
|
||
|
return photo_url + "?nip=" + etud["code_nip"]
|
||
|
else:
|
||
|
return None
|
||
|
|
||
|
def etud_photo_url(context, etud, size="small", REQUEST=None):
|
||
|
"""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_photo_image?etudid=%s&size=%s" % (etud["etudid"], size)
|
||
|
path = photo_pathname(context, etud, size=size)
|
||
|
if not path:
|
||
|
# Portail ?
|
||
|
ext_url = photo_portal_url(context, etud)
|
||
|
if not ext_url:
|
||
|
# fallback: Photo "unknown"
|
||
|
photo_url = UNKNOWN_IMAGE_URL
|
||
|
else:
|
||
|
# essaie de copier la photo du portail
|
||
|
new_path, diag = copy_portal_photo_to_fs(context, etud, REQUEST=REQUEST)
|
||
|
if not new_path:
|
||
|
# copy failed, can we use external url ?
|
||
|
# nb: rarement utile, car le portail est rarement accessible sans authentification
|
||
|
if CONFIG.PUBLISH_PORTAL_PHOTO_URL:
|
||
|
photo_url = ext_url
|
||
|
else:
|
||
|
photo_url = UNKNOWN_IMAGE_URL
|
||
|
return photo_url
|
||
|
|
||
|
|
||
|
def get_photo_image(context, etudid=None, size="small", REQUEST=None):
|
||
|
"""Returns photo image (HTTP response)
|
||
|
If not etudid, use "unknown" image
|
||
|
"""
|
||
|
if not etudid:
|
||
|
filename = UNKNOWN_IMAGE_PATH
|
||
|
else:
|
||
|
etud = context.getEtudInfo(etudid=etudid, filled=1, REQUEST=REQUEST)[0]
|
||
|
filename = photo_pathname(context, etud, size=size) # os.path.join( PHOTO_DIR, etud["photo_filename"] )
|
||
|
if not filename:
|
||
|
filename = UNKNOWN_IMAGE_PATH
|
||
|
return _http_jpeg_file(context, filename, REQUEST=REQUEST)
|
||
|
|
||
|
|
||
|
def _http_jpeg_file(context, filename, REQUEST=None):
|
||
|
"""returns an image.
|
||
|
This function will be modified when we kill #zope
|
||
|
"""
|
||
|
st = os.stat(filename)
|
||
|
last_modified = st.st_mtime # float timestamp
|
||
|
last_modified_str = time.strftime(
|
||
|
"%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last_modified)
|
||
|
)
|
||
|
file_size = st.st_size
|
||
|
RESPONSE = REQUEST.RESPONSE
|
||
|
RESPONSE.setHeader("Content-Type", "image/jpeg")
|
||
|
RESPONSE.setHeader("Last-Modified", last_modified_str)
|
||
|
RESPONSE.setHeader("Cache-Control", "max-age=3600")
|
||
|
RESPONSE.setHeader("Content-Length", str(file_size))
|
||
|
header = REQUEST.get_header("If-Modified-Since", None)
|
||
|
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:
|
||
|
mod_since = None
|
||
|
if (mod_since is not None) and last_modified <= mod_since:
|
||
|
RESPONSE.setStatus(304) # not modified
|
||
|
return ""
|
||
|
|
||
|
return open(filename, mode="rb").read()
|
||
|
|
||
|
|
||
|
def etud_photo_is_local(context, etud, size="small"):
|
||
|
return photo_pathname(context, etud, size=size)
|
||
|
|
||
|
|
||
|
def etud_photo_html(context, etud=None, etudid=None, title=None, size="small", REQUEST=None):
|
||
|
"""HTML img tag for the photo, either in small size (h90)
|
||
|
or original size (size=="orig")
|
||
|
"""
|
||
|
if not etud:
|
||
|
if etudid:
|
||
|
etud = context.getEtudInfo(etudid=etudid, filled=1, REQUEST=REQUEST)[0]
|
||
|
else:
|
||
|
raise ValueError('etud_photo_html: either etud or etudid must be specified')
|
||
|
photo_url = etud_photo_url(context, etud, size=size, REQUEST=REQUEST)
|
||
|
nom = etud.get("nomprenom", etud["nom_disp"])
|
||
|
if title is None:
|
||
|
title = nom
|
||
|
if not etud_photo_is_local(context, 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(context, etud=None, etudid=None, title=None, REQUEST=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(context, etud=etud, etudid=etudid, title=title, size="orig", REQUEST=REQUEST)
|
||
|
|
||
|
def photo_pathname(context, etud, 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.
|
||
|
"""
|
||
|
if size == "small":
|
||
|
version = H90
|
||
|
elif size == "orig":
|
||
|
version = ""
|
||
|
else:
|
||
|
raise ValueError("invalid size parameter for photo")
|
||
|
if not etud["photo_filename"]:
|
||
|
return False
|
||
|
path = os.path.join(PHOTO_DIR, etud["photo_filename"]) + version + IMAGE_EXT
|
||
|
if os.path.exists(path):
|
||
|
return path
|
||
|
else:
|
||
|
return False
|
||
|
|
||
|
|
||
|
def store_photo(context, etud, data, REQUEST=None):
|
||
|
"""Store image for this etud.
|
||
|
If there is an existing photo, it is erased and replaced.
|
||
|
data is a string with image raw data.
|
||
|
|
||
|
Update database to store filename.
|
||
|
|
||
|
Returns (status, msg)
|
||
|
"""
|
||
|
# basic checks
|
||
|
filesize = len(data)
|
||
|
if filesize < 10 or filesize > MAX_FILE_SIZE:
|
||
|
return 0, "Fichier image de taille invalide ! (%d)" % filesize
|
||
|
filename = save_image(context, etud["etudid"], data)
|
||
|
# update database:
|
||
|
etud["photo_filename"] = filename
|
||
|
etud["foto"] = None
|
||
|
|
||
|
cnx = context.GetDBConnexion()
|
||
|
scolars.identite_edit_nocheck(cnx, etud)
|
||
|
cnx.commit()
|
||
|
#
|
||
|
if REQUEST:
|
||
|
logdb(REQUEST, cnx, method="changePhoto", msg=filename, etudid=etud["etudid"])
|
||
|
#
|
||
|
return 1, "ok"
|
||
|
|
||
|
|
||
|
def suppress_photo(context, etud, REQUEST=None):
|
||
|
"""Suppress a photo"""
|
||
|
log("suppress_photo etudid=%s" % etud["etudid"])
|
||
|
rel_path = photo_pathname(context, etud)
|
||
|
# 1- remove ref. from database
|
||
|
etud["photo_filename"] = None
|
||
|
cnx = context.GetDBConnexion()
|
||
|
scolars.identite_edit_nocheck(cnx, etud)
|
||
|
cnx.commit()
|
||
|
# 2- erase images files
|
||
|
#log("rel_path=%s" % rel_path)
|
||
|
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("removing file %s" % filename)
|
||
|
os.remove(filename)
|
||
|
# 3- log
|
||
|
if REQUEST:
|
||
|
logdb(
|
||
|
REQUEST, cnx, method="changePhoto", msg="suppression", etudid=etud["etudid"]
|
||
|
)
|
||
|
|
||
|
|
||
|
# ---------------------------------------------------------------------------
|
||
|
# Internal functions
|
||
|
|
||
|
def save_image(context, etudid, data):
|
||
|
"""img_file is a file-like object.
|
||
|
Save image in JPEG in 2 sizes (original and h90).
|
||
|
Returns filename (relative to PHOTO_DIR), without extension
|
||
|
"""
|
||
|
data_file = StringIO()
|
||
|
data_file.write(data)
|
||
|
data_file.seek(0)
|
||
|
img = PILImage.open(data_file)
|
||
|
filename = get_new_filename(context, etudid)
|
||
|
path = os.path.join( PHOTO_DIR, filename )
|
||
|
log("saving %dx%d jpeg to %s" % (img.size[0], img.size[1], path))
|
||
|
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 = (img.size[0] * H) / img.size[1]
|
||
|
img.thumbnail((W, H), PILImage.ANTIALIAS)
|
||
|
return img
|
||
|
|
||
|
|
||
|
def get_new_filename(context, etudid):
|
||
|
"""Constructs a random filename to store a new image.
|
||
|
The path is constructed as: Fxx/etudid
|
||
|
"""
|
||
|
dept = context.DeptId()
|
||
|
return find_new_dir() + dept + "_" + 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("creating directory %s" % path)
|
||
|
os.mkdir(path)
|
||
|
return d + "/"
|
||
|
|
||
|
|
||
|
def copy_portal_photo_to_fs(context, etud, REQUEST=None):
|
||
|
"""Copy the photo from portal (distant website) to local fs.
|
||
|
Returns rel. path or None if copy failed, with a diagnotic message
|
||
|
"""
|
||
|
scolars.format_etud_ident(etud)
|
||
|
url = photo_portal_url(context, etud)
|
||
|
if not url:
|
||
|
return None, "%(nomprenom)s: pas de code NIP" % etud
|
||
|
portal_timeout = context.get_preference("portal_timeout")
|
||
|
f = None
|
||
|
try:
|
||
|
log("copy_portal_photo_to_fs: getting %s" % url)
|
||
|
f = urllib2.urlopen(url, timeout=portal_timeout) # python >= 2.7
|
||
|
except:
|
||
|
log("download failed: exception:\n%s" % traceback.format_exc())
|
||
|
return None, "%s: erreur chargement de %s" % (etud["nomprenom"], url)
|
||
|
if not f:
|
||
|
log("download failed")
|
||
|
return None, "%s: erreur chargement de %s" % (etud["nomprenom"], url)
|
||
|
data = f.read()
|
||
|
try:
|
||
|
status, diag = store_photo(context, etud, data, REQUEST=REQUEST)
|
||
|
except:
|
||
|
status = 0
|
||
|
diag = "Erreur chargement photo du portail"
|
||
|
log("copy_portal_photo_to_fs: failure (exception in store_photo)!")
|
||
|
if status == 1:
|
||
|
log("copy_portal_photo_to_fs: copied %s" % url)
|
||
|
return photo_pathname(context, etud), "%s: photo chargée" % etud["nomprenom"]
|
||
|
else:
|
||
|
return None, "%s: <b>%s</b>" % (etud["nomprenom"], diag)
|