diff --git a/app/models/preferences.py b/app/models/preferences.py index 65b88508..b04ad0da 100644 --- a/app/models/preferences.py +++ b/app/models/preferences.py @@ -33,6 +33,17 @@ class ScoDocSiteConfig(db.Model): value = db.Column(db.Text()) BONUS_SPORT = "bonus_sport_func_name" + NAMES = { + BONUS_SPORT: str, + "always_require_ine": bool, + "SCOLAR_FONT": str, + "SCOLAR_FONT_SIZE": str, + "SCOLAR_FONT_SIZE_FOOT": str, + "INSTITUTION_NAME": str, + "INSTITUTION_ADDRESS": str, + "INSTITUTION_CITY": str, + "DEFAULT_PDF_FOOTER_TEMPLATE": str, + } def __init__(self, name, value): self.name = name @@ -41,6 +52,13 @@ class ScoDocSiteConfig(db.Model): def __repr__(self): return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>" + def get_dict(self) -> dict: + "Returns all data as a dict name = value" + return { + c.name: self.NAMES.get(c.name, lambda x: x)(c.value) + for c in ScoDocSiteConfig.query.all() + } + @classmethod def set_bonus_sport_func(cls, func_name): """Record bonus_sport config. diff --git a/app/scodoc/sco_logos.py b/app/scodoc/sco_logos.py new file mode 100644 index 00000000..e29b5183 --- /dev/null +++ b/app/scodoc/sco_logos.py @@ -0,0 +1,95 @@ +# -*- 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@viennet.net +# +############################################################################## + +"""Gestion des images logos (nouveau ScoDoc 9) + +Les logos sont `logo_header.` et `logo_footer.` +avec `ext` membre de LOGOS_IMAGES_ALLOWED_TYPES (= jpg, png) + +SCODOC_LOGOS_DIR /opt/scodoc-data/config/logos +""" +import imghdr +import os + +from flask import abort, current_app + +from app.scodoc import sco_utils as scu + + +def get_logo_filename(logo_type: str, scodoc_dept: str) -> str: + """return full filename for this logo, or "" if not found + an existing file with extension. + logo_type: "header" or "footer" + scodoc-dept: acronym + """ + # Search logos in dept specific dir (/opt/scodoc-data/config/logos/logos_), + # then in config dir /opt/scodoc-data/config/logos/ + for image_dir in ( + scu.SCODOC_LOGOS_DIR + "/logos_" + scodoc_dept, + scu.SCODOC_LOGOS_DIR, # global logos + ): + for suffix in scu.LOGOS_IMAGES_ALLOWED_TYPES: + filename = os.path.join(image_dir, f"logo_{logo_type}.{suffix}") + if os.path.isfile(filename) and os.access(filename, os.R_OK): + return filename + + return "" + + +def guess_image_type(stream) -> str: + "guess image type from header in stream" + header = stream.read(512) + stream.seek(0) + fmt = imghdr.what(None, header) + if not fmt: + return None + return fmt if fmt != "jpeg" else "jpg" + + +def _ensure_directory_exists(filename): + "create enclosing directory if necessary" + directory = os.path.split(filename)[0] + if not os.path.exists(directory): + current_app.logger.info(f"sco_logos creating directory %s", directory) + os.mkdir(directory) + + +def store_image(stream, basename): + img_type = guess_image_type(stream) + if img_type not in scu.LOGOS_IMAGES_ALLOWED_TYPES: + abort(400, "type d'image invalide") + filename = basename + "." + img_type + _ensure_directory_exists(filename) + with open(filename, "wb") as f: + f.write(stream.read()) + current_app.logger.info(f"sco_logos.store_image %s", filename) + # erase other formats if they exists + for extension in set(scu.LOGOS_IMAGES_ALLOWED_TYPES) - set([img_type]): + try: + os.unlink(basename + "." + extension) + except IOError: + pass diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index f4615313..26ff4ec2 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -111,7 +111,7 @@ get_base_preferences(formsemestre_id) """ import flask -from flask import g +from flask import g, url_for from app.models import Departement from app.scodoc import sco_cache @@ -2021,6 +2021,8 @@ class BasePreferences(object): H = [ html_sco_header.sco_header(page_title="Préférences"), "

Préférences globales pour %s

" % scu.ScoURL(), + f"""

modification des logos du département (pour documents pdf)

""", """

Ces paramètres s'appliquent par défaut à tous les semestres, sauf si ceux-ci définissent des valeurs spécifiques.

Attention: cliquez sur "Enregistrer les modifications" en bas de page pour appliquer vos changements !

""", diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index bf6a332a..2447c4e8 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -232,6 +232,8 @@ if not os.path.exists(SCO_TMP_DIR): os.mkdir(SCO_TMP_DIR, 0o755) # ----- Les logos: /opt/scodoc-data/config/logos SCODOC_LOGOS_DIR = os.path.join(SCODOC_CFG_DIR, "logos") +LOGOS_IMAGES_ALLOWED_TYPES = ("jpg", "jpeg", "png") # remind that PIL does not read pdf + # ----- Les outils distribués SCO_TOOLS_DIR = os.path.join(Config.SCODOC_DIR, "tools") @@ -305,8 +307,6 @@ PDF_MIMETYPE = "application/pdf" XML_MIMETYPE = "text/xml" JSON_MIMETYPE = "application/json" -LOGOS_IMAGES_ALLOWED_TYPES = ("jpg", "png") # remind that PIL does not read pdf - # Admissions des étudiants # Différents types de voies d'admission: # (stocké en texte libre dans la base, mais saisie par menus pour harmoniser) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 01213d54..95b332bd 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -816,11 +816,22 @@ a.discretelink:hover { div.sco_help { margin-top: 12px; + margin-bottom: 3px; font-style: italic; color: navy; background-color: rgb(200,200,220); } +span.wtf-field ul.errors li { + color: red; +} +.configuration_logo div.img-container { + width: 256px; +} +.configuration_logo div.img-container img { + max-width: 100%; +} + p.indent { padding-left: 2em; } diff --git a/app/templates/configuration.html b/app/templates/configuration.html index 1580979c..6dcf1c51 100644 --- a/app/templates/configuration.html +++ b/app/templates/configuration.html @@ -2,32 +2,52 @@ {% import 'bootstrap/wtf.html' as wtf %} {% macro render_field(field) %} -
- {{ field.label }} : - {{ field()|safe }} +
+ {{ field.label }} : + {{ field()|safe }} {% if field.errors %} -
    +
      {% for error in field.errors %} -
    • {{ error }}
    • +
    • {{ error }}
    • {% endfor %} -
    +
{% endif %} -
-
+
+
{% endmacro %} {% block app_content %} -

Configuration générale

-

Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements).

+{% if scodoc_dept %} +

Logos du département {{ scodoc_dept }}

+{% else %} +

Configuration générale {{ scodoc_dept }}

+{% endif %} -
+ {{ form.hidden_tag() }} + + {% if not scodoc_dept %} +
Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):
+ {{ render_field(form.bonus_sport_func_name)}} - {#

- {{ form.bonus_sport_func_name.label }}
- {{ form.bonus_sport_func_name() }} -

#} + {% endif %} + + +
{{ form.submit() }}
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 1d4b7799..d2fd9f9d 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -30,19 +30,29 @@ Module main: page d'accueil, avec liste des départements Emmanuel Viennet, 2021 """ +import os + import flask -from flask import flash, url_for, redirect, render_template +from flask import abort, flash, url_for, redirect, render_template, send_file from flask import request from flask.app import Flask from flask_login.utils import login_required from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed +from werkzeug.exceptions import BadRequest, NotFound from wtforms import SelectField, SubmitField +from wtforms.fields import IntegerField +from wtforms.fields.simple import BooleanField, StringField, TextAreaField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo -# from wtforms.validators import DataRequired - -from app.models import Departement, ScoDocSiteConfig +import app +from app.models import Departement, Identite +from app.models import FormSemestre, NotesFormsemestreInscription +from app.models import ScoDocSiteConfig import sco_version +from app.scodoc import sco_logos from app.scodoc import sco_find_etud +from app.scodoc import sco_utils as scu from app.decorators import admin_required from app.scodoc.sco_permissions import Permission from app.views import scodoc_bp as bp @@ -72,13 +82,56 @@ def table_etud_in_accessible_depts(): return sco_find_etud.table_etud_in_accessible_depts(expnom=request.form["expnom"]) +@bp.route("/ScoDoc/get_etud_dept") +@login_required +def get_etud_dept(): + """Returns the dept acronym (eg "GEII") of an etud (identified by etudid, + code_nip ou code_ine in the request). + API: ramène la chaine brute, sans JSON ou XML. + """ + if "etudid" in request.args: + # zero ou une réponse: + etuds = [Identite.query.get(request.args["etudid"])] + elif "code_nip" in request.args: + # il peut y avoir plusieurs réponses si l'étudiant est passé par plusieurs départements + etuds = Identite.query.filter_by(code_nip=request.args["code_nip"]).all() + elif "code_ine" in request.args: + etuds = Identite.query.filter_by(code_nip=request.args["code_ine"]).all() + else: + raise BadRequest( + "missing argument (expected one among: etudid, code_nip or code_ine)" + ) + if not etuds: + raise NotFound("student not found") + elif len(etuds) == 1: + last_etud = etuds[0] + else: + # inscriptions dans plusieurs departements: cherche la plus recente + last_etud = None + last_date = None + for etud in etuds: + inscriptions = NotesFormsemestreInscription.query.filter_by( + etudid=etud.id + ).all() + for ins in inscriptions: + date_fin = FormSemestre.query.get(ins.formsemestre_id).date_fin + if (last_date is None) or date_fin > last_date: + last_date = date_fin + last_etud = etud + if not last_etud: + # est présent dans plusieurs semestres mais inscrit dans aucun ! + # le choix a peu d'importance... + last_etud = etuds[-1] + + return Departement.query.get(last_etud.dept_id).acronym + + # ---- CONFIGURATION class ScoDocConfigurationForm(FlaskForm): "Panneau de configuration général" - # très préliminaire ;-) - # On veut y mettre la fonction bonus et ensuite les logos + bonus_sport_func_name = SelectField( label="Fonction de calcul des bonus sport&culture", choices=[ @@ -86,28 +139,104 @@ class ScoDocConfigurationForm(FlaskForm): for x in ScoDocSiteConfig.get_bonus_sport_func_names() ], ) + + logo_header = FileField( + label="Modifier l'image:", + description="logo placé en haut des documents PDF", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + ) + ], + ) + + logo_footer = FileField( + label="Modifier l'image:", + description="logo placé en pied des documents PDF", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + ) + ], + ) + submit = SubmitField("Enregistrer") +# Notes pour variables config: (valeurs par défaut des paramètres de département) +# Chaines simples +# SCOLAR_FONT = "Helvetica" +# SCOLAR_FONT_SIZE = 10 +# SCOLAR_FONT_SIZE_FOOT = 6 +# INSTITUTION_NAME = "Institut Universitaire de Technologie - Université Georges Perec" +# INSTITUTION_ADDRESS = "Web www.sor.bonne.top - 11, rue Simon Crubelier - 75017 Paris" +# INSTITUTION_CITY = "Paris" +# Textareas: +# DEFAULT_PDF_FOOTER_TEMPLATE = "Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s sur %(server_url)s" + +# Booléens +# always_require_ine + +# Logos: +# LOGO_FOOTER*, LOGO_HEADER* + + @bp.route("/ScoDoc/configuration", methods=["GET", "POST"]) @admin_required def configuration(): "Panneau de configuration général" form = ScoDocConfigurationForm( - bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func_name() + bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func_name(), ) if form.validate_on_submit(): ScoDocSiteConfig.set_bonus_sport_func(form.bonus_sport_func_name.data) + if form.logo_header.data: + sco_logos.store_image( + form.logo_header.data, os.path.join(scu.SCODOC_LOGOS_DIR, "logo_header") + ) + if form.logo_footer.data: + sco_logos.store_image( + form.logo_footer.data, os.path.join(scu.SCODOC_LOGOS_DIR, "logo_footer") + ) + app.clear_scodoc_cache() flash(f"Configuration enregistrée") return redirect(url_for("scodoc.index")) + return render_template( "configuration.html", title="Configuration ScoDoc", form=form, - # bonus_sport_func_name=ScoDocSiteConfig.get_bonus_sport_func(), + scodoc_dept=None, ) +def _return_logo(logo_type="header", scodoc_dept=""): + # stockée dans /opt/scodoc-data/config/logos donc servie manuellement ici + filename = sco_logos.get_logo_filename(logo_type, scodoc_dept) + if filename: + extension = os.path.splitext(filename)[1] + return send_file(filename, mimetype=f"image/{extension}") + else: + return "" + + +@bp.route("/ScoDoc/logo_header") +@bp.route("/ScoDoc//logo_header") +def logo_header(scodoc_dept=""): + "Image logo header" + # "/opt/scodoc-data/config/logos/logo_header") + return _return_logo(logo_type="header", scodoc_dept=scodoc_dept) + + +@bp.route("/ScoDoc/logo_footer") +@bp.route("/ScoDoc//logo_footer") +def logo_footer(scodoc_dept=""): + "Image logo footer" + return _return_logo(logo_type="footer", scodoc_dept=scodoc_dept) + + # essais # @bp.route("/testlog") # def testlog(): diff --git a/app/views/scolar.py b/app/views/scolar.py index 41632ff7..73371619 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -30,7 +30,7 @@ issu de ScoDoc7 / ZScolar.py Emmanuel Viennet, 2021 """ - +import os import sys import time @@ -40,9 +40,12 @@ from zipfile import ZipFile import psycopg2 import flask -from flask import jsonify, url_for +from flask import jsonify, url_for, flash, redirect, render_template from flask import current_app, g, request from flask_login import current_user +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed +from wtforms import SubmitField from config import Config from app.decorators import ( @@ -71,8 +74,8 @@ from app.scodoc.sco_exceptions import ( ) from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message import sco_version +import app from app.scodoc.gen_tables import GenTable - from app.scodoc import html_sco_header from app.scodoc import html_sidebar from app.scodoc import imageresize @@ -94,6 +97,7 @@ from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_groups_edit from app.scodoc import sco_groups_view +from app.scodoc import sco_logos from app.scodoc import sco_news from app.scodoc import sco_page_etud from app.scodoc import sco_parcours_dut @@ -201,6 +205,66 @@ def doc_preferences(REQUEST): return sco_preferences.doc_preferences() +class DeptLogosConfigurationForm(FlaskForm): + "Panneau de configuration logos dept" + + logo_header = FileField( + label="Modifier l'image:", + description="logo placé en haut des documents PDF", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + ) + ], + ) + + logo_footer = FileField( + label="Modifier l'image:", + description="logo placé en pied des documents PDF", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", + ) + ], + ) + + submit = SubmitField("Enregistrer") + + +@bp.route("/config_logos", methods=["GET", "POST"]) +@permission_required(Permission.ScoChangePreferences) +def config_logos(scodoc_dept): + "Panneau de configuration général" + form = DeptLogosConfigurationForm() + if form.validate_on_submit(): + if form.logo_header.data: + sco_logos.store_image( + form.logo_header.data, + os.path.join( + scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" + ), + ) + if form.logo_footer.data: + sco_logos.store_image( + form.logo_footer.data, + os.path.join( + scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" + ), + ) + app.clear_scodoc_cache() + flash(f"Logos enregistrés") + return redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) + + return render_template( + "configuration.html", + title="Configuration Logos du département", + form=form, + scodoc_dept=scodoc_dept, + ) + + # -------------------------------------------------------------------- # # ETUDIANTS diff --git a/config.py b/config.py index aed798d6..2e424a58 100755 --- a/config.py +++ b/config.py @@ -30,9 +30,9 @@ class Config: SCODOC_DIR = os.environ.get("SCODOC_DIR", "/opt/scodoc") SCODOC_VAR_DIR = os.environ.get("SCODOC_VAR_DIR", "/opt/scodoc-data") SCODOC_LOG_FILE = os.path.join(SCODOC_VAR_DIR, "log", "scodoc.log") - # For legacy ScoDoc7 installs: postgresql user - SCODOC7_SQL_USER = os.environ.get("SCODOC7_SQL_USER", "www-data") - DEFAULT_SQL_PORT = os.environ.get("DEFAULT_SQL_PORT", "5432") + # + MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # Flask uploads + # STATIC_URL_PATH = "/ScoDoc/static" # static_folder = "stat" # SERVER_NAME = os.environ.get("SERVER_NAME") diff --git a/tools/scodoc_config.py b/tools/scodoc_config.py index 9c33c9f0..2937dfd4 100644 --- a/tools/scodoc_config.py +++ b/tools/scodoc_config.py @@ -23,10 +23,6 @@ CONFIG = CFG() CONFIG.always_require_ine = 0 # set to 1 if you want to require INE -# The base URL, use only if you are behind a proxy -# eg "https://scodoc.example.net/ScoDoc" -CONFIG.ABSOLUTE_URL = "" - # # ------------- Documents PDF ------------- # @@ -78,6 +74,7 @@ CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE = "Edité par %(scodoc_name)s le %(day)s/%(mo # # - règle "LMD": capitalisation uniquement des UE avec moy. > 10 +# XXX à revoir pour le BUT: variable à intégrer aux parcours CONFIG.CAPITALIZE_ALL_UES = ( True # si vrai, capitalise toutes les UE des semestres validés (règle "LMD"). ) @@ -86,7 +83,7 @@ CONFIG.CAPITALIZE_ALL_UES = ( # # ----------------------------------------------------- # -# -------------- Personnalisation des pages +# -------------- Personnalisation des pages (DEPRECATED) # # ----------------------------------------------------- # Nom (chemin complet) d'un fichier .html à inclure juste après le