diff --git a/pontoon/base/static/css/check-box.css b/pontoon/base/static/css/check-box.css index bcb6284cf2..eb8e9c7771 100644 --- a/pontoon/base/static/css/check-box.css +++ b/pontoon/base/static/css/check-box.css @@ -6,7 +6,7 @@ text-align: left; .check-box { - padding: 8px 0; + padding: 4px 0; } .check-box:last-child { diff --git a/pontoon/base/static/css/style.css b/pontoon/base/static/css/style.css index 58f63ccd58..e7b417d544 100755 --- a/pontoon/base/static/css/style.css +++ b/pontoon/base/static/css/style.css @@ -139,6 +139,7 @@ input[type='password'], input[type='url'], input[type='number'], input[type='email'], +input[type='date'], textarea { background: var(--input-background-1); border: 1px solid var(--main-border-1); @@ -1152,13 +1153,15 @@ body > form, } .check-box .fa:before { - color: var(--status-error); - content: ''; + color: var(--toggle-color-1); + content: ''; + font-weight: normal; } .check-box.enabled .fa:before { color: var(--status-translated); content: ''; + font-weight: bold; } #helpers > section ul { diff --git a/pontoon/contributors/static/css/settings.css b/pontoon/contributors/static/css/settings.css index 5ef8db6b18..4859d52286 100644 --- a/pontoon/contributors/static/css/settings.css +++ b/pontoon/contributors/static/css/settings.css @@ -129,6 +129,10 @@ font-style: italic; } +#main .check-list .check-box { + padding: 8px 0; +} + #main .appearance .field .help { margin: 0 0 -12px; } diff --git a/pontoon/messaging/forms.py b/pontoon/messaging/forms.py index 8e385c36fe..80ed85acda 100644 --- a/pontoon/messaging/forms.py +++ b/pontoon/messaging/forms.py @@ -22,3 +22,16 @@ class MessageForm(forms.Form): projects = forms.CharField( 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) + + 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) + + login_from = forms.DateField(required=False) + login_to = forms.DateField(required=False) diff --git a/pontoon/messaging/static/css/messaging.css b/pontoon/messaging/static/css/messaging.css index 0d2eab9335..c13de2eae8 100644 --- a/pontoon/messaging/static/css/messaging.css +++ b/pontoon/messaging/static/css/messaging.css @@ -4,13 +4,10 @@ } #compose { - padding: 20px; box-sizing: border-box; h3 { - color: var(--white-1); - font-size: 22px; - letter-spacing: 0; + margin-bottom: 20px; .stress { color: var(--status-translated); @@ -19,6 +16,7 @@ .controls { margin: 0; + text-align: right; } .errors { @@ -33,7 +31,7 @@ } #send-message > section { - margin: 20px 0; + padding: 20px; } .check-list { @@ -64,16 +62,27 @@ .field { color: var(--light-grey-7); font-size: 16px; - margin-bottom: 20px; + margin-bottom: 10px; text-align: left; } + .field.half { + float: left; + width: 285px; + } + + .field.half:nth-child(2n) { + float: right; + } + label { display: block; margin-bottom: 5px; } input[type='text'], + input[type='date'], + input[type='number'], textarea { background: var(--black-3); float: none; @@ -88,7 +97,7 @@ height: 150px; } - .message-editor .subtitle { + .message-content .subtitle { color: var(--light-grey-7); float: right; font-size: 13px; diff --git a/pontoon/messaging/static/js/messaging.js b/pontoon/messaging/static/js/messaging.js index dd681b401e..d1c80da107 100644 --- a/pontoon/messaging/static/js/messaging.js +++ b/pontoon/messaging/static/js/messaging.js @@ -32,6 +32,19 @@ $(function () { const isValidProject = $form.find('[name=projects]').val(); + const isValidTranslationMinimum = $form + .find('#translation-minimum')[0] + .checkValidity(); + const isValidTranslationMaximum = $form + .find('#translation-maximum')[0] + .checkValidity(); + const isValidReviewMinimum = $form + .find('#review-minimum')[0] + .checkValidity(); + const isValidReviewMaximum = $form + .find('#review-maximum')[0] + .checkValidity(); + $form.find('.errors').css('visibility', 'hidden'); function showErrorIfNotValid(isValid, selector) { @@ -46,6 +59,16 @@ $(function () { showErrorIfNotValid(isValidRole, '.filter-user-role'); showErrorIfNotValid(isValidLocale, '.filter-locale'); showErrorIfNotValid(isValidProject, '.filter-project'); + showErrorIfNotValid( + isValidTranslationMinimum, + '.filter-translation > .minimum', + ); + showErrorIfNotValid( + isValidTranslationMaximum, + '.filter-translation > .maximum', + ); + showErrorIfNotValid(isValidReviewMinimum, '.filter-review > .minimum'); + showErrorIfNotValid(isValidReviewMaximum, '.filter-review > .maximum'); return ( isValidType && @@ -53,7 +76,11 @@ $(function () { isValidBody && isValidRole && isValidLocale && - isValidProject + isValidProject && + isValidTranslationMinimum && + isValidTranslationMaximum && + isValidReviewMinimum && + isValidReviewMaximum ); } diff --git a/pontoon/messaging/templates/messaging/messaging.html b/pontoon/messaging/templates/messaging/messaging.html index f9d78c0981..219ebdcab6 100644 --- a/pontoon/messaging/templates/messaging/messaging.html +++ b/pontoon/messaging/templates/messaging/messaging.html @@ -15,116 +15,195 @@ {% block bottom %}
-
-
- - - +
{% endblock %} diff --git a/pontoon/messaging/views.py b/pontoon/messaging/views.py index fcd874b7d0..cace0d7fc9 100644 --- a/pontoon/messaging/views.py +++ b/pontoon/messaging/views.py @@ -11,6 +11,7 @@ from django.core.exceptions import PermissionDenied from django.core.mail import EmailMultiAlternatives from django.db import transaction +from django.db.models import Count, F from django.http import JsonResponse from django.shortcuts import get_object_or_404, render from django.utils import timezone @@ -43,103 +44,188 @@ def messaging(request): @require_POST @transaction.atomic def send_message(request): - # Send notifications - if request.method == "POST": - form = forms.MessageForm(request.POST) - - if not form.is_valid(): - return JsonResponse(dict(form.errors.items())) - - locale_ids = sorted(split_ints(form.cleaned_data.get("locales"))) - project_ids = sorted(split_ints(form.cleaned_data.get("projects"))) - - recipients = User.objects.none() - - if form.cleaned_data.get("contributors"): - contributors = ( - Translation.objects.filter( - locale_id__in=locale_ids, - entity__resource__project_id__in=project_ids, - ) - .values("user") - .distinct() - ) - recipients = recipients | User.objects.filter(pk__in=contributors) + form = forms.MessageForm(request.POST) + + if not form.is_valid(): + return JsonResponse(dict(form.errors.items())) + + recipients = User.objects.none() + + """ + Filter recipients by user role: + - 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 = sorted(split_ints(form.cleaned_data.get("projects"))) + translations = Translation.objects.filter( + locale_id__in=locale_ids, + entity__resource__project_id__in=project_ids, + ) - 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) + if form.cleaned_data.get("contributors"): + contributors = translations.values("user").distinct() + recipients = recipients | User.objects.filter(pk__in=contributors) - 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) + 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) - log.info( - f"{recipients.count()} Recipients: {list(recipients.values_list('email', flat=True))}" + 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) + + """ + Filter recipients by login date: + - Logged in after provided From date + - Logged in before provided To date + """ + login_from = form.cleaned_data.get("login_from") + login_to = form.cleaned_data.get("login_to") + + if login_from: + recipients = recipients.filter(last_login__gte=login_from) + + if login_to: + recipients = recipients.filter(last_login__lte=login_to) + + """ + Filter recipients by translation submissions: + - Submitted more than provided Minimum translations + - Submitted less than provided Maximum translations + - Submitted translations after provided From date + - Submitted translations before provided To date + """ + translation_minimum = form.cleaned_data.get("translation_minimum") + translation_maximum = form.cleaned_data.get("translation_maximum") + translation_from = form.cleaned_data.get("translation_from") + translation_to = form.cleaned_data.get("translation_to") + + submitted = translations + + if translation_from: + submitted = submitted.filter(date__gte=translation_from) + + if translation_to: + submitted = submitted.filter(date__lte=translation_to) + + submitted = submitted.values("user").annotate(count=Count("user")) + + if translation_minimum: + submitted = submitted.filter(count__gte=translation_minimum) + + if translation_maximum: + submitted = submitted.filter(count__lte=translation_maximum) + + """ + Filter recipients by reviews performed: + - Reviewed more than provided Minimum translations + - Reviewed less than provided Maximum translations + - Reviewed translations after provided From date + - Reviewed translations before provided To date + """ + review_minimum = form.cleaned_data.get("review_minimum") + review_maximum = form.cleaned_data.get("review_maximum") + review_from = form.cleaned_data.get("review_from") + review_to = form.cleaned_data.get("review_to") + + approved = translations.filter(approved_user__isnull=False).exclude( + user=F("approved_user") + ) + rejected = translations.filter(rejected_user__isnull=False).exclude( + user=F("rejected_user") + ) + + if review_from: + approved = approved.filter(approved_date__gte=review_from) + rejected = rejected.filter(rejected_date__gte=review_from) + + if review_to: + 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")) - # 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") - subject = form.cleaned_data.get("subject") - body = form.cleaned_data.get("body") - - if is_notification: - identifier = uuid.uuid4().hex - for recipient in recipients.distinct(): - notify.send( - request.user, - recipient=recipient, - verb="has sent you a message", - target=None, - description=f"{subject}

{body}", - identifier=identifier, - ) - - log.info( - f"Notifications sent to the following {recipients.count()} users: {recipients.values_list('email', flat=True)}." + if review_minimum: + approved = approved.filter(count__gte=review_minimum) + rejected = rejected.filter(count__gte=review_minimum) + + if review_maximum: + 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()) + ) + + 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") + subject = form.cleaned_data.get("subject") + body = form.cleaned_data.get("body") + + if is_notification: + identifier = uuid.uuid4().hex + for recipient in recipients.distinct(): + notify.send( + request.user, + recipient=recipient, + verb="has sent you a message", + target=None, + description=f"{subject}

{body}", + identifier=identifier, ) - if is_email: - footer = ( - """

+ log.info( + f"Notifications sent to the following {recipients.count()} users: {recipients.values_list('email', flat=True)}." + ) + + if is_email: + footer = ( + """

You’re receiving this email as a contributor to Mozilla localization on Pontoon.
To no longer receive emails like these, unsubscribe here: Unsubscribe. - """ - if not is_transactional - else "" - ) - html_template = body + footer - text_template = utils.html_to_plain_text_with_links(html_template) + """ + if not is_transactional + else "" + ) + html_template = body + footer + text_template = utils.html_to_plain_text_with_links(html_template) - email_recipients = recipients.filter( - profile__email_communications_enabled=True - ) + email_recipients = recipients.filter(profile__email_communications_enabled=True) + + 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) - 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) - - msg = EmailMultiAlternatives( - subject=subject, - body=text, - from_email=settings.DEFAULT_FROM_EMAIL, - to=[recipient.contact_email], - ) - 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)}." + msg = EmailMultiAlternatives( + subject=subject, + body=text, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[recipient.contact_email], ) + 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)}." + ) return JsonResponse( {