# -*- 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//") 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/", 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 ScoDocSiteConfig.set("assi_tick_time", form.data["tick_time"]): flash("Granularité de la timeline enregistrée") 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) ) form.tick_time.data = float(ScoDocSiteConfig.get("assi_tick_time", 15)) 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//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 = "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" # 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/", 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"""
{first_line}
{text}
""" @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//small", defaults={"dept_id": None}) @bp.route("/ScoDoc//logos//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/", defaults={"dept_id": None} ) # if dept not specified, take global logo @bp.route("/ScoDoc//logos/") @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//") 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())