ScoDoc-Lille/app/views/scodoc.py

713 lines
25 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 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, log
from app.auth.models import User, Role
from app.auth.cas import set_cas_configuration
from app.decorators import (
admin_required,
scodoc7func,
scodoc,
)
from app.forms.generic import SimpleConfirmationForm
from app.forms.main import config_logos, config_main
from app.forms.main.config_assiduites import ConfigAssiduitesForm
from app.forms.main.config_apo import CodesDecisionsForm
from app.forms.main.config_cas import ConfigCASForm
from app.forms.main.config_personalized_links import PersonalizedLinksForm
from app.forms.main.create_dept import CreateDeptForm
from app.forms.main.role_create import CreateRoleForm
from app import models
from app.models import (
Departement,
FormSemestre,
FormSemestreInscription,
Identite,
ScoDocSiteConfig,
UniteEns,
)
from app.models import departements
from app.models.config import PersonalizedLink
from app.scodoc import sco_edt_cal
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.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
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_roles", methods=["GET", "POST"])
@admin_required
def config_roles():
"""Form associations rôles / permissions"""
permissions_names = sorted(Permission.permission_by_value.values())
roles = Role.query.order_by(Role.name).all()
return render_template(
"scodoc/role_editor.j2",
Permission=Permission,
permissions_names=permissions_names,
roles=roles,
SCO_ROLES_DEFAULTS=SCO_ROLES_DEFAULTS,
)
@bp.route("/ScoDoc/permission_info/<string:perm_name>")
@admin_required
def permission_info(perm_name: str):
"""Infos sur une permission"""
permission = Permission.get_by_name(perm_name)
if permission is None:
raise ScoValueError("permission_info: permission inconnue")
return render_template(
"scodoc/permission_info.j2",
permission=permission,
permission_name=perm_name,
Permission=Permission,
)
@bp.route("/ScoDoc/role_info/<string:role_name>")
@admin_required
def role_info(role_name: str):
"""Infos sur un rôle"""
role = Role.query.filter_by(name=role_name).first_or_404()
return render_template(
"scodoc/role_info.j2", role=role, SCO_ROLES_DEFAULTS=SCO_ROLES_DEFAULTS
)
@bp.route("/ScoDoc/role_create", methods=["GET", "POST"])
@admin_required
def role_create():
"""Création d'un nouveau rôle"""
form = CreateRoleForm()
dest_url = url_for("scodoc.config_roles")
if request.method == "POST" and form.cancel.data:
return redirect(dest_url)
if form.validate_on_submit():
role_name = form.name.data.strip()
role: Role = Role.query.filter_by(name=role_name).first()
if role:
raise ScoValueError("Un rôle du même nom existe déjà", dest_url=dest_url)
role = Role(name=role_name)
db.session.add(role)
db.session.commit()
flash(f"Rôle {role_name} créé")
return redirect(dest_url)
roles = Role.query.order_by(Role.name).all()
return render_template(
"scodoc/role_create.j2", form=form, roles_names=[role.name for role in roles]
)
@bp.route("/ScoDoc/role_delete/<string:role_name>", methods=["GET", "POST"])
@admin_required
def role_delete(role_name: str):
"""Suppression d'un rôle"""
role = Role.query.filter_by(name=role_name).first_or_404()
# Ne permet de supprimer les rôles standards (on peut le faire via la ligne de commande ou l'API)
if role.name in SCO_ROLES_DEFAULTS:
raise ScoValueError(
f"Le rôle {role_name} est standard et ne peux pas être supprimé ici."
)
form = SimpleConfirmationForm()
if request.method == "POST" and form.cancel.data:
return redirect(url_for("scodoc.config_roles"))
if form.validate_on_submit():
db.session.delete(role)
db.session.commit()
flash(f"Rôle {role_name} supprimé")
return redirect(url_for("scodoc.config_roles"))
return render_template(
"form_confirmation.j2",
title=f"Supprimer le rôle {role_name} ?",
form=form,
info_message="""<p>Cette suppression est irréversible.</p>""",
)
@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_allow_for_new_users", form.data["cas_allow_for_new_users"]
):
flash(
f"""CAS {'' if form.data['cas_allow_for_new_users'] else 'non'
} autorisé par défaut aux nouveaux"""
)
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_uid_from_mail_regexp", form.data["cas_uid_from_mail_regexp"]
):
flash("Expression extraction identifiant CAS enregistrée")
if ScoDocSiteConfig.set(
"cas_edt_id_from_xml_regexp", form.data["cas_edt_id_from_xml_regexp"]
):
flash("Expression extraction identifiant edt enregistrée")
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_allow_for_new_users.data = ScoDocSiteConfig.get(
"cas_allow_for_new_users"
)
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_uid_from_mail_regexp.data = ScoDocSiteConfig.get(
"cas_uid_from_mail_regexp"
)
form.cas_edt_id_from_xml_regexp.data = ScoDocSiteConfig.get(
"cas_edt_id_from_xml_regexp"
)
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"))
edt_options = (
("edt_ics_path", "Chemin vers les calendriers ics"),
("edt_ics_title_field", "Champ contenant titre"),
("edt_ics_title_regexp", "Expression extraction titre"),
("edt_ics_group_field", "Champ contenant groupe"),
("edt_ics_group_regexp", "Expression extraction groupe"),
("edt_ics_mod_field", "Champ contenant module"),
("edt_ics_mod_regexp", "Expression extraction module"),
("edt_ics_uid_field", "Champ contenant l'enseignant"),
("edt_ics_uid_regexp", "Expression extraction de l'enseignant"),
("edt_ics_user_path", "Chemin vers les ics des enseignants"),
)
assi_options = (
("assi_morning_time", "Heure du début de la journée"),
("assi_lunch_time", "Heure du midi"),
("assi_afternoon_time", "Heure du fin de la journée"),
("assi_tick_time", "Granularité de la timeline"),
)
if form.validate_on_submit():
# --- Options assiduités
for opt_name, message in assi_options:
if ScoDocSiteConfig.set(opt_name, form.data[opt_name]):
flash(f"{message} enregistrée")
# --- Calendriers emploi du temps
for opt_name, message in edt_options:
if ScoDocSiteConfig.set(opt_name, form.data[opt_name]):
flash(f"{message} enregistré")
return redirect(url_for("scodoc.configuration"))
if request.method == "GET":
form.assi_morning_time.data = ScoDocSiteConfig.get(
"assi_morning_time", datetime.time(8, 0, 0)
)
form.assi_lunch_time.data = ScoDocSiteConfig.get(
"assi_lunch_time", datetime.time(13, 0, 0)
)
form.assi_afternoon_time.data = ScoDocSiteConfig.get(
"assi_afternoon_time", datetime.time(18, 0, 0)
)
try:
form.assi_tick_time.data = float(
ScoDocSiteConfig.get("assi_tick_time", 15.0)
)
except ValueError:
form.assi_tick_time.data = 15.0
ScoDocSiteConfig.set("assi_tick_time", 15.0)
# --- Emplois du temps
for opt_name, _ in edt_options:
getattr(form, opt_name).data = ScoDocSiteConfig.get(opt_name)
return render_template(
"assiduites/pages/config_assiduites.j2",
form=form,
title="Configuration du module Assiduité",
)
@bp.route("/ScoDoc/ics_raw_sample/<string:edt_id>")
@admin_required
def ics_raw_sample(edt_id: str):
"Renvoie un extrait de l'ics brut, pour aider à configurer les extractions"
log(f"ics_raw_sample/{edt_id}")
try:
raw_ics, _ = sco_edt_cal.formsemestre_load_calendar(edt_id=edt_id)
except ScoValueError as exc:
log(f"ics_raw_sample: formsemestre_load_calendar({edt_id}) failed")
return exc.args[0]
try:
ics = raw_ics.decode(scu.SCO_ENCODING)
except SyntaxError:
log("ics_raw_sample: raw_ics.decode failed")
return f"Erreur lors de la conversion vers {scu.SCO_ENCODING}"
evs = ics.split("BEGIN:VEVENT")
if len(evs) < 1:
log("ics_raw_sample: empty calendar")
return "pas d'évènements VEVENT détectés dans ce fichier"
return "BEGIN:VEVENT" + evs[len(evs) // 2]
@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/config_personalized_links", methods=["GET", "POST"])
@admin_required
def config_personalized_links():
"""Form config liens perso"""
form = PersonalizedLinksForm()
if request.method == "POST" and form.cancel.data: # cancel button
return redirect(url_for("scodoc.index"))
if form.validate_on_submit():
links = []
for idx in list(form.links_by_id) + ["new"]:
title = form.data.get(f"link_{idx}")
url = form.data.get(f"link_url_{idx}")
with_args = form.data.get(f"link_with_args_{idx}")
if title and url:
links.append(
PersonalizedLink(title=title, url=url, with_args=with_args)
)
ScoDocSiteConfig.set_perso_links(links)
flash("Liens enregistrés")
return redirect(url_for("scodoc.configuration"))
for idx, link in form.links_by_id.items():
getattr(form, f"link_{idx}").data = link.title
getattr(form, f"link_url_{idx}").data = link.url
getattr(form, f"link_with_args_{idx}").data = link.with_args
return render_template(
"config_personalized_links.j2",
form=form,
title="Configuration des liens personnalisés",
)
@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, fmt="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, fmt=fmt)
@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())