# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2021 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 six.moves.urllib.request, six.moves.urllib.error, six.moves.urllib.parse import traceback from PIL import Image as PILImage try: from io import StringIO # for Python 3 except ImportError: from cStringIO import StringIO # for Python 2 import glob from flask import request from config import Config from app.scodoc import sco_etud from app.scodoc import sco_portal_apogee from app.scodoc import sco_preferences from app.scodoc.notes_log import log from app.scodoc.scolog import logdb import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu # Full paths on server's filesystem. Something like "/opt/scodoc/var/scodoc/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 = 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", fast=False, 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 = scu.ScoURL() + "/get_photo_image?etudid=%s&size=%s" % ( etud["etudid"], size, ) if fast: return photo_url 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 = scu.ScoURL() + "/" + UNKNOWN_IMAGE_URL else: # essaie de copier la photo du portail new_path, _ = 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 scu.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 = sco_etud.get_etud_info(etudid=etudid, filled=1, REQUEST=REQUEST)[0] filename = photo_pathname(context, etud, size=size) 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.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: 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 = sco_etud.get_etud_info(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 'photo %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 = ndb.GetDBConnexion() sco_etud.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 = ndb.GetDBConnexion() sco_etud.identite_edit_nocheck(cnx, etud) cnx.commit() # 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("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 = int((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 = scu.get_dept_id() 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 """ sco_etud.format_etud_ident(etud) url = photo_portal_url(context, etud) if not url: return None, "%(nomprenom)s: pas de code NIP" % etud portal_timeout = sco_preferences.get_preference(context, "portal_timeout") f = None try: log("copy_portal_photo_to_fs: getting %s" % url) f = six.moves.urllib.request.urlopen( url, timeout=portal_timeout ) # python >= 2.7 except: log("download failed: exception:\n%s" % traceback.format_exc()) log("called from:\n" + "".join(traceback.format_stack())) 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: %s" % (etud["nomprenom"], diag)