ScoDoc/app/scodoc/sco_users.py

496 lines
17 KiB
Python
Raw Normal View History

# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
2023-12-31 23:04:06 +01:00
# 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
#
##############################################################################
"""Fonctions sur les utilisateurs
"""
2023-02-26 21:24:07 +01:00
# Anciennement ZScoUsers.py, fonctions de gestion des données réécrites avec flask/SQLAlchemy
import datetime
2021-10-10 09:26:46 +02:00
import re
from flask import url_for, g, render_template, request
from flask_login import current_user
2024-06-14 20:15:20 +02:00
from app import Departement
from app.auth.models import Permission, Role, User, UserRole
from app.models import ScoDocSiteConfig, USERNAME_STR_LEN
from app.scodoc import sco_preferences
from app.scodoc.gen_tables import GenTable
2023-02-26 21:24:07 +01:00
from app import cache
2023-02-26 21:24:07 +01:00
from app.scodoc.sco_exceptions import ScoValueError
def index_html(
all_depts=False,
having_role_name: str = "",
with_inactives=False,
detail_roles=False,
fmt="html",
):
"gestion utilisateurs..."
all_depts = int(all_depts)
2021-06-27 12:11:39 +02:00
with_inactives = int(with_inactives)
H = ["<h1>Gestion des utilisateurs</h1>"]
if current_user.has_permission(Permission.UsersAdmin, g.scodoc_dept):
H.append(
2023-02-26 21:24:07 +01:00
f"""<p><a href="{url_for("users.create_user_form",
scodoc_dept=g.scodoc_dept)
}" class="stdlink">Ajouter un utilisateur</a>"""
)
2021-08-22 13:24:36 +02:00
if current_user.is_administrator():
H.append(
f"""&nbsp;&nbsp; <a href="{url_for("users.import_users_form",
2023-02-26 21:24:07 +01:00
scodoc_dept=g.scodoc_dept)
}" class="stdlink">Importer des utilisateurs</a></p>"""
2021-08-22 13:24:36 +02:00
)
2023-02-26 21:24:07 +01:00
2021-08-22 13:24:36 +02:00
else:
H.append(
2023-02-26 21:24:07 +01:00
"""&nbsp;&nbsp; Pour importer des utilisateurs en masse (via fichier xlsx)
contactez votre administrateur scodoc."""
)
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">
2023-02-26 21:24:07 +01:00
<input type="checkbox" name="all_depts" value="1" onchange="document.f.submit();"
{"checked" if all_depts else ""}>Tous les départements</input>
2023-02-26 21:24:07 +01:00
<input type="checkbox" name="with_inactives" value="1" onchange="document.f.submit();"
{"checked" if with_inactives else ""}>Avec anciens utilisateurs</input>
<input type="checkbox" name="detail_roles" value="1" onchange="document.f.submit();"
{"checked" if detail_roles else ""}>Sépare les rôles</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>
2023-11-21 22:28:50 +01:00
</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,
2021-06-27 12:11:39 +02:00
with_inactives=with_inactives,
with_links=current_user.has_permission(Permission.UsersAdmin, g.scodoc_dept),
detail_roles=detail_roles,
)
if fmt != "html":
return content
H.append(content)
return render_template(
"sco_page_dept.j2", content="\n".join(H), title="Gestion des utilisateurs"
)
def list_users(
dept,
all_depts=False, # tous les departements
fmt="html",
having_role: Role = None,
with_inactives=False,
with_links=True,
detail_roles=False,
):
"""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.
Si detail_roles, affiche les rôles dans des colonnes séparées.
"""
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"
2021-06-27 12:11:39 +02:00
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(
2021-06-27 12:11:39 +02:00
"users.user_info_page", scodoc_dept=dept, user_name=u.user_name
2021-07-27 16:55:50 +02:00
)
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"
)
if not current_user.is_administrator():
# si non super-admin, ne donne pas la date exacte de derniere connexion
d["last_seen"] = _approximate_date(u.last_seen)
d["passwd_must_be_changed"] = "OUI" if d["passwd_must_be_changed"] else ""
else:
d["date_modif_passwd"] = "(non visible)"
d["non_migre"] = ""
d["passwd_must_be_changed"] = ""
if detail_roles:
d["roles_set"] = {
f"{r.role.name or ''}_{r.dept or ''}" for r in u.user_roles
}
if detail_roles:
roles_set = set()
for d in rows:
roles_set.update(d["roles_set"])
roles_columns = sorted(roles_set)
for d in rows:
d.update({r: "X" if r in d["roles_set"] else "" for r in roles_columns})
columns_ids = [
"user_name",
"nom_fmt",
"prenom_fmt",
"email",
"dept",
"roles_string",
"date_expiration",
"date_modif_passwd",
"passwd_must_be_changed",
"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",
]
2024-09-12 14:42:23 +02:00
elif current_user.has_permission(Permission.UsersAdmin, g.scodoc_dept):
# Si l'utilisateur peut administrer des comptes mais pas super admin,
# indique une date de dernière connexion approchée (mois, année)
columns_ids.append("last_seen")
columns_ids += ["email_institutionnel", "edt_id"]
title = "Utilisateurs définis dans ScoDoc"
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",
"passwd_must_be_changed": "À changer",
"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",
}
if detail_roles:
columns_ids += roles_columns
titles.update({r: r for r in roles_columns})
tab = GenTable(
rows=rows,
columns_ids=columns_ids,
titles=titles,
caption=title,
page_title="title",
html_title=f"""<h2>{len(rows)} utilisateurs {comm}</h2>
2023-02-26 21:24:07 +01:00
<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,
2024-09-05 18:27:03 +02:00
base_url=f"""{request.base_url}?all_depts={
1 if all_depts else 0}&with_inactives={
1 if with_inactives else 0}&having_role_name={
having_role.name if having_role else ''}&detail_roles={
1 if detail_roles else 0}""",
pdf_link=False, # table is too wide to fit in a paper page => disable pdf
preferences=sco_preferences.SemPreferences(),
table_id="list-users",
)
return tab.make_page(fmt=fmt, with_html_headers=False)
def _approximate_date(date: datetime.datetime) -> str:
if not date:
return "jamais vu"
now = datetime.datetime.now()
# Calculate the difference in years and months
delta_years = now.year - date.year
delta_months = now.month - date.month
if delta_years == 0 and delta_months == 0:
return "ce mois"
if delta_years == 0 or (delta_years == 1 and delta_months < 0):
return "cette année"
if delta_years == 1:
return "l'an dernier"
if delta_years == 2:
return "il y a 2 ans"
return "pas vu depuis très lontemps"
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)
2021-06-27 12:11:39 +02:00
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.prenom, User.user_name).all()
2021-06-27 12:11:39 +02:00
2021-11-02 23:42:46 +01:00
@cache.memoize(timeout=50) # seconds
2022-04-05 22:23:55 +02:00
def user_info(user_name_or_id=None, user: User = None):
2021-06-28 10:45:00 +02:00
"""Dict avec infos sur l'utilisateur (qui peut ne pas etre dans notre base).
2021-08-10 12:57:38 +02:00
Si user_name est specifie (string ou id), interroge la BD. Sinon, user doit etre une instance
2021-07-21 15:53:15 +02:00
de User.
2021-06-27 12:11:39 +02:00
"""
2021-08-22 13:24:36 +02:00
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()
2021-08-10 12:57:38 +02:00
else:
2021-08-22 13:24:36 +02:00
u = User.query.filter_by(user_name=user_name_or_id).first()
2021-08-10 12:57:38 +02:00
if u:
2021-08-22 13:24:36 +02:00
user_name = u.user_name
2021-08-10 12:57:38 +02:00
info = u.to_dict()
else:
info = None
2021-09-11 15:59:06 +02:00
user_name = "inconnu"
2021-06-27 12:11:39 +02:00
else:
if user is None: # utilisateur supprimé (rare)
user_name = "inconnu !"
info = None
else:
info = user.to_dict()
user_name = user.user_name
2021-06-27 12:11:39 +02:00
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,
2021-07-04 12:32:13 +02:00
# "nomnoacc": scu.suppress_accents(user_name),
2021-06-27 12:11:39 +02:00
"passwd_temp": 0,
"status": "",
"date_expiration": None,
}
# Ensure we never publish password hash
if "password_hash" in info:
del info["password_hash"]
return info
2023-02-26 21:24:07 +01:00
MSG_OPT = """<br>Attention: (vous pouvez forcer l'opération en cochant "<em>Ignorer les avertissements</em>" en bas de page)"""
2021-10-10 10:52:06 +02:00
def check_modif_user(
edit: bool,
enforce_optionals: bool = False,
user_name: str = "",
nom: str = "",
prenom: str = "",
email: str = "",
dept: str = "",
2023-02-26 21:24:07 +01:00
roles: list = None,
cas_id: str = None,
) -> tuple[bool, str]:
2021-07-03 16:19:42 +02:00
"""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)
2021-07-03 16:19:42 +02:00
- 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
2021-07-03 16:19:42 +02:00
"""
2023-02-26 21:24:07 +01:00
roles = roles or []
2021-07-03 16:19:42 +02:00
# ce login existe ?
2023-02-26 21:24:07 +01:00
user = User.query.filter_by(user_name=user_name).first()
2021-07-21 15:53:15 +02:00
if edit and not user: # safety net, le user_name ne devrait pas changer
2023-02-26 21:24:07 +01:00
return False, f"identifiant {user_name} inexistant"
2021-07-21 15:53:15 +02:00
if not edit and user:
2023-02-26 21:24:07 +01:00
return False, f"identifiant {user_name} déjà utilisé"
2021-10-10 10:52:06 +02:00
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,
2023-02-26 21:24:07 +01:00
f"identifiant '{user_name}' invalide (pas d'accents ni de caractères spéciaux)",
2021-10-10 10:52:06 +02:00
)
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,
)
2023-02-26 21:24:07 +01:00
# check that same user_name has not already been described in this import
2021-10-10 10:52:06 +02:00
if not email:
return False, "vous devriez indiquer le mail de l'utilisateur créé !"
if len(email) > 120:
2023-02-26 21:24:07 +01:00
return False, f"email '{email}' trop long (120 caractères)"
2021-10-10 10:52:06 +02:00
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
2021-10-10 10:52:06 +02:00
and Departement.query.filter_by(acronym=dept).first() is None
):
2023-02-26 21:24:07 +01:00
return False, f"département '{dept}' inexistant" + MSG_OPT
if enforce_optionals and not roles:
2021-10-10 10:52:06 +02:00
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
2023-02-26 21:24:07 +01:00
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"
2023-02-26 21:24:07 +01:00
# Unicité du cas_id
if cas_id:
2023-05-12 12:59:23 +02:00
cas_users = User.query.filter_by(cas_id=str(cas_id)).all()
2023-02-26 21:24:07 +01:00
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"
2021-07-03 16:19:42 +02:00
# 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:
2021-07-03 16:19:42 +02:00
return (
False,
"des utilisateurs proches existent: "
+ ", ".join(
[
"%s %s (pseudo=%s)" % (x.prenom, x.nom, x.user_name)
for x in similar_users
]
2021-10-10 21:09:27 +02:00
)
+ MSG_OPT,
2021-07-03 16:19:42 +02:00
)
# Roles ?
return True, ""