From 138be1b8c9da4ffe825d7d5c7b3df68665491cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Thu, 3 Oct 2024 22:00:53 +0200 Subject: [PATCH] Add ability to see and recycle sent messages (#3353) - Fix #3246: After the message is sent, it gets stored to the DB and displayed in the Sent panel. - Fix #3247: Add ability to edit sent messages as new. - Compose and Sent panels can now be linked to. - After the message is sent, show it in the Sent panel. Also included are some under the hood changes that will make code more maintainable: - Create MessageForm from the Message model - Use select element instead of input in multiple item selector widget - Use form tags in the template --- pontoon/base/static/css/check-box.css | 4 + pontoon/base/static/css/sidebar_menu.css | 11 +- pontoon/base/static/js/tabs.js | 4 +- pontoon/base/templates/widgets/checkbox.html | 5 +- .../templates/widgets/item_selector_list.html | 6 +- pontoon/base/templatetags/helpers.py | 23 ++ pontoon/localizations/urls.py | 4 +- pontoon/messaging/admin.py | 11 + pontoon/messaging/forms.py | 82 +++-- pontoon/messaging/migrations/0001_initial.py | 101 ++++++ pontoon/messaging/migrations/__init__.py | 0 pontoon/messaging/models.py | 55 ++++ pontoon/messaging/static/css/messaging.css | 298 +++++++++++++----- pontoon/messaging/static/js/messaging.js | 114 +++++-- .../templates/messaging/includes/compose.html | 224 +++++++++++++ .../templates/messaging/includes/sent.html | 39 +++ .../templates/messaging/messaging.html | 243 +------------- pontoon/messaging/urls.py | 63 +++- pontoon/messaging/views.py | 96 +++++- pontoon/settings/base.py | 1 - pontoon/urls.py | 2 +- 21 files changed, 987 insertions(+), 399 deletions(-) create mode 100644 pontoon/messaging/admin.py create mode 100644 pontoon/messaging/migrations/0001_initial.py create mode 100644 pontoon/messaging/migrations/__init__.py create mode 100644 pontoon/messaging/models.py create mode 100644 pontoon/messaging/templates/messaging/includes/compose.html create mode 100644 pontoon/messaging/templates/messaging/includes/sent.html diff --git a/pontoon/base/static/css/check-box.css b/pontoon/base/static/css/check-box.css index eb8e9c7771..54c1b0856f 100644 --- a/pontoon/base/static/css/check-box.css +++ b/pontoon/base/static/css/check-box.css @@ -7,6 +7,10 @@ .check-box { padding: 4px 0; + + [type='checkbox'] { + display: none; + } } .check-box:last-child { diff --git a/pontoon/base/static/css/sidebar_menu.css b/pontoon/base/static/css/sidebar_menu.css index 09be38c721..0a6b45e863 100644 --- a/pontoon/base/static/css/sidebar_menu.css +++ b/pontoon/base/static/css/sidebar_menu.css @@ -1,5 +1,9 @@ .menu.left-column { + background: var(--background-hover-1); + border-radius: 6px; + box-sizing: border-box; float: left; + padding: 10px; width: 300px; } @@ -8,7 +12,8 @@ } .menu.left-column li.selected { - background: var(--background-hover-1); + background: var(--background-hover-2); + border-radius: 6px; } .menu.left-column li.selected a { @@ -21,8 +26,8 @@ .menu.left-column li a { display: block; - font-size: 15px; - padding: 5px; + font-size: 16px; + padding: 10px; } .menu.left-column .count { diff --git a/pontoon/base/static/js/tabs.js b/pontoon/base/static/js/tabs.js index c62f17d545..b96b6c53ad 100644 --- a/pontoon/base/static/js/tabs.js +++ b/pontoon/base/static/js/tabs.js @@ -19,8 +19,8 @@ $(function () { 'click', '#middle .links a, #main .contributors .links a', function (e) { - // Keep default middle-, control- and command-click behaviour (open in new tab) - if (e.which === 2 || e.metaKey || e.ctrlKey) { + // Keep default middle-, shift-, control- and command-click behaviour + if (e.which === 2 || e.metaKey || e.shiftKey || e.ctrlKey) { return; } diff --git a/pontoon/base/templates/widgets/checkbox.html b/pontoon/base/templates/widgets/checkbox.html index 59ef1aa65b..1e822d27ec 100644 --- a/pontoon/base/templates/widgets/checkbox.html +++ b/pontoon/base/templates/widgets/checkbox.html @@ -1,4 +1,4 @@ -{% macro checkbox(label, class, attribute, is_enabled=False, title=False, help=False) %} +{% macro checkbox(label, class, attribute, is_enabled=False, title=False, help=False, form_field=None) %}
  • {{ label }} @@ -7,5 +7,8 @@ {% if help %}

    {{ help|safe }}

    {% endif %} + {% if form_field %} + {{ form_field }} + {% endif %}
  • {% endmacro %} diff --git a/pontoon/base/templates/widgets/item_selector_list.html b/pontoon/base/templates/widgets/item_selector_list.html index 0607e3e492..b93f00ffe2 100644 --- a/pontoon/base/templates/widgets/item_selector_list.html +++ b/pontoon/base/templates/widgets/item_selector_list.html @@ -1,4 +1,4 @@ -{% macro list(id, title, items, left_shortcut=None, right_shortcut=None, sortable=False, form_field=None) %} +{% macro list(id, title, items, left_shortcut=None, right_shortcut=None, sortable=False) %}
    - - {% if form_field %} - - {% endif %} {% endmacro %} diff --git a/pontoon/base/templatetags/helpers.py b/pontoon/base/templatetags/helpers.py index ca419cab6d..05886ef56d 100644 --- a/pontoon/base/templatetags/helpers.py +++ b/pontoon/base/templatetags/helpers.py @@ -2,6 +2,8 @@ import html import json +from datetime import timedelta + import markupsafe from allauth.socialaccount import providers @@ -15,6 +17,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse +from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from pontoon.base.fluent import get_simple_preview @@ -177,6 +180,26 @@ def format_timedelta(value): return "---" +@library.filter +def format_for_inbox(date): + # Localize the date to the current timezone + date = timezone.localtime(date) + now = timezone.now() + + if date.date() == now.date(): + # Same day: Only show time, e.g., "13:34" + return date.strftime("%H:%M") + elif date.date() == (now - timedelta(days=1)).date(): + # Yesterday: Yesterday and time, e.g., "Yesterday, 13:34" + return date.strftime("Yesterday, %H:%M") + elif (now - date).days < 7: + # Within a week: Day and time, e.g., "Monday, 13:34" + return date.strftime("%A, %H:%M") + else: + # Older than a week: Full date and time, e.g., "Sep 12, 13:34" + return date.strftime("%b %d, %H:%M") + + @register.filter @library.filter def nospam(self): diff --git a/pontoon/localizations/urls.py b/pontoon/localizations/urls.py index 6a6a977b3e..14f9f4eb0a 100644 --- a/pontoon/localizations/urls.py +++ b/pontoon/localizations/urls.py @@ -29,7 +29,7 @@ views.localization, name="pontoon.localizations.contributors", ), - # Project insights + # Localization insights path( "insights/", views.localization, @@ -70,7 +70,7 @@ views.LocalizationContributorsView.as_view(), name="pontoon.localizations.ajax.contributors", ), - # Project insights + # Localization insights path( "insights/", views.ajax_insights, diff --git a/pontoon/messaging/admin.py b/pontoon/messaging/admin.py new file mode 100644 index 0000000000..8647ef423d --- /dev/null +++ b/pontoon/messaging/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from pontoon.messaging import models + + +class MessageAdmin(admin.ModelAdmin): + list_display = ("pk", "sent_at", "sender", "subject", "body") + autocomplete_fields = ["sender", "recipients"] + + +admin.site.register(models.Message, MessageAdmin) diff --git a/pontoon/messaging/forms.py b/pontoon/messaging/forms.py index 5855bf30a1..422c3c7037 100644 --- a/pontoon/messaging/forms.py +++ b/pontoon/messaging/forms.py @@ -2,38 +2,72 @@ from django.core import validators from pontoon.base.forms import HtmlField +from pontoon.base.models import Project +from .models import Message -class MessageForm(forms.Form): - notification = forms.BooleanField(required=False) - email = forms.BooleanField(required=False) - transactional = forms.BooleanField(required=False) - subject = forms.CharField() +class MessageForm(forms.ModelForm): body = HtmlField() - - managers = forms.BooleanField(required=False) - translators = forms.BooleanField(required=False) - contributors = forms.BooleanField(required=False) + send_to_myself = forms.BooleanField(required=False) locales = forms.CharField( - validators=[validators.validate_comma_separated_integer_list] - ) - projects = forms.CharField( - validators=[validators.validate_comma_separated_integer_list] + widget=forms.Textarea(), + validators=[validators.validate_comma_separated_integer_list], ) - translation_minimum = forms.IntegerField(required=False, min_value=0) - translation_maximum = forms.IntegerField(required=False, min_value=0) - translation_from = forms.DateField(required=False) - translation_to = forms.DateField(required=False) + class Meta: + model = Message + fields = [ + "notification", + "email", + "transactional", + "subject", + "body", + "managers", + "translators", + "contributors", + "locales", + "projects", + "translation_minimum", + "translation_maximum", + "translation_from", + "translation_to", + "review_minimum", + "review_maximum", + "review_from", + "review_to", + "login_from", + "login_to", + "send_to_myself", + ] + widgets = { + "translation_from": forms.DateInput(attrs={"type": "date"}), + "translation_to": forms.DateInput(attrs={"type": "date"}), + "review_from": forms.DateInput(attrs={"type": "date"}), + "review_to": forms.DateInput(attrs={"type": "date"}), + "login_from": forms.DateInput(attrs={"type": "date"}), + "login_to": forms.DateInput(attrs={"type": "date"}), + } + labels = { + "translation_minimum": "Minimum", + "translation_maximum": "Maximum", + "translation_from": "From", + "translation_to": "To", + "review_minimum": "Minimum", + "review_maximum": "Maximum", + "review_from": "From", + "review_to": "To", + "login_from": "From", + "login_to": "To", + } - review_minimum = forms.IntegerField(required=False, min_value=0) - review_maximum = forms.IntegerField(required=False, min_value=0) - review_from = forms.DateField(required=False) - review_to = forms.DateField(required=False) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - login_from = forms.DateField(required=False) - login_to = forms.DateField(required=False) + # Set all available Projects as selected + self.fields["projects"].initial = Project.objects.available() - send_to_myself = forms.BooleanField(required=False) + # Remove the colon from all field labels + for field in self.fields.values(): + field.label_suffix = "" diff --git a/pontoon/messaging/migrations/0001_initial.py b/pontoon/messaging/migrations/0001_initial.py new file mode 100644 index 0000000000..0653a6e5cd --- /dev/null +++ b/pontoon/messaging/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# Generated by Django 4.2.11 on 2024-09-18 19:47 + +import django.db.models.deletion +import django.utils.timezone + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("base", "0065_fix_projectlocale_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="Message", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("sent_at", models.DateTimeField(default=django.utils.timezone.now)), + ("notification", models.BooleanField(default=False)), + ("email", models.BooleanField(default=False)), + ("transactional", models.BooleanField(default=False)), + ("subject", models.CharField(max_length=255)), + ("body", models.TextField()), + ("managers", models.BooleanField(default=True)), + ("translators", models.BooleanField(default=True)), + ("contributors", models.BooleanField(default=True)), + ( + "translation_minimum", + models.PositiveIntegerField(blank=True, null=True), + ), + ( + "translation_maximum", + models.PositiveIntegerField(blank=True, null=True), + ), + ("translation_from", models.DateField(blank=True, null=True)), + ("translation_to", models.DateField(blank=True, null=True)), + ("review_minimum", models.PositiveIntegerField(blank=True, null=True)), + ("review_maximum", models.PositiveIntegerField(blank=True, null=True)), + ("review_from", models.DateField(blank=True, null=True)), + ("review_to", models.DateField(blank=True, null=True)), + ("login_from", models.DateField(blank=True, null=True)), + ("login_to", models.DateField(blank=True, null=True)), + ( + "locales", + models.ManyToManyField(related_name="messages", to="base.locale"), + ), + ( + "projects", + models.ManyToManyField(related_name="messages", to="base.project"), + ), + ( + "recipients", + models.ManyToManyField( + related_name="received_messages", to=settings.AUTH_USER_MODEL + ), + ), + ( + "sender", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddConstraint( + model_name="message", + constraint=models.CheckConstraint( + check=models.Q( + ("notification", True), ("email", True), _connector="OR" + ), + name="at_least_one_message_type", + ), + ), + migrations.AddConstraint( + model_name="message", + constraint=models.CheckConstraint( + check=models.Q( + ("managers", True), + ("translators", True), + ("contributors", True), + _connector="OR", + ), + name="at_least_one_user_role", + ), + ), + ] diff --git a/pontoon/messaging/migrations/__init__.py b/pontoon/messaging/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pontoon/messaging/models.py b/pontoon/messaging/models.py new file mode 100644 index 0000000000..176b4ff520 --- /dev/null +++ b/pontoon/messaging/models.py @@ -0,0 +1,55 @@ +from django.db import models +from django.db.models import Q +from django.utils import timezone + +from pontoon.base.models.locale import Locale +from pontoon.base.models.project import Project +from pontoon.base.models.user import User + + +class Message(models.Model): + sent_at = models.DateTimeField(default=timezone.now) + sender = models.ForeignKey(User, models.CASCADE, related_name="messages") + recipients = models.ManyToManyField(User, related_name="received_messages") + + notification = models.BooleanField(default=False) + email = models.BooleanField(default=False) + transactional = models.BooleanField(default=False) + + subject = models.CharField(max_length=255) + body = models.TextField() + + managers = models.BooleanField(default=True) + translators = models.BooleanField(default=True) + contributors = models.BooleanField(default=True) + + locales = models.ManyToManyField(Locale, related_name="messages") + projects = models.ManyToManyField(Project, related_name="messages") + + translation_minimum = models.PositiveIntegerField(blank=True, null=True) + translation_maximum = models.PositiveIntegerField(blank=True, null=True) + translation_from = models.DateField(blank=True, null=True) + translation_to = models.DateField(blank=True, null=True) + + review_minimum = models.PositiveIntegerField(blank=True, null=True) + review_maximum = models.PositiveIntegerField(blank=True, null=True) + review_from = models.DateField(blank=True, null=True) + review_to = models.DateField(blank=True, null=True) + + login_from = models.DateField(blank=True, null=True) + login_to = models.DateField(blank=True, null=True) + + class Meta: + constraints = [ + models.CheckConstraint( + check=Q(notification=True) | Q(email=True), + name="at_least_one_message_type", + ), + models.CheckConstraint( + check=Q(managers=True) | Q(translators=True) | Q(contributors=True), + name="at_least_one_user_role", + ), + ] + + def is_new(self): + return self.sent_at > timezone.now() - timezone.timedelta(minutes=1) diff --git a/pontoon/messaging/static/css/messaging.css b/pontoon/messaging/static/css/messaging.css index b5b0917b52..56d2a44f5a 100644 --- a/pontoon/messaging/static/css/messaging.css +++ b/pontoon/messaging/static/css/messaging.css @@ -4,9 +4,7 @@ } .right-column { - > div:not(.selected) { - display: none; - } + background: transparent; section { padding: 20px; @@ -14,10 +12,11 @@ } .controls { - margin: 0; + margin: 40px 0 20px; .button { - width: 160px; + font-size: 15px; + padding: 10px 20px; .fa-chevron-left { margin-right: 5px; } @@ -30,10 +29,6 @@ color: inherit; } - .button:not(.active) { - background: var(--button-background-2); - } - .right { float: right; @@ -43,9 +38,18 @@ } } + .no-results { + padding: 26px; + } + #compose { box-sizing: border-box; + form { + background: var(--dark-grey-1); + border-radius: 6px; + } + li:hover { background: inherit; } @@ -62,6 +66,8 @@ } .check-list { + overflow: visible; + .check-box.transactional { display: none; padding-left: 20px; @@ -80,10 +86,6 @@ margin: 2px 10px 0 0; float: left; } - - [type='checkbox'] { - display: none; - } } .field { @@ -119,15 +121,15 @@ box-sizing: border-box; } - input.send-to-myself { - display: none; - } - textarea { - border-radius: 3px; height: 300px; } + input#id_send_to_myself, + textarea#id_body { + display: none; + } + .message-content .subtitle { color: var(--light-grey-7); float: right; @@ -142,6 +144,8 @@ } #review { + display: none; + p { color: var(--light-grey-6); font-size: 16px; @@ -151,103 +155,233 @@ p.transactional { margin-top: 20px; font-style: italic; - padding: 20px; - background: var(--background-1); - border-radius: 5px; i { - color: var(--status-translated); + color: var(--status-error); } } - .body { + > section { + background: var(--dark-grey-1); + border-radius: 6px; + margin-bottom: 40px; + } + + > section:last-child { + margin-bottom: 0; + } + + .subject { .value { - h1, - h2, - h3, - h4, - h5, - h6 { - color: inherit; - font-size: inherit; - font-style: inherit; - font-weight: bold; - letter-spacing: inherit; - margin: inherit; - padding-bottom: 1em; - text-transform: inherit; - } + color: var(--white-1); + font-size: 20px; + font-weight: bold; + margin-bottom: 14px; + } + } - h1 { - font-size: 2em; - } + .recipients { + > div:not(:last-child) { + margin-bottom: 20px; + } - h2 { - font-size: 1.8em; - } + h5 { + color: var(--light-grey-6); + font-size: 16px; + } - h3 { - font-size: 1.5em; - } + .value { + font-weight: 100; + } - h4 { - font-size: 1.3em; - } + .submitted-translations, + .performed-reviews, + .last-login { + display: none; + } + } + } - h5 { - font-size: 1.15em; - } + #sent { + background: var(--black-3); + + .no { + padding: 20px; + text-align: center; - p { - padding-bottom: 1em; + .icon { + color: var(--light-grey-1); + font-size: 100px; + } + + .title { + color: var(--light-grey-6); + font-size: 20px; + font-weight: 100; + } + } + + ul { + max-height: none; + + .message { + background: var(--dark-grey-1); + border-radius: 6px; + color: var(--light-grey-6); + margin-bottom: 60px; + padding: 20px; + position: relative; + transition: background-color 2s ease-in; + + > div { + display: inline-block; } - a { - color: var(--status-translated); - font-weight: inherit; + .user-avatar { + float: left; + width: 68px; } - ol, - ul { - color: var(--light-grey-6); + .details { + width: 532px; font-size: 16px; - margin-left: 1.5em; - padding-bottom: 1em; - } - ul { - overflow: inherit; - list-style: disc; + .info { + margin-bottom: 10px; + font-size: 14px; + + .sender { + color: var(--status-translated-alt); + width: 50%; + } + + .types { + float: right; + text-align: right; + width: 50%; + + i { + color: var(--status-translated); + font-size: 18px; + margin-left: 5px; + } + } + } + + .subject { + color: var(--white-1); + font-size: 20px; + font-weight: bold; + margin-bottom: 14px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } } - li { - color: var(--light-grey-6); + .footer { + position: absolute; + bottom: -25px; + left: 5px; + right: 5px; + + .use-as-template { + color: inherit; + float: left; + text-transform: uppercase; + + .fa { + color: var(--status-translated); + margin-right: 5px; + } + } + + .use-as-template:hover { + color: var(--white-1); + } + + time { + float: right; + } } + } - li:hover { - background: inherit; - } + .message.new { + background: var(--light-grey-1); } } + } - .recipients { - > div:not(:last-child) { - margin-bottom: 20px; + .body { + .value { + h1, + h2, + h3, + h4, + h5, + h6 { + color: inherit; + font-size: inherit; + font-style: inherit; + font-weight: bold; + letter-spacing: inherit; + margin: inherit; + padding-bottom: 1em; + text-transform: inherit; + } + + h1 { + font-size: 2em; + } + + h2 { + font-size: 1.8em; + } + + h3 { + font-size: 1.5em; + } + + h4 { + font-size: 1.3em; } h5 { - color: var(--light-grey-6); + font-size: 1.15em; + } + + p { + font-weight: normal; + line-height: 1.5em; + } + + p:not(:last-child) { + padding-bottom: 1em; + } + + a { + color: var(--status-translated); + font-weight: inherit; + } + + ol, + ul { font-size: 16px; + margin-left: 1.5em; + padding-bottom: 1em; } - .value { - font-weight: 100; + ul { + overflow: inherit; + list-style: disc; } - .submitted-translations, - .performed-reviews, - .last-login { - display: none; + li { + color: var(--light-grey-6); + } + + li:hover { + background: inherit; } } } diff --git a/pontoon/messaging/static/js/messaging.js b/pontoon/messaging/static/js/messaging.js index 5fa5199eb2..b25693dcac 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, }); + let inProgress = false; function validateForm() { const $form = $('#send-message'); @@ -18,19 +19,19 @@ $(function () { const isValidLocale = $form.find('[name=locales]').val(); - const isValidProject = $form.find('[name=projects]').val(); + const isValidProject = $form.find('[name=projects]').val().length; const isValidTranslationMinimum = $form - .find('#translation-minimum')[0] + .find('[name=translation_minimum]')[0] .checkValidity(); const isValidTranslationMaximum = $form - .find('#translation-maximum')[0] + .find('[name=translation_maximum]')[0] .checkValidity(); const isValidReviewMinimum = $form - .find('#review-minimum')[0] + .find('[name=review_minimum]')[0] .checkValidity(); const isValidReviewMaximum = $form - .find('#review-maximum')[0] + .find('[name=review_maximum]')[0] .checkValidity(); $form.find('.errors').css('visibility', 'hidden'); @@ -110,7 +111,7 @@ $(function () { } // Subject - $('#review .subject p').html($('#subject').val()); + $('#review .subject .value').html($('#id_subject').val()); // Body const bodyValue = $('#body').val(); @@ -162,13 +163,88 @@ $(function () { $('#review .message-type .transactional').toggle(isTransactional); } + function loadPanelContent(path) { + if (inProgress) { + inProgress.abort(); + } + + const panel = container.find('.right-column'); + const relative_path = path.split('/messaging/')[1]; + const isSentPage = relative_path === 'sent/'; + const menu_path = isSentPage ? '/messaging/sent/' : '/messaging/'; + + // Update menu selected state + container + .find(`.left-column a[href="${menu_path}"]`) + .parents('li') + .addClass('selected') + .siblings() + .removeClass('selected'); + + panel.empty(); + + inProgress = $.ajax({ + url: `/messaging/ajax/${relative_path}`, + success: function (data) { + panel.append(data); + + if (isSentPage) { + // Dissolve new message state + setTimeout(function () { + container.find('.message.new').removeClass('new'); + }, 1000); + } else if (relative_path) { + // Convert body content from HTML to markdown + const html = $('#compose [name=body]').val(); + const markdown = converter.makeMarkdown(html); + $('#body').val(markdown); + } + }, + error: function (error) { + if (error.status === 0 && error.statusText !== 'abort') { + const message = $('

    ', { + class: 'no-results', + html: 'Oops, something went wrong.', + }); + + panel.append(message); + } + }, + }); + } + + // Load panel content on page load + loadPanelContent(window.location.pathname); + + // Load panel content on history change + window.onpopstate = function () { + loadPanelContent(window.location.pathname); + }; + + // Load panel content on menu click + container.on( + 'click', + '.left-column a, .right-column .use-as-template', + function (e) { + // Keep default middle-, shift-, control- and command-click behaviour + if (e.which === 2 || e.metaKey || e.shiftKey || e.ctrlKey) { + return; + } + + e.preventDefault(); + + const path = $(this).attr('href'); + loadPanelContent(path); + window.history.pushState({}, '', path); + }, + ); + // Toggle check box - $('.check-box').click(function () { + container.on('click', '.check-box', function () { const self = $(this); + const checkbox = self.find('[type=checkbox]')[0]; - const name = self.data('attribute'); - $(`[type=checkbox][name=${name}]`).click(); - + checkbox.checked = !checkbox.checked; self.toggleClass('enabled'); // Toggle Transactional check box @@ -182,14 +258,6 @@ $(function () { } }); - // Make sure custom checkboxes reflect the state of the HTML checkboxes - // TODO: Replace checkboxes with native HTML checkboxes and style them with CSS - $(`[type=checkbox]`).each(function () { - const name = $(this).attr('name'); - const isChecked = $(this)[0].checked; - $(`.check-box[data-attribute=${name}]`).toggleClass('enabled', isChecked); - }); - // Toggle between Edit and Review mode container.on('click', '.controls .toggle.button', function (e) { e.preventDefault(); @@ -227,10 +295,11 @@ $(function () { container.on('click', '.controls .send.button', function (e) { e.preventDefault(); - // Distinguish between Send and Send to myself - $('.send-to-myself').prop('checked', $(this).is('.to-myself')); - const $form = $('#send-message'); + const sendToMyself = $(this).is('.to-myself'); + + // Distinguish between Send and Send to myself + $('#id_send_to_myself').prop('checked', sendToMyself); // Submit form $.ajax({ @@ -239,6 +308,9 @@ $(function () { data: $form.serialize(), success: function () { Pontoon.endLoader('Message sent.'); + if (!sendToMyself) { + container.find('.left-column .sent a').click(); + } }, error: function () { Pontoon.endLoader('Oops, something went wrong.', 'error'); diff --git a/pontoon/messaging/templates/messaging/includes/compose.html b/pontoon/messaging/templates/messaging/includes/compose.html new file mode 100644 index 0000000000..098abb9cf3 --- /dev/null +++ b/pontoon/messaging/templates/messaging/includes/compose.html @@ -0,0 +1,224 @@ +{% import "widgets/checkbox.html" as Checkbox %} +{% import 'teams/widgets/multiple_team_selector.html' as multiple_team_selector %} +{% import 'widgets/multiple_item_selector.html' as multiple_item_selector %} + +

    +
    + {% csrf_token %} + {{ form.send_to_myself }} + +
    +

    Message type

    +
      + {{ Checkbox.checkbox('Notification', class='notification', attribute='notification', + is_enabled=form.notification.value(), form_field=form.notification) }} + {{ Checkbox.checkbox('Email', class='email', attribute='email', is_enabled=form.email.value(), + form_field=form.email) }} + {{ Checkbox.checkbox('Transactional', class='transactional', attribute='transactional', + is_enabled=form.transactional.value(), form_field=form.transactional, help='Transactional emails are + also sent to users who have not opted in to email + communication. They are restricted in the type of content that can be included.') }} +
    +
    +

    You must select at least one message type

    +
    +
    + +
    +

    Message content

    +
    + {{ form.subject.label_tag() }} + {{ form.subject }} +
    +

    Your message must include a subject

    +
    +
    +
    + + + {{ form.body }} +
    +

    Supports Markdown

    +
    +
    +

    Your message must include a body

    +
    +
    +
    + +
    +

    Filter by user role

    +
      + {{ Checkbox.checkbox('Managers', class='managers', attribute='managers', + is_enabled=form.managers.value(), form_field=form.managers) }} + {{ Checkbox.checkbox('Translators', class='translators', attribute='translators', + is_enabled=form.translators.value(), form_field=form.translators) }} + {{ Checkbox.checkbox('Contributors', class='contributors', attribute='contributors', + is_enabled=form.contributors.value(), form_field=form.contributors) }} +
    +
    +

    You must select at least one user role

    +
    +
    + +
    +

    Filter by locale

    +
    +
    + {{ multiple_team_selector.render(available_locales, selected_locales, form_field='locales') }} +
    +
    +
    +

    You must select at least one locale

    +
    +
    + +
    +

    Filter by project

    +
    +
    + {{ multiple_item_selector.render(available_projects, selected_projects, form_field=form.projects) }} +
    +
    +
    +

    You must select at least one project

    +
    +
    + + + +
    +

    Filter by performed reviews

    +
    +
    + {{ form.review_minimum.label_tag() }} + {{ form.review_minimum }} +
    +

    The value must be an integer

    +
    +
    +
    + {{ form.review_maximum.label_tag() }} + {{ form.review_maximum }} +
    +

    The value must be an integer

    +
    +
    +
    +
    +
    + {{ form.review_from.label_tag() }} + {{ form.review_from }} +
    +
    + {{ form.review_to.label_tag() }} + {{ form.review_to }} +
    +
    +
    + + +
    + + + +
    +
    +
    +

    Review message

    +
    +
    +

    +
    +
    +
    +
    +
    +
    +
    +

    Recipients

    +
    +
    User roles
    +

    +
    +
    +
    Locales
    +

    +
    +
    +
    Projects
    +

    +
    + +
    +
    Performed reviews
    +

    +

    +
    + +
    +
    +

    Message type

    +

    +

    Warning: Transactional emails are also sent to users who have + not opted in to email communication. They are restricted in the type of content that can be + included. When in doubt, please review with legal.

    +
    + + +
    + + +
    +
    +
    diff --git a/pontoon/messaging/templates/messaging/includes/sent.html b/pontoon/messaging/templates/messaging/includes/sent.html new file mode 100644 index 0000000000..fa9bb50b75 --- /dev/null +++ b/pontoon/messaging/templates/messaging/includes/sent.html @@ -0,0 +1,39 @@ +
    + {% if sent_messages|length == 0 %} +
    + +

    No messages sent yet.

    +
    + {% endif %} + +
    diff --git a/pontoon/messaging/templates/messaging/messaging.html b/pontoon/messaging/templates/messaging/messaging.html index 62ba10e7d3..dde5d3d0ff 100644 --- a/pontoon/messaging/templates/messaging/messaging.html +++ b/pontoon/messaging/templates/messaging/messaging.html @@ -1,9 +1,5 @@ {% extends 'base.html' %} -{% import "widgets/checkbox.html" as Checkbox %} {% import 'heading.html' as Heading %} -{% import "contributors/widgets/notifications_menu.html" as Notifications with context %} -{% import 'teams/widgets/multiple_team_selector.html' as multiple_team_selector %} -{% import 'widgets/multiple_item_selector.html' as multiple_item_selector %} {% block title %}Messaging Center{% endblock %} @@ -14,252 +10,25 @@ {% endblock %} {% block bottom %} +{% set current_page = request.path.split('/')[2]|default('') %}
    diff --git a/pontoon/messaging/urls.py b/pontoon/messaging/urls.py index 434b55d6df..a5a5ce3eb6 100644 --- a/pontoon/messaging/urls.py +++ b/pontoon/messaging/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import include, path from . import views @@ -7,13 +7,60 @@ # Messaging center path( "messaging/", - views.messaging, - name="pontoon.messaging", - ), - path( - "send-message/", - views.send_message, - name="pontoon.messaging.send_message", + include( + [ + # Compose + path( + "", + views.messaging, + name="pontoon.messaging.compose", + ), + # Edit as new + path( + "/", + views.messaging, + name="pontoon.messaging.use_as_template", + ), + # Sent + path( + "sent/", + views.messaging, + name="pontoon.messaging.sent", + ), + # AJAX views + path( + "ajax/", + include( + [ + # Compose + path( + "", + views.ajax_compose, + name="pontoon.messaging.ajax.compose", + ), + # Edit as new + path( + "/", + views.ajax_use_as_template, + name="pontoon.messaging.ajax.use_as_template", + ), + # Sent + path( + "sent/", + views.ajax_sent, + name="pontoon.messaging.ajax.sent", + ), + # Send message + path( + "send/", + views.send_message, + name="pontoon.messaging.ajax.send_message", + ), + ] + ), + ), + ] + ), ), # Email consent path( diff --git a/pontoon/messaging/views.py b/pontoon/messaging/views.py index 5180f72ada..b6b661bd97 100644 --- a/pontoon/messaging/views.py +++ b/pontoon/messaging/views.py @@ -20,12 +20,13 @@ from pontoon.base.models import Locale, Project, Translation, UserProfile from pontoon.base.utils import require_AJAX, split_ints from pontoon.messaging import forms, utils +from pontoon.messaging.models import Message log = logging.getLogger(__name__) -def messaging(request): +def messaging(request, pk=None): if not request.user.has_perm("base.can_manage_project"): raise PermissionDenied @@ -33,8 +34,63 @@ def messaging(request): request, "messaging/messaging.html", { - "available_locales": Locale.objects.available(), - "available_projects": Project.objects.available().order_by("name"), + "count": Message.objects.count(), + }, + ) + + +@require_AJAX +def ajax_compose(request): + if not request.user.has_perm("base.can_manage_project"): + raise PermissionDenied + + return render( + request, + "messaging/includes/compose.html", + { + "form": forms.MessageForm(), + "available_locales": [], + "selected_locales": Locale.objects.available(), + "available_projects": [], + "selected_projects": Project.objects.available().order_by("name"), + }, + ) + + +@require_AJAX +def ajax_use_as_template(request, pk): + if not request.user.has_perm("base.can_manage_project"): + raise PermissionDenied + + message = get_object_or_404(Message, pk=pk) + + return render( + request, + "messaging/includes/compose.html", + { + "form": forms.MessageForm(instance=message), + "available_locales": Locale.objects.available().exclude( + pk__in=message.locales.all() + ), + "selected_locales": message.locales.all(), + "available_projects": Project.objects.available().exclude( + pk__in=message.projects.all() + ), + "selected_projects": message.projects.all().order_by("name"), + }, + ) + + +@require_AJAX +def ajax_sent(request): + if not request.user.has_perm("base.can_manage_project"): + raise PermissionDenied + + return render( + request, + "messaging/includes/sent.html", + { + "sent_messages": Message.objects.order_by("-sent_at"), }, ) @@ -49,7 +105,7 @@ def get_recipients(form): - Translators of selected Locales """ locale_ids = sorted(split_ints(form.cleaned_data.get("locales"))) - project_ids = sorted(split_ints(form.cleaned_data.get("projects"))) + project_ids = form.cleaned_data.get("projects") translations = Translation.objects.filter( locale_id__in=locale_ids, entity__resource__project_id__in=project_ids, @@ -168,21 +224,22 @@ def send_message(request): form = forms.MessageForm(request.POST) if not form.is_valid(): - return JsonResponse(dict(form.errors.items())) + 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) - if form.cleaned_data.get("send_to_myself"): - recipients = User.objects.filter(pk=request.user.pk) - else: + """ + 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) + """ log.info( f"{recipients.count()} Recipients: {list(recipients.values_list('email', flat=True))}" ) - # While the feature is in development, notifications and emails are sent only to the current user. - # TODO: Remove this line when the feature is ready - recipients = User.objects.filter(pk=request.user.pk) - is_notification = form.cleaned_data.get("notification") is_email = form.cleaned_data.get("email") is_transactional = form.cleaned_data.get("transactional") @@ -236,6 +293,21 @@ def send_message(request): f"Email sent to the following {email_recipients.count()} users: {email_recipients.values_list('email', flat=True)}." ) + if not send_to_myself: + message = form.save(commit=False) + message.sender = request.user + message.save() + + message.recipients.set(recipients) + + locale_ids = sorted(split_ints(form.cleaned_data.get("locales"))) + locales = Locale.objects.filter(pk__in=locale_ids) + message.locales.set(locales) + + project_ids = form.cleaned_data.get("projects") + projects = Project.objects.filter(pk__in=project_ids) + message.projects.set(projects) + return JsonResponse( { "status": True, diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index 327290db6a..8c294504ca 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -705,7 +705,6 @@ def _default_from_email(): "messaging": { "source_filenames": ( "js/lib/showdown.js", - "js/sidebar_menu.js", "js/multiple_team_selector.js", "js/multiple_item_selector.js", "js/messaging.js", diff --git a/pontoon/urls.py b/pontoon/urls.py index 8968417d84..8cf369f242 100644 --- a/pontoon/urls.py +++ b/pontoon/urls.py @@ -55,13 +55,13 @@ class LocaleConverter(StringConverter): # Include URL configurations from installed apps path("terminology/", include("pontoon.terminology.urls")), path("translations/", include("pontoon.translations.urls")), + path("", include("pontoon.messaging.urls")), path("", include("pontoon.teams.urls")), path("", include("pontoon.tour.urls")), path("", include("pontoon.tags.urls")), path("", include("pontoon.sync.urls")), path("", include("pontoon.projects.urls")), path("", include("pontoon.machinery.urls")), - path("", include("pontoon.messaging.urls")), path("", include("pontoon.insights.urls")), path("", include("pontoon.contributors.urls")), path("", include("pontoon.localizations.urls")),