# -*- 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 import entreprises 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.activate_entreprises import ActivateEntreprisesForm 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.forms.main.config_rgpd import ConfigRGPDForm 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//") 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_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/config_roles.j2", Permission=Permission, permissions_names=permissions_names, roles=roles, SCO_ROLES_DEFAULTS=SCO_ROLES_DEFAULTS, ) @bp.route("/ScoDoc/config_rgpd", methods=["GET", "POST"]) @admin_required def config_rgpd(): """Form configuration RGPD""" form = ConfigRGPDForm() if request.method == "POST" and form.cancel.data: # cancel button return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): if ScoDocSiteConfig.set( "rgpd_coordonnees_dpo", form.data["rgpd_coordonnees_dpo"] ): flash("coordonnées DPO enregistrées") return redirect(url_for("scodoc.configuration")) elif request.method == "GET": form.rgpd_coordonnees_dpo.data = ScoDocSiteConfig.get( "rgpd_coordonnees_dpo", "" ) return render_template( "config_rgpd.j2", form=form, title="Configuration des fonctions liées au RGPD", ) @bp.route("/ScoDoc/permission_info/") @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/") @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/", 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="""

Cette suppression est irréversible.

""", ) @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.configuration")) 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.configuration")) 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", "08:00") form.assi_lunch_time.data = ScoDocSiteConfig.get("assi_lunch_time", "13:00") form.assi_afternoon_time.data = ScoDocSiteConfig.get( "assi_afternoon_time", "18:00" ) 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/") @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.configuration")) 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.configuration")) 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.configuration")) 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/activate_entreprises", methods=["GET", "POST"]) @admin_required def activate_entreprises(): """Form activation module entreprises""" is_enabled = ScoDocSiteConfig.is_entreprises_enabled() form = ActivateEntreprisesForm( data={ "set_default_roles_permission": True, } ) if request.method == "POST" and form.cancel.data: # cancel button return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): if entreprises.activate_module( enable=not is_enabled, set_default_roles_permission=form.data["set_default_roles_permission"], ): flash("Module entreprise " + ("activé" if not is_enabled else "désactivé")) return redirect(url_for("scodoc.configuration")) if is_enabled: form.submit.label.text = "Désactiver le module relations entreprises" else: form.submit.label.text = "Activer le module relations entreprises" return render_template( "activate_entreprises.j2", form=form, is_enabled=is_enabled, title="Activation module Relations Entreprises", ) @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//about") @login_required def about(scodoc_dept=None): "version info" return render_template( "about.j2", logo=scu.icontag("borgne_img"), news=sco_version.SCONEWS, ScoDocSiteConfig=ScoDocSiteConfig, version=scu.get_scodoc_version(), ) # ---- 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())