# -*- 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 = ["

Gestion des utilisateurs

"] if current_user.has_permission(Permission.UsersAdmin, g.scodoc_dept): H.append( f"""

Ajouter un utilisateur""" ) if current_user.is_administrator(): H.append( f"""   Importer des utilisateurs

""" ) else: H.append( """   Pour importer des utilisateurs en masse (via fichier xlsx) contactez votre administrateur scodoc.""" ) menu_roles = "\n".join( f"""""" for r in Role.query.order_by(Role.name) ) H.append( f"""
Tous les départements Avec anciens utilisateurs Sépare les rôles
""" ) 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) else: d["date_modif_passwd"] = "(non visible)" d["non_migre"] = "" 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", "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 can_modify: # Si l'utilisateur est admin local (can_modify) 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", "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"""

{len(rows)} utilisateurs {comm}

Cliquer sur un nom pour changer son mot de passe

""", 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: 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 = """
Attention: (vous pouvez forcer l'opération en cochant "Ignorer les avertissements" 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, ""