ScoDoc/app/scodoc/sco_users.py

435 lines
14 KiB
Python

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# 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
#
##############################################################################
"""Fonctions sur les utilisateurs
"""
# Anciennement ZScoUsers.py, fonctions de gestion des données réécrites avec flask/SQLAlchemy
import re
from flask import url_for, g, request
from flask_login import current_user
from app import db, Departement
from app.auth.models import Permission, Role, User, UserRole
from app.models import ScoDocSiteConfig, USERNAME_STR_LEN
from app.scodoc import html_sco_header
from app.scodoc import sco_preferences
from app.scodoc.gen_tables import GenTable
from app import cache
from app.scodoc.sco_exceptions import ScoValueError
def index_html(
all_depts=False, having_role_name: str = "", with_inactives=False, fmt="html"
):
"gestion utilisateurs..."
all_depts = int(all_depts)
with_inactives = int(with_inactives)
H = [html_sco_header.html_sem_header("Gestion des utilisateurs")]
if current_user.has_permission(Permission.UsersAdmin, g.scodoc_dept):
H.append(
f"""<p><a href="{url_for("users.create_user_form",
scodoc_dept=g.scodoc_dept)
}" class="stdlink">Ajouter un utilisateur</a>"""
)
if current_user.is_administrator():
H.append(
f"""&nbsp;&nbsp; <a href="{url_for("users.import_users_form",
scodoc_dept=g.scodoc_dept)
}" class="stdlink">Importer des utilisateurs</a></p>"""
)
else:
H.append(
"""&nbsp;&nbsp; Pour importer des utilisateurs en masse (via fichier xlsx)
contactez votre administrateur scodoc."""
)
if all_depts:
checked = "checked"
else:
checked = ""
if with_inactives:
olds_checked = "checked"
else:
olds_checked = ""
menu_roles = "\n".join(
f"""<option value="{r.name}" {
'selected' if having_role_name == r.name else ''
}>{r.name}</option>"""
for r in Role.query.order_by(Role.name)
)
H.append(
f"""
<form name="f" action="{request.base_url}" method="get">
<input type="checkbox" name="all_depts" value="1" onchange="document.f.submit();"
{checked}>Tous les départements</input>
<input type="checkbox" name="with_inactives" value="1" onchange="document.f.submit();"
{olds_checked}>Avec anciens utilisateurs</input>
<label for="having_role_name" style="margin-left:16px;">Filtrer par rôle:</label>
<select id="having_role_name" name="having_role_name" onchange="document.f.submit();">
<option value="">--Choisir--</option>
{menu_roles}
</select>
</form>
"""
)
if having_role_name:
having_role: Role = Role.query.filter_by(name=having_role_name).first()
if not having_role:
raise ScoValueError("nom de rôle invalide")
else:
having_role = None
content = list_users(
g.scodoc_dept,
all_depts=all_depts,
fmt=fmt,
having_role=having_role,
with_inactives=with_inactives,
with_links=current_user.has_permission(Permission.UsersAdmin, g.scodoc_dept),
)
if fmt != "html":
return content
H.append(content)
F = html_sco_header.sco_footer()
return "\n".join(H) + F
def list_users(
dept,
all_depts=False, # tous les departements
fmt="html",
having_role: Role = None,
with_inactives=False,
with_links=True,
):
"""List users, returns a table in the specified format.
Si with_inactives, inclut les anciens utilisateurs (status "old").
Si having_role, ne liste que les utilisateurs ayant ce rôle.
"""
from app.scodoc.sco_permissions_check import can_handle_passwd
if dept and not all_depts:
users = get_user_list(
dept=dept, having_role=having_role, with_inactives=with_inactives
)
comm = f"dept. {dept}"
else:
users = get_user_list(having_role=having_role, with_inactives=with_inactives)
comm = "tous"
if with_inactives:
comm += ", avec anciens"
comm = "(" + comm + ")"
if having_role:
comm += f" avec le rôle {having_role.name}"
# -- Add some information and links:
rows = []
for u in users:
# Can current user modify this user ?
can_modify = can_handle_passwd(u, allow_admindepts=True)
d = u.to_dict()
rows.append(d)
# Add links
if with_links and can_modify:
target = url_for(
"users.user_info_page", scodoc_dept=dept, user_name=u.user_name
)
d["_user_name_target"] = target
d["_nom_target"] = target
d["_prenom_target"] = target
# Hide passwd modification date (depending on visitor's permission)
if can_modify:
d["non_migre"] = (
"NON MIGRÉ" if u.passwd_temp or u.password_scodoc7 else "ok"
)
else:
d["date_modif_passwd"] = "(non visible)"
d["non_migre"] = ""
columns_ids = [
"user_name",
"nom_fmt",
"prenom_fmt",
"email",
"dept",
"roles_string",
"date_expiration",
"date_modif_passwd",
"non_migre",
"status_txt",
]
# Seul l'admin peut voir les dates de dernière connexion
# et les infos CAS
if current_user.is_administrator():
columns_ids.append("last_seen")
if ScoDocSiteConfig.is_cas_enabled():
columns_ids += [
"cas_id",
"cas_allow_login",
"cas_allow_scodoc_login",
"cas_last_login",
]
columns_ids += ["email_institutionnel", "edt_id"]
title = "Utilisateurs définis dans ScoDoc"
tab = GenTable(
rows=rows,
columns_ids=columns_ids,
titles={
"user_name": "Login",
"nom_fmt": "Nom",
"prenom_fmt": "Prénom",
"email": "Mail",
"email_institutionnel": "Mail institutionnel (opt.)",
"dept": "Dept.",
"roles_string": "Rôles",
"date_expiration": "Expiration",
"date_modif_passwd": "Modif. mot de passe",
"last_seen": "Dernière cnx.",
"non_migre": "Non migré (!)",
"status_txt": "Etat",
"cas_id": "Id CAS",
"cas_allow_login": "CAS autorisé",
"cas_allow_scodoc_login": "Cnx sans CAS",
"cas_last_login": "Dernier login CAS",
"edt_id": "Identifiant emploi du temps",
},
caption=title,
page_title="title",
html_title=f"""<h2>{len(rows)} utilisateurs {comm}</h2>
<p class="help">Cliquer sur un nom pour changer son mot de passe</p>""",
html_class="table_leftalign list_users",
html_with_td_classes=True,
html_sortable=True,
base_url="%s?all_depts=%s" % (request.base_url, 1 if all_depts else 0),
pdf_link=False, # table is too wide to fit in a paper page => disable pdf
preferences=sco_preferences.SemPreferences(),
)
return tab.make_page(fmt=fmt, with_html_headers=False)
def get_users_count(dept=None) -> int:
"""Nombre de comptes utilisateurs, tout état confondu, dans ce dept
(ou dans tous si None)"""
q = User.query
if dept is not None:
q = q.filter_by(dept=dept)
return q.count()
def get_user_list(
dept=None, with_inactives=False, having_role: Role = None
) -> list[User]:
"""Returns list of users.
If dept, select users from this dept,
else return all users.
"""
# was get_userlist
q = User.query
if dept is not None:
q = q.filter_by(dept=dept)
if not with_inactives:
q = q.filter_by(active=True)
if having_role:
q = q.join(UserRole).filter_by(role_id=having_role.id)
return q.order_by(User.nom, User.user_name).all()
@cache.memoize(timeout=50) # seconds
def user_info(user_name_or_id=None, user: User = None):
"""Dict avec infos sur l'utilisateur (qui peut ne pas etre dans notre base).
Si user_name est specifie (string ou id), interroge la BD. Sinon, user doit etre une instance
de User.
"""
if user_name_or_id is not None:
if isinstance(user_name_or_id, int):
u = User.query.filter_by(id=user_name_or_id).first()
else:
u = User.query.filter_by(user_name=user_name_or_id).first()
if u:
user_name = u.user_name
info = u.to_dict()
else:
info = None
user_name = "inconnu"
else:
info = user.to_dict()
user_name = user.user_name
if not info:
# special case: user is not in our database
return {
"user_name": user_name,
"nom": user_name,
"prenom": "",
"email": "",
"dept": "",
"nomprenom": user_name,
"prenomnom": user_name,
"prenom_fmt": "",
"nom_fmt": user_name,
"nomcomplet": user_name,
"nomplogin": user_name,
# "nomnoacc": scu.suppress_accents(user_name),
"passwd_temp": 0,
"status": "",
"date_expiration": None,
}
else:
# Ensure we never publish password hash
if "password_hash" in info:
del info["password_hash"]
return info
MSG_OPT = """<br>Attention: (vous pouvez forcer l'opération en cochant "<em>Ignorer les avertissements</em>" en bas de page)"""
def check_modif_user(
edit: bool,
enforce_optionals: bool = False,
user_name: str = "",
nom: str = "",
prenom: str = "",
email: str = "",
dept: str = "",
roles: list = None,
cas_id: str = None,
) -> tuple[bool, str]:
"""Vérifie que cet utilisateur peut être créé (edit=0) ou modifié (edit=1)
Cherche homonymes.
Ne vérifie PAS que l'on a la permission de faire la modif.
edit: si vrai, mode "edition" (modif d'un objet existant)
enforce_optionals: vérifie que les champs optionnels sont cohérents.
Returns (ok, msg)
- ok : si vrai, peut continuer avec ces parametres
(si ok est faux, l'utilisateur peut quand même forcer la creation)
- msg: message warning à presenter à l'utilisateur
"""
roles = roles or []
# ce login existe ?
user = User.query.filter_by(user_name=user_name).first()
if edit and not user: # safety net, le user_name ne devrait pas changer
return False, f"identifiant {user_name} inexistant"
if not edit and user:
return False, f"identifiant {user_name} déjà utilisé"
if not user_name or not nom or not prenom:
return False, "champ requis vide"
if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", user_name):
return (
False,
f"identifiant '{user_name}' invalide (pas d'accents ni de caractères spéciaux)",
)
if len(user_name) > USERNAME_STR_LEN:
return (
False,
f"identifiant '{user_name}' trop long ({USERNAME_STR_LEN} caractères)",
)
if len(nom) > USERNAME_STR_LEN:
return False, f"nom '{nom}' trop long ({USERNAME_STR_LEN} caractères)" + MSG_OPT
if len(prenom) > 64:
return (
False,
f"prenom '{prenom}' trop long ({USERNAME_STR_LEN} caractères)" + MSG_OPT,
)
# check that same user_name has not already been described in this import
if not email:
return False, "vous devriez indiquer le mail de l'utilisateur créé !"
if len(email) > 120:
return False, f"email '{email}' trop long (120 caractères)"
if not re.fullmatch(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", email):
return False, "l'adresse mail semble incorrecte"
# check département
if (
enforce_optionals
and dept
and Departement.query.filter_by(acronym=dept).first() is None
):
return False, f"département '{dept}' inexistant" + MSG_OPT
if enforce_optionals and not roles:
return False, "aucun rôle sélectionné, êtes vous sûr ?" + MSG_OPT
# Unicité du mail
users_with_this_mail = User.query.filter_by(email=email).all()
if edit: # modification
if email != user.email and len(users_with_this_mail) > 0:
return False, "un autre utilisateur existe déjà avec cette adresse mail"
else: # création utilisateur
if len(users_with_this_mail) > 0:
return False, "un autre utilisateur existe déjà avec cette adresse mail"
# Unicité du cas_id
if cas_id:
cas_users = User.query.filter_by(cas_id=str(cas_id)).all()
if edit:
if cas_users and (
len(cas_users) > 1 or cas_users[0].user_name != user_name
):
return (
False,
"un autre utilisateur existe déjà avec cet identifiant CAS",
)
elif cas_users:
return False, "un autre utilisateur existe déjà avec cet identifiant CAS"
# Des noms/prénoms semblables existent ?
nom = nom.lower().strip()
prenom = prenom.lower().strip()
similar_users = User.query.filter(
User.nom.ilike(nom), User.prenom.ilike(prenom)
).all()
if edit:
minmatch = 1
else:
minmatch = 0
if enforce_optionals and len(similar_users) > minmatch:
return (
False,
"des utilisateurs proches existent: "
+ ", ".join(
[
"%s %s (pseudo=%s)" % (x.prenom, x.nom, x.user_name)
for x in similar_users
]
)
+ MSG_OPT,
)
# Roles ?
return True, ""