Configuration des logos via formulaires

This commit is contained in:
Emmanuel Viennet 2021-09-08 23:00:01 +02:00
parent 381e081844
commit 72dfc4f49b
10 changed files with 374 additions and 38 deletions

View File

@ -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.

95
app/scodoc/sco_logos.py Normal file
View File

@ -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.<ext>` et `logo_footer.<ext>`
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_<dept>),
# 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

View File

@ -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"),
"<h2>Préférences globales pour %s</h2>" % scu.ScoURL(),
f"""<p><a href="{url_for("scolar.config_logos", scodoc_dept=g.scodoc_dept)
}">modification des logos du département (pour documents pdf)</a></p>""",
"""<p class="help">Ces paramètres s'appliquent par défaut à tous les semestres, sauf si ceux-ci définissent des valeurs spécifiques.</p>
<p class="msg">Attention: cliquez sur "Enregistrer les modifications" en bas de page pour appliquer vos changements !</p>
""",

View File

@ -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)

View File

@ -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;
}

View File

@ -2,32 +2,52 @@
{% import 'bootstrap/wtf.html' as wtf %}
{% macro render_field(field) %}
<div>
<span class="wtf-field">{{ field.label }} :</span>
<span class="wtf-field">{{ field()|safe }}
<div>
<span class="wtf-field">{{ field.label }} :</span>
<span class="wtf-field">{{ field()|safe }}
{% if field.errors %}
<ul class=errors>
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
<li>{{ error }}</li>
{% endfor %}
</ul>
</ul>
{% endif %}
</span>
</div>
</span>
</div>
{% endmacro %}
{% block app_content %}
<h1>Configuration générale</h1>
<p class="help">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements).</p>
{% if scodoc_dept %}
<h1>Logos du département {{ scodoc_dept }}</h1>
{% else %}
<h1>Configuration générale {{ scodoc_dept }}</h1>
{% endif %}
<form class="sco-form" action="" method="post" novalidate>
<form class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{% if not scodoc_dept %}
<div class="sco_help">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements):</div>
{{ render_field(form.bonus_sport_func_name)}}
{# <p>
{{ form.bonus_sport_func_name.label }}<br>
{{ form.bonus_sport_func_name() }}
</p> #}
{% endif %}
<div class="configuration_logo">
<h3>Logo en-tête</h3>
<p class="help">image placée en haut de certains documents documents PDF. Image actuelle:</p>
<div class="img-container"><img src="{{ url_for('scodoc.logo_header', scodoc_dept=scodoc_dept) }}"
alt="pas de logo chargé" /></div>
{{ render_field(form.logo_header) }}
<h3>Logo pied de page</h3>
<p class="help">image placée en pied de page de certains documents documents PDF. Image actuelle:</p>
<div class="img-container"><img src="{{ url_for('scodoc.logo_footer', scodoc_dept=g.scodoc_dept) }}"
alt="pas de logo chargé" /></div>
{{ render_field(form.logo_footer) }}
</div>
<!-- <div class="sco_help">Les paramètres ci-dessous peuvent être changés dans chaque département
(paramétrage).<br />On indique ici les valeurs initiales par défaut:
</div> -->
<div class="sco-submit">{{ form.submit() }}</div>
</form>
{% endblock %}
{% endblock %}

View File

@ -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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
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 = "<b>Institut Universitaire de Technologie - Université Georges Perec</b>"
# INSTITUTION_ADDRESS = "Web <b>www.sor.bonne.top</b> - 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/<scodoc_dept>/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/<scodoc_dept>/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():

View File

@ -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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
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 <tt>{','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}</tt>",
)
],
)
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

View File

@ -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")

View File

@ -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 <body>