From 3f24ffd4b903765ebcc64ef0b6e2c214bf3fe7fd Mon Sep 17 00:00:00 2001 From: Guillaume Englert Date: Tue, 11 Apr 2023 11:29:27 +0200 Subject: [PATCH] Better HTML render for signatures. Co-authored-by: Hugo Smett --- CHANGELOG.txt | 7 + creme/emails/bricks.py | 23 ++- creme/emails/locale/fr/LC_MESSAGES/django.po | 5 +- creme/emails/models/mail.py | 14 +- creme/emails/models/sending.py | 57 +++--- .../static/chantilly/emails/css/emails.css | 4 + .../static/icecream/emails/css/emails.css | 4 + .../templates/emails/bricks/signatures.html | 8 + .../templates/emails/signature/content.html | 4 + .../templates/emails/signature/content.txt | 3 + .../templates/emails/signature/preview.html | 4 + creme/emails/tests/test_utils.py | 72 ++++++- creme/emails/utils.py | 175 +++++++++++++----- 13 files changed, 286 insertions(+), 94 deletions(-) create mode 100644 creme/emails/templates/emails/signature/content.html create mode 100644 creme/emails/templates/emails/signature/content.txt create mode 100644 creme/emails/templates/emails/signature/preview.html diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f32a44793b..d42e14feef 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -30,6 +30,8 @@ - A color field has been added to SalesPhase. * Tickets : - A color field has been added to Status. + * Emails : + - The blocks for signatures (in "My settings") displays now a preview. Developers side : ----------------- @@ -256,6 +258,8 @@ - The template "graphs/graph_error.html" has been removed. * Tickets : - In 'models.status.BASE_STATUS', the tuples contain an additional column for colors. + * Emails : + - The attribute 'utils.EMailSender._mime_images' has been removed. * Polls : - In the template "polls/bricks/preplies.html", the block '{% block poll_replies_extra_rows %}' has been removed ; use '{{block.super}}' instead. @@ -269,6 +273,9 @@ # In the class 'creme_core.gui.field_printers._FieldPrintersRegistry', the following attributes have been removed/replaced : _html_printers, _csv_printers, _printers_maps, _choice_printers. # In the class 'creme_core.templatetags.creme_cells.CellRenderNode', the class attribute 'RENDER_METHODS' has been removed. + # Apps : + * Emails : + - The constant 'utils._MIME_IMG_CACHE' has been removed. == Version 2.4 == diff --git a/creme/emails/bricks.py b/creme/emails/bricks.py index 733ec5f317..88647cd6af 100644 --- a/creme/emails/bricks.py +++ b/creme/emails/bricks.py @@ -36,6 +36,7 @@ EmailToSyncPerson, LightWeightEmail, ) +from .utils import SignatureRenderer Document = documents.get_document_model() @@ -357,14 +358,24 @@ class MySignaturesBrick(QuerysetBrick): # render to disabled only forbidden things. # permissions = '' - def detailview_display(self, context): - user = context['user'] + signature_render_cls = SignatureRenderer - return self._render(self.get_template_context( + def detailview_display(self, context): + # user = context['user'] + # return self._render(self.get_template_context( + # context, + # EmailSignature.objects.filter(user=user), + # # has_app_perm=user.has_perm('emails'), + # )) + btc = self.get_template_context( context, - EmailSignature.objects.filter(user=user), - # has_app_perm=user.has_perm('emails'), - )) + EmailSignature.objects.filter(user=context['user']).prefetch_related('images') + ) + + for signature in btc['page'].object_list: + signature.renderer = self.signature_render_cls(signature) + + return self._render(btc) class EmailSyncConfigItemsBrick(QuerysetBrick): diff --git a/creme/emails/locale/fr/LC_MESSAGES/django.po b/creme/emails/locale/fr/LC_MESSAGES/django.po index 366470d879..25c191702e 100644 --- a/creme/emails/locale/fr/LC_MESSAGES/django.po +++ b/creme/emails/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Creme Emails 2.5\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-02-24 11:21+0100\n" +"POT-Creation-Date: 2023-04-18 23:28+0200\n" "Last-Translator: Hybird \n" "Language: fr\n" "MIME-Version: 1.0\n" @@ -1041,6 +1041,9 @@ msgctxt "emails" msgid "New signature" msgstr "Nouvelle signature" +msgid "Content preview" +msgstr "Aperçu du contenu" + msgctxt "emails" msgid "Edit this signature" msgstr "Modifier cette signature" diff --git a/creme/emails/models/mail.py b/creme/emails/models/mail.py index b6a1481eee..bf63796877 100644 --- a/creme/emails/models/mail.py +++ b/creme/emails/models/mail.py @@ -93,6 +93,11 @@ def synchronised(self): return self.status == self.Status.SYNCHRONIZED +class EntityEmailSender(utils.EMailSender): + def get_subject(self, mail): + return mail.subject + + class AbstractEntityEmail(_Email, CremeEntity): identifier = models.CharField( _('Email ID'), unique=True, max_length=ID_LENGTH, editable=False, @@ -111,6 +116,8 @@ class AbstractEntityEmail(_Email, CremeEntity): save_label = _('Save the email') sending_label = _('Send the email') + email_sender_cls = EntityEmailSender + class Meta: abstract = True app_label = 'emails' @@ -186,7 +193,7 @@ def restore(self): entity_emails_send_type.refresh_job() def send(self): - sender = EntityEmailSender( + sender = self.email_sender_cls( body=self.body, body_html=self.body_html, signature=self.signature, @@ -200,8 +207,3 @@ def send(self): class EntityEmail(AbstractEntityEmail): class Meta(AbstractEntityEmail.Meta): swappable = 'EMAILS_EMAIL_MODEL' - - -class EntityEmailSender(utils.EMailSender): - def get_subject(self, mail): - return mail.subject diff --git a/creme/emails/models/sending.py b/creme/emails/models/sending.py index c7c1059b62..5ffbed6a1a 100644 --- a/creme/emails/models/sending.py +++ b/creme/emails/models/sending.py @@ -16,6 +16,8 @@ # along with this program. If not, see . ################################################################################ +from __future__ import annotations + import logging from json import loads as json_load from time import sleep @@ -42,6 +44,31 @@ logger = logging.getLogger(__name__) +class LightWeightEmailSender(EMailSender): + def __init__(self, sending: EmailSending): + super().__init__( + body=sending.body, + body_html=sending.body_html, + signature=sending.signature, + attachments=sending.attachments.all(), + ) + self._sending = sending + self._body_template = Template(self._body) + self._body_html_template = Template(self._body_html) + + def get_subject(self, mail): + return self._sending.subject + + def _process_bodies(self, mail): + body = mail.body + context = Context(json_load(body) if body else {}) + + return ( + self._body_template.render(context), + self._body_html_template.render(context), + ) + + class EmailSending(CremeModel): class Type(models.IntegerChoices): IMMEDIATE = 1, _('Immediate'), @@ -86,6 +113,8 @@ class State(models.IntegerChoices): creation_label = pgettext_lazy('emails', 'Create a sending') save_label = pgettext_lazy('emails', 'Save the sending') + email_sender_cls = LightWeightEmailSender + class Meta: app_label = 'emails' verbose_name = _('Email campaign sending') @@ -105,7 +134,7 @@ def get_related_entity(self): # For generic views def send_mails(self): try: - sender = LightWeightEmailSender(sending=self) + sender = self.email_sender_cls(sending=self) except ImageFromHTMLError as e: send_mail( gettext('[{software}] Campaign email sending error.').format( @@ -217,30 +246,8 @@ def genid_n_save(self): try: with atomic(): self.save(force_insert=True) - except IntegrityError: # A mail with this id already exists - logger.debug('Mail id already exists: %s', self.id) + except IntegrityError: + logger.debug('Mail ID already exists: %s', self.id) self.pk = None else: return - - -class LightWeightEmailSender(EMailSender): - def __init__(self, sending): - super().__init__( - body=sending.body, - body_html=sending.body_html, - signature=sending.signature, - attachments=sending.attachments.all(), - ) - self._sending = sending - self._body_template = Template(self._body) - self._body_html_template = Template(self._body_html) - - def get_subject(self, mail): - return self._sending.subject - - def _process_bodies(self, mail): - body = mail.body - context = Context(json_load(body) if body else {}) - - return self._body_template.render(context), self._body_html_template.render(context) diff --git a/creme/emails/static/chantilly/emails/css/emails.css b/creme/emails/static/chantilly/emails/css/emails.css index c76aa39a5d..91f272a278 100644 --- a/creme/emails/static/chantilly/emails/css/emails.css +++ b/creme/emails/static/chantilly/emails/css/emails.css @@ -4,6 +4,10 @@ top: -1.3em; } +.emails-signatures-brick .creme-emails-signature img { + max-width: 200px; +} + /* Email synchronization */ .emails-sync_config_items-brick .emails-sync-default { diff --git a/creme/emails/static/icecream/emails/css/emails.css b/creme/emails/static/icecream/emails/css/emails.css index 3328463a07..166bfadd96 100644 --- a/creme/emails/static/icecream/emails/css/emails.css +++ b/creme/emails/static/icecream/emails/css/emails.css @@ -4,6 +4,10 @@ top: -1.3em; } +.emails-signatures-brick .creme-emails-signature img { + max-width: 200px; +} + /* Email synchronization */ .emails-sync_config_items-brick .emails-sync-default { diff --git a/creme/emails/templates/emails/bricks/signatures.html b/creme/emails/templates/emails/bricks/signatures.html index 6a1646a10d..c88b5361fe 100644 --- a/creme/emails/templates/emails/bricks/signatures.html +++ b/creme/emails/templates/emails/bricks/signatures.html @@ -16,8 +16,11 @@ {% block brick_table_columns %} {% brick_table_column_for_field ctype=objects_ctype field='name' status='primary nowrap' %} +{% comment %} {% brick_table_column_for_field ctype=objects_ctype field='body' %} {% brick_table_column title=_('Images') %} +{% endcomment %} + {% brick_table_column title=_('Content preview') %} {% brick_table_column title=_('Actions') status='action' colspan=2 %} {% endblock %} @@ -27,8 +30,13 @@ {% for signature in page.object_list %} {{signature.name}} +{% comment %} {{signature.body}} {{signature.images.count}} +{% endcomment %} + + {{signature.renderer.render_html_preview}} + {% brick_table_action id='edit' url=signature.get_edit_absolute_url label=edit_label %} diff --git a/creme/emails/templates/emails/signature/content.html b/creme/emails/templates/emails/signature/content.html new file mode 100644 index 0000000000..1d4836731d --- /dev/null +++ b/creme/emails/templates/emails/signature/content.html @@ -0,0 +1,4 @@ +
+


