From 96788d588e5d6658444cab1f52033b7d3d251564 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 22 Aug 2021 13:24:36 +0200 Subject: [PATCH] gestion responsables de semestres --- app/scodoc/sco_excel.py | 26 ++++-- app/scodoc/sco_import_users.py | 146 ++++++++++++++++++++++++--------- app/scodoc/sco_users.py | 13 ++- app/views/users.py | 91 ++++++++++++++++++-- 4 files changed, 224 insertions(+), 52 deletions(-) diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index d04a1fb61..9da67ec01 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -38,6 +38,7 @@ import openpyxl.utils.datetime from openpyxl import Workbook, load_workbook from openpyxl.cell import WriteOnlyCell from openpyxl.styles import Font, Border, Side, Alignment, PatternFill +from openpyxl.comments import Comment import app.scodoc.sco_utils as scu from app.scodoc import notesdb @@ -257,7 +258,7 @@ class ScoExcelSheet: """ self.ws.column_dimensions[cle].hidden = value - def make_cell(self, value: any = None, style=None): + def make_cell(self, value: any = None, style=None, comment=None): """Construit une cellule. value -- contenu de la cellule (texte ou numérique) style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié @@ -277,10 +278,20 @@ class ScoExcelSheet: cell.fill = style["fill"] if "alignment" in style: cell.alignment = style["alignment"] + if not comment is None: + cell.comment = Comment(comment, "scodoc") + cell.comment.width = 400 + cell.comment.height = 150 return cell - def make_row(self, values: list, style=None): - return [self.make_cell(value, style) for value in values] + def make_row(self, values: list, style=None, comments=None): + # TODO make possible differents styles in a row + if comments is None: + comments = [None] * len(values) + return [ + self.make_cell(value, style, comment) + for value, comment in zip(values, comments) + ] def append_single_cell_row(self, value: any, style=None): """construit une ligne composée d'une seule cellule et l'ajoute à la feuille. @@ -367,7 +378,7 @@ class ScoExcelSheet: def excel_simple_table( - titles=None, lines=None, sheet_name=b"feuille", titles_styles=None + titles=None, lines=None, sheet_name=b"feuille", titles_styles=None, comments=None ): """Export simple type 'CSV': 1ere ligne en gras, le reste tel quel""" ws = ScoExcelSheet(sheet_name) @@ -378,9 +389,14 @@ def excel_simple_table( if titles_styles is None: style = excel_make_style(bold=True) titles_styles = [style] * len(titles) + if comments is None: + comments = [None] * len(titles) # ligne de titres ws.append_row( - [ws.make_cell(it, style) for (it, style) in zip(titles, titles_styles)] + [ + ws.make_cell(it, style, comment) + for (it, style, comment) in zip(titles, titles_styles, comments) + ] ) default_style = excel_make_style() text_style = excel_make_style(format_number="@") diff --git a/app/scodoc/sco_import_users.py b/app/scodoc/sco_import_users.py index 5280559ee..655a16c2b 100644 --- a/app/scodoc/sco_import_users.py +++ b/app/scodoc/sco_import_users.py @@ -28,11 +28,13 @@ """Import d'utilisateurs via fichier Excel """ import random, time +import re from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.header import Header +from app import db, Departement from app.scodoc import sco_emails import app.scodoc.sco_utils as scu from app.scodoc.notes_log import log @@ -41,38 +43,61 @@ from app.scodoc import sco_excel from app.scodoc import sco_preferences from app.scodoc import sco_users +from flask import g +from flask_login import current_user +from app.auth.models import User, UserRole + TITLES = ("user_name", "nom", "prenom", "email", "roles", "dept") +COMMENTS = ( + """user_name: + Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _ + """, + """nom: + Maximum 64 caractères""", + """prenom: + Maximum 64 caractères""", + """email: + Maximum 120 caractères""", + """roles: + un plusieurs rôles séparés par ',' + chaque role est fait de 2 composantes séparées par _: + 1. Le role (Ens, Secr ou Admin) + 2. Le département (en majuscule) + Exemple: "Ens_RT,Admin_INFO" + """, + """dept: + Le département d'appartenance du l'utillsateur. Laisser vide si l'utilisateur intervient dans plusieurs dépatements + """, +) def generate_excel_sample(): """generates an excel document suitable to import users""" style = sco_excel.excel_make_style(bold=True) titles = TITLES - titlesStyles = [style] * len(titles) + titles_styles = [style] * len(titles) return sco_excel.excel_simple_table( - titles=titles, titlesStyles=titlesStyles, sheet_name="Utilisateurs ScoDoc" + titles=titles, + titles_styles=titles_styles, + sheet_name="Utilisateurs ScoDoc", + comments=COMMENTS, ) -def import_excel_file(datafile, REQUEST=None, context=None): +def import_excel_file(datafile): "Create users from Excel file" - authuser = REQUEST.AUTHENTICATED_USER - auth_name = str(authuser) - authuser_info = context._user_list(args={"user_name": auth_name}) - zope_roles = authuser.getRolesInContext(context) - if not authuser_info and not ("Manager" in zope_roles): - # not admin, and not in database - raise AccessDenied("invalid user (%s)" % auth_name) - if authuser_info: - auth_dept = authuser_info[0]["dept"] - else: - auth_dept = "" + # Check current user privilege + auth_dept = current_user.dept + auth_name = str(current_user) + if not current_user.is_administrator(): + raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) + # Récupération des informations sur l'utilisateur courant log("sco_import_users.import_excel_file by %s" % auth_name) exceldata = datafile.read() if not exceldata: raise ScoValueError("Ficher excel vide ou invalide") - _, data = sco_excel.Excel_to_list(exceldata) + _, data = sco_excel.excel_bytes_to_list(exceldata) if not data: # probably a bug raise ScoException("import_excel_file: empty file !") # 1- --- check title line @@ -99,10 +124,10 @@ def import_excel_file(datafile, REQUEST=None, context=None): d[fs[i]] = line[i] U.append(d) - return import_users(U, auth_dept=auth_dept, context=context) + return import_users(U, auth_dept=auth_dept) -def import_users(U, auth_dept="", context=None): +def import_users(users, auth_dept=""): """Import des utilisateurs: Pour chaque utilisateur à créer: - vérifier données @@ -112,9 +137,17 @@ def import_users(U, auth_dept="", context=None): En cas d'erreur: supprimer tous les utilisateurs que l'on vient de créer. """ + + def append_msg(msg): + msg_list.append("Ligne %s : %s" % (line, msg)) + created = [] # liste de uid créés + msg_list = [] + line = 1 # satr from excel line #2 + ok = True try: - for u in U: + for u in users: + line = line + 1 ok, msg = sco_users.check_modif_user( 0, user_name=u["user_name"], @@ -124,27 +157,60 @@ def import_users(U, auth_dept="", context=None): roles=u["roles"], ) if not ok: - raise ScoValueError( - "données invalides pour %s: %s" % (u["user_name"], msg) - ) + append_msg("identifiant '%s' %s" % (u["user_name"], msg)) + # raise ScoValueError( + # "données invalides pour %s: %s" % (u["user_name"], msg) + # ) u["passwd"] = generate_password() - # si auth_dept, crée tous les utilisateurs dans ce departement - if auth_dept: - u["dept"] = auth_dept # - context.create_user(u.copy()) - created.append(u["user_name"]) - except: - log("import_users: exception: deleting %s" % str(created)) - # delete created users - for user_name in created: - context._user_delete(user_name) - raise # re-raise exception + # check identifiant + if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", u["user_name"]): + ok = False + append_msg( + "identifiant '%s' invalide (pas d'accents ni de caractères spéciaux)" + % u["user_name"] + ) + elif len(u["user_name"]) > 64: + ok = False + append_msg( + "identifiant '%s' trop long (64 caractères)" % u["user_name"] + ) + if len(u["nom"]) > 64: + ok = False + append_msg("nom '%s' trop long (64 caractères)" % u["nom"]) + if len(u["prenom"]) > 64: + ok = False + append_msg("prenom '%s' trop long (64 caractères)" % u["prenom"]) + if len(u["email"]) > 120: + ok = False + append_msg("email '%s' trop long (120 caractères)" % u["email"]) + # check département + if u["dept"] != "": + dept = Departement.query.filter_by(acronym=u["dept"]).first() + if dept is None: + ok = False + append_msg("département '%s' inexistant" % u["dept"]) + for role in u["roles"].split(","): + try: + _, _ = UserRole.role_dept_from_string(role) + except ScoValueError as value_error: + ok = False + append_msg("role : %s " % role) + # Création de l'utilisateur (via SQLAlchemy) + if ok: + user = User() + user.from_dict(u, new_user=True) + db.session.add(user) + created.append(u["user_name"]) + db.session.commit() + except ScoValueError as value_error: + log("import_users: exception: abort create %s" % str(created)) + db.session.rollback() + raise ScoValueError(msg) # re-raise exception - for u in U: - mail_password(u, context=context) - - return "ok" + for user in users: + mail_password(user) + return ok, msg_list # --------- Génération du mot de passe initial ----------- @@ -173,7 +239,11 @@ def mail_password(u, context=None, reset=False): if not u["email"]: return - u["url"] = scu.ScoURL() + u[ + "url" + ] = ( + scu.ScoURL() + ) # TODO set auth page URL ? (shared by all departments) ../auth/login txt = ( """ @@ -230,4 +300,4 @@ Pour plus d'informations sur ce logiciel, voir %s msg.epilogue = "" txt = MIMEText(txt, "plain", scu.SCO_ENCODING) msg.attach(txt) - sco_emails.sendEmail(msg) + # sco_emails.sendEmail(msg) # TODO ScoDoc9 pending function diff --git a/app/scodoc/sco_users.py b/app/scodoc/sco_users.py index 0068d14ab..65bdfe238 100644 --- a/app/scodoc/sco_users.py +++ b/app/scodoc/sco_users.py @@ -94,11 +94,16 @@ def index_html(REQUEST, all_depts=False, with_inactives=False, format="html"): url_for("users.create_user_form", scodoc_dept=g.scodoc_dept) ) ) - H.append( - '   Importer des utilisateurs

