From b8fb1d02e06528f96825666ecdaffdca6671f593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Sat, 16 Nov 2024 14:13:41 +0100 Subject: [PATCH] Release Messaging center (#3420) This is the final step of functional changes to the Messaging center before we can start using it in production: * Messaging link is added to the main menu, visible only to users that can actually access the page * As soon as you hit the Review page, contributors start getting fetched, as indicated in the button in the bottom right part of the screen. * Once fetching is complete, the button to send the messages is made available with the recipient count written over it. * While messages are being sent or if the recipient count is 0, the Send button is disabled. * If fetching contributors fails, the button turns red and offers you the ability to retry fetching. Other changes: * Change styling of the No messages sent yet. page * Only filter users by Min and Max values if they are actually set * Only filter by actions if set * Prefetch user profile data if sending as email * Fix margin in the Recipients section of the Review --- pontoon/base/templates/header.html | 3 + pontoon/messaging/forms.py | 7 + pontoon/messaging/static/css/messaging.css | 46 ++++++- pontoon/messaging/static/js/messaging.js | 74 ++++++++++- .../templates/messaging/includes/compose.html | 7 +- pontoon/messaging/urls.py | 6 + pontoon/messaging/views.py | 122 ++++++++++++------ 7 files changed, 210 insertions(+), 55 deletions(-) diff --git a/pontoon/base/templates/header.html b/pontoon/base/templates/header.html index 90ace01909..35c1431586 100644 --- a/pontoon/base/templates/header.html +++ b/pontoon/base/templates/header.html @@ -19,6 +19,9 @@
  • Teams
  • Projects
  • Contributors
  • + {% if user.is_authenticated and user.is_superuser %} +
  • Messaging
  • + {% endif %}
  • Machinery
  • diff --git a/pontoon/messaging/forms.py b/pontoon/messaging/forms.py index 422c3c7037..a6193280b8 100644 --- a/pontoon/messaging/forms.py +++ b/pontoon/messaging/forms.py @@ -11,6 +11,12 @@ class MessageForm(forms.ModelForm): body = HtmlField() send_to_myself = forms.BooleanField(required=False) + recipient_ids = forms.CharField( + required=False, + widget=forms.Textarea(), + validators=[validators.validate_comma_separated_integer_list], + ) + locales = forms.CharField( widget=forms.Textarea(), validators=[validators.validate_comma_separated_integer_list], @@ -19,6 +25,7 @@ class MessageForm(forms.ModelForm): class Meta: model = Message fields = [ + "recipient_ids", "notification", "email", "transactional", diff --git a/pontoon/messaging/static/css/messaging.css b/pontoon/messaging/static/css/messaging.css index 56d2a44f5a..520e99aef6 100644 --- a/pontoon/messaging/static/css/messaging.css +++ b/pontoon/messaging/static/css/messaging.css @@ -23,12 +23,45 @@ .fa-chevron-right { margin-left: 5px; } + .fa-spin { + font-size: 16px; + } } .button:hover { color: inherit; } + .button.fetching { + pointer-events: none; + } + + .button.fetch-again { + display: none; + background-color: var(--status-error); + color: var(--translation-main-button-color); + } + + .button.active { + display: none; + width: auto; + + .fa-spin { + display: none; + } + } + + .button.disabled { + pointer-events: none; + } + + .button.active.sending { + pointer-events: none; + .fa-spin { + display: inline; + } + } + .right { float: right; @@ -126,6 +159,7 @@ } input#id_send_to_myself, + textarea#id_recipient_ids, textarea#id_body { display: none; } @@ -181,8 +215,8 @@ } .recipients { - > div:not(:last-child) { - margin-bottom: 20px; + > div { + margin-top: 20px; } h5 { @@ -210,14 +244,14 @@ text-align: center; .icon { - color: var(--light-grey-1); + color: var(--background-hover-2); font-size: 100px; } .title { - color: var(--light-grey-6); - font-size: 20px; - font-weight: 100; + color: var(--light-grey-7); + font-size: 18px; + font-weight: bold; } } diff --git a/pontoon/messaging/static/js/messaging.js b/pontoon/messaging/static/js/messaging.js index b25693dcac..97f2768b30 100644 --- a/pontoon/messaging/static/js/messaging.js +++ b/pontoon/messaging/static/js/messaging.js @@ -3,6 +3,7 @@ $(function () { const converter = new showdown.Converter({ simpleLineBreaks: true, }); + const nf = new Intl.NumberFormat('en'); let inProgress = false; function validateForm() { @@ -73,6 +74,38 @@ $(function () { ); } + function fetchRecipients() { + $('#review .controls .fetching').show(); + $('#review .controls .fetch-again').hide(); + $('#review .controls .send.active') + .hide() + .removeClass('disabled') + .find('.value') + .html(''); + + $.ajax({ + url: '/messaging/ajax/fetch-recipients/', + type: 'POST', + data: $('#send-message').serialize(), + success: function (data) { + const count = nf.format(data.recipients.length); + $('#review .controls .send.active') + .show() + .toggleClass('disabled', !data.recipients.length) + .find('.value') + .html(count); + $('#compose [name=recipient_ids]').val(data.recipients); + }, + error: function () { + Pontoon.endLoader('Fetching recipients failed.', 'error'); + $('#review .controls .fetch-again').show(); + }, + complete: function () { + $('#review .controls .fetching').hide(); + }, + }); + } + function updateReviewPanel() { function updateMultipleItemSelector(source, target, item) { const allProjects = !$(`${source}.available li:not(.no-match)`).length; @@ -98,7 +131,12 @@ $(function () { let value = $(this).find('input').val().trim(); if (value) { if (className === 'date') { - value = new Date(value).toLocaleDateString(); + // Convert date to the format used in the input field + // and set timezone to UTC to prevent shifts by a day + // when using the local timezone. + value = new Date(value).toLocaleDateString(undefined, { + timeZone: 'UTC', + }); } values.push(`${label}: ${value}`); show = true; @@ -110,17 +148,19 @@ $(function () { $(`#review .${filter}`).toggle(show); } + // Update hidden textarea with the HTML content to be sent to backend + const bodyValue = $('#body').val(); + const html = converter.makeHtml(bodyValue); + $('#compose [name=body]').val(html); + + fetchRecipients(); + // Subject $('#review .subject .value').html($('#id_subject').val()); // Body - const bodyValue = $('#body').val(); - const html = converter.makeHtml(bodyValue); $('#review .body .value').html(html); - // Update hidden textarea with the HTML content to be sent to backend - $('#compose [name=body]').val(html); - // User roles const userRoles = $('#compose .user-roles .enabled') .map(function () { @@ -291,12 +331,25 @@ $(function () { window.scrollTo(0, 0); }); + // Fetch recipients again + container.on('click', '.controls .fetch-again', function (e) { + e.preventDefault(); + fetchRecipients(); + }); + // Send message container.on('click', '.controls .send.button', function (e) { e.preventDefault(); const $form = $('#send-message'); const sendToMyself = $(this).is('.to-myself'); + const button = $(this); + + if (button.is('.sending')) { + return; + } + + button.addClass('sending'); // Distinguish between Send and Send to myself $('#id_send_to_myself').prop('checked', sendToMyself); @@ -309,12 +362,19 @@ $(function () { success: function () { Pontoon.endLoader('Message sent.'); if (!sendToMyself) { - container.find('.left-column .sent a').click(); + const count = container.find('.left-column .sent .count'); + // Update count in the menu + count.html(parseInt(count.html(), 10) + 1); + // Load Sent panel + count.parents('a').click(); } }, error: function () { Pontoon.endLoader('Oops, something went wrong.', 'error'); }, + complete: function () { + button.removeClass('sending'); + }, }); }); }); diff --git a/pontoon/messaging/templates/messaging/includes/compose.html b/pontoon/messaging/templates/messaging/includes/compose.html index 098abb9cf3..3d833a11a8 100644 --- a/pontoon/messaging/templates/messaging/includes/compose.html +++ b/pontoon/messaging/templates/messaging/includes/compose.html @@ -6,6 +6,7 @@
    {% csrf_token %} {{ form.send_to_myself }} + {{ form.recipient_ids }}

    Message type

    @@ -217,8 +218,10 @@

    Message type

    - - + + + +
    diff --git a/pontoon/messaging/urls.py b/pontoon/messaging/urls.py index a5a5ce3eb6..25cb0eef5e 100644 --- a/pontoon/messaging/urls.py +++ b/pontoon/messaging/urls.py @@ -50,6 +50,12 @@ views.ajax_sent, name="pontoon.messaging.ajax.sent", ), + # Fetch recipients + path( + "fetch-recipients/", + views.fetch_recipients, + name="pontoon.messaging.ajax.fetch_recipients", + ), # Send message path( "send/", diff --git a/pontoon/messaging/views.py b/pontoon/messaging/views.py index b6b661bd97..0ce41b21d3 100644 --- a/pontoon/messaging/views.py +++ b/pontoon/messaging/views.py @@ -99,33 +99,40 @@ def get_recipients(form): recipients = User.objects.none() """ - Filter recipients by user role: + Filter recipients by user role, locale and project: - Contributors of selected Locales and Projects - Managers of selected Locales - Translators of selected Locales """ locale_ids = sorted(split_ints(form.cleaned_data.get("locales"))) project_ids = form.cleaned_data.get("projects") + translations = Translation.objects.filter( locale_id__in=locale_ids, entity__resource__project_id__in=project_ids, ) + locales = Locale.objects.filter(pk__in=locale_ids) + manager_ids = ( + locales.exclude(managers_group__user__isnull=True) + .values("managers_group__user") + .distinct() + ) + translator_ids = ( + locales.exclude(translators_group__user__isnull=True) + .values("translators_group__user") + .distinct() + ) + if form.cleaned_data.get("contributors"): contributors = translations.values("user").distinct() recipients = recipients | User.objects.filter(pk__in=contributors) if form.cleaned_data.get("managers"): - managers = Locale.objects.filter(pk__in=locale_ids).values( - "managers_group__user" - ) - recipients = recipients | User.objects.filter(pk__in=managers) + recipients = recipients | User.objects.filter(pk__in=manager_ids) if form.cleaned_data.get("translators"): - translators = Locale.objects.filter(pk__in=locale_ids).values( - "translators_group__user" - ) - recipients = recipients | User.objects.filter(pk__in=translators) + recipients = recipients | User.objects.filter(pk__in=translator_ids) """ Filter recipients by login date: @@ -161,12 +168,15 @@ def get_recipients(form): if translation_to: submitted = submitted.filter(date__lte=translation_to) - submitted = submitted.values("user").annotate(count=Count("user")) + # For the Minimum count, no value is the same as 0 + # For the Maximum count, distinguish between no value and 0 + if translation_minimum or translation_maximum is not None: + submitted = submitted.values("user").annotate(count=Count("user")) if translation_minimum: submitted = submitted.filter(count__gte=translation_minimum) - if translation_maximum: + if translation_maximum is not None: submitted = submitted.filter(count__lte=translation_maximum) """ @@ -196,22 +206,38 @@ def get_recipients(form): approved = approved.filter(approved_date__lte=review_to) rejected = rejected.filter(rejected_date__lte=review_to) - approved = approved.values("approved_user").annotate(count=Count("approved_user")) - rejected = rejected.values("rejected_user").annotate(count=Count("rejected_user")) + # For the Minimum count, no value is the same as 0 + # For the Maximum count, distinguish between no value and 0 + if review_minimum or review_maximum is not None: + approved = approved.values("approved_user").annotate( + count=Count("approved_user") + ) + rejected = rejected.values("rejected_user").annotate( + count=Count("rejected_user") + ) if review_minimum: approved = approved.filter(count__gte=review_minimum) rejected = rejected.filter(count__gte=review_minimum) - if review_maximum: + if review_maximum is not None: approved = approved.filter(count__lte=review_maximum) rejected = rejected.filter(count__lte=review_maximum) - recipients = recipients.filter( - pk__in=list(submitted.values_list("user", flat=True).distinct()) - + list(approved.values_list("approved_user", flat=True).distinct()) - + list(rejected.values_list("rejected_user", flat=True).distinct()) - ) + if ( + translation_from + or translation_to + or translation_minimum + or translation_maximum is not None + ): + submission_filters = submitted.values_list("user", flat=True).distinct() + recipients = recipients.filter(pk__in=submission_filters) + + if review_from or review_to or review_minimum or review_maximum is not None: + approved_filters = approved.values_list("approved_user", flat=True).distinct() + rejected_filters = rejected.values_list("rejected_user", flat=True).distinct() + review_filters = approved_filters.union(rejected_filters) + recipients = recipients.filter(pk__in=review_filters) return recipients @@ -220,35 +246,54 @@ def get_recipients(form): @require_AJAX @require_POST @transaction.atomic -def send_message(request): +def fetch_recipients(request): form = forms.MessageForm(request.POST) if not form.is_valid(): return JsonResponse(dict(form.errors.items()), status=400) - send_to_myself = form.cleaned_data.get("send_to_myself") - recipients = User.objects.filter(pk=request.user.pk) - - """ - While the feature is in development, messages are sent only to the current user. - TODO: Uncomment lines below when the feature is ready. - if not send_to_myself: - recipients = get_recipients(form) - """ + recipients = get_recipients(form).distinct().values_list("pk", flat=True) - log.info( - f"{recipients.count()} Recipients: {list(recipients.values_list('email', flat=True))}" + return JsonResponse( + { + "recipients": list(recipients), + } ) + +@permission_required_or_403("base.can_manage_project") +@require_AJAX +@require_POST +@transaction.atomic +def send_message(request): + form = forms.MessageForm(request.POST) + + if not form.is_valid(): + return JsonResponse(dict(form.errors.items()), status=400) + is_notification = form.cleaned_data.get("notification") is_email = form.cleaned_data.get("email") is_transactional = form.cleaned_data.get("transactional") + send_to_myself = form.cleaned_data.get("send_to_myself") + recipient_ids = split_ints(form.cleaned_data.get("recipient_ids")) + + if send_to_myself: + recipients = User.objects.filter(pk=request.user.pk) + else: + recipients = User.objects.filter(pk__in=recipient_ids) + + if is_email: + recipients = recipients.prefetch_related("profile") + + log.info(f"Total recipients count: {len(recipients)}.") + subject = form.cleaned_data.get("subject") body = form.cleaned_data.get("body") if is_notification: identifier = uuid.uuid4().hex - for recipient in recipients.distinct(): + + for recipient in recipients: notify.send( request.user, recipient=recipient, @@ -258,9 +303,7 @@ def send_message(request): identifier=identifier, ) - log.info( - f"Notifications sent to the following {recipients.count()} users: {recipients.values_list('email', flat=True)}." - ) + log.info(f"Notifications sent to {len(recipients)} users.") if is_email: footer = ( @@ -273,9 +316,10 @@ def send_message(request): html_template = body + footer text_template = utils.html_to_plain_text_with_links(html_template) - email_recipients = recipients.filter(profile__email_communications_enabled=True) + for recipient in recipients: + if not recipient.profile.email_communications_enabled: + continue - for recipient in email_recipients.distinct(): unique_id = str(recipient.profile.unique_id) text = text_template.replace("{ uuid }", unique_id) html = html_template.replace("{ uuid }", unique_id) @@ -289,9 +333,7 @@ def send_message(request): msg.attach_alternative(html, "text/html") msg.send() - log.info( - f"Email sent to the following {email_recipients.count()} users: {email_recipients.values_list('email', flat=True)}." - ) + log.info(f"Emails sent to {len(recipients)} users.") if not send_to_myself: message = form.save(commit=False)