forked from ScoDoc/ScoDoc
496 lines
17 KiB
Python
496 lines
17 KiB
Python
# -*- mode: python -*-
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##############################################################################
|
|
#
|
|
# Gestion scolarite IUT
|
|
#
|
|
# 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
|
|
"""
|
|
|
|
# Anciennement ZScoUsers.py, fonctions de gestion des données réécrites avec flask/SQLAlchemy
|
|
import datetime
|
|
import re
|
|
|
|
from flask import url_for, g, render_template, request
|
|
from flask_login import current_user
|
|
|
|
|
|
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
|
|
from app import cache
|
|
|
|
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)
|
|
with_inactives = int(with_inactives)
|
|
|
|
H = ["<h1>Gestion des utilisateurs</h1>"]
|
|
|
|
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""" <a href="{url_for("users.import_users_form",
|
|
scodoc_dept=g.scodoc_dept)
|
|
}" class="stdlink">Importer des utilisateurs</a></p>"""
|
|
)
|
|
|
|
else:
|
|
H.append(
|
|
""" 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">
|
|
<input type="checkbox" name="all_depts" value="1" onchange="document.f.submit();"
|
|
{"checked" if all_depts else ""}>Tous les départements</input>
|
|
<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>
|
|
|
|
</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),
|
|
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"
|
|
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"
|
|
)
|
|
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",
|
|
]
|
|
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>
|
|
<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=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)
|
|
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()
|
|
|
|
|
|
@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:
|
|
if user is None: # utilisateur supprimé (rare)
|
|
user_name = "inconnu !"
|
|
info = None
|
|
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,
|
|
}
|
|
# 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, ""
|