'.format( - url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) + if current_user.is_administrator(): + H.append( + '   Importer des utilisateurs

'.format( + url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) + ) + ) + else: + H.append( + "   Pour importer des utilisateurs en masse (via xlsx file) contactez votre administrateur scodoc." ) - ) if all_depts: checked = "checked" else: diff --git a/app/views/users.py b/app/views/users.py index 7238da64b..983ab5a4c 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -38,7 +38,8 @@ import re from xml.etree import ElementTree import flask -from flask import g +from flask import g, url_for + from flask_login import current_user from app import db @@ -54,7 +55,7 @@ from app.decorators import ( permission_required, ) -from app.scodoc import html_sco_header +from app.scodoc import html_sco_header, sco_import_users, sco_excel from app.scodoc import sco_users from app.scodoc import sco_utils as scu from app.scodoc import sco_xml @@ -62,6 +63,8 @@ from app.scodoc.notes_log import log from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_permissions_check import can_handle_passwd from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message +from app.scodoc.sco_excel import send_excel_file +from app.scodoc.sco_import_users import generate_excel_sample from app.views import users_bp as bp @@ -459,9 +462,87 @@ def create_user_form(REQUEST, user_name=None, edit=0): ) -@bp.route("/import_users_form") -def import_users_form(): - raise NotImplementedError() +@bp.route("/import_users_generate_excel_sample") +@scodoc +@permission_required(Permission.ScoUsersAdmin) +@scodoc7func +def import_users_generate_excel_sample(REQUEST): + "une feuille excel pour importation utilisateurs" + data = sco_import_users.generate_excel_sample() + return sco_excel.send_excel_file(REQUEST, data, "ImportUtilisateurs") + + +@bp.route("/import_users_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoUsersAdmin) +@scodoc7func +def import_users_form(REQUEST=None): + """Import utilisateurs depuis feuille Excel""" + head = html_sco_header.sco_header(page_title="Import utilisateurs") + H = [ + head, + """

