diff --git a/src/imio/esign/browser/settings.py b/src/imio/esign/browser/settings.py index 53243c9..87bb98e 100644 --- a/src/imio/esign/browser/settings.py +++ b/src/imio/esign/browser/settings.py @@ -3,6 +3,8 @@ from imio.esign import _ from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper from plone.app.registry.browser.controlpanel import RegistryEditForm +from plone.app.z3cform.wysiwyg import WysiwygFieldWidget +from plone.autoform.directives import widget from plone.z3cform import layout from zope import schema from zope.interface import Interface @@ -76,9 +78,26 @@ class IImioEsignSettings(Interface): required=False, ) + parapheo_url = schema.TextLine( + title=_("Parapheo url"), + description=_("Used in signers email template."), + required=False, + ) + + widget("signing_users_email_content", WysiwygFieldWidget) + signing_users_email_content = schema.Text( + title=_("Email content model for signing users"), + description=_( + "Email content sent to users when inviting them to Parapheo. " + "TAL compliant with variables: view, context, user_data, parapheo_url, request and modules." + ), + required=False, + ) + max_session_size = schema.Int( title=_("Max session size (MB)"), - description=_("Maximum size of the session in megabytes. If the total size of files to be signed exceeds this limit, a new session will be created."), + description=_("Maximum size of the session in megabytes. If the total size of files to be signed exceeds this " + "limit, a new session will be created."), default=100, min=1, required=True, diff --git a/src/imio/esign/browser/templates/signing_users.pt b/src/imio/esign/browser/templates/signing_users.pt new file mode 100644 index 0000000..2805b43 --- /dev/null +++ b/src/imio/esign/browser/templates/signing_users.pt @@ -0,0 +1,410 @@ + + + + + +
+ Status message +
+ +
+ + +

Signing Users: Export CSV

+ + + +
+ Warning: Duplicate email addresses detected +
    + +
  • + Email: email@example.com - Users: user1, user2 +
  • +
    +
+
+
+ +
+
+ + + + + Selected: + 0 + / 0 + +
+ +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + User IDEmailLast NameFirst NameFull Name
+ + useridemail@example.comLastnameFirstnameFull Name
+
+ + +
+ +
+ + +
+
+
+ \ No newline at end of file diff --git a/src/imio/esign/browser/views.py b/src/imio/esign/browser/views.py index 1542a4a..a24056b 100644 --- a/src/imio/esign/browser/views.py +++ b/src/imio/esign/browser/views.py @@ -9,11 +9,15 @@ from imio.esign.browser.table import external_session_link from imio.esign.browser.table import SessionsTable from imio.esign.config import get_registry_enabled +from imio.esign.config import get_registry_parapheo_url +from imio.esign.config import get_registry_signing_users_email_content from imio.esign.utils import create_external_session from imio.esign.utils import get_session_annotation from imio.esign.utils import get_state_description from imio.esign.utils import remove_session from imio.helpers.content import uuidToObject +from imio.helpers.emailer import create_html_email +from imio.helpers.emailer import send_email from imio.helpers.security import separate_fullname from imio.prettylink.interfaces import IPrettyLink from imio.pyutils.utils import safe_encode @@ -22,13 +26,16 @@ from plone.app.layout.viewlets import ViewletBase from Products.CMFCore.utils import getToolByName from Products.Five import BrowserView +from Products.PageTemplates.Expressions import SecureModuleImporter from zope.browserpage.viewpagetemplatefile import ViewPageTemplateFile from zope.component import getMultiAdapter from zope.i18n import translate from zope.interface import implementer +from zope.pagetemplate.pagetemplate import PageTemplate from zope.publisher.interfaces import IPublishTraverse import csv +import json import os @@ -373,53 +380,61 @@ def html_message(self, message): """.format( - title=page_title, - heading=heading, - message=message + title=page_title, heading=heading, message=message ) return html class SigningUsersCsv(BrowserView): - """Get users, checking for duplicate emails, and output a CSV. + """Get users, checking for duplicate emails, output a CSV, and send emails. This view can be subclassed to redefine custom filtering logic. """ + index = ViewPageTemplateFile("templates/signing_users.pt") + def __call__(self): - fn_first = True - if self.request.get("fn_first", "1") == "0": - fn_first = False - apply_filter = self.request.get("apply_filter", "1") == "1" - if self.request.get("download", "") == "1": - return self._generate_csv(fn_first, apply_filter) - return self._generate_html(fn_first, apply_filter=apply_filter) + # Handle CSV download + if self.request.get("action") == "download_csv": + return self._download_csv() + + # Handle email sending + if self.request.get("action") == "send_emails": + return self._send_emails() + + # Default: display the table + return self.index() def filter_user(self, user_data): - """Filter method to determine if a user should be included in CSV output. + """Filter method to determine if a user should be included by default. :param user_data: dict containing user data (userid, email, lastname, firstname, fullname) - :return: True to include the user in CSV, False to exclude + :return: True to include the user by default, False to exclude """ return True - def _collect_users_data(self, fn_first): - """Get users and duplicates. + def get_users_data(self): + """Get all users data sorted by filter status then userid. - :param fn_first: Boolean indicating if firstname comes first - :return: (all_users_data, filtered_users_data, duplicates) + :return: list of user data dictionaries with 'checked' status """ + fn_first = True portal = api.portal.get() catalog = getToolByName(portal, "portal_catalog") acl_users = getToolByName(portal, "acl_users") - all_users_data = {} + all_users_data = [] email_registry = {} for user_info in acl_users.searchUsers(): userid = user_info.get("userid") - if not userid or userid in all_users_data: + if not userid: continue + + # Skip duplicates + if any(u["userid"] == userid for u in all_users_data): + continue + user_obj = api.user.get(userid=userid) if not user_obj: continue @@ -428,11 +443,8 @@ def _collect_users_data(self, fn_first): fullname = user_obj.getProperty("fullname", "") lastname = firstname = "" - # Do we have a person with this userid ? - brains = catalog.searchResults( - portal_type="person", - userid=userid - ) + # Do we have a person with this userid? + brains = catalog.searchResults(portal_type="person", userid=userid) if brains: person = brains[0].getObject() lastname = getattr(person, "lastname", "") or "" @@ -453,125 +465,190 @@ def _collect_users_data(self, fn_first): "firstname": firstname, "fullname": fullname, } - all_users_data[userid] = user_data + + # Determine if user should be checked by default + user_data["checked"] = self.filter_user(user_data) + + all_users_data.append(user_data) if email: email_registry.setdefault(email, []).append(userid) + # Calculate duplicates duplicates = {email: userids for email, userids in email_registry.items() if len(userids) > 1} - # Apply custom filter - filtered_users_data = {} + # Mark users with duplicate emails + for user_data in all_users_data: + user_data["has_duplicate_email"] = user_data["email"] in duplicates + + # Sort: filtered users first (checked=True), then by userid + all_users_data.sort(key=lambda x: (not x["checked"], x["userid"])) - for userid, user_data in all_users_data.items(): - if self.filter_user(user_data): - filtered_users_data[userid] = user_data + return all_users_data, duplicates - return all_users_data, filtered_users_data, duplicates + def _get_selected_userids(self): + """Get list of selected user IDs from request. - def _create_csv(self, users_data): + Expects a JSON-formatted list in 'selected_users' parameter. + Returns an empty list if the input is not valid JSON. + """ + selected = self.request.get("selected_users", "") + + if not selected: + return [] + + # Parse JSON array + try: + return json.loads(selected) + except (json.JSONDecodeError, ValueError, TypeError): + return [] + + def _download_csv(self): + """Generate and download CSV file with selected users.""" + selected_userids = self._get_selected_userids() + + if not selected_userids: + api.portal.show_message(_("No users selected for CSV download."), request=self.request, type="warning") + return self.request.RESPONSE.redirect(self.context.absolute_url() + "/@@signing-users-csv") + + all_users_data, __ = self.get_users_data() + + # Filter to only selected users + selected_users = [u for u in all_users_data if u["userid"] in selected_userids] + + # Generate CSV csv_output = StringIO() writer = csv.DictWriter( csv_output, fieldnames=["userid", "email", "lastname", "firstname", "fullname"], delimiter=",", - quoting=csv.QUOTE_MINIMAL + quoting=csv.QUOTE_MINIMAL, ) writer.writeheader() - for userid in users_data: - user_data = users_data[userid] - writer.writerow({ - "userid": safe_encode(userid), - "email": safe_encode(user_data["email"]), - "lastname": safe_encode(user_data["lastname"]), - "firstname": safe_encode(user_data["firstname"]), - "fullname": safe_encode(user_data["fullname"]), - }) - return csv_output.getvalue() - - def _generate_csv(self, fn_first, apply_filter=True): - """Generate csv file - - :param fn_first: Boolean indicating if firstname comes first - :param apply_filter: Boolean to apply or not the filter_user method - """ - all_users_data, filtered_users_data, duplicates = self._collect_users_data(fn_first) - users_data = filtered_users_data if apply_filter else all_users_data - output = self._create_csv(users_data) + for user_data in selected_users: + writer.writerow( + { + "userid": safe_encode(user_data["userid"]), + "email": safe_encode(user_data["email"]), + "lastname": safe_encode(user_data["lastname"]), + "firstname": safe_encode(user_data["firstname"]), + "fullname": safe_encode(user_data["fullname"]), + } + ) + + output = csv_output.getvalue() response = self.request.RESPONSE response.setHeader("Content-Type", "text/csv; charset=utf-8") - filename = "plone_users_list_filtered.csv" if apply_filter else "plone_users_list_all.csv" - response.setHeader("Content-Disposition", "attachment; filename={}".format(filename)) + response.setHeader("Content-Disposition", "attachment; filename=signing_users_selected.csv") return output - def _generate_html(self, fn_first, apply_filter=True): - """Generate html output with duplicates.""" - # Get all users and filtered users in one call - all_users_data, filtered_users_data, duplicates = self._collect_users_data(fn_first) - users_data = filtered_users_data if apply_filter else all_users_data - csv_text = self._create_csv(users_data) - - base_url = self.context.absolute_url() + "/@@signing-users-csv" - - html = [ - "", - "", - "", - "Liste des utilisateurs Plone", - "", - "", - "

Plone list users

", - "
", - "

Total users (all) : {}

".format(len(all_users_data)), - "

Total users (filtered) : {}

".format(len(filtered_users_data)), - "

Total duplicated emails : {}

".format(len(duplicates)), - "
", - ] - if duplicates: - html.append("
") - html.append("

⚠️ email duplicate

") - for email, userids in sorted(duplicates.items()): - html.append("
") - html.append("Email : {}
".format(safe_encode(email))) - html.append("Users : {}".format(", ".join([safe_encode(uid) for uid in userids]))) - html.append("
") - html.append("
") - else: - html.append("
") - html.append("

✓ No email duplicate

") - html.append("
") - - html.append("

Download CSV file

") - html.append("📥 Download CSV " - "(filtered)".format(base_url)) - html.append("📥 Download CSV " - "(all users)".format(base_url)) - html.append("

Overview of CSV file{}

".format(" (filtered)" if apply_filter else "")) - html.append("
{}
".format(csv_text.replace("<", "<").replace(">", ">"))) - html.append("") + def _send_emails(self): + """Send emails to selected users.""" + selected_userids = self._get_selected_userids() - response = self.request.RESPONSE - response.setHeader("Content-Type", "text/html; charset=utf-8") + if not selected_userids: + api.portal.show_message(_("No users selected for email sending."), request=self.request, type="warning") + return self.request.RESPONSE.redirect(self.context.absolute_url() + "/@@signing-users-csv") - return "\n".join(html) + email_content = get_registry_signing_users_email_content() + if not email_content: + api.portal.show_message( + _("Email content is not configured in the settings."), request=self.request, type="error" + ) + return self.request.RESPONSE.redirect(self.context.absolute_url() + "/@@signing-users-csv") + + all_users_data, _duplicates = self.get_users_data() + selected_users = [u for u in all_users_data if u["userid"] in selected_userids] + + portal_email = api.portal.get().getProperty("email_from_address") + if not portal_email: + api.portal.show_message(_("Portal from email is not configured."), request=self.request, type="error") + return self.request.RESPONSE.redirect(self.context.absolute_url() + "/@@mail-controlpanel") + + success_count = 0 + failed_count = 0 + for user_data in selected_users: + if not user_data["email"]: + failed_count += 1 + api.portal.show_message( + _( + "User ${userid} has no email address configured. Skipping.", + mapping={"userid": user_data["userid"]}, + ), + request=self.request, + type="warning", + ) + continue + + personalized_content = self._render_email_content(email_content, user_data) + # personalized_content = str(email_content).format(**user_data).replace("\n", "
\n") + + # Create and send email + try: + eml = create_html_email(personalized_content, with_plain=True) + subject = translate(_(u"You have been invited to Paraphéo"), context=self.request) + + status, error = send_email( + eml, subject=subject, mfrom=portal_email, mto=user_data["email"], immediate=False + ) + + if status: + success_count += 1 + else: + raise Exception(error) + except Exception as e: + failed_count += 1 + error = str(e) + api.portal.show_message( + _( + "Failed to send email to ${userid}.", + mapping={"userid": user_data["userid"]}, + ) + + " " + + error, + request=self.request, + type="error", + ) + continue + + if success_count > 0: + api.portal.show_message( + _("Emails sent successfully to ${count} users.", mapping={"count": success_count}), + request=self.request, + type="info", + ) + + if failed_count > 0: + api.portal.show_message( + _("Failed to send emails to ${count} users.", mapping={"count": failed_count}), + request=self.request, + type="warning", + ) + + return self.request.RESPONSE.redirect(self.context.absolute_url() + "/@@signing-users-csv") + + def _render_email_content(self, template, user_data): + """Render the email content template with user data. + + :param template: The email content template (TAL compliant) + :param user_data: dict containing user data (userid, email, lastname, firstname, fullname) + :return: Rendered email content as a string + """ + pt = PageTemplate() + pt.pt_source_file = lambda: "none" + pt.write(template) + namespace = pt.pt_getContext() + namespace.update( + { + "request": self.request, + "view": self, + "context": self.context, + "user_data": user_data, + "parapheo_url": get_registry_parapheo_url(), + "modules": SecureModuleImporter, + } + ) + return pt.pt_render(namespace) class EsignMacros(BrowserView): diff --git a/src/imio/esign/config.py b/src/imio/esign/config.py index 6cb785e..553c9aa 100644 --- a/src/imio/esign/config.py +++ b/src/imio/esign/config.py @@ -27,6 +27,14 @@ def get_registry_sign_code(default=""): return api.portal.get_registry_record("imio.esign.sign_code", default=default) +def get_registry_parapheo_url(default=""): + return api.portal.get_registry_record("imio.esign.parapheo_url", default=default) + + +def get_registry_signing_users_email_content(default=""): + return api.portal.get_registry_record("imio.esign.signing_users_email_content", default=default) + + def get_registry_max_session_size(default=100): return api.portal.get_registry_record("imio.esign.max_session_size", default=default) @@ -55,5 +63,58 @@ def set_registry_sign_code(value): api.portal.set_registry_record("imio.esign.sign_code", value) +def set_registry_parapheo_url(value): + api.portal.set_registry_record("imio.esign.parapheo_url", value) + + +def set_registry_signing_users_email_content(value): + api.portal.set_registry_record("imio.esign.signing_users_email_content", value) + + def set_registry_max_session_size(value): api.portal.set_registry_record("imio.esign.max_session_size", value) + + +SIGNERS_EMAIL_CONTENT = u""" + +

!! Attention: ne pas modifier ceci directement mais passer +par "Source" !!

+ +

Bonjour FULLNAME,

+ +

Vous avez été défini comme signataire dans une application IMIO (iA.Delib, iA.Docs, etc.). +
Avant de pouvoir signer des documents, vous devez activer votre compte auprès de Paraphéo.

+ +

Pour ce faire, il est nécessaire de s'y connecter une toute première fois en suivant ces étapes:

+ +
    +
  1. Vous rendre sur Paraphéo
  2. +
  3. Cliquer sur le mode de connexion "Portal d'authentification"
  4. +
  5. Entrer votre adresse email "EMAIL" et cliquer +sur "Connexion"
  6. +
+ +

Si vous n'avez jamais défini votre mot de passe dans Wallonie Connect ou que vous l'avez oublié:

+ +
    +
  1. Cliquer sur "Mot de passe oublié ?" (situé sous le champ "mot de passe")
  2. +
  3. Entrer à nouveau votre email et cliquer sur "Soumettre"
  4. +
  5. Consulter votre boîte mail qui doit contenir un mail intitulé "Réinitialiser le mot de passe" +(vérifier les spams au cas ou...)
  6. +
  7. Suivre les étapes pour configurer l'authentification par mobile
  8. +
+ +

En cas de besoin, n'hésitez pas à faire appel à votre référent interne Wallonie Connect !

+ +

Une fois votre mot de passe défini, vous pouvez poursuivre l'identification sur le site Paraphéo:

+ +
    +
  1. Si besoin, répéter les 3 premières étapes du premier paragraphe
  2. +
  3. Entrer votre adresse email et votre mot de passe et cliquer sur "Connexion"
  4. +
  5. Entrer votre "code à usage unique" depuis votre mobile et cliquer sur "Connexion"
  6. +
  7. Une fois connecté dans Paraphéo, votre compte est validé et prêt à être utilisé dans une session de signature.
  8. +
+ +

Cordialement +
L'équipe IMIO

+
""" diff --git a/src/imio/esign/setuphandlers.py b/src/imio/esign/setuphandlers.py index 6463609..bffee44 100644 --- a/src/imio/esign/setuphandlers.py +++ b/src/imio/esign/setuphandlers.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- - +from imio.esign.config import get_registry_parapheo_url +from imio.esign.config import get_registry_signing_users_email_content +from imio.esign.config import set_registry_parapheo_url +from imio.esign.config import set_registry_signing_users_email_content +from imio.esign.config import SIGNERS_EMAIL_CONTENT from Products.CMFPlone.interfaces import INonInstallable from zope.interface import implementer @@ -19,6 +23,10 @@ def getNonInstallableProducts(self): def post_install(context): """Post install script""" + if not get_registry_parapheo_url(): + set_registry_parapheo_url(u"https://simplycosi-1-test.trustsigneurope.com/login?tenantName=IMIO") + if not get_registry_signing_users_email_content(): + set_registry_signing_users_email_content(SIGNERS_EMAIL_CONTENT) def uninstall(context): diff --git a/test-4.3.cfg b/test-4.3.cfg index 027ba2d..0f7c669 100644 --- a/test-4.3.cfg +++ b/test-4.3.cfg @@ -76,6 +76,13 @@ pyrsistent = 0.15.7 z3c.jbot = 1.1.0 zipp = 1.2.0 +# Required by: +# imio.helpers==1.3.11.dev0 +cryptography = 3.3.2 +cffi = 1.15.1 +ipaddress = 1.0.23 +pycparser = 2.21 + # Added by buildout at 2023-02-21 14:51:26.709124 Products.DocFinderTab = 1.0.5 aws.zope2zcmldoc = 1.1.0 @@ -143,4 +150,3 @@ Pygments = 2.5.2 # beautifulsoup soupsieve = 1.9.2 backports.functools-lru-cache = 1.5 -