ScoDoc/app/scodoc/sco_import_users.py

344 lines
11 KiB
Python
Raw Normal View History

2020-09-26 16:19:37 +02:00
# -*- 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.
2020-09-26 16:19:37 +02:00
#
# 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
#
##############################################################################
"""Import d'utilisateurs via fichier Excel
"""
import random
import time
2021-07-19 19:53:01 +02:00
from flask import url_for
from flask_login import current_user
2020-09-26 16:19:37 +02:00
from app import db
from app import email
from app.auth.models import User, UserRole
import app.scodoc.sco_utils as scu
2021-08-29 19:57:32 +02:00
from app import log
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc import sco_excel
2021-07-03 16:19:42 +02:00
from app.scodoc import sco_users
2020-09-26 16:19:37 +02:00
2021-08-26 23:43:54 +02:00
TITLES = (
"user_name",
"nom",
"prenom",
"email",
"roles",
"dept",
"cas_id",
"cas_allow_login",
"cas_allow_scodoc_login",
2023-03-14 16:30:27 +01:00
"email_institutionnel",
)
2021-08-22 13:24:36 +02:00
COMMENTS = (
"""user_name:
L'identifiant (login).
2021-08-22 13:24:36 +02:00
Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _
""",
"""nom:
Maximum 64 caractères.""",
2021-08-22 13:24:36 +02:00
"""prenom:
Maximum 64 caractères.""",
2021-08-22 13:24:36 +02:00
"""email:
2023-03-14 16:30:27 +01:00
L'adresse mail utilisée en priorité par ScoDoc pour contacter l'utilisateur.
Maximum 120 caractères.""",
2021-08-22 13:24:36 +02:00
"""roles:
un plusieurs rôles séparés par ','
chaque rôle est fait de 2 composantes séparées par _:
1. Le rôle (Ens, Secr ou Admin)
2021-08-22 13:24:36 +02:00
2. Le département (en majuscule)
Exemple: "Ens_RT,Admin_INFO".
2021-08-22 13:24:36 +02:00
""",
"""dept:
Le département d'appartenance de l'utilisateur. Laisser vide si l'utilisateur
intervient dans plusieurs départements.
""",
"""cas_id:
identifiant de l'utilisateur sur CAS (optionnel).
""",
"""cas_allow_login:
autorise la connexion via CAS (optionnel, faux par défaut)
""",
"""cas_allow_scodoc_login
autorise connexion via ScoDoc même si CAS activé (optionnel, faux par défaut)
2021-08-22 13:24:36 +02:00
""",
2023-03-14 16:30:27 +01:00
"""email_institutionnel
optionnel, le mail officiel de l'utilisateur.
Maximum 120 caractères.""",
2021-08-22 13:24:36 +02:00
)
2020-09-26 16:19:37 +02:00
def generate_excel_sample():
2020-12-15 08:48:29 +01:00
"""generates an excel document suitable to import users"""
2021-08-12 14:55:25 +02:00
style = sco_excel.excel_make_style(bold=True)
2020-09-26 16:19:37 +02:00
titles = TITLES
2021-08-22 13:24:36 +02:00
titles_styles = [style] * len(titles)
2021-08-12 14:49:53 +02:00
return sco_excel.excel_simple_table(
2021-08-22 13:24:36 +02:00
titles=titles,
titles_styles=titles_styles,
sheet_name="Utilisateurs ScoDoc",
comments=COMMENTS,
2020-09-26 16:19:37 +02:00
)
def read_users_excel_file(datafile, titles=TITLES) -> list[dict]:
"""Lit le fichier excel et renvoie liste de dict
titles : titres (ids) des colonnes attendues
"""
# Read the data from the stream
2020-09-26 16:19:37 +02:00
exceldata = datafile.read()
if not exceldata:
raise ScoValueError("Ficher excel vide ou invalide")
2021-08-22 13:24:36 +02:00
_, data = sco_excel.excel_bytes_to_list(exceldata)
if not data:
raise ScoValueError("Le fichier xlsx attendu semble vide !")
2020-09-26 16:19:37 +02:00
# 1- --- check title line
xls_titles = [scu.stripquotes(s).lower() for s in data[0]]
# log("excel: fs='%s'\ndata=%s" % (str(fs), str(data)))
2020-09-26 16:19:37 +02:00
# check cols
cols = {}.fromkeys(titles)
2020-09-26 16:19:37 +02:00
unknown = []
for tit in xls_titles:
2021-07-09 17:47:06 +02:00
if tit not in cols:
2020-09-26 16:19:37 +02:00
unknown.append(tit)
else:
del cols[tit]
if cols or unknown:
raise ScoValueError(
f"""colonnes incorrectes (on attend {len(titles)}, et non {len(xls_titles)})
<br>
(colonnes manquantes: {list(cols.keys())}, colonnes invalides: {unknown})
"""
2020-09-26 16:19:37 +02:00
)
# ok, same titles... : build the list of dictionaries
users = []
2020-09-26 16:19:37 +02:00
for line in data[1:]:
d = {}
for i, field in enumerate(xls_titles):
2024-06-14 20:15:20 +02:00
d[field] = (line[i] or "").strip()
users.append(d)
return users
def import_excel_file(datafile, force="") -> tuple[bool, list[str], int]:
"""
Import scodoc users from Excel file.
This method:
* checks that the current_user has the ability to do so (at the moment only a SuperAdmin).
He may thereoff import users with any well formed role into any department (or all)
* Once the check is done ans successfull, build the list of users (does not check the data)
* call :func:`import_users` to actually do the job
:param datafile: the stream to be imported
:return: same as import users
"""
if not current_user.is_administrator():
raise AccessDenied(f"invalid user ({current_user}) must be SuperAdmin")
log("sco_import_users.import_excel_file by {current_user}")
users = read_users_excel_file(datafile)
2020-09-26 16:19:37 +02:00
return import_users(users=users, force=force)
2020-09-26 16:19:37 +02:00
def import_users(users, force="") -> tuple[bool, list[str], int]:
2020-09-26 16:19:37 +02:00
"""
Import users from a list of users_descriptors.
2021-08-22 13:24:36 +02:00
descriptors are dictionaries hosting users's data.
The operation is atomic (all the users are imported or none)
:param users: list of descriptors to be imported
:return: a tuple that describe the result of the import:
* ok: import ok or aborted
* messages: the list of messages
* the # of users created
2022-07-04 17:23:51 +02:00
Implémentation:
Pour chaque utilisateur à créer:
* vérifier données (y compris que le même nom d'utilisateur n'est pas utilisé
plusieurs fois)
* générer mot de passe aléatoire
* créer utilisateur et mettre le mot de passe
* envoyer mot de passe par mail
2022-07-04 17:23:51 +02:00
Les utilisateurs à créer sont stockés dans un dictionnaire.
L'ajout effectif ne se fait qu'en fin de fonction si aucune erreur n'a été détectée.
"""
2021-08-22 13:24:36 +02:00
2022-07-04 17:23:51 +02:00
created = {} # uid créés
if len(users) == 0:
import_ok = False
msg_list = ["Feuille vide ou illisible"]
else:
msg_list = []
line = 1 # start from excel line #2
import_ok = True
def append_msg(msg):
msg_list.append(f"Ligne {line} : {msg}")
try:
for u in users:
line = line + 1
user_ok, msg = sco_users.check_modif_user(
0,
enforce_optionals=not force,
user_name=u["user_name"],
nom=u["nom"],
prenom=u["prenom"],
email=u["email"],
2021-10-13 15:03:41 +02:00
roles=[r for r in u["roles"].split(",") if r],
2021-10-10 10:52:06 +02:00
dept=u["dept"],
cas_id=u["cas_id"],
2021-08-22 13:24:36 +02:00
)
if not user_ok:
append_msg(f"""identifiant '{u["user_name"]}' {msg}""")
u["passwd"] = generate_password()
#
# check identifiant
if u["user_name"] in created.keys():
user_ok = False
append_msg(
f"""l'utilisateur '{u["user_name"]}' a déjà été décrit ligne {
created[u["user_name"]]["line"]}"""
)
# check roles / ignore whitespaces around roles / build roles_string
# roles_string (expected by User) appears as column 'roles' in excel file
roles_list = []
for role in u["roles"].split(","):
try:
2021-09-29 10:27:49 +02:00
role = role.strip()
if role:
_, _ = UserRole.role_dept_from_string(role)
roles_list.append(role)
except ScoValueError as value_error:
user_ok = False
append_msg(f"role {role} : {value_error}")
u["roles_string"] = ",".join(roles_list)
if user_ok:
u["line"] = line
created[u["user_name"]] = u
else:
import_ok = False
except ScoValueError as value_error:
2022-07-04 17:23:51 +02:00
log(f"import_users: exception: abort create {str(created.keys())}")
raise ScoValueError(msg) from value_error
if import_ok:
for u in created.values():
# Création de l'utilisateur (via SQLAlchemy)
2023-11-22 17:55:15 +01:00
user = User(user_name=u["user_name"])
user.from_dict(u, new_user=True)
db.session.add(user)
db.session.commit()
mail_password(u)
else:
2022-07-04 17:23:51 +02:00
created = {} # reset # of created users to 0
return import_ok, msg_list, len(created)
2020-09-26 16:19:37 +02:00
# --------- Génération du mot de passe initial -----------
# Adapté de http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/440564
# Alphabet tres simple pour des mots de passe simples...
ALPHABET = r"""ABCDEFGHIJKLMNPQRSTUVWXYZ123456789123456789AEIOU"""
PASSLEN = 8
2020-09-26 16:19:37 +02:00
RNG = random.Random(time.time())
def generate_password():
"""This function creates a pseudo random number generator object, seeded with
the cryptographic hash of the passString. The contents of the character set
is then shuffled and a selection of passLength words is made from this list.
This selection is returned as the generated password."""
l = list(ALPHABET) # make this mutable so that we can shuffle the characters
RNG.shuffle(l) # shuffle the character set
# pick up only a subset from the available characters:
return "".join(RNG.sample(l, PASSLEN))
def mail_password(user: dict, reset=False) -> None:
2020-09-26 16:19:37 +02:00
"Send password by email"
if not user["email"]:
2020-09-26 16:19:37 +02:00
return
user["url"] = url_for("scodoc.index", _external=True)
2020-09-26 16:19:37 +02:00
txt = (
"""
Bonjour %(prenom)s %(nom)s,
"""
% user
2020-09-26 16:19:37 +02:00
)
if reset:
txt += (
"""
votre mot de passe ScoDoc a été -initialisé.
Le nouveau mot de passe est: %(passwd)s
Votre nom d'utilisateur est %(user_name)s
Vous devrez changer ce mot de passe lors de votre première connexion
2020-09-26 16:19:37 +02:00
sur %(url)s
"""
% user
2020-09-26 16:19:37 +02:00
)
else:
txt += (
"""
vous avez été déclaré comme utilisateur du logiciel de gestion de scolarité ScoDoc.
Votre nom d'utilisateur est %(user_name)s
Votre mot de passe est: %(passwd)s
Le logiciel est accessible sur: %(url)s
Vous êtes invité à changer ce mot de passe au plus vite (cliquez sur votre nom en haut à gauche de la page d'accueil).
2020-09-26 16:19:37 +02:00
"""
% user
2020-09-26 16:19:37 +02:00
)
txt += (
"""
_______
ScoDoc est un logiciel libre développé par Emmanuel Viennet et l'association ScoDoc.
2020-09-26 16:19:37 +02:00
Pour plus d'informations sur ce logiciel, voir %s
"""
2021-02-04 20:02:44 +01:00
% scu.SCO_WEBSITE
2020-09-26 16:19:37 +02:00
)
2020-09-26 16:19:37 +02:00
if reset:
2021-08-26 23:43:54 +02:00
subject = "Mot de passe ScoDoc"
2020-09-26 16:19:37 +02:00
else:
2021-08-26 23:43:54 +02:00
subject = "Votre accès ScoDoc"
email.send_email(subject, email.get_from_addr(), [user["email"]], txt)