ScoDoc/app/views/scodoc.py

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 = 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_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())