# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # ScoDoc # # Copyright (c) 1999 - 2022 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 users: interface gestion utilisateurs ré-écriture pour Flask ScoDoc7 / ZScoUsers.py Vues s'appuyant sur auth et sco_users Emmanuel Viennet, 2021 """ import datetime import re from enum import auto, IntEnum from xml.etree import ElementTree import flask from flask import g, url_for, request, current_app, flash from flask import redirect, render_template from flask_login import current_user from flask_wtf import FlaskForm from wtforms import HiddenField, PasswordField, StringField, SubmitField from wtforms.validators import DataRequired, Email, ValidationError, EqualTo from app import db from app.auth.forms import DeactivateUserForm from app.auth.models import Permission from app.auth.models import User from app.auth.models import Role from app.auth.models import UserRole from app.auth.models import is_valid_password from app.email import send_email from app.models import Departement from app.decorators import ( scodoc, scodoc7func, permission_required, ) from app.scodoc import html_sco_header, sco_import_users, sco_roles_default from app.scodoc import sco_users from app.scodoc import sco_utils as scu from app.scodoc import sco_xml from app import log from app.scodoc.sco_exceptions import AccessDenied, ScoPermissionDenied, ScoValueError from app.scodoc.sco_import_users import generate_password from app.scodoc.sco_permissions_check import can_handle_passwd from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message from app.views import users_bp as bp _ = lambda x: x # sans babel _l = _ class ChangePasswordForm(FlaskForm): """formulaire changement mot de passe et mail""" user_name = HiddenField() old_password = PasswordField(_l("Identifiez-vous")) new_password = PasswordField(_l("Nouveau mot de passe de l'utilisateur")) bis_password = PasswordField( _l("Répéter"), validators=[ EqualTo( "new_password", message="Les deux saisies sont " "différentes, recommencez", ), ], ) email = StringField( _l("Email"), validators=[ DataRequired(), Email(message="adresse email invalide, recommencez"), ], ) submit = SubmitField() cancel = SubmitField("Annuler") def validate_email(self, email): "vérifie que le mail est unique" user = User.query.filter_by(email=email.data.strip()).first() if user is not None and self.user_name.data != user.user_name: raise ValidationError( _("Cette adresse e-mail est déjà attribuée à un autre compte") ) def validate_new_password(self, new_password): "vérifie que le mot de passe est acceptable" if new_password.data != "" and not is_valid_password(new_password.data): raise ValidationError("Mot de passe trop simple, recommencez") def validate_old_password(self, old_password): if not current_user.check_password(old_password.data): raise ValidationError("Mot de passe actuel incorrect, ré-essayez") class Mode(IntEnum): WELCOME_AND_CHANGE_PASSWORD = auto() WELCOME_ONLY = auto() SILENT = auto() @bp.route("/") @bp.route("/index_html") @scodoc @permission_required(Permission.ScoUsersView) @scodoc7func def index_html(all_depts=False, with_inactives=False, format="html"): "Page accueil utilisateur: tableau avec liste des comptes" return sco_users.index_html( all_depts=all_depts, with_inactives=with_inactives, format=format, ) def _get_administrable_depts() -> list[str]: """Liste des acronymes des départements dans lesquels l'utilisateur courant peut administrer des utilisateurs. Si SuperAdmin, tous les départements Sinon, les départements dans lesquels l'utilisateur a la permission ScoUsersAdmin """ # if current_user.is_administrator(): log(f"create_user_form called by {current_user.user_name} (super admin)") administrable_dept_acronyms = sorted( [d.acronym for d in Departement.query.all()] ) else: administrable_dept_acronyms = current_user.get_depts_with_permission( Permission.ScoUsersAdmin ) if None in administrable_dept_acronyms: administrable_dept_acronyms = sorted( [d.acronym for d in Departement.query.all()] ) return administrable_dept_acronyms def _get_editable_roles( administrable_dept_acronyms: list = None, all_roles=True ) -> set[tuple[Role, str]]: """Rôles modifiables: ensemble de tuples (role, dept_acronym) (où dept_acronym est None si tous dept.) Si all_roles, tous les rôles définis et modifiables par l'utilisateurs. Sinon, seulement les rôles "standards" de ScoDoc. """ if all_roles: # tous sauf SuperAdmin roles = [ r for r in Role.query.all() if r.permissions != Permission.ALL_PERMISSIONS[0] ] else: # Les rôles standards créés à l'initialisation de ScoDoc: roles = [ Role.get_named_role(r) for r in sco_roles_default.ROLES_ATTRIBUABLES_DEPT ] # Génère toutes les combinaisons roles/départements editable_roles_set = { (r, dept) for r in roles for dept in administrable_dept_acronyms } if current_user.is_administrator(): editable_roles_set |= { (Role.get_named_role(r), None) for r in sco_roles_default.ROLES_ATTRIBUABLES_SCODOC } # Un super-admin peut nommer d'autres super-admin: editable_roles_set |= {(Role.get_named_role("SuperAdmin"), None)} return editable_roles_set @bp.route("/create_user_form", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoUsersAdmin) @scodoc7func def create_user_form(user_name=None, edit=0, all_roles=True): "form. création ou édition utilisateur" if user_name is not None: # scodoc7func converti en int ! user_name = str(user_name) Role.ensure_standard_roles() # assure la présence des rôles en base auth_dept = current_user.dept from_mail = current_app.config["SCODOC_MAIL_FROM"] # current_user.email initvalues = {} edit = int(edit) all_roles = int(all_roles) H = [ html_sco_header.sco_header( bodyOnLoad="init_tf_form('')", javascripts=["js/user_form.js"], ) ] F = html_sco_header.sco_footer() the_user: User = None if edit: if not user_name: raise ValueError("missing argument: user_name") the_user = User.query.filter_by(user_name=user_name).first() if not the_user: raise ScoValueError("utilisateur inexistant") initvalues = the_user.to_dict() H.append(f"
Vous êtes super administrateur !
""") administrable_dept_acronyms = _get_administrable_depts() if edit: if the_user.dept is None: # seul le super admin peut le toucher edit_only_roles = not current_user.is_administrator() else: edit_only_roles = the_user.dept not in administrable_dept_acronyms else: edit_only_roles = False # création nouvel utilisateur editable_roles_set = _get_editable_roles( administrable_dept_acronyms, all_roles=all_roles ) editable_roles_strings = { r.name + "_" + (dept or "") for (r, dept) in editable_roles_set } orig_roles_strings = {r.name + "_" + (dept or "") for (r, dept) in orig_roles} # add existing user roles displayed_roles = list(editable_roles_set.union(orig_roles)) displayed_roles.sort( key=lambda x: ( x[1] or "", (x[0].name or "") if x[0].name != "SuperAdmin" else "A", ) ) displayed_roles_strings = [ r.name + "_" + (dept or "") for (r, dept) in displayed_roles ] displayed_roles_labels = [ f"{dept or 'tout dépt.'}: {r.name}" for (r, dept) in displayed_roles ] disabled_roles = {} # pour désactiver les roles que l'on ne peut pas éditer for i, role_string in enumerate(displayed_roles_strings): if role_string not in editable_roles_strings: disabled_roles[i] = True # Formulaire: descr = [ ("edit", {"input_type": "hidden", "default": edit}), ( "nom", { "title": "Nom", "size": 20, "allow_null": False, "readonly": edit_only_roles, }, ), ( "prenom", { "title": "Prénom", "size": 20, "allow_null": False, "readonly": edit_only_roles, }, ), ] if current_user.user_name != user_name and not edit_only_roles: # no one can change its own status descr.append( ( "status", { "title": "Statut", "input_type": "radio", "labels": ("actif", "ancien"), "allowed_values": ("", "old"), }, ) ) if not edit: descr += [ ( "user_name", { "title": "Pseudo (login)", "size": 20, "allow_null": False, "explanation": "nom utilisé pour la connexion. Doit être unique parmi tous les utilisateurs. " "Lettres ou chiffres uniquement.", }, ), ("formsemestre_id", {"input_type": "hidden"}), ( "password", { "title": "Mot de passe", "input_type": "password", "size": 14, "allow_null": True, "explanation": "optionnel, l'utilisateur pourra le saisir avec son mail", }, ), ( "password2", { "title": "Confirmer mot de passe", "input_type": "password", "size": 14, "allow_null": True, }, ), ] else: # edition: on ne peut pas changer user_name descr += [ ( "user_name", {"input_type": "hidden", "default": initvalues["user_name"]}, ) ] descr += [ ( "email", { "title": "e-mail", "input_type": "text", "explanation": "requis, doit fonctionner" if not edit_only_roles else "", "size": 20, "allow_null": False, "readonly": edit_only_roles, }, ) ] if not edit: # options création utilisateur descr += [ ( "welcome", { "title": "Message d'accueil", "input_type": "checkbox", "explanation": "Envoie un mail d'accueil à l'utilisateur.", "labels": ("",), "allowed_values": ("1",), "default": "1", }, ), ( "reset_password", { "title": "", "input_type": "checkbox", "explanation": "indiquer par mail de changer le mot de passe initial", "labels": ("",), "allowed_values": ("1",), "default": "1", # "attributes": ["style='margin-left:20pt'"], }, ), ] # Si SuperAdmin, propose de choisir librement le dept du nouvel utilisateur selectable_dept_acronyms = set(administrable_dept_acronyms) if edit and the_user.dept is not None: # ajoute dept actuel de l'utilisateur selectable_dept_acronyms |= {the_user.dept} if is_super_admin and len(selectable_dept_acronyms) > 1: selectable_dept_acronyms = sorted(list(selectable_dept_acronyms)) descr.append( ( "dept", { "title": "Département", "input_type": "menu", "explanation": """département de rattachement de l'utilisateur""", "labels": selectable_dept_acronyms, "allowed_values": selectable_dept_acronyms, "default": g.scodoc_dept if g.scodoc_dept in selectable_dept_acronyms else (auth_dept or ""), }, ) ) can_choose_dept = True else: # pas de choix de département can_choose_dept = False if edit: descr.append( ( "d", { "input_type": "separator", "title": f"""L'utilisateur appartient au département {the_user.dept or "(tous)"}""", }, ) ) else: descr.append( ( "d", { "input_type": "separator", "title": f"L'utilisateur sera crée dans le département {auth_dept}", }, ) ) descr += [ ( "date_expiration", { "title": "Date d'expiration", # j/m/a "input_type": "datedmy", "explanation": "j/m/a, laisser vide si pas de limite" if not edit_only_roles else "", "size": 9, "allow_null": True, "readonly": edit_only_roles, }, ), ( "roles", { "title": "Rôles", "input_type": "checkbox", "vertical": True, "labels": displayed_roles_labels, "allowed_values": displayed_roles_strings, "disabled_items": disabled_roles, }, ), ] if not edit_only_roles: descr += [ ( "force", { "title": "Ignorer les avertissements", "input_type": "checkbox", "explanation": "passer outre les avertissements (homonymes, etc)", "labels": ("",), "allowed_values": ("1",), }, ), ] vals = scu.get_request_args() if "tf_submitted" in vals and "roles" not in vals: vals["roles"] = [] if "tf_submitted" in vals: # Ajoute roles existants mais non modifiables (disabled dans le form) vals["roles"] = list( set(vals["roles"]).union(orig_roles_strings - editable_roles_strings) ) tf = TrivialFormulator( request.base_url, vals, descr, initvalues=initvalues, submitlabel=submitlabel, cancelbutton="Annuler", ) if tf[0] == 0: return "\n".join(H) + "\n" + tf[1] + F elif tf[0] == -1: return flask.redirect(scu.UsersURL()) else: vals = tf[2] roles = set(vals["roles"]).intersection(editable_roles_strings) if "edit" in vals: edit = int(vals["edit"]) else: edit = 0 try: force = int(vals.get("force", "0")[0]) except (IndexError, ValueError, TypeError): force = 0 if edit: user_name = initvalues["user_name"] else: user_name = vals["user_name"] # ce login existe ? err = None existing_user = User.query.filter_by(user_name=user_name) if ( edit and existing_user is None ): # safety net, le user_name ne devrait pas changer err = f"identifiant {user_name} inexistant" if not edit and existing_user is not None: err = f"identifiant %{user_name} déjà utilisé" if err: H.append(tf_error_message(f"""Erreur: {err}""")) return "\n".join(H) + "\n" + tf[1] + F if not edit_only_roles: ok_modif, msg = sco_users.check_modif_user( edit, enforce_optionals=not force, user_name=user_name, nom=vals["nom"], prenom=vals["prenom"], email=vals["email"], dept=vals.get("dept", auth_dept), roles=vals["roles"], ) if not ok_modif: H.append(tf_error_message(msg)) return "\n".join(H) + "\n" + tf[1] + F if "date_expiration" in vals: try: if vals["date_expiration"]: vals["date_expiration"] = datetime.datetime.strptime( vals["date_expiration"], "%d/%m/%Y" ) if vals["date_expiration"] < datetime.datetime.now(): H.append(tf_error_message("date expiration passée")) return "\n".join(H) + "\n" + tf[1] + F else: vals["date_expiration"] = None except ValueError: H.append(tf_error_message("date expiration invalide")) return "\n".join(H) + "\n" + tf[1] + F if edit: # modif utilisateur (mais pas password ni user_name !) if (not can_choose_dept) and "dept" in vals: del vals["dept"] if "password" in vals: del vals["passwordd"] if "date_modif_passwd" in vals: del vals["date_modif_passwd"] if "user_name" in vals: del vals["user_name"] if (current_user.user_name == user_name) and "status" in vals: del vals["status"] # no one can't change its own status if "status" in vals: vals["active"] = vals["status"] == "" # Département: if auth_dept: # pas super-admin if ("dept" in vals) and (vals["dept"] not in selectable_dept_acronyms): del vals["dept"] # ne change pas de dept # Traitement des roles: ne doit pas affecter les rôles # que l'on en contrôle pas: for role in orig_roles_strings: # { "Ens_RT", "Secr_CJ", ... } if role and not role in editable_roles_strings: roles.add(role) vals["roles_string"] = ",".join(roles) # ok, edit if not edit_only_roles: log(f"sco_users: editing {user_name} by {current_user.user_name}") log(f"sco_users: previous_values={initvalues}") log(f"sco_users: new_values={vals}") sco_users.user_edit(user_name, vals) flash(f"Utilisateur {user_name} modifié") else: sco_users.user_edit(user_name, {"roles_string": vals["roles_string"]}) return flask.redirect( url_for( "users.user_info_page", scodoc_dept=g.scodoc_dept, user_name=user_name, ) ) else: # création utilisateur vals["roles_string"] = ",".join(vals["roles"]) # check identifiant if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]+$", vals["user_name"]): msg = tf_error_message( "identifiant invalide (pas d'accents ni de caractères spéciaux)" ) return "\n".join(H) + msg + "\n" + tf[1] + F # Traitement initial (mode) : 3 cas # cf énumération Mode # A: envoi de welcome + procedure de reset # B: envoi de welcome seulement (mot de passe saisie dans le formulaire) # C: Aucun envoi (mot de passe saisi dans le formulaire) if vals["welcome"] != "1": if vals["reset_password"] != "1": mode = Mode.WELCOME_AND_CHANGE_PASSWORD else: mode = Mode.WELCOME_ONLY else: mode = Mode.SILENT # check passwords if mode == Mode.WELCOME_AND_CHANGE_PASSWORD: vals["password"] = generate_password() else: if vals["password"]: if vals["password"] != vals["password2"]: msg = tf_error_message( """Les deux mots de passes ne correspondent pas !""" ) return "\n".join(H) + msg + "\n" + tf[1] + F if not is_valid_password(vals["password"]): msg = tf_error_message( """Mot de passe trop simple, recommencez !""" ) return "\n".join(H) + msg + "\n" + tf[1] + F # Département: if not can_choose_dept: vals["dept"] = auth_dept else: if auth_dept: # pas super-admin if vals["dept"] not in selectable_dept_acronyms: raise ScoValueError("département invalide") # ok, go log( f"""sco_users: new_user {vals["user_name"]} by {current_user.user_name}""" ) the_user = User() the_user.from_dict(vals, new_user=True) db.session.add(the_user) db.session.commit() # envoi éventuel d'un message if mode == Mode.WELCOME_AND_CHANGE_PASSWORD or mode == Mode.WELCOME_ONLY: if mode == Mode.WELCOME_AND_CHANGE_PASSWORD: token = the_user.get_reset_password_token() else: token = None send_email( "[ScoDoc] Création de votre compte", sender=from_mail, # current_app.config["ADMINS"][0], recipients=[the_user.email], text_body=render_template( "email/welcome.txt", user=the_user, token=token ), html_body=render_template( "email/welcome.html", user=the_user, token=token ), ) return flask.redirect( url_for( "users.user_info_page", scodoc_dept=g.scodoc_dept, user_name=user_name, head_message="Nouvel utilisateur créé", ) ) @bp.route("/import_users_generate_excel_sample") @scodoc @permission_required(Permission.ScoUsersAdmin) @scodoc7func def import_users_generate_excel_sample(): "une feuille excel pour importation utilisateurs" data = sco_import_users.generate_excel_sample() return scu.send_file(data, "ImportUtilisateurs", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) @bp.route("/import_users_form", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoUsersAdmin) @scodoc7func def import_users_form(): """Import utilisateurs depuis feuille Excel""" head = html_sco_header.sco_header(page_title="Import utilisateurs") H = [ head, """A utiliser pour importer de nouveaux utilisateurs (enseignants ou secrétaires)
L'opération se déroule en deux étapes. Dans un premier temps, vous téléchargez une feuille Excel type. Vous devez remplir cette feuille, une ligne décrivant chaque utilisateur. Ensuite, vous indiquez le nom de votre fichier dans la case "Fichier Excel" ci-dessous, et cliquez sur "Télécharger" pour envoyer au serveur votre liste.
""", ] help_msg = """Lors de la creation des utilisateurs, les opérations suivantes sont effectuées:
Ok, Import terminé ({nb_created} utilisateurs créés)!
""" ) else: dest_url = url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) H.append( f"""Erreur, importation annulée !
""" ) return "\n".join(H) + html_sco_header.sco_footer() @bp.route("/user_info_page") @scodoc @permission_required(Permission.ScoUsersView) @scodoc7func def user_info_page(user_name=None): """Display page of info about given user. If user_name not specified, user current_user """ if user_name is not None: # scodoc7func converti en int ! user_name = str(user_name) # peut-on divulguer ces infos ? if not can_handle_passwd(current_user, allow_admindepts=True): raise AccessDenied("Vous n'avez pas la permission de voir cette page") dept = g.scodoc_dept if not user_name: user = current_user else: user = User.query.filter_by(user_name=user_name).first() if not user: raise ScoValueError("invalid user_name") return render_template( "auth/user_info_page.html", user=user, title=f"Utilisateur {user.user_name}", Permission=Permission, dept=dept, ) @bp.route("/get_user_list_xml") @scodoc @permission_required(Permission.ScoView) @scodoc7func def get_user_list_xml(dept=None, start="", limit=25): """Returns XML list of users with name (nomplogin) starting with start. Used for forms auto-completion. """ # suggère seulement seulement les utilisateurs actifs: userlist = sco_users.get_user_list(dept=dept) start = scu.suppress_accents(str(start)).lower() # TODO : à refaire avec une requete SQL #py3 # (et en json) userlist = [ user for user in userlist if scu.suppress_accents((user.nom or "").lower()).startswith(start) ] doc = ElementTree.Element("results") for user in userlist[:limit]: x_rs = ElementTree.Element("rs", id=str(user.id), info="") x_rs.text = user.get_nomplogin() doc.append(x_rs) data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) return scu.send_file(data, mime=scu.XML_MIMETYPE) @bp.route("/form_change_password", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) @scodoc7func def form_change_password(user_name=None): """Formulaire de changement mot de passe de l'utilisateur user_name. Un utilisateur peut toujours changer son propre mot de passe. """ if user_name is not None: # scodoc7func converti en int ! user_name = str(user_name) if not user_name: user = current_user else: user = User.query.filter_by(user_name=user_name).first() # check access if not can_handle_passwd(user): return "\n".join( [ html_sco_header.sco_header(user_check=False), "Vous n'avez pas la permission de changer ce mot de passe
", html_sco_header.sco_footer(), ] ) form = ChangePasswordForm(user_name=user.user_name, email=user.email) destination = url_for( "users.user_info_page", scodoc_dept=g.scodoc_dept, user_name=user_name, ) if request.method == "POST" and form.cancel.data: # cancel button clicked return redirect(destination) if form.validate_on_submit(): messages = [] if form.new_password.data != "": # change password user.set_password(form.new_password.data) messages.append("Mot de passe modifié") if form.email.data.strip() != user.email: # change email user.email = form.email.data.strip() messages.append("Adresse email modifiée") db.session.commit() flash("\n".join(messages)) return redirect(destination) return render_template( "auth/change_password.html", form=form, title="Modification compte ScoDoc", auth_username=current_user.user_name, ) @bp.route("/change_password", methods=["POST"]) @scodoc @permission_required(Permission.ScoView) @scodoc7func def change_password(user_name, password, password2): "Change the password for user given by user_name" if user_name is not None: # scodoc7func converti en int ! user_name = str(user_name) u = User.query.filter_by(user_name=user_name).first() # Check access permission if not can_handle_passwd(u): # access denied log( f"change_password: access denied (authuser={current_user}, user_name={user_name})" ) raise AccessDenied("vous n'avez pas la permission de changer ce mot de passe") H = [] F = html_sco_header.sco_footer() # check password dest_url = url_for( "users.form_change_password", scodoc_dept=g.scodoc_dept, user_name=user_name ) if password != password2: H.append( f"""Les deux mots de passes saisis sont différents !
""" ) else: if not is_valid_password(password): H.append( f"""ce mot de passe n'est pas assez compliqué !
(oui, il faut un mot de passe vraiment compliqué !)
Ne notez pas ce mot de passe, mais mémorisez le !
Rappel: il est interdit de communiquer son mot de passe à un tiers, même si c'est un collègue de confiance !
Si vous n'êtes pas administrateur, le système va vous redemander votre login et nouveau mot de passe au prochain accès.
""" ) return ( f"""