MonScoDocEssai/app/scodoc/sco_import_users.py

344 lines
11 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
#
##############################################################################
"""Import d'utilisateurs via fichier Excel
"""
import random
import time
from flask import url_for
from flask_login import current_user
from app import db
from app import email
from app.auth.models import User, UserRole
import app.scodoc.sco_utils as scu
from app import log
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc import sco_excel
from app.scodoc import sco_users
TITLES = (
"user_name",
"nom",
"prenom",
"email",
"roles",
"dept",
"cas_id",
"cas_allow_login",
"cas_allow_scodoc_login",
"email_institutionnel",
)
COMMENTS = (
"""user_name:
L'identifiant (login).
Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _
""",
"""nom:
Maximum 64 caractères.""",
"""prenom:
Maximum 64 caractères.""",
"""email:
L'adresse mail utilisée en priorité par ScoDoc pour contacter l'utilisateur.
Maximum 120 caractères.""",
"""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)
2. Le département (en majuscule)
Exemple: "Ens_RT,Admin_INFO".
""",
"""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 obligatoire (optionnel, faux par défaut)
""",
"""email_institutionnel
optionnel, le mail officiel de l'utilisateur.
Maximum 120 caractères.""",
)
def generate_excel_sample():
"""generates an excel document suitable to import users"""
style = sco_excel.excel_make_style(bold=True)
titles = TITLES
titles_styles = [style] * len(titles)
return sco_excel.excel_simple_table(
titles=titles,
titles_styles=titles_styles,
sheet_name="Utilisateurs ScoDoc",
comments=COMMENTS,
)
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
exceldata = datafile.read()
if not exceldata:
raise ScoValueError("Ficher excel vide ou invalide")
_, data = sco_excel.excel_bytes_to_list(exceldata)
if not data:
raise ScoValueError("Le fichier xlsx attendu semble vide !")
# 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)))
# check cols
cols = {}.fromkeys(titles)
unknown = []
for tit in xls_titles:
if tit not in cols:
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})
"""
)
# ok, same titles... : build the list of dictionaries
users = []
for line in data[1:]:
d = {}
for i, field in enumerate(xls_titles):
d[field] = line[i]
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)
return import_users(users=users, force=force)
def import_users(users, force="") -> tuple[bool, list[str], int]:
"""
Import users from a list of users_descriptors.
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
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
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.
"""
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"],
roles=[r for r in u["roles"].split(",") if r],
dept=u["dept"],
cas_id=u["cas_id"],
)
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:
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:
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)
user = User()
user.from_dict(u, new_user=True)
db.session.add(user)
db.session.commit()
mail_password(u)
else:
created = {} # reset # of created users to 0
return import_ok, msg_list, len(created)
# --------- 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
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:
"Send password by email"
if not user["email"]:
return
user["url"] = url_for("scodoc.index", _external=True)
txt = (
"""
Bonjour %(prenom)s %(nom)s,
"""
% user
)
if reset:
txt += (
"""
votre mot de passe ScoDoc a été ré-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
sur %(url)s
"""
% user
)
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).
"""
% user
)
txt += (
"""
_______
ScoDoc est un logiciel libre développé par Emmanuel Viennet et l'association ScoDoc.
Pour plus d'informations sur ce logiciel, voir %s
"""
% scu.SCO_WEBSITE
)
if reset:
subject = "Mot de passe ScoDoc"
else:
subject = "Votre accès ScoDoc"
email.send_email(subject, email.get_from_addr(), [user["email"]], txt)