forked from ScoDoc/ScoDoc
1110 lines
39 KiB
Python
1110 lines
39 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 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, 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 import email
|
|
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.models import Departement
|
|
from app.models.config import ScoDocSiteConfig
|
|
|
|
from app.decorators import (
|
|
scodoc,
|
|
scodoc7func,
|
|
permission_required,
|
|
)
|
|
|
|
from app.scodoc import 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("Mot de passe actuel"))
|
|
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, e_mail):
|
|
"vérifie que le mail est unique (inline wtf validator)"
|
|
user = User.query.filter_by(email=e_mail.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):
|
|
"vérifie password actuel"
|
|
if not current_user.check_password(old_password.data):
|
|
raise ValidationError("Mot de passe actuel incorrect, ré-essayez")
|
|
|
|
|
|
class Mode(IntEnum):
|
|
"Mode d'envoi de mail lors de création d'utilisateur"
|
|
WELCOME_AND_CHANGE_PASSWORD = auto() # mail indiquant de changer mdp
|
|
WELCOME_ONLY = auto() # mail d'accueil simple
|
|
SILENT = auto() # pas de mail
|
|
|
|
|
|
@bp.route("/")
|
|
@bp.route("/index_html", alias=True)
|
|
@scodoc
|
|
@permission_required(Permission.UsersView)
|
|
def index_html():
|
|
"Page accueil utilisateur: tableau avec liste des comptes"
|
|
all_depts = scu.to_bool(request.args.get("all_depts", False))
|
|
detail_roles = scu.to_bool(request.args.get("detail_roles", False))
|
|
fmt = request.args.get("fmt", "html")
|
|
having_role_name = request.args.get("having_role_name", "")
|
|
with_inactives = scu.to_bool(request.args.get("with_inactives", False))
|
|
return sco_users.index_html(
|
|
all_depts=all_depts,
|
|
detail_roles=detail_roles,
|
|
having_role_name=having_role_name,
|
|
with_inactives=with_inactives,
|
|
fmt=fmt,
|
|
)
|
|
|
|
|
|
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 UsersAdmin
|
|
"""
|
|
#
|
|
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.UsersAdmin
|
|
)
|
|
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.UsersAdmin)
|
|
@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
|
|
initvalues = {}
|
|
edit = int(edit)
|
|
all_roles = int(all_roles)
|
|
H = []
|
|
the_user: User = None
|
|
if edit:
|
|
if not user_name:
|
|
raise ValueError("missing argument: user_name")
|
|
the_user: 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"<h2>Modification de l'utilisateur {user_name}</h2>")
|
|
submitlabel = "Modifier utilisateur"
|
|
if "roles_string" in initvalues:
|
|
initvalues["roles"] = [
|
|
r.strip() for r in initvalues["roles_string"].split(",")
|
|
]
|
|
else:
|
|
initvalues["roles"] = []
|
|
if "date_expiration" in initvalues:
|
|
initvalues["date_expiration"] = (
|
|
the_user.date_expiration.strftime(scu.DATE_FMT)
|
|
if the_user.date_expiration
|
|
else ""
|
|
)
|
|
initvalues["status"] = "" if the_user.active else "old"
|
|
orig_roles = { # set des roles existants avant édition
|
|
UserRole.role_dept_from_string(role_dept)
|
|
for role_dept in initvalues["roles"]
|
|
if role_dept
|
|
}
|
|
if not initvalues["active"]:
|
|
editable_roles_set = set() # can't change roles of a disabled user
|
|
else:
|
|
H.append("<h2>Création d'un utilisateur</h2>")
|
|
submitlabel = "Créer utilisateur"
|
|
orig_roles = set()
|
|
|
|
is_super_admin = current_user.is_administrator()
|
|
if is_super_admin:
|
|
H.append("""<p class="warning">Vous êtes super administrateur !</p>""")
|
|
|
|
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 '<em>tout dépt.</em>'}: {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,
|
|
"strip": True,
|
|
},
|
|
),
|
|
(
|
|
"prenom",
|
|
{
|
|
"title": "Prénom",
|
|
"size": 20,
|
|
"allow_null": False,
|
|
"readonly": edit_only_roles,
|
|
"strip": True,
|
|
},
|
|
),
|
|
]
|
|
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"]},
|
|
)
|
|
]
|
|
cas_enabled = ScoDocSiteConfig.is_cas_enabled()
|
|
if edit:
|
|
cas_allow_login_default = the_user.cas_allow_login
|
|
else:
|
|
cas_allow_login_default = int(
|
|
bool(ScoDocSiteConfig.get("cas_allow_for_new_users"))
|
|
)
|
|
require_email_institutionnel = (
|
|
ScoDocSiteConfig.is_user_require_email_institutionnel_enabled()
|
|
)
|
|
descr += [
|
|
(
|
|
"email",
|
|
{
|
|
"title": "e-mail",
|
|
"input_type": "text",
|
|
"explanation": (
|
|
"requis, doit fonctionner" if not edit_only_roles else ""
|
|
),
|
|
"size": 36,
|
|
"allow_null": False,
|
|
"readonly": edit_only_roles,
|
|
"strip": True,
|
|
},
|
|
),
|
|
(
|
|
"cas_id",
|
|
{
|
|
"title": "Identifiant CAS",
|
|
"input_type": "text",
|
|
"explanation": "id du compte utilisateur sur le CAS de l'établissement "
|
|
+ (
|
|
"<b>pa défaut identique à l'identifiant ScoDoc</b> "
|
|
if ScoDocSiteConfig.get("cas_uid_use_scodoc")
|
|
else (
|
|
"(<b>sera déduit de son e-mail institutionnel</b>) "
|
|
if ScoDocSiteConfig.get("cas_uid_from_mail_regexp")
|
|
else ""
|
|
)
|
|
)
|
|
+ (
|
|
"(service CAS activé)"
|
|
if cas_enabled
|
|
else "(service CAS non activé)"
|
|
),
|
|
"size": 36,
|
|
"allow_null": True,
|
|
"readonly": not cas_enabled
|
|
or not current_user.has_permission(Permission.UsersChangeCASId),
|
|
},
|
|
),
|
|
(
|
|
"cas_allow_login",
|
|
{
|
|
"title": "Autorise connexion via CAS",
|
|
"input_type": "boolcheckbox",
|
|
"explanation": """ si CAS est activé.
|
|
Seul le super-administrateur peut changer ce réglage.""",
|
|
"enabled": current_user.is_administrator(),
|
|
"default": cas_allow_login_default,
|
|
},
|
|
),
|
|
(
|
|
"cas_allow_scodoc_login",
|
|
{
|
|
"title": "Autorise connexion via ScoDoc",
|
|
"input_type": "boolcheckbox",
|
|
"explanation": """ même si "Forcer l'utilisation de CAS" est activé.
|
|
Seul le super-administrateur peut changer ce réglage""",
|
|
"enabled": current_user.is_administrator(),
|
|
},
|
|
),
|
|
(
|
|
"email_institutionnel",
|
|
{
|
|
"title": "e-mail institutionnel",
|
|
"input_type": "text",
|
|
"explanation": (
|
|
("requis" if require_email_institutionnel else "facultatif")
|
|
if not edit_only_roles
|
|
else ""
|
|
),
|
|
"size": 36,
|
|
"strip": True,
|
|
"allow_null": not require_email_institutionnel,
|
|
"readonly": edit_only_roles,
|
|
},
|
|
),
|
|
(
|
|
"edt_id",
|
|
{
|
|
"title": "Identifiant sur l'emploi du temps",
|
|
"input_type": "text",
|
|
"explanation": """id du compte utilisateur sur l'emploi du temps
|
|
ou l'annuaire de l'établissement (par défaut, l'e-mail institutionnel )""",
|
|
"size": 36,
|
|
"allow_null": True,
|
|
},
|
|
),
|
|
]
|
|
if not edit: # options création utilisateur
|
|
descr += [
|
|
(
|
|
"welcome",
|
|
{
|
|
"title": "Message d'accueil",
|
|
"input_type": "boolcheckbox",
|
|
"explanation": "Envoie un mail d'accueil à l'utilisateur.",
|
|
"default": 1,
|
|
},
|
|
),
|
|
(
|
|
"reset_password",
|
|
{
|
|
"title": "",
|
|
"input_type": "boolcheckbox",
|
|
"explanation": """indiquer par mail de changer le mot
|
|
de passe initial (sauf si CAS forcé)""",
|
|
"default": 1,
|
|
},
|
|
),
|
|
]
|
|
# Si on a le droit d'administrer les utilisateurs de plusieurs départements,
|
|
# propose le choix du dept du nouvel utilisateur
|
|
selectable_dept_acronyms = set(administrable_dept_acronyms)
|
|
if edit:
|
|
if the_user.dept is not None: # ajoute dept actuel de l'utilisateur
|
|
selectable_dept_acronyms |= {the_user.dept}
|
|
default_dept = the_user.dept
|
|
else:
|
|
default_dept = (
|
|
g.scodoc_dept
|
|
if g.scodoc_dept in selectable_dept_acronyms
|
|
else (auth_dept or "")
|
|
)
|
|
if len(selectable_dept_acronyms) > 0:
|
|
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": default_dept,
|
|
},
|
|
)
|
|
)
|
|
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/aucun)"}""",
|
|
},
|
|
)
|
|
)
|
|
else:
|
|
descr.append(
|
|
(
|
|
"d",
|
|
{
|
|
"input_type": "separator",
|
|
"title": f"""L'utilisateur sera créé dans le département {
|
|
auth_dept or 'aucun'}""",
|
|
},
|
|
)
|
|
)
|
|
|
|
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 render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
if tf[0] == -1:
|
|
return flask.redirect(url_for("users.index_html", scodoc_dept=g.scodoc_dept))
|
|
|
|
vals = tf[2]
|
|
roles = set(vals["roles"]).intersection(editable_roles_strings)
|
|
if not current_user.is_administrator():
|
|
# empeche modification des paramètres CAS
|
|
if "cas_allow_login" in vals:
|
|
vals["cas_allow_login"] = cas_allow_login_default
|
|
if "cas_allow_scodoc_login" in vals:
|
|
if the_user is None:
|
|
vals.pop("cas_allow_scodoc_login", None)
|
|
else:
|
|
vals["cas_allow_scodoc_login"] = the_user.cas_allow_scodoc_login
|
|
|
|
if not current_user.has_permission(Permission.UsersChangeCASId):
|
|
vals.pop("cas_id", None)
|
|
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_msg = None
|
|
nb_existing_user = User.query.filter_by(user_name=user_name).count() > 0
|
|
if edit and (
|
|
nb_existing_user == 0
|
|
): # safety net, le user_name ne devrait pas changer
|
|
err_msg = f"identifiant {user_name} inexistant"
|
|
if not edit and nb_existing_user > 0:
|
|
err_msg = f"identifiant {user_name} déjà utilisé"
|
|
if err_msg:
|
|
H.append(tf_error_message(f"""Erreur: {err_msg}"""))
|
|
return render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
|
|
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"],
|
|
cas_id=vals.get("cas_id"), # pas présent si pas super-admin
|
|
)
|
|
if not ok_modif:
|
|
H.append(tf_error_message(msg))
|
|
return render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
if "date_expiration" in vals:
|
|
try:
|
|
if vals["date_expiration"]:
|
|
vals["date_expiration"] = datetime.datetime.strptime(
|
|
vals["date_expiration"], scu.DATE_FMT
|
|
)
|
|
if vals["date_expiration"] < datetime.datetime.now():
|
|
H.append(tf_error_message("date expiration passée"))
|
|
return render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
else:
|
|
vals["date_expiration"] = None
|
|
except ValueError:
|
|
H.append(tf_error_message("date expiration invalide"))
|
|
return render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
|
|
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 ("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}")
|
|
else:
|
|
vals = {"roles_string": vals["roles_string"]}
|
|
the_user.from_dict(vals)
|
|
db.session.add(the_user)
|
|
db.session.commit()
|
|
flash(f"Utilisateur {user_name} modifié")
|
|
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 render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + msg + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
# 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"]: # "Envoie un mail d'accueil" coché
|
|
if vals["reset_password"] and (
|
|
(not ScoDocSiteConfig.get("cas_force"))
|
|
or vals.get("cas_allow_scodoc_login", False)
|
|
):
|
|
# nb: si login scodoc non autorisé car CAS seul, n'envoie pas le mot de passe.
|
|
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 render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + msg + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
if not is_valid_password(vals["password"]):
|
|
msg = tf_error_message(
|
|
"""Mot de passe trop simple, recommencez !"""
|
|
)
|
|
return render_template(
|
|
"base.j2",
|
|
content="\n".join(H) + msg + "\n" + tf[1],
|
|
javascripts=["js/user_form.js"],
|
|
)
|
|
# 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(user_name=user_name)
|
|
the_user.from_dict(vals, new_user=True)
|
|
db.session.add(the_user)
|
|
db.session.commit()
|
|
# envoi éventuel d'un message
|
|
if mode in (Mode.WELCOME_AND_CHANGE_PASSWORD, Mode.WELCOME_ONLY):
|
|
token = (
|
|
the_user.get_reset_password_token()
|
|
if mode == Mode.WELCOME_AND_CHANGE_PASSWORD
|
|
else None
|
|
)
|
|
|
|
cas_force = ScoDocSiteConfig.get("cas_force")
|
|
# Le from doit utiliser la préférence du département de l'utilisateur
|
|
email.send_email(
|
|
"[ScoDoc] Création de votre compte",
|
|
sender=email.get_from_addr(),
|
|
recipients=[the_user.email],
|
|
text_body=render_template(
|
|
"email/welcome.txt",
|
|
user=the_user,
|
|
token=token,
|
|
cas_force=cas_force,
|
|
),
|
|
html_body=render_template(
|
|
"email/welcome.j2",
|
|
user=the_user,
|
|
token=token,
|
|
cas_force=cas_force,
|
|
),
|
|
)
|
|
flash(f"Mail accueil envoyé à {the_user.email}")
|
|
|
|
flash("Nouvel utilisateur créé")
|
|
return flask.redirect(
|
|
url_for(
|
|
"users.user_info_page",
|
|
scodoc_dept=g.scodoc_dept,
|
|
user_name=user_name,
|
|
)
|
|
)
|
|
|
|
|
|
@bp.route("/import_users_generate_excel_sample")
|
|
@scodoc
|
|
@permission_required(Permission.UsersAdmin)
|
|
@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.UsersAdmin)
|
|
@scodoc7func
|
|
def import_users_form():
|
|
"""Import utilisateurs depuis feuille Excel"""
|
|
H = [
|
|
"""<h2>Téléchargement d'une liste d'utilisateurs</h2>
|
|
<p style="color: red">A utiliser pour importer de <b>nouveaux</b>
|
|
utilisateurs (enseignants ou secrétaires)
|
|
</p>
|
|
<p>
|
|
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.
|
|
</p>
|
|
""",
|
|
]
|
|
help_msg = """<p class="help">
|
|
Lors de la creation des utilisateurs, les opérations suivantes sont effectuées:
|
|
</p>
|
|
<ol class="help">
|
|
<li>vérification des données;</li>
|
|
<li>génération d'un mot de passe alétoire pour chaque utilisateur;</li>
|
|
<li>création de chaque utilisateur;</li>
|
|
<li>envoi à chaque utilisateur de son <b>mot de passe initial par mail</b>.</li>
|
|
</ol>"""
|
|
H.append(
|
|
f"""<ul>
|
|
<li><b>Étape 1: </b><a class="stdlink" href="{
|
|
url_for("users.import_users_generate_excel_sample", scodoc_dept=g.scodoc_dept)
|
|
}">Obtenir la feuille excel vide à remplir</a>
|
|
</li>
|
|
<li><b> Étape 2:</b>
|
|
"""
|
|
)
|
|
tf = TrivialFormulator(
|
|
request.base_url,
|
|
scu.get_request_args(),
|
|
(
|
|
(
|
|
"xlsfile",
|
|
{"title": "Fichier Excel:", "input_type": "file", "size": 40},
|
|
),
|
|
(
|
|
"force",
|
|
{
|
|
"title": "Ignorer les avertissements",
|
|
"input_type": "checkbox",
|
|
"explanation": "passer outre les avertissements (homonymes, etc)",
|
|
"labels": ("",),
|
|
"allowed_values": ("1",),
|
|
},
|
|
),
|
|
("formsemestre_id", {"input_type": "hidden"}),
|
|
),
|
|
submitlabel="Télécharger",
|
|
)
|
|
if tf[0] == 0:
|
|
return render_template(
|
|
"sco_page_dept.j2",
|
|
title="Import utilisateurs",
|
|
content="\n".join(H) + tf[1] + "</li></ul>" + help_msg,
|
|
)
|
|
elif tf[0] == -1:
|
|
return flask.redirect(url_for("scolar.index_html", docodc_dept=g.scodoc_dept))
|
|
|
|
# IMPORT
|
|
ok, diags, nb_created = sco_import_users.import_excel_file(
|
|
tf[2]["xlsfile"], tf[2]["force"]
|
|
)
|
|
H = [
|
|
"""<h2>Téléchargement d'une liste d'utilisateurs</h2>
|
|
<ul>"""
|
|
]
|
|
for diag in diags:
|
|
H.append(f"<li>{diag}</li>")
|
|
H.append("</ul>")
|
|
if ok:
|
|
dest_url = url_for("users.index_html", scodoc_dept=g.scodoc_dept, all_depts=1)
|
|
H.append(
|
|
f"""<p>Ok, Import terminé ({nb_created} utilisateurs créés)!</p>
|
|
<p><a class="stdlink" href="{dest_url}">Continuer</a></p>
|
|
"""
|
|
)
|
|
else:
|
|
dest_url = url_for("users.import_users_form", scodoc_dept=g.scodoc_dept)
|
|
H.append(
|
|
f"""<p>Erreur, importation annulée !</p>
|
|
<p><a class="stdlink" href="{dest_url}">Continuer</a></p>
|
|
"""
|
|
)
|
|
return render_template(
|
|
"sco_page_dept.j2", title="Import utilisateurs", content="\n".join(H)
|
|
)
|
|
|
|
|
|
@bp.route("/user_info_page")
|
|
@scodoc
|
|
@permission_required(Permission.UsersView)
|
|
@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")
|
|
|
|
session_info = None
|
|
if user.id == current_user.id:
|
|
session_info = flask.session.get("scodoc_cas_login_date")
|
|
|
|
return render_template(
|
|
"auth/user_info_page.j2",
|
|
dept=dept,
|
|
Permission=Permission,
|
|
ScoDocSiteConfig=ScoDocSiteConfig,
|
|
session_info=session_info,
|
|
title=f"Utilisateur {user.user_name}",
|
|
user=user,
|
|
)
|
|
|
|
|
|
@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 "").strip().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 render_template(
|
|
"base.j2",
|
|
content="<p>Vous n'avez pas la permission de changer ce mot de passe</p>",
|
|
title="Accès refusé",
|
|
)
|
|
|
|
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.j2",
|
|
form=form,
|
|
title="Modification compte ScoDoc",
|
|
auth_username=current_user.user_name,
|
|
)
|
|
|
|
|
|
@bp.route("/toggle_active_user/<user_name>", methods=["GET", "POST"])
|
|
@scodoc
|
|
@permission_required(Permission.UsersAdmin)
|
|
def toggle_active_user(user_name: str = None):
|
|
"""Change active status of a user account"""
|
|
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()
|
|
if not u:
|
|
raise ScoValueError("invalid user_name")
|
|
# permission check:
|
|
if not (
|
|
current_user.is_administrator()
|
|
or current_user.has_permission(Permission.UsersAdmin, u.dept)
|
|
):
|
|
raise ScoPermissionDenied()
|
|
form = DeactivateUserForm()
|
|
if request.method == "POST" and form.cancel.data:
|
|
# if cancel button is clicked, the form.cancel.data will be True
|
|
return redirect(url_for("users.index_html", scodoc_dept=g.scodoc_dept))
|
|
if form.validate_on_submit():
|
|
u.active = not u.active
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
flash(
|
|
f"Compte utilisateur {u.user_name} {'activé' if u.active else 'désactivé'}"
|
|
)
|
|
return redirect(url_for("users.index_html", scodoc_dept=g.scodoc_dept))
|
|
return render_template("auth/toogle_active_user.j2", form=form, u=u)
|