Téléchargement d'une nouvelle liste d'utilisateurs

+

A utiliser pour importer de nouveaux utilisateurs (enseignants ou secrétaires) +

+

+ L'opération se déroule en deux étapes. Dans un premier temps, + vous téléchargez une feuille Excel type. Vous devez remplir + cette feuille, une ligne décrivant chaque utilisateur. Ensuite, + vous indiquez le nom de votre fichier dans la case "Fichier Excel" + ci-dessous, et cliquez sur "Télécharger" pour envoyer au serveur + votre liste. +

+ """, + ] + help = """

+ Lors de la creation des utilisateurs, les opérations suivantes sont effectuées: +

+
    +
  1. vérification des données;
  2. +
  3. génération d'un mot de passe alétoire pour chaque utilisateur;
  4. +
  5. création de chaque utilisateur;
  6. +
  7. envoi à chaque utilisateur de son mot de passe initial par mail.
  8. +
""" + H.append( + """
  1. + Obtenir la feuille excel à remplir
  2. """ + ) + F = html_sco_header.sco_footer() + tf = TrivialFormulator( + REQUEST.URL0, + REQUEST.form, + ( + ( + "xlsfile", + {"title": "Fichier Excel:", "input_type": "file", "size": 40}, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ), + submitlabel="Télécharger", + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + "
" + help + F + elif tf[0] == -1: + return flask.redirect(back_url) + else: + # IMPORT + ok, diag = sco_import_users.import_excel_file(tf[2]["xlsfile"]) + # TODO Afficher la liste des messages + H = [html_sco_header.sco_header(page_title="Import utilisateurs")] + H.append("") + if ok: + dest = url_for("users.index_html", scodoc_dept=g.scodoc_dept) + H.append("

Ok, Import terminé !

") + H.append('

Continuer

' % dest) + else: + dest = url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) + H.append("

Erreur, importation annulée !

") + H.append('

Continuer

' % dest) + return "\n".join(H) + html_sco_header.sco_footer() + return "\n".join(H) + help + F @bp.route("/user_info_page")