forked from ScoDoc/ScoDoc
511 lines
18 KiB
Python
511 lines
18 KiB
Python
# -*- mode: python -*-
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##############################################################################
|
|
#
|
|
# ScoDoc
|
|
#
|
|
# Copyright (c) 1999 - 2023 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
|
|
#
|
|
##############################################################################
|
|
|
|
"""
|
|
Module main: page d'accueil, avec liste des départements
|
|
|
|
Emmanuel Viennet, 2021
|
|
"""
|
|
import datetime
|
|
import io
|
|
import re
|
|
|
|
import flask
|
|
from flask import (
|
|
abort,
|
|
flash,
|
|
make_response,
|
|
redirect,
|
|
render_template,
|
|
send_file,
|
|
url_for,
|
|
)
|
|
from flask import request
|
|
import flask_login
|
|
from flask_login.utils import login_required, current_user
|
|
from PIL import Image as PILImage
|
|
|
|
from werkzeug.exceptions import BadRequest, NotFound
|
|
|
|
|
|
from app import db
|
|
from app.auth.models import User
|
|
from app.auth.cas import set_cas_configuration
|
|
from app.decorators import (
|
|
admin_required,
|
|
scodoc7func,
|
|
scodoc,
|
|
)
|
|
from app.forms.main import config_logos, config_main
|
|
from app.forms.main.create_dept import CreateDeptForm
|
|
from app.forms.main.config_apo import CodesDecisionsForm
|
|
from app.forms.main.config_cas import ConfigCASForm
|
|
from app.forms.main.config_assiduites import ConfigAssiduitesForm
|
|
from app import models
|
|
from app.models import Departement, Identite
|
|
from app.models import departements
|
|
from app.models import FormSemestre, FormSemestreInscription
|
|
from app.models import ScoDocSiteConfig
|
|
from app.models import UniteEns
|
|
|
|
from app.scodoc import sco_find_etud
|
|
from app.scodoc import sco_logos
|
|
from app.scodoc import sco_utils as scu
|
|
|
|
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
|
|
from app.scodoc.sco_permissions import Permission
|
|
from app.views import scodoc_bp as bp
|
|
import sco_version
|
|
|
|
|
|
@bp.route("/")
|
|
@bp.route("/ScoDoc")
|
|
@bp.route("/ScoDoc/index")
|
|
def index():
|
|
"Page d'accueil: liste des départements"
|
|
depts = Departement.query.filter_by().order_by(Departement.acronym).all()
|
|
return render_template(
|
|
"scodoc.j2",
|
|
title=sco_version.SCONAME,
|
|
current_app=flask.current_app,
|
|
depts=depts,
|
|
Permission=Permission,
|
|
scu=scu,
|
|
)
|
|
|
|
|
|
# Renvoie les url /ScoDoc/RT/ vers /ScoDoc/RT/Scolarite
|
|
@bp.route("/ScoDoc/<scodoc_dept>/")
|
|
def index_dept(scodoc_dept):
|
|
return redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept))
|
|
|
|
|
|
@bp.route("/ScoDoc/create_dept", methods=["GET", "POST"])
|
|
@admin_required
|
|
def create_dept():
|
|
"""Form création département"""
|
|
form = CreateDeptForm()
|
|
if request.method == "POST" and form.cancel.data: # cancel button
|
|
return redirect(url_for("scodoc.index"))
|
|
if form.validate_on_submit():
|
|
departements.create_dept(
|
|
form.acronym.data,
|
|
visible=form.visible.data,
|
|
# description=form.description.data,
|
|
)
|
|
flash(f"Département {form.acronym.data} créé.")
|
|
return redirect(url_for("scodoc.index"))
|
|
return render_template(
|
|
"create_dept.j2",
|
|
form=form,
|
|
title="Création d'un nouveau département",
|
|
)
|
|
|
|
|
|
@bp.route("/ScoDoc/toggle_dept_vis/<int:dept_id>", methods=["GET", "POST"])
|
|
@admin_required
|
|
def toggle_dept_vis(dept_id):
|
|
"""Cache ou rend visible un dept"""
|
|
dept = Departement.query.get_or_404(dept_id)
|
|
dept.visible = not dept.visible
|
|
db.session.add(dept)
|
|
db.session.commit()
|
|
return redirect(url_for("scodoc.index"))
|
|
|
|
|
|
@bp.route("/ScoDoc/config_cas", methods=["GET", "POST"])
|
|
@admin_required
|
|
def config_cas():
|
|
"""Form config CAS"""
|
|
form = ConfigCASForm()
|
|
if request.method == "POST" and form.cancel.data: # cancel button
|
|
return redirect(url_for("scodoc.index"))
|
|
if form.validate_on_submit():
|
|
if ScoDocSiteConfig.set("cas_enable", form.data["cas_enable"]):
|
|
flash("CAS " + ("activé" if form.data["cas_enable"] else "désactivé"))
|
|
if ScoDocSiteConfig.set("cas_force", form.data["cas_force"]):
|
|
flash("CAS " + ("forcé" if form.data["cas_force"] else "non forcé"))
|
|
if ScoDocSiteConfig.set("cas_server", form.data["cas_server"]):
|
|
flash("URL du serveur CAS enregistrée")
|
|
if ScoDocSiteConfig.set("cas_login_route", form.data["cas_login_route"]):
|
|
flash("Route du login CAS enregistrée")
|
|
if ScoDocSiteConfig.set("cas_logout_route", form.data["cas_logout_route"]):
|
|
flash("Route du logout CAS enregistrée")
|
|
if ScoDocSiteConfig.set("cas_validate_route", form.data["cas_validate_route"]):
|
|
flash("Route de validation CAS enregistrée")
|
|
if ScoDocSiteConfig.set("cas_attribute_id", form.data["cas_attribute_id"]):
|
|
flash("Attribut CAS ID enregistré")
|
|
if ScoDocSiteConfig.set("cas_ssl_verify", form.data["cas_ssl_verify"]):
|
|
flash("Vérification SSL modifiée")
|
|
if form.cas_ssl_certificate_file.data:
|
|
data = request.files[form.cas_ssl_certificate_file.name].read()
|
|
try:
|
|
data_str = data.decode("ascii")
|
|
except UnicodeDecodeError as exc:
|
|
raise ScoValueError("Fichier certificat invalide (non ASCII)") from exc
|
|
if ScoDocSiteConfig.set("cas_ssl_certificate", data_str):
|
|
flash("Certificat SSL enregistré")
|
|
set_cas_configuration()
|
|
return redirect(url_for("scodoc.configuration"))
|
|
|
|
elif request.method == "GET":
|
|
form.cas_enable.data = ScoDocSiteConfig.get("cas_enable")
|
|
form.cas_force.data = ScoDocSiteConfig.get("cas_force")
|
|
form.cas_server.data = ScoDocSiteConfig.get("cas_server")
|
|
form.cas_login_route.data = ScoDocSiteConfig.get("cas_login_route")
|
|
form.cas_logout_route.data = ScoDocSiteConfig.get("cas_logout_route")
|
|
form.cas_validate_route.data = ScoDocSiteConfig.get("cas_validate_route")
|
|
form.cas_attribute_id.data = ScoDocSiteConfig.get("cas_attribute_id")
|
|
form.cas_ssl_verify.data = ScoDocSiteConfig.get("cas_ssl_verify")
|
|
return render_template(
|
|
"config_cas.j2",
|
|
form=form,
|
|
title="Configuration du Service d'Authentification Central (CAS)",
|
|
cas_ssl_certificate_loaded=ScoDocSiteConfig.get("cas_ssl_certificate"),
|
|
)
|
|
|
|
|
|
@bp.route("/ScoDoc/config_assiduites", methods=["GET", "POST"])
|
|
@admin_required
|
|
def config_assiduites():
|
|
"""Form config Assiduites"""
|
|
form = ConfigAssiduitesForm()
|
|
if request.method == "POST" and form.cancel.data: # cancel button
|
|
return redirect(url_for("scodoc.index"))
|
|
if form.validate_on_submit():
|
|
if ScoDocSiteConfig.set("assi_morning_time", form.data["morning_time"]):
|
|
flash("Heure du début de la journée enregistrée")
|
|
if ScoDocSiteConfig.set("assi_lunch_time", form.data["lunch_time"]):
|
|
flash("Heure de midi enregistrée")
|
|
if ScoDocSiteConfig.set("assi_afternoon_time", form.data["afternoon_time"]):
|
|
flash("Heure de fin de la journée enregistrée")
|
|
if (
|
|
form.data["tick_time"] > 0
|
|
and form.data["tick_time"] < 60
|
|
and ScoDocSiteConfig.set("assi_tick_time", float(form.data["tick_time"]))
|
|
):
|
|
flash("Granularité de la timeline enregistrée")
|
|
else:
|
|
flash("Erreur : Granularité invalide ou identique")
|
|
|
|
return redirect(url_for("scodoc.configuration"))
|
|
|
|
elif request.method == "GET":
|
|
form.morning_time.data = ScoDocSiteConfig.get(
|
|
"assi_morning_time", datetime.time(8, 0, 0)
|
|
)
|
|
form.lunch_time.data = ScoDocSiteConfig.get(
|
|
"assi_lunch_time", datetime.time(13, 0, 0)
|
|
)
|
|
form.afternoon_time.data = ScoDocSiteConfig.get(
|
|
"assi_afternoon_time", datetime.time(18, 0, 0)
|
|
)
|
|
try:
|
|
form.tick_time.data = float(ScoDocSiteConfig.get("assi_tick_time", 15.0))
|
|
except ValueError:
|
|
form.tick_time.data = 15.0
|
|
ScoDocSiteConfig.set("assi_tick_time", 15.0)
|
|
|
|
return render_template(
|
|
"assiduites/config_assiduites.j2",
|
|
form=form,
|
|
title="Configuration du module Assiduités",
|
|
)
|
|
|
|
|
|
@bp.route("/ScoDoc/config_codes_decisions", methods=["GET", "POST"])
|
|
@admin_required
|
|
def config_codes_decisions():
|
|
"""Form config codes decisions"""
|
|
form = CodesDecisionsForm()
|
|
if request.method == "POST" and form.cancel.data: # cancel button
|
|
return redirect(url_for("scodoc.index"))
|
|
if form.validate_on_submit():
|
|
for code in models.config.CODES_SCODOC_TO_APO:
|
|
ScoDocSiteConfig.set_code_apo(code, getattr(form, code).data)
|
|
flash("Codes décisions enregistrés")
|
|
return redirect(url_for("scodoc.index"))
|
|
elif request.method == "GET":
|
|
for code in models.config.CODES_SCODOC_TO_APO:
|
|
getattr(form, code).data = ScoDocSiteConfig.get_code_apo(code)
|
|
|
|
return render_template(
|
|
"config_codes_decisions.j2",
|
|
form=form,
|
|
title="Configuration des codes de décisions",
|
|
)
|
|
|
|
|
|
@bp.route("/ScoDoc/table_etud_in_accessible_depts", methods=["POST"])
|
|
@login_required
|
|
def table_etud_in_accessible_depts():
|
|
"""recherche étudiants sur plusieurs départements"""
|
|
return sco_find_etud.table_etud_in_accessible_depts(expnom=request.form["expnom"])
|
|
|
|
|
|
# Fonction d'API accessible sans aucun authentification
|
|
@bp.route("/ScoDoc/get_etud_dept")
|
|
def get_etud_dept():
|
|
"""Returns the dept acronym (eg "GEII") of an etud (identified by etudid,
|
|
code_nip ou code_ine in the request).
|
|
Ancienne API: ramène la chaine brute, texte sans JSON ou XML.
|
|
"""
|
|
if "etudid" in request.args:
|
|
# zero ou une réponse:
|
|
etuds = [Identite.get_etud(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_ine=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 = FormSemestreInscription.query.filter_by(etudid=etud.id).all()
|
|
for ins in inscriptions:
|
|
date_fin = db.session.get(FormSemestre, 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_or_404(last_etud.dept_id).acronym
|
|
|
|
|
|
# Bricolage pour le portail IUTV avec ScoDoc 7: (DEPRECATED: NE PAS UTILISER !)
|
|
@bp.route(
|
|
"/ScoDoc/search_inscr_etud_by_nip", methods=["GET"]
|
|
) # pour compat anciens clients PHP
|
|
@scodoc
|
|
@scodoc7func
|
|
def search_inscr_etud_by_nip(code_nip, format="json", __ac_name="", __ac_password=""):
|
|
auth_ok = False
|
|
user_name = __ac_name
|
|
user_password = __ac_password
|
|
if user_name and user_password:
|
|
u = User.query.filter_by(user_name=user_name).first()
|
|
if u and u.check_password(user_password):
|
|
auth_ok = True
|
|
flask_login.login_user(u)
|
|
if not auth_ok:
|
|
abort(403)
|
|
else:
|
|
return sco_find_etud.search_inscr_etud_by_nip(code_nip=code_nip, format=format)
|
|
|
|
|
|
@bp.route("/ScoDoc/about")
|
|
@bp.route("/ScoDoc/Scolarite/<scodoc_dept>/about")
|
|
@login_required
|
|
def about(scodoc_dept=None):
|
|
"version info"
|
|
return render_template(
|
|
"about.j2",
|
|
version=scu.get_scodoc_version(),
|
|
news=sco_version.SCONEWS,
|
|
logo=scu.icontag("borgne_img"),
|
|
)
|
|
|
|
|
|
# ---- CONFIGURATION
|
|
|
|
# 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"
|
|
|
|
# Booléens
|
|
# always_require_ine
|
|
|
|
# Logos:
|
|
# LOGO_FOOTER*, LOGO_HEADER*
|
|
|
|
|
|
@bp.route("/ScoDoc/configuration", methods=["GET", "POST"])
|
|
@admin_required
|
|
def configuration():
|
|
"Page de configuration globale"
|
|
if not current_user.is_administrator():
|
|
raise AccessDenied("invalid user (%s) must be SuperAdmin" % current_user)
|
|
return config_main.configuration()
|
|
|
|
|
|
@bp.route("/ScoDoc/get_bonus_description/<bonus_name>", methods=["GET"])
|
|
def get_bonus_description(bonus_name: str):
|
|
"description text/html du bonus"
|
|
if bonus_name == "default":
|
|
bonus_name = ""
|
|
bonus_class = ScoDocSiteConfig.get_bonus_sport_class_from_name(bonus_name)
|
|
text = bonus_class.__doc__
|
|
if text:
|
|
fields = re.split(r"\n\n", text, maxsplit=1)
|
|
if len(fields) > 1:
|
|
first_line, text = fields
|
|
else:
|
|
first_line, text = "", fields[0]
|
|
else:
|
|
text = ""
|
|
first_line = "pas de fonction bonus configurée."
|
|
|
|
return f"""<div class="bonus_description_head">{first_line}</div>
|
|
<div>{text}</div>
|
|
"""
|
|
|
|
|
|
@bp.route("/ScoDoc/configure_logos", methods=["GET", "POST"])
|
|
@admin_required
|
|
def configure_logos():
|
|
"Page de configuration des logos (globale)"
|
|
if not current_user.is_administrator():
|
|
raise AccessDenied("invalid user (%s) must be SuperAdmin" % current_user)
|
|
return config_logos.config_logos()
|
|
|
|
|
|
SMALL_SIZE = (200, 200)
|
|
|
|
|
|
def _return_logo(name="header", dept_id="", small=False, strict: bool = True):
|
|
# stockée dans /opt/scodoc-data/config/logos donc servie manuellement ici
|
|
# from app.scodoc.sco_photos import _http_jpeg_file
|
|
|
|
logo = sco_logos.find_logo(name, dept_id, strict)
|
|
if logo is not None:
|
|
logo.select()
|
|
suffix = logo.suffix
|
|
if small:
|
|
with PILImage.open(logo.filepath) as im:
|
|
# on garde le même format (on pourrait plus simplement générer systématiquement du JPEG)
|
|
fmt = { # adapt suffix to be compliant with PIL save format
|
|
"PNG": "PNG",
|
|
"JPG": "JPEG",
|
|
"JPEG": "JPEG",
|
|
}[suffix.upper()]
|
|
if fmt == "JPEG":
|
|
im = im.convert("RGB")
|
|
im.thumbnail(SMALL_SIZE)
|
|
stream = io.BytesIO()
|
|
im.save(stream, fmt)
|
|
stream.seek(0)
|
|
return send_file(stream, mimetype=f"image/{fmt}")
|
|
else:
|
|
# return _http_jpeg_file(logo.filepath)
|
|
# ... replaces ...
|
|
return send_file(
|
|
logo.filepath,
|
|
mimetype=f"image/{suffix}",
|
|
last_modified=datetime.datetime.now(),
|
|
)
|
|
else:
|
|
abort(404)
|
|
|
|
|
|
# small version (copy/paste from get_logo
|
|
@bp.route("/ScoDoc/logos/<name>/small", defaults={"dept_id": None})
|
|
@bp.route("/ScoDoc/<int:dept_id>/logos/<name>/small")
|
|
@admin_required
|
|
def get_logo_small(name: str, dept_id: int):
|
|
strict = request.args.get("strict", "False")
|
|
return _return_logo(
|
|
name,
|
|
dept_id=dept_id,
|
|
small=True,
|
|
strict=strict.upper() not in ["0", "FALSE"],
|
|
)
|
|
|
|
|
|
@bp.route(
|
|
"/ScoDoc/logos/<name>", defaults={"dept_id": None}
|
|
) # if dept not specified, take global logo
|
|
@bp.route("/ScoDoc/<int:dept_id>/logos/<name>")
|
|
@admin_required
|
|
def get_logo(name: str, dept_id: int):
|
|
strict = request.args.get("strict", "False")
|
|
return _return_logo(
|
|
name,
|
|
dept_id=dept_id,
|
|
small=False,
|
|
strict=strict.upper() not in ["0", "FALSE"],
|
|
)
|
|
|
|
|
|
# ---
|
|
@bp.route("/ScoDoc/ue_colors_css/<int:formation_id>/<int:semestre_idx>")
|
|
def ue_colors_css(formation_id: int, semestre_idx: int):
|
|
"""Feuille de style pour les couleurs d'UE"""
|
|
ues = UniteEns.query.filter_by(
|
|
formation_id=formation_id, semestre_idx=semestre_idx
|
|
).order_by(UniteEns.numero)
|
|
txt = (
|
|
":root{\n"
|
|
+ "\n".join(
|
|
[
|
|
f"--color-UE{semestre_idx}-{ue_idx+1}: {ue.color};"
|
|
for ue_idx, ue in enumerate(ues)
|
|
if ue.color
|
|
]
|
|
)
|
|
+ "\n}\n"
|
|
)
|
|
response = make_response(txt)
|
|
response.headers["Content-Type"] = "text/css"
|
|
return response
|
|
|
|
|
|
# essais
|
|
# @bp.route("/testlog")
|
|
# def testlog():
|
|
# import time
|
|
# from flask import current_app
|
|
# from app import log
|
|
|
|
# log(f"testlog called: handlers={current_app.logger.handlers}")
|
|
# current_app.logger.debug(f"testlog message DEBUG")
|
|
# current_app.logger.info(f"testlog message INFO")
|
|
# current_app.logger.warning(f"testlog message WARNING")
|
|
# current_app.logger.error(f"testlog message ERROR")
|
|
# current_app.logger.critical(f"testlog message CRITICAL")
|
|
# raise SyntaxError("une erreur de syntaxe")
|
|
# return "testlog completed at " + str(time.time())
|