--
{{signature.body}}

+ {% for image in images %}
{% endfor %} +
\ No newline at end of file diff --git a/creme/emails/templates/emails/signature/content.txt b/creme/emails/templates/emails/signature/content.txt new file mode 100644 index 0000000000..c00c0fa556 --- /dev/null +++ b/creme/emails/templates/emails/signature/content.txt @@ -0,0 +1,3 @@ +{% autoescape off %} +-- +{{signature.body}}{% endautoescape %} \ No newline at end of file diff --git a/creme/emails/templates/emails/signature/preview.html b/creme/emails/templates/emails/signature/preview.html new file mode 100644 index 0000000000..fd57d2a404 --- /dev/null +++ b/creme/emails/templates/emails/signature/preview.html @@ -0,0 +1,4 @@ +
+


--
{{signature.body}}

+ {% for image in images %}
{{image.entity.title}}{% endfor %} +
\ No newline at end of file diff --git a/creme/emails/tests/test_utils.py b/creme/emails/tests/test_utils.py index 8bbfe278f5..e90b58616e 100644 --- a/creme/emails/tests/test_utils.py +++ b/creme/emails/tests/test_utils.py @@ -1,11 +1,12 @@ from email.mime.image import MIMEImage from django.core import mail as django_mail +from django.utils.html import escape from creme.documents.tests.base import _DocumentsTestCase from ..models import EmailSignature -from ..utils import EMailSender, get_mime_image +from ..utils import EMailSender, SignatureRenderer, get_mime_image from .base import EntityEmail, _EmailsTestCase @@ -16,8 +17,8 @@ class TestEMailSender(EMailSender): def get_subject(self, mail): return self.subject - def test_get_mime_image02(self): - "PNG" + def test_get_mime_image(self): + "PNG." self.login() img = self._create_image() @@ -37,6 +38,48 @@ def test_get_mime_image02(self): self.assertRegex(content_disp, r'inline; filename="creme_22(.*).png"') + def test_signature_renderer(self): + "With images." + user = self.login() + + create_img = self._create_image + img1 = create_img(title='My image#1', ident=1) + img2 = create_img(title='My image#2', ident=2) + + signature = EmailSignature.objects.create( + user=user, name='Funny signature', body='I love you... not', + ) + signature.images.set([img1, img2]) + + renderer = SignatureRenderer(signature=signature) + self.assertEqual(f'\n--\n{signature.body}', renderer.render_text()) + self.assertHTMLEqual( + f'
' + f'


--
{escape(signature.body)}

' + f'

' + f'
', + renderer.render_html(), + ) + self.assertHTMLEqual( + f'
' + f'


--
{escape(signature.body)}

' + f'
' + f'{img1.title}' + f'
' + f'{img2.title}' + f'
', + renderer.render_html_preview(), + ) + + rend_images = [*renderer.images] + self.assertEqual(2, len(rend_images)) + + rend_image1 = rend_images[0] + self.assertIsInstance(rend_image1.mime, MIMEImage) + self.assertEqual(img1, rend_image1.entity) + def test_sender01(self): user = self.login() self.assertFalse(django_mail.outbox) @@ -81,7 +124,7 @@ def test_sender02(self): img2 = create_img(title='My image#2', ident=2) signature = EmailSignature.objects.create( - user=user, name='Funny signature', body='I love you... not', + user=user, name='Funny signature', body='I love you... not', ) signature.images.set([img1, img2]) @@ -109,12 +152,21 @@ def test_sender02(self): alternative = alternatives[0] self.assertEqual('text/html', alternative[1]) - self.assertEqual( - body_html - + '\n--\n' - + signature.body - + f'

', - alternative[0] + self.maxDiff = None + # self.assertEqual( + # body_html + # + '\n--\n' + # + signature.body + # + f'

', + # alternative[0] + # ) + self.assertHTMLEqual( + f'{body_html}' + f'
' + f'


--
{escape(signature.body)}

' + f'

' + f'
', + alternative[0], ) attachments = message.attachments diff --git a/creme/emails/utils.py b/creme/emails/utils.py index 5c3b4311b7..79cf462d9b 100644 --- a/creme/emails/utils.py +++ b/creme/emails/utils.py @@ -1,6 +1,6 @@ ################################################################################ # Creme is a free/open-source Customer Relationship Management software -# Copyright (C) 2009-2022 Hybird +# Copyright (C) 2009-2023 Hybird # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -16,17 +16,24 @@ # along with this program. If not, see . ################################################################################ +from __future__ import annotations + import logging +from dataclasses import dataclass from email.mime.image import MIMEImage from os.path import basename, join from random import choice from re import compile as re_compile from string import ascii_letters, digits +from typing import Iterable, Iterator from django.conf import settings from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template from django.utils.timezone import now +from creme.documents.models import AbstractDocument + logger = logging.getLogger(__name__) ALLOWED_CHARS = ascii_letters + digits @@ -51,59 +58,132 @@ def filename(self): return self._filename -_MIME_IMG_CACHE = '_mime_image_cache' - +# _MIME_IMG_CACHE = '_mime_image_cache' +# +# def get_mime_image(image_entity): +# try: +# mime_image = getattr(image_entity, _MIME_IMG_CACHE) +# except AttributeError: +# try: +# with image_entity.filedata.open() as image_file: +# mime_image = MIMEImage(image_file.read()) +# mime_image.add_header( +# 'Content-ID', f'', +# ) +# mime_image.add_header( +# 'Content-Disposition', 'inline', filename=basename(image_file.name), +# ) +# except OSError as e: +# logger.error('Exception when reading image : %s', e) +# mime_image = None +# +# setattr(image_entity, _MIME_IMG_CACHE, mime_image) +# +# return mime_image +def get_mime_image(image_entity: AbstractDocument) -> MIMEImage | None: + mime_image = None -def get_mime_image(image_entity): try: - mime_image = getattr(image_entity, _MIME_IMG_CACHE) - except AttributeError: - try: - with image_entity.filedata.open() as image_file: - mime_image = MIMEImage(image_file.read()) - mime_image.add_header( - 'Content-ID', f'', - ) - mime_image.add_header( - 'Content-Disposition', 'inline', filename=basename(image_file.name), - ) - except OSError as e: - logger.error('Exception when reading image : %s', e) - mime_image = None - - setattr(image_entity, _MIME_IMG_CACHE, mime_image) + with image_entity.filedata.open() as image_file: + mime_image = MIMEImage(image_file.read()) + mime_image.add_header( + 'Content-ID', f'', + ) + mime_image.add_header( + 'Content-Disposition', 'inline', filename=basename(image_file.name), + ) + except OSError as e: + logger.error('Exception when reading image: %s', e) return mime_image -class EMailSender: - def __init__(self, body, body_html, signature=None, attachments=()): - "@throws ImageFromHTMLError" - mime_images = [] +class SignatureRenderer: + text_template_name = 'emails/signature/content.txt' + html_template_name = 'emails/signature/content.html' + html_preview_template_name = 'emails/signature/preview.html' - if signature: - signature_body = '\n--\n' + signature.body + @dataclass + class Image: + entity: AbstractDocument + mime: MIMEImage - body += signature_body - body_html += signature_body + def __init__(self, signature): + self._signature = signature + self._images = images = [] - for image_entity in signature.images.all(): - mime_image = get_mime_image(image_entity) + for image_entity in signature.images.all(): + mime_image = get_mime_image(image_entity) - if mime_image is None: - logger.error( - 'Error during reading attached image in signature: %s', - image_entity, - ) - else: - mime_images.append(mime_image) - body_html += f'
' + if mime_image is None: + logger.error( + 'Error during reading attached image in signature: %s', + image_entity, + ) + else: + images.append(self.Image(entity=image_entity, mime=mime_image)) + + @property + def images(self) -> Iterator[Image]: + yield from self._images + + def get_context(self) -> dict: + return { + 'signature': self._signature, + 'images': self._images, + } + + def render_text(self) -> str: + return get_template(self.text_template_name).render(self.get_context()) - self._body = body + def render_html(self) -> str: + return get_template(self.html_template_name).render(self.get_context()) + + def render_html_preview(self) -> str: + return get_template(self.html_preview_template_name).render(self.get_context()) + + +class EMailSender: + signature_render_cls = SignatureRenderer + + # def __init__(self, body: str, body_html: str, signature=None, attachments=()): + # mime_images = [] + # + # if signature: + # signature_body = f'\n--\n{signature.body}' + # body += signature_body + # body_html += signature_body + # + # for image_entity in signature.images.all(): + # mime_image = get_mime_image(image_entity) + # + # if mime_image is None: + # logger.error( + # 'Error during reading attached image in signature: %s', + # image_entity, + # ) + # else: + # mime_images.append(mime_image) + # body_html += f'
' + # + # self._body = body + # self._body_html = body_html + # + # self._attachments = attachments + # self._mime_images = mime_images + def __init__(self, body: str, body_html: str, signature=None, + attachments: Iterable[AbstractDocument] = (), + ): + "@raise ImageFromHTMLError." + self._body = body self._body_html = body_html + self._attachments = [*attachments] + self._signature_renderer = None - self._attachments = attachments - self._mime_images = mime_images + if signature: + self._signature_renderer = renderer = self.signature_render_cls(signature) + self._body += renderer.render_text() + self._body_html += renderer.render_html() def get_subject(self, mail): raise NotImplementedError @@ -112,9 +192,9 @@ def _process_bodies(self, mail): return self._body, self._body_html def send(self, mail, connection=None): - """ - @param mail: Object with a class inheriting emails.models.mail._Email - @return True means + """Send the email & update its status. + @param mail: Object with a class inheriting . + @return True means 'OK mail was sent>'. """ ok = False @@ -129,8 +209,11 @@ def send(self, mail, connection=None): ) msg.attach_alternative(body_html, 'text/html') - for image in self._mime_images: - msg.attach(image) + # for image in self._mime_images: + # msg.attach(image) + if self._signature_renderer: + for image in self._signature_renderer.images: + msg.attach(image.mime) MEDIA_ROOT = settings.MEDIA_ROOT for attachment in self._attachments: