From ff529a9b07b3e25bd3d5c3f6271d822ce297a628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Sat, 16 Nov 2024 00:42:05 +0100 Subject: [PATCH 01/17] Add Translation Memory tab to the team page (#3437) * List all TM entries for the locale * Markup empty strings to make them accessible in translate view * Implement infinite scroll with skeleton loader * Add ability to search TM entries with match highlighting * Add ability to Delete TM entries * Add ability to Edit TM entries --- pontoon/actionlog/admin.py | 1 + ..._tm_entries_alter_actionlog_action_type.py | 37 +++ pontoon/actionlog/models.py | 80 +++++-- pontoon/actionlog/tests/test_models.py | 42 ++++ pontoon/actionlog/utils.py | 9 +- pontoon/base/templatetags/helpers.py | 32 +++ pontoon/settings/base.py | 2 + .../teams/static/css/translation_memory.css | 221 ++++++++++++++++++ pontoon/teams/static/js/translation_memory.js | 145 ++++++++++++ .../teams/includes/translation_memory.html | 26 +++ pontoon/teams/templates/teams/team.html | 10 + .../widgets/translation_memory_entries.html | 62 +++++ pontoon/teams/urls.py | 24 ++ pontoon/teams/views.py | 114 ++++++++- 14 files changed, 781 insertions(+), 24 deletions(-) create mode 100644 pontoon/actionlog/migrations/0006_actionlog_tm_entries_alter_actionlog_action_type.py create mode 100644 pontoon/teams/static/css/translation_memory.css create mode 100644 pontoon/teams/static/js/translation_memory.js create mode 100644 pontoon/teams/templates/teams/includes/translation_memory.html create mode 100644 pontoon/teams/templates/teams/widgets/translation_memory_entries.html diff --git a/pontoon/actionlog/admin.py b/pontoon/actionlog/admin.py index 095c79bd17..26e334ce20 100644 --- a/pontoon/actionlog/admin.py +++ b/pontoon/actionlog/admin.py @@ -20,6 +20,7 @@ class ActionLogAdmin(admin.ModelAdmin): "performed_by", "entity", "translation", + "tm_entries", ) readonly_fields = ("created_at",) diff --git a/pontoon/actionlog/migrations/0006_actionlog_tm_entries_alter_actionlog_action_type.py b/pontoon/actionlog/migrations/0006_actionlog_tm_entries_alter_actionlog_action_type.py new file mode 100644 index 0000000000..844fb41e1b --- /dev/null +++ b/pontoon/actionlog/migrations/0006_actionlog_tm_entries_alter_actionlog_action_type.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.16 on 2024-11-14 00:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("base", "0067_remove_userprofile_community_builder_level_and_more"), + ("actionlog", "0005_migrate_old_translations_to_actionlog"), + ] + + operations = [ + migrations.AddField( + model_name="actionlog", + name="tm_entries", + field=models.ManyToManyField(blank=True, to="base.translationmemoryentry"), + ), + migrations.AlterField( + model_name="actionlog", + name="action_type", + field=models.CharField( + choices=[ + ("translation:created", "Translation created"), + ("translation:deleted", "Translation deleted"), + ("translation:approved", "Translation approved"), + ("translation:unapproved", "Translation unapproved"), + ("translation:rejected", "Translation rejected"), + ("translation:unrejected", "Translation unrejected"), + ("comment:added", "Comment added"), + ("tm_entry:deleted", "TranslationMemoryEntry deleted"), + ("tm_entries:edited", "TranslationMemoryEntries edited"), + ("tm_entries:uploaded", "TranslationMemoryEntries uploaded"), + ], + max_length=50, + ), + ), + ] diff --git a/pontoon/actionlog/models.py b/pontoon/actionlog/models.py index 37a5bd6cac..353ec18162 100644 --- a/pontoon/actionlog/models.py +++ b/pontoon/actionlog/models.py @@ -19,6 +19,12 @@ class ActionType(models.TextChoices): TRANSLATION_UNREJECTED = "translation:unrejected", "Translation unrejected" # A comment has been added. COMMENT_ADDED = "comment:added", "Comment added" + # A TranslationMemoryEntry has been deleted. + TM_ENTRY_DELETED = "tm_entry:deleted", "TranslationMemoryEntry deleted" + # TranslationMemoryEntries have been edited. + TM_ENTRIES_EDITED = "tm_entries:edited", "TranslationMemoryEntries edited" + # TranslationMemoryEntries have been uploaded. + TM_ENTRIES_UPLOADED = "tm_entries:uploaded", "TranslationMemoryEntries uploaded" action_type = models.CharField(max_length=50, choices=ActionType.choices) created_at = models.DateTimeField(default=timezone.now) @@ -26,7 +32,7 @@ class ActionType(models.TextChoices): "auth.User", models.SET_NULL, related_name="actions", null=True ) - # Used to track on what translation related actions apply. + # Used to track translation-related actions. translation = models.ForeignKey( "base.Translation", models.CASCADE, @@ -34,7 +40,7 @@ class ActionType(models.TextChoices): null=True, ) - # Used when a translation has been deleted or a team comment has been added. + # Used when a translation or TM entry has been deleted, or a team comment has been added. entity = models.ForeignKey( "base.Entity", models.CASCADE, @@ -48,6 +54,12 @@ class ActionType(models.TextChoices): null=True, ) + # Used to track actions related to TM entries. + tm_entries = models.ManyToManyField( + "base.TranslationMemoryEntry", + blank=True, + ) + def validate_action_type_choice(self): valid_types = self.ActionType.values if self.action_type not in valid_types: @@ -58,31 +70,59 @@ def validate_action_type_choice(self): ) def validate_foreign_keys_per_action(self): - if self.action_type == self.ActionType.TRANSLATION_DELETED and ( - self.translation or not self.entity or not self.locale + if self.action_type in ( + self.ActionType.TRANSLATION_DELETED, + self.ActionType.TM_ENTRY_DELETED, ): - raise ValidationError( - f'For action type "{self.action_type}", `entity` and `locale` are required' - ) + if self.translation or not self.entity or not self.locale: + raise ValidationError( + f'For action type "{self.action_type}", only `entity` and `locale` are accepted' + ) + + elif self.action_type == self.ActionType.COMMENT_ADDED: + if not ( + (self.translation and not self.locale and not self.entity) + or (not self.translation and self.locale and self.entity) + ): + raise ValidationError( + f'For action type "{self.action_type}", either `translation` or `entity` and `locale` are accepted' + ) - if self.action_type == self.ActionType.COMMENT_ADDED and not ( - (self.translation and not self.locale and not self.entity) - or (not self.translation and self.locale and self.entity) + elif self.action_type in ( + self.ActionType.TRANSLATION_CREATED, + self.ActionType.TRANSLATION_APPROVED, + self.ActionType.TRANSLATION_UNAPPROVED, + self.ActionType.TRANSLATION_REJECTED, + self.ActionType.TRANSLATION_UNREJECTED, ): - raise ValidationError( - f'For action type "{self.action_type}", either `translation` or `entity` and `locale` are required' - ) + if not self.translation or self.entity or self.locale: + raise ValidationError( + f'For action type "{self.action_type}", only `translation` is accepted' + ) - if ( - self.action_type != self.ActionType.TRANSLATION_DELETED - and self.action_type != self.ActionType.COMMENT_ADDED - ) and (not self.translation or self.entity or self.locale): - raise ValidationError( - f'For action type "{self.action_type}", only `translation` is accepted' - ) + elif self.action_type in ( + self.ActionType.TM_ENTRIES_EDITED, + self.ActionType.TM_ENTRIES_UPLOADED, + ): + if self.translation or self.entity or self.locale: + raise ValidationError( + f'For action type "{self.action_type}", only `tm_entries` is accepted' + ) + + def validate_many_to_many_relationships_per_action(self): + if self.action_type in ( + self.ActionType.TM_ENTRIES_EDITED, + self.ActionType.TM_ENTRIES_UPLOADED, + ): + if not self.tm_entries or self.translation or self.entity or self.locale: + raise ValidationError( + f'For action type "{self.action_type}", only `tm_entries` is accepted' + ) def save(self, *args, **kwargs): self.validate_action_type_choice() self.validate_foreign_keys_per_action() super().save(*args, **kwargs) + + self.validate_many_to_many_relationships_per_action() diff --git a/pontoon/actionlog/tests/test_models.py b/pontoon/actionlog/tests/test_models.py index 97cb496b2a..0b409c6a96 100644 --- a/pontoon/actionlog/tests/test_models.py +++ b/pontoon/actionlog/tests/test_models.py @@ -4,6 +4,7 @@ from pontoon.actionlog import utils from pontoon.actionlog.models import ActionLog +from pontoon.test.factories import TranslationMemoryFactory @pytest.mark.django_db @@ -88,3 +89,44 @@ def test_log_action_valid_with_entity_locale(user_a, entity_a, locale_a): ) assert len(log) == 1 assert log[0].action_type == ActionLog.ActionType.TRANSLATION_DELETED + + +@pytest.mark.django_db +def test_log_action_valid_with_tm_entry_deleted(user_a, entity_a, locale_a): + utils.log_action( + ActionLog.ActionType.TM_ENTRY_DELETED, + user_a, + entity=entity_a, + locale=locale_a, + ) + + log = ActionLog.objects.filter( + performed_by=user_a, + entity=entity_a, + locale=locale_a, + ) + assert len(log) == 1 + assert log[0].action_type == ActionLog.ActionType.TM_ENTRY_DELETED + + +@pytest.mark.django_db +def test_log_action_valid_with_tm_entries_edited(user_a, entity_a, locale_a): + tm_entry = TranslationMemoryFactory.create( + entity=entity_a, + source=entity_a.string, + target="tm_translation", + locale=locale_a, + ) + + utils.log_action( + ActionLog.ActionType.TM_ENTRIES_EDITED, + user_a, + tm_entries=[tm_entry], + ) + + log = ActionLog.objects.filter( + performed_by=user_a, + tm_entries=tm_entry.pk, + ) + assert len(log) == 1 + assert log[0].action_type == ActionLog.ActionType.TM_ENTRIES_EDITED diff --git a/pontoon/actionlog/utils.py b/pontoon/actionlog/utils.py index 9477bcaa13..a252672ca5 100644 --- a/pontoon/actionlog/utils.py +++ b/pontoon/actionlog/utils.py @@ -7,6 +7,7 @@ def log_action( translation=None, entity=None, locale=None, + tm_entries=None, ): """Save a new action in the database. @@ -17,10 +18,11 @@ def log_action( :arg Translation translation: The Translation the action was performed on. :arg Entity entity: The Entity the action was performed on. - Only used for the "translation:deleted" action. + Only used for the "translation:deleted", "tm_entries:deleted" and "comment:added" actions. :arg Locale locale: The Locale the action was performed on. - Only used for the "translation:deleted" action. + Only used for the "translation:deleted", "tm_entries:deleted" and "comment:added" actions. + :arg list tm_entries: A list of TranslationMemoryEntries the action was performed on. :returns: None """ @@ -32,3 +34,6 @@ def log_action( locale=locale, ) action.save() + + if tm_entries: + action.tm_entries.set(tm_entries) diff --git a/pontoon/base/templatetags/helpers.py b/pontoon/base/templatetags/helpers.py index 05886ef56d..00f757b001 100644 --- a/pontoon/base/templatetags/helpers.py +++ b/pontoon/base/templatetags/helpers.py @@ -1,6 +1,7 @@ import datetime import html import json +import re from datetime import timedelta @@ -18,7 +19,9 @@ from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse from django.utils import timezone +from django.utils.html import escape from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.safestring import mark_safe from pontoon.base.fluent import get_simple_preview @@ -288,3 +291,32 @@ def set_attrs(attrs, new=False): linker = Linker(callbacks=[set_attrs]) return linker.linkify(source) + + +@library.filter +def highlight_matches(text, search_query): + """Highlight all occurrences of the search query in the text.""" + if not search_query: + return text + + # First, escape the text to prevent HTML rendering + escaped_text = escape(text) + + # Then apply highlighting to the escaped text + highlighted_text = re.sub( + f"({re.escape(search_query)})", + r"\1", + escaped_text, + flags=re.IGNORECASE, + ) + + # Mark as safe to include tags only + return mark_safe(highlighted_text) + + +@library.filter +def default_if_empty(value, default=""): + """Return the original value if it's not empty or None, else use the default""" + + # Mark as safe to include HTML tags + return value if value else mark_safe(default) diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index 2dbd3b2bf2..f09bcf729a 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -495,6 +495,7 @@ def _default_from_email(): "css/insights_charts.css", "css/insights_tab.css", "css/info.css", + "css/translation_memory.css", ), "output_filename": "css/team.min.css", }, @@ -665,6 +666,7 @@ def _default_from_email(): "js/insights_charts.js", "js/insights_tab.js", "js/info.js", + "js/translation_memory.js", ), "output_filename": "js/team.min.js", }, diff --git a/pontoon/teams/static/css/translation_memory.css b/pontoon/teams/static/css/translation_memory.css new file mode 100644 index 0000000000..bbf8f1bdd8 --- /dev/null +++ b/pontoon/teams/static/css/translation_memory.css @@ -0,0 +1,221 @@ +.translation-memory { + .upload-button { + float: right; + + .fa { + padding-right: 2px; + } + } + + table { + height: 100%; + + tr { + height: 100%; + + border-top: 1px solid var(--main-border-1); + + .text { + width: 340px; + } + + .actions { + text-align: right; + } + } + + tr:first-child { + border-top: none; + } + + tbody { + tr.skeleton-loader { + display: none; + + .skeleton { + animation: fading 1.5s infinite; + background-color: var(--translation-secondary-color); + font-size: 15px; + height: 28px; + width: 80%; + } + + .actions { + .skeleton { + float: right; + width: 54%; + } + } + } + + tr.skeleton-loader.loading { + display: table-row; + } + + tr { + td { + padding: 10px; + } + + td.no-results { + text-align: left; + } + + .text { + color: var(--light-grey-7); + font-size: 15px; + line-height: 1.8em; + + mark { + background: var(--search-background); + color: var(--search-color); + font-weight: inherit; + font-style: inherit; + border-radius: 3px; + } + + .empty { + border: 1px solid var(--main-border-1); + border-radius: 3px; + color: var(--light-grey-7); + padding: 0 4px; + } + + a { + color: var(--white-1); + display: inline-block; + } + + a:hover { + color: var(--status-translated); + + .empty { + color: var(--status-translated); + } + } + } + + .target { + textarea { + display: none; + box-sizing: border-box; + color: var(--white-1); + font-size: 15px; + font-weight: 100; + height: 100%; + width: 100%; + } + } + + .actions { + vertical-align: top; + width: 240px; + + .button { + background: var(--button-background-1); + margin-left: 5px; + + .fa { + margin-right: 2px; + } + } + + .button.edit:hover { + background: var(--status-translated); + } + + .button.save { + display: none; + } + + .button.save:hover { + background: var(--status-translated); + } + + .button.delete:hover { + background: var(--status-error); + } + + .button.are-you-sure { + display: none; + background: var(--status-error); + color: var(--black-3); + } + + .button.cancel { + display: none; + background-color: transparent; + margin-left: 0; + + .fa { + margin-right: 0; + } + } + + .button.cancel:hover { + .fa { + color: var(--white-1); + } + } + } + } + + tr.editing { + .target { + .content-wrapper { + display: none; + } + + textarea { + display: block; + } + } + + .button { + display: none; + } + + .button.save { + display: inline-block; + } + + .button.cancel { + display: inline-block; + } + } + + tr.deleting { + .button { + display: none; + } + + .button.are-you-sure { + display: inline-block; + } + + .button.cancel { + display: inline-block; + } + } + + tr.fade-out { + opacity: 0; + transition: opacity 0.5s ease; + } + } + } +} + +@keyframes fading { + 0% { + opacity: 0.1; + } + + 50% { + opacity: 0.2; + } + + 100% { + opacity: 0.1; + } +} diff --git a/pontoon/teams/static/js/translation_memory.js b/pontoon/teams/static/js/translation_memory.js new file mode 100644 index 0000000000..9279b7f11a --- /dev/null +++ b/pontoon/teams/static/js/translation_memory.js @@ -0,0 +1,145 @@ +$(function () { + let currentPage = 1; // First page is loaded on page load + let search = ''; + const locale = $('#server').data('locale'); + + function loadMoreEntries() { + const tmList = $('#main .container .translation-memory-list'); + const loader = tmList.find('.skeleton-loader'); + + // If the loader is already loading, don't load more entries + if (loader.is('.loading')) { + return; + } + + loader.each(function () { + $(this).addClass('loading'); + }); + + $.ajax({ + url: `/${locale}/ajax/translation-memory/`, + data: { page: currentPage + 1, search: search }, + success: function (data) { + loader.each(function () { + $(this).remove(); + }); + const tbody = tmList.find('tbody'); + if (currentPage === 0) { + // Clear the table if it's a new search + tbody.empty(); + } + tbody.append(data); + currentPage += 1; + }, + error: function () { + Pontoon.endLoader('Error loading more TM entries.'); + loader.each(function () { + $(this).removeClass('loading'); + }); + }, + }); + } + + $(window).scroll(function () { + if ( + $(window).scrollTop() + $(window).height() >= + $(document).height() - 300 + ) { + // If there's no loader, there are no more entries to load + if (!$('#main .translation-memory-list .skeleton-loader').length) { + return; + } + loadMoreEntries(); + } + }); + + // Listen for search on Enter key press + $('body').on('keypress', '.table-filter', function (e) { + if (e.key === 'Enter') { + currentPage = 0; // Reset page count for a new search + search = $(this).val(); + loadMoreEntries(); + } + }); + + // Cancel action + $('body').on('click', '.button.cancel', function () { + const row = $(this).parents('tr'); + row.removeClass('deleting editing'); + }); + + // Edit TM entries + $('body').on('click', '.button.edit', function () { + const row = $(this).parents('tr'); + row.addClass('editing'); + row.find('.target textarea').focus(); + }); + $('body').on('click', '.button.save', function () { + const row = $(this).parents('tr'); + const ids = row.data('ids'); + const target = row.find('.target textarea').val(); + + $.ajax({ + url: `/${locale}/ajax/translation-memory/edit/`, + method: 'POST', + data: { + csrfmiddlewaretoken: $('body').data('csrf'), + ids: ids, + target: target, + }, + success: function () { + Pontoon.endLoader('TM entries edited.'); + + let node = row.find('.target .content-wrapper'); + if (node.find('a').length) { + node = node.find('a'); + } + + let new_target = target; + if (target === '') { + new_target = 'Empty string'; + } + + node.html(new_target); + }, + error: function () { + Pontoon.endLoader('Error editing TM entries.'); + }, + complete: function () { + row.removeClass('editing'); + }, + }); + }); + + // Delete TM entries + $('body').on('click', '.button.delete', function () { + const row = $(this).parents('tr'); + row.addClass('deleting'); + }); + $('body').on('click', '.button.are-you-sure', function () { + const row = $(this).parents('tr'); + const ids = row.data('ids'); + + $.ajax({ + url: `/${locale}/ajax/translation-memory/delete/`, + method: 'POST', + data: { + csrfmiddlewaretoken: $('body').data('csrf'), + ids: ids, + }, + success: function () { + row.addClass('fade-out'); + setTimeout(function () { + row.remove(); + Pontoon.endLoader('TM entries deleted.'); + }, 500); + }, + error: function () { + Pontoon.endLoader('Error deleting TM entries.'); + }, + complete: function () { + row.removeClass('deleting'); + }, + }); + }); +}); diff --git a/pontoon/teams/templates/teams/includes/translation_memory.html b/pontoon/teams/templates/teams/includes/translation_memory.html new file mode 100644 index 0000000000..e916bbe4d5 --- /dev/null +++ b/pontoon/teams/templates/teams/includes/translation_memory.html @@ -0,0 +1,26 @@ +{% if tm_entries|length == 0 %} +

No translation memory entries stored yet.

+{% else %} +
+ +
+ + +
+
+ + + + + + + + + + + {% include "teams/widgets/translation_memory_entries.html" with context %} + +
SourceTranslationActions
+
+{% endif %} diff --git a/pontoon/teams/templates/teams/team.html b/pontoon/teams/templates/teams/team.html index dd6232deb4..df05443bc2 100644 --- a/pontoon/teams/templates/teams/team.html +++ b/pontoon/teams/templates/teams/team.html @@ -128,6 +128,16 @@

) }} {% endif %} + {% if request.user.has_perm('base.can_manage_locale', locale) %} + {{ Menu.item( + 'TM', + url('pontoon.teams.translation-memory', locale.code), + is_active = (current_page == 'translation-memory'), + count = False, + icon = 'database', + ) + }} + {% endif %} {% endcall %} diff --git a/pontoon/teams/templates/teams/widgets/translation_memory_entries.html b/pontoon/teams/templates/teams/widgets/translation_memory_entries.html new file mode 100644 index 0000000000..45f6447eab --- /dev/null +++ b/pontoon/teams/templates/teams/widgets/translation_memory_entries.html @@ -0,0 +1,62 @@ +{% macro linkify(text, entity_ids) %} + {% if entity_ids %} + + {{ text | default_if_empty('Empty string') }} + + {% else %} + {{ text | default_if_empty('Empty string') }} + {% endif %} +{% endmacro %} + +{% for entry in tm_entries %} + + + {{ linkify(entry.source|highlight_matches(search_query), entry.entity_ids) }} + + +
{{ linkify(entry.target|highlight_matches(search_query), entry.entity_ids) }}
+ + + + + + + + + + +{% endfor %} + +{% if tm_entries|length == 0 %} +No matches found. +{% endif %} + +{% if has_next %} +{% for i in range(3) %} + + +
Loading...
+ + +
Loading...
+ + +
Loading...
+ + +{% endfor %} +{% endif %} diff --git a/pontoon/teams/urls.py b/pontoon/teams/urls.py index 03959b0c18..89f7e38860 100644 --- a/pontoon/teams/urls.py +++ b/pontoon/teams/urls.py @@ -56,6 +56,12 @@ ), name="pontoon.teams.manage", ), + # Team translation memory + path( + "translation-memory/", + views.team, + name="pontoon.teams.translation-memory", + ), # AJAX views path( "ajax/", @@ -97,6 +103,24 @@ views.ajax_permissions, name="pontoon.teams.ajax.permissions", ), + # Team translation memory + path( + "translation-memory/", + views.ajax_translation_memory, + name="pontoon.teams.ajax.translation-memory", + ), + # Edit translation memory entries + path( + "translation-memory/edit/", + views.ajax_translation_memory_edit, + name="pontoon.teams.ajax.translation-memory.edit", + ), + # Delete translation memory entries + path( + "translation-memory/delete/", + views.ajax_translation_memory_delete, + name="pontoon.teams.ajax.translation-memory.delete", + ), ] ), ), diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py index f69c0ff9c6..821dcb2c2b 100644 --- a/pontoon/teams/views.py +++ b/pontoon/teams/views.py @@ -6,24 +6,31 @@ from django.conf import settings from django.contrib.auth.decorators import login_required +from django.contrib.postgres.aggregates import ArrayAgg, StringAgg from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured from django.core.mail import EmailMessage +from django.core.paginator import Paginator from django.db import transaction -from django.db.models import Count, Prefetch, Q +from django.db.models import Count, Prefetch, Q, TextField +from django.db.models.functions import Cast from django.http import ( Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, + JsonResponse, ) from django.shortcuts import get_object_or_404, render from django.template.loader import get_template +from django.utils.datastructures import MultiValueDictKeyError from django.views.decorators.http import require_POST from django.views.generic.detail import DetailView +from pontoon.actionlog.models import ActionLog +from pontoon.actionlog.utils import log_action from pontoon.base import forms -from pontoon.base.models import Locale, Project, User +from pontoon.base.models import Locale, Project, TranslationMemoryEntry, User from pontoon.base.utils import get_locale_or_redirect, require_AJAX from pontoon.contributors.views import ContributorsMixin from pontoon.insights.utils import get_locale_insights @@ -253,6 +260,109 @@ def ajax_permissions(request, locale): ) +@require_AJAX +@permission_required_or_403("base.can_manage_locale", (Locale, "code", "locale")) +@transaction.atomic +def ajax_translation_memory(request, locale): + """Translation Memory tab.""" + locale = get_object_or_404(Locale, code=locale) + search_query = request.GET.get("search", "").strip() + page_number = request.GET.get("page", 1) + + tm_entries = TranslationMemoryEntry.objects.filter(locale=locale) + + # Apply search filter if a search query is provided + if search_query: + tm_entries = tm_entries.filter( + Q(source__icontains=search_query) | Q(target__icontains=search_query) + ) + + tm_entries = ( + # Group by "source" and "target" + tm_entries.values("source", "target").annotate( + count=Count("id"), + ids=ArrayAgg("id"), + # Concatenate entity IDs + entity_ids=StringAgg( + Cast("entity_id", output_field=TextField()), delimiter="," + ), + ) + ) + + per_page = 100 # Number of entries per page + paginator = Paginator(tm_entries, per_page) + page = paginator.get_page(page_number) + + # If the subsequent page is requested, return only the entries + template = ( + "teams/widgets/translation_memory_entries.html" + if page_number != 1 + else "teams/includes/translation_memory.html" + ) + + return render( + request, + template, + { + "locale": locale, + "search_query": search_query, + "tm_entries": page, + "has_next": page.has_next(), + }, + ) + + +@require_AJAX +@require_POST +@permission_required_or_403("base.can_manage_locale", (Locale, "code", "locale")) +@transaction.atomic +def ajax_translation_memory_edit(request, locale): + """Edit Translation Memory entries.""" + ids = request.POST.getlist("ids[]") + + try: + target = request.POST["target"] + except MultiValueDictKeyError as e: + return JsonResponse( + {"status": False, "message": f"Bad Request: {e}"}, + status=400, + ) + + tm_entries = TranslationMemoryEntry.objects.filter(id__in=ids) + tm_entries.update(target=target) + + log_action( + ActionLog.ActionType.TM_ENTRIES_EDITED, + request.user, + tm_entries=tm_entries, + ) + + return HttpResponse("ok") + + +@require_AJAX +@require_POST +@permission_required_or_403("base.can_manage_locale", (Locale, "code", "locale")) +@transaction.atomic +def ajax_translation_memory_delete(request, locale): + """Delete Translation Memory entries.""" + ids = request.POST.getlist("ids[]") + tm_entries = TranslationMemoryEntry.objects.filter(id__in=ids) + + for tm_entry in tm_entries: + if tm_entry.entity and tm_entry.locale: + log_action( + ActionLog.ActionType.TM_ENTRY_DELETED, + request.user, + entity=tm_entry.entity, + locale=tm_entry.locale, + ) + + tm_entries.delete() + + return HttpResponse("ok") + + @login_required(redirect_field_name="", login_url="/403") @require_POST def request_item(request, locale=None): 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 02/17] 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) From 6948122e363e147448485efd8f99181ee297d712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 18 Nov 2024 12:31:27 +0100 Subject: [PATCH 03/17] Only show local part of the email address in the Profile page title (#3446) The motivation for this change is that we currently publicly show the email address of a user that doesn't have any contributions yet, but our Terms of Use say "Your name and email address, or a derivative of it, may be attached to your *contributions* and so be visible worldwide". Note that we show the custom display name in the Profile page title for users that set it. --- pontoon/contributors/templates/contributors/profile.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pontoon/contributors/templates/contributors/profile.html b/pontoon/contributors/templates/contributors/profile.html index 3b019427ae..e954a83448 100644 --- a/pontoon/contributors/templates/contributors/profile.html +++ b/pontoon/contributors/templates/contributors/profile.html @@ -3,7 +3,7 @@ {% extends "base.html" %} -{% block title %}{{ contributor.name_or_email }}{% endblock %} +{% block title %}{{ contributor.display_name }}{% endblock %} {% block before %} From 514c3bd8a2b8a8e73bac22f86414dfe6ed55645a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Wed, 20 Nov 2024 08:21:04 +0100 Subject: [PATCH 04/17] Reflect search and pagination in TM tab (#3450) It is now possible to link to a particular view in a TM tab, reflecting any search parameters and the number of pages to load. Also included are the following changes: * Make TM tab available to Translators, not just Managers * Add more contrast to the search fields * Bugfix: Narrow down event targets --- pontoon/base/static/css/dark-theme.css | 1 + pontoon/base/static/css/light-theme.css | 1 + pontoon/base/static/css/style.css | 1 + pontoon/teams/static/js/translation_memory.js | 94 ++++++++++++------- .../teams/includes/translation_memory.html | 6 +- pontoon/teams/templates/teams/team.html | 2 +- pontoon/teams/views.py | 38 +++++--- 7 files changed, 92 insertions(+), 51 deletions(-) diff --git a/pontoon/base/static/css/dark-theme.css b/pontoon/base/static/css/dark-theme.css index 7ed364dc40..53c0d49e11 100644 --- a/pontoon/base/static/css/dark-theme.css +++ b/pontoon/base/static/css/dark-theme.css @@ -19,6 +19,7 @@ --popup-background-1: #333941; --input-background-1: #333941; --input-color-1: #aaaaaa; + --input-color-2: #ffffff; --toggle-color-1: #777777; --icon-background-1: #3f4752; --icon-border-1: #4d5967; diff --git a/pontoon/base/static/css/light-theme.css b/pontoon/base/static/css/light-theme.css index a480f34520..937ad79c4a 100644 --- a/pontoon/base/static/css/light-theme.css +++ b/pontoon/base/static/css/light-theme.css @@ -18,6 +18,7 @@ --popup-background-1: #ffffff; --input-background-1: #ffffff; --input-color-1: #000000; + --input-color-2: #000000; --toggle-color-1: #888888; --icon-background-1: #d0d0d0; --icon-border-1: #bbbbbb; diff --git a/pontoon/base/static/css/style.css b/pontoon/base/static/css/style.css index e7b417d544..7da9a5f9fb 100755 --- a/pontoon/base/static/css/style.css +++ b/pontoon/base/static/css/style.css @@ -1424,6 +1424,7 @@ body > form, .controls > .search-wrapper input { background: var(--dark-grey-1); border: 1px solid var(--main-border-1); + color: var(--input-color-2); font-size: 13px; height: 28px; width: 100%; diff --git a/pontoon/teams/static/js/translation_memory.js b/pontoon/teams/static/js/translation_memory.js index 9279b7f11a..262e5a205c 100644 --- a/pontoon/teams/static/js/translation_memory.js +++ b/pontoon/teams/static/js/translation_memory.js @@ -1,10 +1,31 @@ $(function () { - let currentPage = 1; // First page is loaded on page load - let search = ''; const locale = $('#server').data('locale'); + // Update state from URL parameters + const urlParams = new URLSearchParams(window.location.search); + let currentPage = parseInt(urlParams.get('pages')) || 1; + let search = urlParams.get('search') || ''; + + function updateURL() { + const url = new URL(window.location); + + if (currentPage > 1) { + url.searchParams.set('pages', currentPage); + } else { + url.searchParams.delete('pages'); + } + + if (search) { + url.searchParams.set('search', search); + } else { + url.searchParams.delete('search'); + } + + history.replaceState(null, '', url); + } + function loadMoreEntries() { - const tmList = $('#main .container .translation-memory-list'); + const tmList = $('#main .translation-memory-list'); const loader = tmList.find('.skeleton-loader'); // If the loader is already loading, don't load more entries @@ -25,11 +46,12 @@ $(function () { }); const tbody = tmList.find('tbody'); if (currentPage === 0) { - // Clear the table if it's a new search + // Clear the table for a new search tbody.empty(); } tbody.append(data); currentPage += 1; + updateURL(); // Update the URL with the new pages count and search query }, error: function () { Pontoon.endLoader('Error loading more TM entries.'); @@ -54,7 +76,7 @@ $(function () { }); // Listen for search on Enter key press - $('body').on('keypress', '.table-filter', function (e) { + $('body').on('keypress', '.translation-memory .table-filter', function (e) { if (e.key === 'Enter') { currentPage = 0; // Reset page count for a new search search = $(this).val(); @@ -63,18 +85,18 @@ $(function () { }); // Cancel action - $('body').on('click', '.button.cancel', function () { + $('body').on('click', '.translation-memory .button.cancel', function () { const row = $(this).parents('tr'); row.removeClass('deleting editing'); }); // Edit TM entries - $('body').on('click', '.button.edit', function () { + $('body').on('click', '.translation-memory .button.edit', function () { const row = $(this).parents('tr'); row.addClass('editing'); row.find('.target textarea').focus(); }); - $('body').on('click', '.button.save', function () { + $('body').on('click', '.translation-memory .button.save', function () { const row = $(this).parents('tr'); const ids = row.data('ids'); const target = row.find('.target textarea').val(); @@ -112,34 +134,38 @@ $(function () { }); // Delete TM entries - $('body').on('click', '.button.delete', function () { + $('body').on('click', '.translation-memory .button.delete', function () { const row = $(this).parents('tr'); row.addClass('deleting'); }); - $('body').on('click', '.button.are-you-sure', function () { - const row = $(this).parents('tr'); - const ids = row.data('ids'); + $('body').on( + 'click', + '.translation-memory .button.are-you-sure', + function () { + const row = $(this).parents('tr'); + const ids = row.data('ids'); - $.ajax({ - url: `/${locale}/ajax/translation-memory/delete/`, - method: 'POST', - data: { - csrfmiddlewaretoken: $('body').data('csrf'), - ids: ids, - }, - success: function () { - row.addClass('fade-out'); - setTimeout(function () { - row.remove(); - Pontoon.endLoader('TM entries deleted.'); - }, 500); - }, - error: function () { - Pontoon.endLoader('Error deleting TM entries.'); - }, - complete: function () { - row.removeClass('deleting'); - }, - }); - }); + $.ajax({ + url: `/${locale}/ajax/translation-memory/delete/`, + method: 'POST', + data: { + csrfmiddlewaretoken: $('body').data('csrf'), + ids: ids, + }, + success: function () { + row.addClass('fade-out'); + setTimeout(function () { + row.remove(); + Pontoon.endLoader('TM entries deleted.'); + }, 500); + }, + error: function () { + Pontoon.endLoader('Error deleting TM entries.'); + }, + complete: function () { + row.removeClass('deleting'); + }, + }); + }, + ); }); diff --git a/pontoon/teams/templates/teams/includes/translation_memory.html b/pontoon/teams/templates/teams/includes/translation_memory.html index e916bbe4d5..0753b4b788 100644 --- a/pontoon/teams/templates/teams/includes/translation_memory.html +++ b/pontoon/teams/templates/teams/includes/translation_memory.html @@ -1,11 +1,8 @@ -{% if tm_entries|length == 0 %} -

    No translation memory entries stored yet.

    -{% else %}
    -
    @@ -23,4 +20,3 @@
    -{% endif %} diff --git a/pontoon/teams/templates/teams/team.html b/pontoon/teams/templates/teams/team.html index df05443bc2..bd9fa60d1c 100644 --- a/pontoon/teams/templates/teams/team.html +++ b/pontoon/teams/templates/teams/team.html @@ -128,7 +128,7 @@

    ) }} {% endif %} - {% if request.user.has_perm('base.can_manage_locale', locale) %} + {% if request.user.has_perm('base.can_translate_locale', locale) %} {{ Menu.item( 'TM', url('pontoon.teams.translation-memory', locale.code), diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py index 821dcb2c2b..0d5f6c08d7 100644 --- a/pontoon/teams/views.py +++ b/pontoon/teams/views.py @@ -261,13 +261,21 @@ def ajax_permissions(request, locale): @require_AJAX -@permission_required_or_403("base.can_manage_locale", (Locale, "code", "locale")) +@permission_required_or_403("base.can_translate_locale", (Locale, "code", "locale")) @transaction.atomic def ajax_translation_memory(request, locale): """Translation Memory tab.""" locale = get_object_or_404(Locale, code=locale) search_query = request.GET.get("search", "").strip() - page_number = request.GET.get("page", 1) + + try: + first_page_number = int(request.GET.get("page", 1)) + page_count = int(request.GET.get("pages", 1)) + except ValueError as e: + return JsonResponse( + {"status": False, "message": f"Bad Request: {e}"}, + status=400, + ) tm_entries = TranslationMemoryEntry.objects.filter(locale=locale) @@ -289,14 +297,22 @@ def ajax_translation_memory(request, locale): ) ) - per_page = 100 # Number of entries per page - paginator = Paginator(tm_entries, per_page) - page = paginator.get_page(page_number) + entries_per_page = 100 + paginator = Paginator(tm_entries, entries_per_page) + + combined_entries = [] + + for page_number in range(first_page_number, first_page_number + page_count): + if page_number > paginator.num_pages: + break + page = paginator.get_page(page_number) + combined_entries.extend(page.object_list) - # If the subsequent page is requested, return only the entries + # For the inital load, render the entire tab. For subsequent requests + # (determined by the "page" attribute), only render the entries. template = ( "teams/widgets/translation_memory_entries.html" - if page_number != 1 + if "page" in request.GET else "teams/includes/translation_memory.html" ) @@ -306,15 +322,15 @@ def ajax_translation_memory(request, locale): { "locale": locale, "search_query": search_query, - "tm_entries": page, - "has_next": page.has_next(), + "tm_entries": combined_entries, + "has_next": paginator.num_pages > page_number, }, ) @require_AJAX @require_POST -@permission_required_or_403("base.can_manage_locale", (Locale, "code", "locale")) +@permission_required_or_403("base.can_translate_locale", (Locale, "code", "locale")) @transaction.atomic def ajax_translation_memory_edit(request, locale): """Edit Translation Memory entries.""" @@ -342,7 +358,7 @@ def ajax_translation_memory_edit(request, locale): @require_AJAX @require_POST -@permission_required_or_403("base.can_manage_locale", (Locale, "code", "locale")) +@permission_required_or_403("base.can_translate_locale", (Locale, "code", "locale")) @transaction.atomic def ajax_translation_memory_delete(request, locale): """Delete Translation Memory entries.""" From 24f37e576426447491d07a9493f008504bbfe130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Wed, 20 Nov 2024 13:10:19 +0100 Subject: [PATCH 05/17] Special-case Public projects for Locale stats calculation during translation actions (#3451) --- pontoon/base/models/translated_resource.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pontoon/base/models/translated_resource.py b/pontoon/base/models/translated_resource.py index 27c45c9e76..26a759b9f4 100644 --- a/pontoon/base/models/translated_resource.py +++ b/pontoon/base/models/translated_resource.py @@ -153,7 +153,10 @@ def adjust_all_stats(self, *args, **kwargs): self.adjust_stats(*args, **kwargs) project.adjust_stats(*args, **kwargs) - if not project.system_project: + if ( + not project.system_project + and project.visibility == Project.Visibility.PUBLIC + ): locale.adjust_stats(*args, **kwargs) if project_locale: From bdd64467631ef9b2bfc3560beb46742de71b651c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Thu, 21 Nov 2024 12:11:37 +0100 Subject: [PATCH 06/17] Formatting updates for the Transaction Emails spec --- specs/0120-transactional-emails.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/specs/0120-transactional-emails.md b/specs/0120-transactional-emails.md index ac4cf34a25..732bdde6f0 100644 --- a/specs/0120-transactional-emails.md +++ b/specs/0120-transactional-emails.md @@ -179,11 +179,11 @@ Once ### Onboarding: Follow up 2 days later - more tips for getting started -### Trigger +#### Trigger 48 hours after account creation -### Template +#### Template Subject: Translating on Pontoon Body: @@ -209,7 +209,6 @@ Speaking of team managers, teams can have up to 3 different roles. While all rol 1. Team Manager - Team managers are responsible for managing and growing their language community, including: mentoring new contributors, providing feedback on their translation submissions, and creating language resources. Each locale will have at least one team manager. 2. Translator - Translators are responsible for reviewing translation submissions and providing feedback to team contributors. Community members that have been evaluated by the team manager and other translators can be assigned the translator role. 3. Contributor - Contributors submit translation suggestions in Pontoon for their target language. New members of a language community start as a contributor. - We’ve found that the best way for new contributors to learn and grow within their community is to network with other team members and proactively request feedback from translators and managers on their submissions. If you have trouble reaching other team members or getting feedback, let us or the project manager know (see below). @@ -235,7 +234,7 @@ Once 1 week after account creation -### Template +#### Template Subject: More resources for contributors Body: @@ -265,6 +264,10 @@ You can also receive Pontoon notifications in a daily or weekly digest — check Thanks, Pontoon Team +#### Frequency + +Once + ### Inactive account: Contributor #### Trigger From 90eaebf44f9d3eb083700567c38d13484566d795 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Tue, 26 Nov 2024 16:14:34 +0200 Subject: [PATCH 07/17] Include media/ in pytest norecursedirs (#3457) --- pytest.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest.ini b/pytest.ini index 13fe229f40..bebcac2172 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] addopts = --doctest-glob='*.tst' --reuse-db -norecursedirs = .git _build tmp* requirements commands/* node_modules/* +norecursedirs = .git _build commands media node_modules requirements tmp* DJANGO_SETTINGS_MODULE = pontoon.settings From f751a514d06c8861bc51b5fea09f2a241eb911d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Wed, 27 Nov 2024 11:03:47 +0100 Subject: [PATCH 08/17] Add ability to upload personal translation memory (#3452) This is the final step towards Translation Memory Management: ability for team managers and translators to upload .TMX files to their locale's TM. Details: - The maximum file upload size is 20 MB. - Supported srclang values (read from
    and elements) are en, en-US and en_US (in all cases). - In elements, lang or xml:lang value must match the locale code. - Source-target combinations that already exist in the DB are not imported. Also included: * Cancel button that appears while editing or deleting a TM entry is now positioned left to the main button. * Error messages are now presented as errors (red color), rather than regular messages (green). --- .../teams/static/css/translation_memory.css | 5 +- pontoon/teams/static/js/translation_memory.js | 40 ++++- .../teams/includes/translation_memory.html | 5 + .../widgets/translation_memory_entries.html | 6 +- pontoon/teams/urls.py | 6 + pontoon/teams/views.py | 151 ++++++++++++++++++ 6 files changed, 206 insertions(+), 7 deletions(-) diff --git a/pontoon/teams/static/css/translation_memory.css b/pontoon/teams/static/css/translation_memory.css index bbf8f1bdd8..c7cadc1ae8 100644 --- a/pontoon/teams/static/css/translation_memory.css +++ b/pontoon/teams/static/css/translation_memory.css @@ -113,7 +113,6 @@ .button { background: var(--button-background-1); - margin-left: 5px; .fa { margin-right: 2px; @@ -132,6 +131,10 @@ background: var(--status-translated); } + .button.delete { + margin-left: 5px; + } + .button.delete:hover { background: var(--status-error); } diff --git a/pontoon/teams/static/js/translation_memory.js b/pontoon/teams/static/js/translation_memory.js index 262e5a205c..3b9dff7405 100644 --- a/pontoon/teams/static/js/translation_memory.js +++ b/pontoon/teams/static/js/translation_memory.js @@ -54,7 +54,7 @@ $(function () { updateURL(); // Update the URL with the new pages count and search query }, error: function () { - Pontoon.endLoader('Error loading more TM entries.'); + Pontoon.endLoader('Error loading more TM entries.', 'error'); loader.each(function () { $(this).removeClass('loading'); }); @@ -125,7 +125,7 @@ $(function () { node.html(new_target); }, error: function () { - Pontoon.endLoader('Error editing TM entries.'); + Pontoon.endLoader('Error editing TM entries.', 'error'); }, complete: function () { row.removeClass('editing'); @@ -160,7 +160,7 @@ $(function () { }, 500); }, error: function () { - Pontoon.endLoader('Error deleting TM entries.'); + Pontoon.endLoader('Error deleting TM entries.', 'error'); }, complete: function () { row.removeClass('deleting'); @@ -168,4 +168,38 @@ $(function () { }); }, ); + + // Upload TM entries + $('body').on('click', '.translation-memory .upload-button', function () { + const fileInput = $(''); + fileInput.on('change', function () { + const file = this.files[0]; + if (!file) { + return; + } + + const formData = new FormData(); + formData.append('tmx_file', file); + formData.append('csrfmiddlewaretoken', $('body').data('csrf')); + + $.ajax({ + url: `/${locale}/ajax/translation-memory/upload/`, + method: 'POST', + data: formData, + processData: false, + contentType: false, + success: function (response) { + Pontoon.endLoader(response.message); + }, + error: function (xhr) { + Pontoon.endLoader( + xhr.responseJSON?.message ?? 'Error uploading TMX file.', + 'error', + ); + }, + }); + }); + + fileInput.click(); + }); }); diff --git a/pontoon/teams/templates/teams/includes/translation_memory.html b/pontoon/teams/templates/teams/includes/translation_memory.html index 0753b4b788..cf42bccca7 100644 --- a/pontoon/teams/templates/teams/includes/translation_memory.html +++ b/pontoon/teams/templates/teams/includes/translation_memory.html @@ -5,6 +5,11 @@ + + diff --git a/pontoon/teams/templates/teams/widgets/translation_memory_entries.html b/pontoon/teams/templates/teams/widgets/translation_memory_entries.html index 45f6447eab..99394ad2ad 100644 --- a/pontoon/teams/templates/teams/widgets/translation_memory_entries.html +++ b/pontoon/teams/templates/teams/widgets/translation_memory_entries.html @@ -18,6 +18,9 @@ {% endfor %} diff --git a/pontoon/teams/urls.py b/pontoon/teams/urls.py index 89f7e38860..474cd28ad7 100644 --- a/pontoon/teams/urls.py +++ b/pontoon/teams/urls.py @@ -121,6 +121,12 @@ views.ajax_translation_memory_delete, name="pontoon.teams.ajax.translation-memory.delete", ), + # Upload .TMX file + path( + "translation-memory/upload/", + views.ajax_translation_memory_upload, + name="pontoon.teams.ajax.translation-memory.upload", + ), ] ), ), diff --git a/pontoon/teams/views.py b/pontoon/teams/views.py index 0d5f6c08d7..3cb0636e65 100644 --- a/pontoon/teams/views.py +++ b/pontoon/teams/views.py @@ -1,4 +1,7 @@ import json +import logging +import re +import xml.etree.ElementTree as ET import bleach @@ -37,6 +40,9 @@ from pontoon.teams.forms import LocaleRequestForm +log = logging.getLogger(__name__) + + def teams(request): """List all active localization teams.""" locales = Locale.objects.available().prefetch_related( @@ -379,6 +385,151 @@ def ajax_translation_memory_delete(request, locale): return HttpResponse("ok") +@require_AJAX +@require_POST +@permission_required_or_403("base.can_translate_locale", (Locale, "code", "locale")) +@transaction.atomic +def ajax_translation_memory_upload(request, locale): + """Upload Translation Memory entries from a .TMX file.""" + try: + file = request.FILES["tmx_file"] + except MultiValueDictKeyError: + return JsonResponse( + {"status": False, "message": "No file uploaded."}, + status=400, + ) + + if file.size > 20 * 1024 * 1024: + return JsonResponse( + { + "status": False, + "message": "File size limit exceeded. The maximum allowed size is 20 MB.", + }, + status=400, + ) + + if not file.name.endswith(".tmx"): + return JsonResponse( + { + "status": False, + "message": "Invalid file format. Only .TMX files are supported.", + }, + status=400, + ) + + locale = get_object_or_404(Locale, code=locale) + code = locale.code + + # Parse the TMX file + try: + tree = ET.parse(file) + root = tree.getroot() + except ET.ParseError as e: + return JsonResponse( + {"status": False, "message": f"Invalid XML file: {e}"}, status=400 + ) + + # Extract TM entries + file_entries = [] + srclang_pattern = re.compile(r"^en(?:[-_](us))?$", re.IGNORECASE) + ns = {"xml": "http://www.w3.org/XML/1998/namespace"} + + header = root.find("header") + header_srclang = header.attrib.get("srclang", "") if header else "" + + def get_seg_text(tu, lang, ns): + # Try to find with the xml:lang attribute + seg = tu.find(f"./tuv[@xml:lang='{lang}']/seg", namespaces=ns) + + # If not found, try the lang attribute + if seg is None: + seg = tu.find(f"./tuv[@lang='{lang}']/seg") + + return seg.text.strip() if seg is not None and seg.text else None + + tu_elements = root.findall(".//tu") + for tu in tu_elements: + try: + srclang = tu.attrib.get("srclang", header_srclang) + tu_str = ET.tostring(tu, encoding="unicode") + + if not srclang_pattern.match(srclang): + log.info(f"Skipping with unsupported srclang: {tu_str}") + continue + + source = get_seg_text(tu, srclang, ns) + target = get_seg_text(tu, code, ns) + + if source and target: + file_entries.append({"source": source, "target": target}) + else: + log.info(f"Skipping with missing or empty segment: {tu_str}") + + except Exception as e: + log.info(f"Error processing : {e}") + + if not file_entries: + return JsonResponse( + {"status": False, "message": "No valid translation entries found."}, + status=400, + ) + + # Create TranslationMemoryEntry objects + tm_entries = [ + TranslationMemoryEntry( + source=entry["source"], + target=entry["target"], + locale=locale, + ) + for entry in file_entries + ] + + # Filter out entries that already exist in the database + existing_combinations = set( + TranslationMemoryEntry.objects.filter(locale=locale).values_list( + "source", "target" + ) + ) + tm_entries_to_create = [ + entry + for entry in tm_entries + if (entry.source, entry.target) not in existing_combinations + ] + + created_entries = TranslationMemoryEntry.objects.bulk_create( + tm_entries_to_create, batch_size=1000 + ) + + log_action( + ActionLog.ActionType.TM_ENTRIES_UPLOADED, + request.user, + tm_entries=created_entries, + ) + + parsed = len(file_entries) + skipped_on_parse = len(tu_elements) - parsed + imported = len(created_entries) + duplicates = parsed - len(tm_entries_to_create) + + message = f"Importing TM entries complete. Imported: {imported}." + if imported == 0: + message = "No TM entries imported." + + if duplicates: + message += f" Skipped duplicates: {duplicates}." + + return JsonResponse( + { + "status": True, + "message": message, + "parsed": parsed, + "skipped_on_parse": skipped_on_parse, + "imported": imported, + "duplicates": duplicates, + } + ) + + @login_required(redirect_field_name="", login_url="/403") @require_POST def request_item(request, locale=None): From 7935bcfb0caa06f60f8dfc0ef70d57d5080f87f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Thu, 28 Nov 2024 12:07:49 +0100 Subject: [PATCH 09/17] De-hardcode "Mozilla" in the codebase (#3458) 1. De-hardcode non-transactionoal email footer text 2. De-hardcode .TBX header title and description * Use multi-line f-strings * Add unit tests --- docs/admin/deployment.rst | 10 ++ pontoon/messaging/views.py | 7 +- pontoon/settings/base.py | 7 + pontoon/terminology/tests/test_tbx.py | 136 ++++++++++++++++++ pontoon/terminology/utils.py | 198 ++++++++++++-------------- 5 files changed, 253 insertions(+), 105 deletions(-) create mode 100644 pontoon/terminology/tests/test_tbx.py diff --git a/docs/admin/deployment.rst b/docs/admin/deployment.rst index a7f64b68e0..f2225fef3b 100644 --- a/docs/admin/deployment.rst +++ b/docs/admin/deployment.rst @@ -161,6 +161,10 @@ you create: It allows to explain what type of communication to expect and to link to deployment-specific privacy notices among other things. +``EMAIL_COMMUNICATIONS_FOOTER_PRE_TEXT`` + Optional. Text to be shown in the footer of the non-transactional emails sent + using the Messaging Center, just above the unsubscribe text. + ``ENABLE_BUGS_TAB`` Optional. Enables Bugs tab on team pages, which pulls team data from bugzilla.mozilla.org. Specific for Mozilla deployments. @@ -296,6 +300,12 @@ you create: Optional. Set your `SYSTRAN Translate API key` to use machine translation by SYSTRAN. +``TBX_DESCRIPTION`` + Optional. Description to be used in the header of the Terminology (.TBX) files. + +``TBX_TITLE`` + Optional. Title to be used in the header of the Terminology (.TBX) files. + ``THROTTLE_ENABLED`` Optional. Enables traffic throttling based on IP address (default: ``False``). diff --git a/pontoon/messaging/views.py b/pontoon/messaging/views.py index 0ce41b21d3..1aec239bb8 100644 --- a/pontoon/messaging/views.py +++ b/pontoon/messaging/views.py @@ -2,6 +2,8 @@ import logging import uuid +from urllib.parse import urljoin + from guardian.decorators import permission_required_or_403 from notifications.signals import notify @@ -306,9 +308,10 @@ def send_message(request): log.info(f"Notifications sent to {len(recipients)} users.") if is_email: + unsubscribe_url = urljoin(settings.SITE_URL, f"unsubscribe/{uuid}") footer = ( - """

    -You’re receiving this email as a contributor to Mozilla localization on Pontoon.
    To no longer receive emails like these, unsubscribe here: Unsubscribe. + f"""

    +{ settings.EMAIL_COMMUNICATIONS_FOOTER_PRE_TEXT }
    To no longer receive emails like these, unsubscribe here: Unsubscribe. """ if not is_transactional else "" diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index f09bcf729a..885690e640 100644 --- a/pontoon/settings/base.py +++ b/pontoon/settings/base.py @@ -223,6 +223,9 @@ def _default_from_email(): EMAIL_CONSENT_MAIN_TEXT = os.environ.get("EMAIL_CONSENT_MAIN_TEXT", "") EMAIL_CONSENT_PRIVACY_NOTICE = os.environ.get("EMAIL_CONSENT_PRIVACY_NOTICE", "") EMAIL_COMMUNICATIONS_HELP_TEXT = os.environ.get("EMAIL_COMMUNICATIONS_HELP_TEXT", "") +EMAIL_COMMUNICATIONS_FOOTER_PRE_TEXT = os.environ.get( + "EMAIL_COMMUNICATIONS_FOOTER_PRE_TEXT", "" +) # Log emails to console if the SendGrid credentials are missing. if EMAIL_HOST_USER and EMAIL_HOST_PASSWORD: @@ -1137,3 +1140,7 @@ def account_username(user): ) DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# Used in the header of the Terminology (.TBX) files. +TBX_TITLE = os.environ.get("TBX_TITLE", "Pontoon Terminology") +TBX_DESCRIPTION = os.environ.get("TBX_DESCRIPTION", "Terms localized in Pontoon") diff --git a/pontoon/terminology/tests/test_tbx.py b/pontoon/terminology/tests/test_tbx.py new file mode 100644 index 0000000000..df806bc6af --- /dev/null +++ b/pontoon/terminology/tests/test_tbx.py @@ -0,0 +1,136 @@ +import xml.etree.ElementTree as ET + +from unittest.mock import MagicMock + +import pytest + +from pontoon.terminology.utils import build_tbx_v2_file, build_tbx_v3_file + + +@pytest.fixture +def mock_term_translations(): + term_mock = MagicMock() + term_mock.pk = 1 + term_mock.text = "Sample Term" + term_mock.part_of_speech = "noun" + term_mock.definition = "Sample Definition" + term_mock.usage = "Sample Usage" + + translation_mock = MagicMock() + translation_mock.term = term_mock + translation_mock.text = "Translation" + + return [translation_mock] + + +def test_build_tbx_v2_file(settings, mock_term_translations): + settings.TBX_TITLE = "Sample Title" + settings.TBX_DESCRIPTION = "Sample Description" + locale = "fr-FR" + + result = "".join(build_tbx_v2_file(mock_term_translations, locale)) + + # Fail if the result is not a valid XML + ET.fromstring(result) + + # Construct the expected XML + expected = """ + + + + + + Sample Title + + +

    Sample Description

    +
    +
    + +

    TBXXCSV02.xcs

    +
    +
    + + + + Sample Usage + + + + Sample Term + noun + + + + Sample Definition + + + + + + Translation + + + + + + +
    """ + + # Assert that the generated result matches the expected XML + assert result.strip() == expected.strip() + + +def test_build_tbx_v3_file(settings, mock_term_translations): + settings.TBX_TITLE = "Sample Title" + settings.TBX_DESCRIPTION = "Sample Description" + locale = "fr-FR" + + result = "".join(build_tbx_v3_file(mock_term_translations, locale)) + + # Fail if the result is not a valid XML + ET.fromstring(result) + + # Construct the expected XML + expected = """ + + + + + + + Sample Title + + +

    Sample Description

    +
    +
    + +

    TBXXCSV02.xcs

    +
    +
    + + + + + + Sample Term + noun + + Sample Definition + Sample Usage + + + + + + Translation + + + + + +
    """ + + # Assert that the generated result matches the expected XML + assert result.strip() == expected.strip() diff --git a/pontoon/terminology/utils.py b/pontoon/terminology/utils.py index e89848a02d..4dd0a8d6be 100644 --- a/pontoon/terminology/utils.py +++ b/pontoon/terminology/utils.py @@ -1,5 +1,7 @@ from xml.sax.saxutils import escape, quoteattr +from django.conf import settings + def build_tbx_v2_file(term_translations, locale): """ @@ -8,63 +10,58 @@ def build_tbx_v2_file(term_translations, locale): TBX files could contain large amount of entries and it's impossible to render all the data with django templates. Rendering a string in memory is a lot faster. """ - yield ( - '' - '\n' - '\n' - "\n\t" - "\n\t\t" - "\n\t\t\t" - "\n\t\t\t\tMozilla Terms" - "\n\t\t\t" - "\n\t\t\t" - "\n\t\t\t\t

    from a Mozilla termbase

    " - "\n\t\t\t
    " - "\n\t\t
    " - "\n\t\t" - '\n\t\t\t

    TBXXCSV02.xcs

    ' - "\n\t\t
    " - "\n\t
    " - "\n\t" - "\n\t\t" - ) + # Header + yield f""" + + + + + + {escape(settings.TBX_TITLE)} + + +

    {escape(settings.TBX_DESCRIPTION)}

    +
    +
    + +

    TBXXCSV02.xcs

    +
    +
    + + """ + # Body for translation in term_translations: term = translation.term - yield ( - '\n\t\t\t' - '\n\t\t\t\t%(usage)s' - '\n\t\t\t\t' - "\n\t\t\t\t\t" - "\n\t\t\t\t\t\t" - "\n\t\t\t\t\t\t\t%(term)s" - '\n\t\t\t\t\t\t\t%(part_of_speech)s' - "\n\t\t\t\t\t\t" - "\n\t\t\t\t\t" - "\n\t\t\t\t\t" - '\n\t\t\t\t\t\t%(definition)s' - "\n\t\t\t\t\t" - "\n\t\t\t\t" - "\n\t\t\t\t" - "\n\t\t\t\t\t" - "\n\t\t\t\t\t\t" - "\n\t\t\t\t\t\t\t%(translation)s" - "\n\t\t\t\t\t\t" - "\n\t\t\t\t\t" - "\n\t\t\t\t" - "\n\t\t\t" - % { - "id": term.pk, - "term": escape(term.text), - "part_of_speech": escape(term.part_of_speech), - "definition": escape(term.definition), - "usage": escape(term.usage), - "locale": quoteattr(locale), - "translation": escape(translation.text), - } - ) + yield f""" + + {escape(term.usage)} + + + + {escape(term.text)} + {escape(term.part_of_speech)} + + + + {escape(term.definition)} + + + + + + {escape(translation.text)} + + + + """ - yield ("\n\t\t" "\n\t" "\n
    \n") + # Footer + yield """ + +
    +
    +""" def build_tbx_v3_file(term_translations, locale): @@ -74,57 +71,52 @@ def build_tbx_v3_file(term_translations, locale): TBX files could contain large amount of entries and it's impossible to render all the data with django templates. Rendering a string in memory is a lot faster. """ - yield ( - '' - '\n' - '\n' - '\n' - "\n\t" - "\n\t\t" - "\n\t\t\t" - "\n\t\t\t\tMozilla Terms" - "\n\t\t\t" - "\n\t\t\t" - "\n\t\t\t\t

    from a Mozilla termbase

    " - "\n\t\t\t
    " - "\n\t\t
    " - "\n\t\t" - '\n\t\t\t

    TBXXCSV02.xcs

    ' - "\n\t\t
    " - "\n\t
    " - "\n\t" - "\n\t\t" - ) + # Header + yield f""" + + + + + + + {escape(settings.TBX_TITLE)} + + +

    {escape(settings.TBX_DESCRIPTION)}

    +
    +
    + +

    TBXXCSV02.xcs

    +
    +
    + + """ + # Body for translation in term_translations: term = translation.term - yield ( - '\n\t\t\t' - '\n\t\t\t\t' - "\n\t\t\t\t\t" - "\n\t\t\t\t\t\t%(term)s" - '\n\t\t\t\t\t\t%(part_of_speech)s' - "\n\t\t\t\t\t\t" - '\n\t\t\t\t\t\t\t%(definition)s' - '\n\t\t\t\t\t\t\t%(usage)s' - "\n\t\t\t\t\t\t" - "\n\t\t\t\t\t" - "\n\t\t\t\t" - "\n\t\t\t\t" - "\n\t\t\t\t\t" - "\n\t\t\t\t\t\t%(translation)s" - "\n\t\t\t\t\t" - "\n\t\t\t\t" - "\n\t\t\t" - % { - "id": term.pk, - "term": escape(term.text), - "part_of_speech": escape(term.part_of_speech), - "definition": escape(term.definition), - "usage": escape(term.usage), - "locale": quoteattr(locale), - "translation": escape(translation.text), - } - ) + yield f""" + + + + {escape(term.text)} + {escape(term.part_of_speech)} + + {escape(term.definition)} + {escape(term.usage)} + + + + + + {escape(translation.text)} + + + """ - yield ("\n\t\t" "\n\t" "\n
    \n") + # Footer + yield """ + +
    +
    +""" From e23b7dbdf857a0f2e931937d5c6dd6db17ceb3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Thu, 28 Nov 2024 18:53:05 +0100 Subject: [PATCH 10/17] Show upload .TMX terms (#3459) --- .../teams/static/css/translation_memory.css | 55 ++++++++++++++++++- pontoon/teams/static/js/translation_memory.js | 16 +++++- .../teams/includes/translation_memory.html | 26 +++++++-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/pontoon/teams/static/css/translation_memory.css b/pontoon/teams/static/css/translation_memory.css index c7cadc1ae8..1f07561778 100644 --- a/pontoon/teams/static/css/translation_memory.css +++ b/pontoon/teams/static/css/translation_memory.css @@ -1,9 +1,58 @@ .translation-memory { - .upload-button { + .upload-wrapper { float: right; - .fa { - padding-right: 2px; + .button.upload { + .fa { + padding-right: 2px; + } + } + + .button.cancel { + display: none; + background-color: transparent; + margin-left: 0; + + .fa { + margin-right: 0; + } + } + + .button.cancel:hover { + .fa { + color: var(--white-1); + } + } + + .button.confirm { + display: none; + } + } + + .upload-terms { + display: none; + color: var(--light-grey-7); + font-style: italic; + line-height: 1.5em; + padding-top: 10px; + text-align: right; + } + + .controls.uploading { + .button.upload { + display: none; + } + + .button.cancel { + display: inline-block; + } + + .button.confirm { + display: inline-block; + } + + .upload-terms { + display: block; } } diff --git a/pontoon/teams/static/js/translation_memory.js b/pontoon/teams/static/js/translation_memory.js index 3b9dff7405..a02bab27ee 100644 --- a/pontoon/teams/static/js/translation_memory.js +++ b/pontoon/teams/static/js/translation_memory.js @@ -169,10 +169,24 @@ $(function () { }, ); + const uploadWrapper = '.translation-memory .upload-wrapper'; + // Upload TM entries - $('body').on('click', '.translation-memory .upload-button', function () { + $('body').on('click', `${uploadWrapper} .upload`, function () { + const controls = $(this).parents('.controls'); + controls.addClass('uploading'); + }); + // Cancel action + $('body').on('click', `${uploadWrapper} .cancel`, function () { + const controls = $(this).parents('.controls'); + controls.removeClass('uploading'); + }); + $('body').on('click', `${uploadWrapper} .confirm`, function () { + const controls = $(this).parents('.controls'); const fileInput = $(''); fileInput.on('change', function () { + controls.removeClass('uploading'); + const file = this.files[0]; if (!file) { return; diff --git a/pontoon/teams/templates/teams/includes/translation_memory.html b/pontoon/teams/templates/teams/includes/translation_memory.html index cf42bccca7..47812902e0 100644 --- a/pontoon/teams/templates/teams/includes/translation_memory.html +++ b/pontoon/teams/templates/teams/includes/translation_memory.html @@ -6,10 +6,28 @@ placeholder="Search translation memory"> - +
    + + + + + +
    + +

    + By uploading content, you confirm that you have the right to share it, either as the owner or under a + license that permits redistribution.
    + Please ensure that the content complies with applicable copyright laws and does not infringe on + third-party rights. +

    + -
    From 8976927654d1261a79847558787ce7971c19486d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 2 Dec 2024 08:25:56 +0100 Subject: [PATCH 11/17] Remove Notifications tab (superseded by Messaging Center) (#3460) --- pontoon/projects/forms.py | 11 -- .../static/css/manual_notifications.css | 85 -------------- .../static/js/manual_notifications.js | 67 ----------- .../includes/manual_notifications.html | 63 ----------- .../projects/templates/projects/project.html | 10 -- pontoon/projects/urls.py | 12 -- pontoon/projects/views.py | 106 +----------------- 7 files changed, 2 insertions(+), 352 deletions(-) delete mode 100644 pontoon/projects/forms.py delete mode 100644 pontoon/projects/static/css/manual_notifications.css delete mode 100644 pontoon/projects/static/js/manual_notifications.js delete mode 100644 pontoon/projects/templates/projects/includes/manual_notifications.html diff --git a/pontoon/projects/forms.py b/pontoon/projects/forms.py deleted file mode 100644 index f7449ae06c..0000000000 --- a/pontoon/projects/forms.py +++ /dev/null @@ -1,11 +0,0 @@ -from django import forms -from django.core import validators - -from pontoon.base.forms import HtmlField - - -class NotificationsForm(forms.Form): - message = HtmlField() - selected_locales = forms.CharField( - validators=[validators.validate_comma_separated_integer_list] - ) diff --git a/pontoon/projects/static/css/manual_notifications.css b/pontoon/projects/static/css/manual_notifications.css deleted file mode 100644 index 27020ffa6d..0000000000 --- a/pontoon/projects/static/css/manual_notifications.css +++ /dev/null @@ -1,85 +0,0 @@ -.manual-notifications .right-column #compose { - padding: 20px; - - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -.manual-notifications #compose h3 { - color: var(--white-1); - font-size: 22px; - letter-spacing: 0; -} - -.manual-notifications #compose h3 .stress { - color: var(--status-translated); -} - -.manual-notifications #compose .toolbar { - padding: 10px 0 0; -} - -.manual-notifications #compose .controls { - margin: 0; - text-align: right; -} - -.manual-notifications #compose .errors { - float: right; - text-align: right; -} - -.manual-notifications #compose .errors p { - color: var(--status-error); - text-transform: uppercase; - visibility: hidden; -} - -.manual-notifications #compose .locale-selector { - margin: 20px 0 40px; -} - -.manual-notifications #compose .locale-selector .locale.select .menu { - width: 285px; /* must be same as .shortcuts */ -} - -.manual-notifications #compose .locale-selector .shortcuts { - float: left; - font-size: 14px; - width: 285px; /* must be same as .menu */ -} - -.manual-notifications #compose .locale-selector .shortcuts .complete { - float: left; -} - -.manual-notifications #compose .locale-selector .shortcuts .incomplete { - float: right; -} - -.manual-notifications #compose .message-wrapper .subtitle { - color: var(--light-grey-7); - float: left; - text-transform: uppercase; -} - -.manual-notifications #compose textarea { - background: var(--black-3); - color: var(--white-1); - font-size: 14px; - font-weight: 300; - height: 150px; - padding: 10px; - width: 100%; - - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -.manual-notifications #sent li.no { - padding-top: 14px; -} - -.manual-notifications #sent li.no .icon { - color: var(--black-3); -} diff --git a/pontoon/projects/static/js/manual_notifications.js b/pontoon/projects/static/js/manual_notifications.js deleted file mode 100644 index c71a3e45c5..0000000000 --- a/pontoon/projects/static/js/manual_notifications.js +++ /dev/null @@ -1,67 +0,0 @@ -$(function () { - const container = $('#main .container'); - - function isValidForm($form, locales, message) { - $form.find('.errors p').css('visibility', 'hidden'); - - if (!locales) { - $form.find('.locale-selector .errors p').css('visibility', 'visible'); - } - - if (!message) { - $form.find('.message-wrapper .errors p').css('visibility', 'visible'); - } - - return locales && message; - } - - // Send notification - container.on('click', '#send-notification .send', function (e) { - e.preventDefault(); - const $form = $('#send-notification'); - - // Validate form - const locales = $form.find('[name=selected_locales]').val(), - message = $form.find('[name=message]').val(); - - if (!isValidForm($form, locales, message)) { - return; - } - - // Submit form - $.ajax({ - url: $form.prop('action'), - type: $form.prop('method'), - data: $form.serialize(), - success: function (data) { - if (data.selected_locales || data.message) { - isValidForm($form, !data.selected_locales, !data.message); - return false; - } - - Pontoon.endLoader('Notification sent.'); - container.empty().append(data); - }, - error: function () { - Pontoon.endLoader('Oops, something went wrong.', 'error'); - }, - }); - }); - - // Recipient shortcuts - container.on('click', '.locale-selector .shortcuts a', function (e) { - e.preventDefault(); - - const locales = $(this).data('ids').reverse(), - $localeSelector = $(this).parents('.locale-selector'); - - $localeSelector.find('.selected .move-all').click(); - - $(locales).each(function (i, id) { - $localeSelector - .find('.locale.select:first') - .find('[data-id=' + id + ']') - .click(); - }); - }); -}); diff --git a/pontoon/projects/templates/projects/includes/manual_notifications.html b/pontoon/projects/templates/projects/includes/manual_notifications.html deleted file mode 100644 index c079fcc9a9..0000000000 --- a/pontoon/projects/templates/projects/includes/manual_notifications.html +++ /dev/null @@ -1,63 +0,0 @@ -{% import 'teams/widgets/multiple_team_selector.html' as multiple_team_selector %} -{% import "contributors/widgets/notifications_menu.html" as Notifications with context %} - -
    - - - -
    diff --git a/pontoon/projects/templates/projects/project.html b/pontoon/projects/templates/projects/project.html index 746611a142..c0ed59538b 100644 --- a/pontoon/projects/templates/projects/project.html +++ b/pontoon/projects/templates/projects/project.html @@ -104,16 +104,6 @@

    icon = 'info-circle', ) }} - {% if request.user.has_perm('base.can_manage_project') %} - {{ Menu.item( - 'Notifications', - url('pontoon.projects.notifications', project.slug), - is_active = (current_page == 'notifications'), - count = False, - icon = 'bell', - ) - }} - {% endif %} {% endcall %} diff --git a/pontoon/projects/urls.py b/pontoon/projects/urls.py index 89de34cc05..4af56580f2 100644 --- a/pontoon/projects/urls.py +++ b/pontoon/projects/urls.py @@ -45,12 +45,6 @@ views.project, name="pontoon.projects.info", ), - # Project notifications - path( - "notifications/", - views.project, - name="pontoon.projects.notifications", - ), # AJAX views path( "ajax/", @@ -86,12 +80,6 @@ views.ajax_info, name="pontoon.projects.ajax.info", ), - # Project notifications - path( - "notifications/", - views.ajax_notifications, - name="pontoon.projects.ajax.notifications", - ), ] ), ), diff --git a/pontoon/projects/views.py b/pontoon/projects/views.py index b53d249b4d..58487272ae 100644 --- a/pontoon/projects/views.py +++ b/pontoon/projects/views.py @@ -1,25 +1,15 @@ -import uuid - -from guardian.decorators import permission_required_or_403 -from notifications.models import Notification -from notifications.signals import notify - from django.conf import settings -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured -from django.db import transaction from django.db.models import Q -from django.http import Http404, HttpResponseRedirect, JsonResponse +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.views.generic.detail import DetailView from pontoon.base.models import Locale, Project -from pontoon.base.utils import get_project_or_redirect, require_AJAX, split_ints +from pontoon.base.utils import get_project_or_redirect, require_AJAX from pontoon.contributors.views import ContributorsMixin from pontoon.insights.utils import get_insights -from pontoon.projects import forms from pontoon.tags.utils import Tags @@ -150,98 +140,6 @@ def ajax_info(request, slug): return render(request, "projects/includes/info.html", {"project": project}) -@permission_required_or_403("base.can_manage_project") -@transaction.atomic -@require_AJAX -def ajax_notifications(request, slug): - """Notifications tab.""" - project = get_object_or_404( - Project.objects.visible_for(request.user).available(), slug=slug - ) - available_locales = project.locales.prefetch_project_locale(project).order_by( - "name" - ) - - # Send notifications - if request.method == "POST": - form = forms.NotificationsForm(request.POST) - - if not form.is_valid(): - return JsonResponse(dict(form.errors.items())) - - contributors = User.objects.filter( - translation__entity__resource__project=project, - ) - - # For performance reasons, only filter contributors for selected - # locales if different from all project locales - available_ids = sorted(list(available_locales.values_list("id", flat=True))) - selected_ids = sorted(split_ints(form.cleaned_data.get("selected_locales"))) - - if available_ids != selected_ids: - contributors = User.objects.filter( - translation__entity__resource__project=project, - translation__locale__in=available_locales.filter(id__in=selected_ids), - ) - - identifier = uuid.uuid4().hex - for contributor in contributors.distinct(): - notify.send( - request.user, - recipient=contributor, - verb="has sent a message in", - target=project, - description=form.cleaned_data.get("message"), - identifier=identifier, - ) - - notifications = list( - Notification.objects.filter( - description__isnull=False, - target_content_type=ContentType.objects.get_for_model(project), - target_object_id=project.id, - ) - # Each project notification is stored in one Notification instance per user. To - # identify unique project Notifications, we use the identifier stored in the - # Notification.data field. - # - # PostgreSQL allows us to retrieve Notifications with unique Notification.data - # fields by combining .order_by(*fields) and .distinct(*fields) calls. Read more: - # https://docs.djangoproject.com/en/3.2/ref/models/querysets/#distinct - # - # That approach doesn't allow us to order Notifications by their timestamp, so - # we have to do that in python below. - .order_by("data") - .distinct("data") - .prefetch_related("actor", "target") - ) - - notifications.sort(key=lambda x: x.timestamp, reverse=True) - - # Recipient shortcuts - incomplete = [] - complete = [] - for available_locale in available_locales: - completion_percent = available_locale.get_chart(project)["completion_percent"] - if completion_percent == 100: - complete.append(available_locale.pk) - else: - incomplete.append(available_locale.pk) - - return render( - request, - "projects/includes/manual_notifications.html", - { - "form": forms.NotificationsForm(), - "project": project, - "available_locales": available_locales, - "notifications": notifications, - "incomplete": incomplete, - "complete": complete, - }, - ) - - class ProjectContributorsView(ContributorsMixin, DetailView): """ Renders view of contributors for the project. From e5a569600665b69166c716364a3b3ebc97995ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 2 Dec 2024 08:43:17 +0100 Subject: [PATCH 12/17] Add an API endpoint to retrieve User action data (#3455) --- pontoon/api/urls.py | 25 ++++++++++- pontoon/api/views.py | 100 +++++++++++++++++++++++++++++++++++++++++++ pontoon/urls.py | 2 +- 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 pontoon/api/views.py diff --git a/pontoon/api/urls.py b/pontoon/api/urls.py index e4b85a57c0..6394030133 100644 --- a/pontoon/api/urls.py +++ b/pontoon/api/urls.py @@ -1,7 +1,8 @@ from graphene_django.views import GraphQLView -from django.urls import re_path +from django.urls import include, path, re_path +from pontoon.api import views from pontoon.api.schema import schema from pontoon.settings import DEV @@ -13,4 +14,26 @@ r"^graphql/?$", GraphQLView.as_view(schema=schema, graphiql=DEV), ), + # API v1 + path( + "api/v1/", + include( + [ + path( + # User actions + "user-actions/", + include( + [ + # In a given project + path( + "/project//", + views.get_user_actions, + name="pontoon.api.get_user_actions.project", + ), + ] + ), + ), + ] + ), + ), ] diff --git a/pontoon/api/views.py b/pontoon/api/views.py new file mode 100644 index 0000000000..a69d93c6a7 --- /dev/null +++ b/pontoon/api/views.py @@ -0,0 +1,100 @@ +from datetime import datetime, timedelta + +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.utils.timezone import make_aware +from django.views.decorators.http import require_GET + +from pontoon.actionlog.models import ActionLog +from pontoon.base.models import Project + + +@require_GET +@login_required(redirect_field_name="", login_url="/403") +def get_user_actions(request, date, slug): + try: + start_date = make_aware(datetime.strptime(date, "%Y-%m-%d")) + except ValueError: + return JsonResponse( + { + "error": "Invalid date format. Please use YYYY-MM-DD.", + }, + status=400, + ) + + end_date = start_date + timedelta(days=1) + + try: + project = Project.objects.get(slug=slug) + except Project.DoesNotExist: + return JsonResponse( + { + "error": "Project not found. Please use a valid project slug.", + }, + status=404, + ) + + actions = ActionLog.objects.filter( + action_type__startswith="translation:", + created_at__gte=start_date, + created_at__lt=end_date, + translation__entity__resource__project=project, + ) + + actions = actions.prefetch_related( + "performed_by__profile", + "translation__entity__resource", + "translation__errors", + "translation__warnings", + "translation__locale", + "entity__resource", + "locale", + ) + + output = [] + + for action in actions: + user = action.performed_by + locale = action.locale or action.translation.locale + entity = action.entity or action.translation.entity + resource = entity.resource + + data = { + "type": action.action_type, + "date": action.created_at, + "user": { + "pk": user.pk, + "name": user.display_name, + "system_user": user.profile.system_user, + }, + "locale": { + "pk": locale.pk, + "code": locale.code, + "name": locale.name, + }, + "entity": { + "pk": entity.pk, + "key": entity.key, + }, + "resource": { + "pk": resource.pk, + "path": resource.path, + "format": resource.format, + }, + } + + if action.translation: + data["translation"] = action.translation.serialize() + + output.append(data) + + return JsonResponse( + { + "actions": output, + "project": { + "pk": project.pk, + "slug": project.slug, + "name": project.name, + }, + } + ) diff --git a/pontoon/urls.py b/pontoon/urls.py index 947f71d806..e848b570e7 100644 --- a/pontoon/urls.py +++ b/pontoon/urls.py @@ -68,9 +68,9 @@ class LocaleConverter(StringConverter): path("", include("pontoon.contributors.urls")), path("", include("pontoon.localizations.urls")), path("", include("pontoon.base.urls")), + path("", include("pontoon.api.urls")), path("", include("pontoon.translate.urls")), path("", include("pontoon.batch.urls")), - path("", include("pontoon.api.urls")), path("", include("pontoon.homepage.urls")), path("", include("pontoon.uxactionlog.urls")), # Team page: Must be at the end From 8ef541dfd17a31b360edc9e8b3f866c0abeb36ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 2 Dec 2024 11:30:01 +0100 Subject: [PATCH 13/17] Support variant keys of type NumberLiteral in Pretranslation of access keys (#3462) --- pontoon/base/fluent.py | 6 +++ pontoon/base/tests/test_fluent.py | 23 ++++++++++ .../pretranslation/tests/test_pretranslate.py | 46 +++++++++++++++++++ pontoon/pretranslation/transformer.py | 19 ++++---- 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/pontoon/base/fluent.py b/pontoon/base/fluent.py index 7293981648..88459f506b 100644 --- a/pontoon/base/fluent.py +++ b/pontoon/base/fluent.py @@ -12,6 +12,12 @@ def get_default_variant(variants): return variant +def get_variant_key(variant): + """Return the key of the variant as represented in the syntax.""" + key = variant.key + return key.value if isinstance(key, ast.NumberLiteral) else key.name + + def serialize_value(value): """Serialize AST value into a simple string. diff --git a/pontoon/base/tests/test_fluent.py b/pontoon/base/tests/test_fluent.py index d2e062bf67..475678aae4 100644 --- a/pontoon/base/tests/test_fluent.py +++ b/pontoon/base/tests/test_fluent.py @@ -7,6 +7,7 @@ from pontoon.base.fluent import ( get_simple_preview, + get_variant_key, is_plural_expression, ) @@ -80,6 +81,28 @@ def test_get_simple_preview(k): assert get_simple_preview(string) == expected +def test_get_variant_key(): + # Return the key of the variant as represented in the syntax. + input = dedent( + """ + my-entry = + { $num -> + [1] Hello! + *[other] World! + }` + """ + ) + + message = parser.parse_entry(input) + element = message.value.elements[0] + + variant = element.expression.variants[0] + assert get_variant_key(variant) == "1" + + variant = element.expression.variants[1] + assert get_variant_key(variant) == "other" + + def test_is_plural_expression_not_select_expression(): # Return False for elements that are not select expressions input = dedent( diff --git a/pontoon/pretranslation/tests/test_pretranslate.py b/pontoon/pretranslation/tests/test_pretranslate.py index ebb13a1f1a..b321b85f18 100644 --- a/pontoon/pretranslation/tests/test_pretranslate.py +++ b/pontoon/pretranslation/tests/test_pretranslate.py @@ -580,6 +580,52 @@ def test_get_pretranslations_fluent_accesskeys_select_expression_source_and_acce assert response == [(pretranslated_string, None, tm_user)] +@patch("pontoon.pretranslation.pretranslate.get_google_translate_data") +@pytest.mark.django_db +def test_get_pretranslations_fluent_accesskeys_number_literal_source( + gt_mock, fluent_resource, google_translate_locale, gt_user +): + # Generate accesskeys from SelectExpression source with variant keys of type NumberLiteral + input_string = dedent( + """ + title = Title + .label = + { $tabCount -> + [1] Add Tab to New Group + *[other] Add Tabs to New Group + } + .accesskey = G + """ + ) + fluent_entity = EntityFactory(resource=fluent_resource, string=input_string) + + gt_mock.return_value = { + "status": True, + "translation": "gt_translation", + } + + google_translate_locale.cldr_plurals = "1,5" + + expected = dedent( + """ + title = gt_translation + .label = + { $tabCount -> + [1] gt_translation + [one] gt_translation + *[other] gt_translation + } + .accesskey = g + """ + ) + + # Re-serialize to match whitespace + pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected)) + + response = get_pretranslations(fluent_entity, google_translate_locale) + assert response == [(pretranslated_string, None, gt_user)] + + @pytest.mark.django_db def test_get_pretranslations_fluent_multiline( fluent_resource, entity_b, locale_b, tm_user diff --git a/pontoon/pretranslation/transformer.py b/pontoon/pretranslation/transformer.py index 68ea9b7f66..2c0c38e161 100644 --- a/pontoon/pretranslation/transformer.py +++ b/pontoon/pretranslation/transformer.py @@ -8,7 +8,7 @@ from fluent.syntax.serializer import serialize_expression from fluent.syntax.visitor import Transformer -from pontoon.base.fluent import is_plural_expression +from pontoon.base.fluent import get_variant_key, is_plural_expression from pontoon.base.models import Locale @@ -96,7 +96,7 @@ def create_locale_plural_variants(node: FTL.SelectExpression, locale: Locale): node.variants = variants -def extract_accesskey_candidates(message: FTL.Message, label: str, variant_name=None): +def extract_accesskey_candidates(message: FTL.Message, label: str, variant_key=None): def get_source(names): for attribute in message.attributes: if attribute.id.name in names: @@ -107,7 +107,8 @@ def get_source(names): elif isinstance(element.expression, FTL.SelectExpression): variants = element.expression.variants variant = next( - (v for v in variants if v.key.name == variant_name), variants[0] + (v for v in variants if get_variant_key(v) == variant_key), + variants[0], ) variant_element = variant.value.elements[0] @@ -189,11 +190,9 @@ def __init__( def visit_Attribute(self, node: FTL.Pattern): name = node.id.name - def set_accesskey(element, variant_name=None): + def set_accesskey(element, variant_key=None): if isinstance(element, FTL.TextElement) and len(element.value) <= 1: - candidates = extract_accesskey_candidates( - self.entry, name, variant_name - ) + candidates = extract_accesskey_candidates(self.entry, name, variant_key) if candidates: element.value = candidates[0] return True @@ -202,8 +201,11 @@ def set_accesskey(element, variant_name=None): if self.locale.accesskey_localization: element = node.value.elements[0] + # If the attribute is a simple text element, set accesskey if set_accesskey(element): return node + + # If the attribute is a select expression, set accesskey for each variant elif isinstance(element, FTL.Placeable) and isinstance( element.expression, FTL.SelectExpression ): @@ -211,7 +213,8 @@ def set_accesskey(element, variant_name=None): processed_variants = 0 for variant in variants: variant_element = variant.value.elements[0] - if set_accesskey(variant_element, variant.key.name): + + if set_accesskey(variant_element, get_variant_key(variant)): processed_variants += 1 if processed_variants == len(variants): return node From 0d15df6dc01cda21a97c2dbb90cceb7b8a73d2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Mon, 2 Dec 2024 12:04:23 +0100 Subject: [PATCH 14/17] Messaging css fixes (#3463) * Use default font weight in
  • elements in the Messaging Center Preview / Inbox *
  • elements in the notification message should have the same color on hover as the rest of the text --- pontoon/base/static/css/style.css | 1 + pontoon/messaging/static/css/messaging.css | 1 + translate/src/modules/user/components/UserNotification.css | 6 ------ 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pontoon/base/static/css/style.css b/pontoon/base/static/css/style.css index 7da9a5f9fb..cbea0bfdd2 100755 --- a/pontoon/base/static/css/style.css +++ b/pontoon/base/static/css/style.css @@ -751,6 +751,7 @@ header .right .select .menu { } .notifications .menu ul.notification-list li.notification-item { + color: var(--light-grey-7); cursor: default; padding: 0; } diff --git a/pontoon/messaging/static/css/messaging.css b/pontoon/messaging/static/css/messaging.css index 520e99aef6..cd61180873 100644 --- a/pontoon/messaging/static/css/messaging.css +++ b/pontoon/messaging/static/css/messaging.css @@ -412,6 +412,7 @@ li { color: var(--light-grey-6); + font-weight: normal; } li:hover { diff --git a/translate/src/modules/user/components/UserNotification.css b/translate/src/modules/user/components/UserNotification.css index 479c067961..aedc3fa5cf 100644 --- a/translate/src/modules/user/components/UserNotification.css +++ b/translate/src/modules/user/components/UserNotification.css @@ -70,12 +70,6 @@ white-space: nowrap; } -.user-notification .item-content:hover .message, -.user-notification .item-content:hover .message.trim p, -.user-notification .item-content:hover .message.trim a { - color: var(--white-1); -} - .user-notification .message.trim a { color: var(--light-grey-7); } From a025ed1843324295a384e317a0376fbc2c44e912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Tue, 3 Dec 2024 15:05:13 +0100 Subject: [PATCH 15/17] Add ability to send notifications via email (#3456) This PR implements all the changes to the user settings as per the spec: https://github.com/mozilla/pontoon/blob/main/specs/0120-transactional-emails.md#settings-changes It also introduces a management command to send daily or weekly email notifications. Related changes: * Add PNG logo, needed for the display in emails (SVG is not supported in Gmail) * Store notification categories in the data model - Use these categories to determine to which category the user is subscribed - Add data migration to store categories for old notifications Other changes, not directly related to the PR: * Reorder sections in the Settings page by moving Email and Notifications sections next to another. * The settings.html file has been reformatted. Please hide whitespace in the diff for easier overview of changes. * Minor changes to the check-box widget markup and styling. * Minor changes to the success messages when toggling checkboxes. * Explicity set from: email address everywhere we send emails. --- pontoon/base/forms.py | 3 +- .../0068_userprofile_notification_emails.py | 56 +++ .../0069_notification_categories.py | 77 ++++ pontoon/base/models/user.py | 20 + pontoon/base/models/user_profile.py | 57 ++- pontoon/base/static/css/check-box.css | 4 +- pontoon/base/static/css/dark-theme.css | 3 +- pontoon/base/static/css/light-theme.css | 3 +- pontoon/base/static/img/logo.png | Bin 0 -> 2537 bytes pontoon/base/templates/widgets/checkbox.html | 4 +- pontoon/base/templatetags/helpers.py | 14 + pontoon/base/tests/models/test_user.py | 66 +++ pontoon/base/tests/test_helpers.py | 23 + pontoon/base/views.py | 2 + pontoon/contributors/static/css/settings.css | 68 ++- pontoon/contributors/static/js/settings.js | 30 +- .../templates/contributors/settings.html | 433 ++++++++++++------ .../widgets/notifications_menu.html | 12 +- pontoon/contributors/utils.py | 1 + pontoon/contributors/views.py | 22 +- pontoon/messaging/emails.py | 85 ++++ pontoon/messaging/management/__init__.py | 0 .../messaging/management/commands/__init__.py | 0 .../commands/send_notification_emails.py | 15 + .../messaging/emails/notification_digest.html | 164 +++++++ .../templates/messaging/includes/compose.html | 8 +- pontoon/messaging/views.py | 1 + .../commands/send_deadline_notifications.py | 7 +- .../commands/send_review_notifications.py | 1 + .../commands/send_suggestion_notifications.py | 6 +- pontoon/sync/changeset.py | 7 +- pontoon/translations/views.py | 1 + 32 files changed, 1016 insertions(+), 177 deletions(-) create mode 100644 pontoon/base/migrations/0068_userprofile_notification_emails.py create mode 100644 pontoon/base/migrations/0069_notification_categories.py create mode 100644 pontoon/base/static/img/logo.png create mode 100644 pontoon/messaging/emails.py create mode 100644 pontoon/messaging/management/__init__.py create mode 100644 pontoon/messaging/management/commands/__init__.py create mode 100644 pontoon/messaging/management/commands/send_notification_emails.py create mode 100644 pontoon/messaging/templates/messaging/emails/notification_digest.html diff --git a/pontoon/base/forms.py b/pontoon/base/forms.py index 61f5512c75..d8610eb636 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -279,7 +279,7 @@ def clean_github(self): return github_username -class UserProfileVisibilityForm(forms.ModelForm): +class UserProfileToggleForm(forms.ModelForm): """ Form is responsible for controlling user profile visibility. """ @@ -291,6 +291,7 @@ class Meta: "visibility_external_accounts", "visibility_self_approval", "visibility_approval", + "notification_email_frequency", ) diff --git a/pontoon/base/migrations/0068_userprofile_notification_emails.py b/pontoon/base/migrations/0068_userprofile_notification_emails.py new file mode 100644 index 0000000000..31f289f619 --- /dev/null +++ b/pontoon/base/migrations/0068_userprofile_notification_emails.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.16 on 2024-11-21 16:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("base", "0067_remove_userprofile_community_builder_level_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="comment_notifications_email", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="userprofile", + name="monthly_activity_summary", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="userprofile", + name="new_contributor_notifications_email", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="userprofile", + name="new_string_notifications_email", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="userprofile", + name="notification_email_frequency", + field=models.CharField( + choices=[("Daily", "Daily"), ("Weekly", "Weekly")], + default="Weekly", + max_length=10, + ), + ), + migrations.AddField( + model_name="userprofile", + name="project_deadline_notifications_email", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="userprofile", + name="review_notifications_email", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="userprofile", + name="unreviewed_suggestion_notifications_email", + field=models.BooleanField(default=False), + ), + ] diff --git a/pontoon/base/migrations/0069_notification_categories.py b/pontoon/base/migrations/0069_notification_categories.py new file mode 100644 index 0000000000..f558c65a56 --- /dev/null +++ b/pontoon/base/migrations/0069_notification_categories.py @@ -0,0 +1,77 @@ +import logging +import re + +from django.db import migrations + + +log = logging.getLogger(__name__) + + +def get_category(notification): + verb = notification.verb + desc = notification.description + + # New strings notifications + if re.match(r"updated with \d+ new string", verb): + return "new_string" + + # Project target dates notifications + if re.match(r"due in \d+ days", verb): + return "project_deadline" + + # Comments notifications + if re.match(r"has (pinned|added) a comment in", verb): + return "comment" + + # New suggestions ready for review notifications + if verb == "": + return "unreviewed_suggestion" + + if verb == "has reviewed suggestions": + # Review actions on own suggestions notifications + if desc.startswith("Your suggestions have been reviewed"): + return "review" + + # New team contributors notifications + if "has made their first contribution to" in desc: + return "new_contributor" + + if verb == "has sent a message in" or verb == "has sent you a message": + return "direct_message" + + return None + + +def store_notification_categories(apps, schema_editor): + Notification = apps.get_model("notifications", "Notification") + notifications = Notification.objects.all() + unchanged = [] + + for notification in notifications: + category = get_category(notification) + + if category == "direct_message": + notification.data["category"] = category + elif category: + notification.data = {"category": category} + else: + unchanged.append(notification) + + Notification.objects.bulk_update(notifications, ["data"], batch_size=2000) + + log.info(f"Notifications categorized: {len(notifications) - len(unchanged)}.") + log.info(f"Notifications left unchanged: {len(unchanged)}.") + + +class Migration(migrations.Migration): + dependencies = [ + ("base", "0068_userprofile_notification_emails"), + ("notifications", "0009_alter_notification_options_and_more"), + ] + + operations = [ + migrations.RunPython( + code=store_notification_categories, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/pontoon/base/models/user.py b/pontoon/base/models/user.py index e839cb7bcc..d2f7b63b04 100644 --- a/pontoon/base/models/user.py +++ b/pontoon/base/models/user.py @@ -433,6 +433,25 @@ def serialized_notifications(self): } +def is_subscribed_to_notification(self, notification): + """ + Determines if the user has email subscription to the given notification. + """ + profile = self.profile + category = notification.data.get("category") if notification.data else None + + CATEGORY_TO_FIELD = { + "new_string": profile.new_string_notifications_email, + "project_deadline": profile.project_deadline_notifications_email, + "comment": profile.comment_notifications_email, + "unreviewed_suggestion": profile.unreviewed_suggestion_notifications_email, + "review": profile.review_notifications_email, + "new_contributor": profile.new_contributor_notifications_email, + } + + return CATEGORY_TO_FIELD.get(category, False) + + def user_serialize(self): """Serialize Project contact""" @@ -485,5 +504,6 @@ def latest_action(self): User.add_to_class("menu_notifications", menu_notifications) User.add_to_class("unread_notifications_display", unread_notifications_display) User.add_to_class("serialized_notifications", serialized_notifications) +User.add_to_class("is_subscribed_to_notification", is_subscribed_to_notification) User.add_to_class("serialize", user_serialize) User.add_to_class("latest_action", latest_action) diff --git a/pontoon/base/models/user_profile.py b/pontoon/base/models/user_profile.py index 4f1a7b3048..78829ebf8d 100644 --- a/pontoon/base/models/user_profile.py +++ b/pontoon/base/models/user_profile.py @@ -24,6 +24,7 @@ class UserProfile(models.Model): contact_email_verified = models.BooleanField(default=False) email_communications_enabled = models.BooleanField(default=False) email_consent_dismissed_at = models.DateTimeField(null=True, blank=True) + monthly_activity_summary = models.BooleanField(default=False) # Theme class Themes(models.TextChoices): @@ -79,7 +80,7 @@ class VisibilityLoggedIn(models.TextChoices): choices=Visibility.choices, ) - # Notification subscriptions + # In-app Notification subscriptions new_string_notifications = models.BooleanField(default=True) project_deadline_notifications = models.BooleanField(default=True) comment_notifications = models.BooleanField(default=True) @@ -87,6 +88,25 @@ class VisibilityLoggedIn(models.TextChoices): review_notifications = models.BooleanField(default=True) new_contributor_notifications = models.BooleanField(default=True) + # Email Notification subscriptions + new_string_notifications_email = models.BooleanField(default=False) + project_deadline_notifications_email = models.BooleanField(default=False) + comment_notifications_email = models.BooleanField(default=False) + unreviewed_suggestion_notifications_email = models.BooleanField(default=False) + review_notifications_email = models.BooleanField(default=False) + new_contributor_notifications_email = models.BooleanField(default=False) + + # Email Notification frequencies + class EmailFrequencies(models.TextChoices): + DAILY = "Daily", "Daily" + WEEKLY = "Weekly", "Weekly" + + notification_email_frequency = models.CharField( + max_length=10, + choices=EmailFrequencies.choices, + default=EmailFrequencies.WEEKLY, + ) + # Translation settings quality_checks = models.BooleanField(default=True) force_suggestions = models.BooleanField(default=False) @@ -122,3 +142,38 @@ def preferred_locales(self): def sorted_locales(self): locales = self.preferred_locales return sorted(locales, key=lambda locale: self.locales_order.index(locale.pk)) + + def save(self, *args, **kwargs): + notification_fields = [ + ( + "new_string_notifications", + "new_string_notifications_email", + ), + ( + "project_deadline_notifications", + "project_deadline_notifications_email", + ), + ( + "comment_notifications", + "comment_notifications_email", + ), + ( + "unreviewed_suggestion_notifications", + "unreviewed_suggestion_notifications_email", + ), + ( + "review_notifications", + "review_notifications_email", + ), + ( + "new_contributor_notifications", + "new_contributor_notifications_email", + ), + ] + + # Ensure notification email fields are False if the corresponding non-email notification field is False + for non_email_field, email_field in notification_fields: + if not getattr(self, non_email_field): + setattr(self, email_field, False) + + super().save(*args, **kwargs) diff --git a/pontoon/base/static/css/check-box.css b/pontoon/base/static/css/check-box.css index 54c1b0856f..dc422df264 100644 --- a/pontoon/base/static/css/check-box.css +++ b/pontoon/base/static/css/check-box.css @@ -1,11 +1,11 @@ #main .check-list { - cursor: pointer; list-style: none; margin: 0; display: inline-block; text-align: left; .check-box { + cursor: pointer; padding: 4px 0; [type='checkbox'] { @@ -35,7 +35,7 @@ } .fa { - margin-left: 27px; + margin-left: 23px; margin-right: 0; } } diff --git a/pontoon/base/static/css/dark-theme.css b/pontoon/base/static/css/dark-theme.css index 53c0d49e11..7d6da909c6 100644 --- a/pontoon/base/static/css/dark-theme.css +++ b/pontoon/base/static/css/dark-theme.css @@ -20,7 +20,8 @@ --input-background-1: #333941; --input-color-1: #aaaaaa; --input-color-2: #ffffff; - --toggle-color-1: #777777; + --toggle-color-1: #666666; + --toggle-color-2: #333941; --icon-background-1: #3f4752; --icon-border-1: #4d5967; diff --git a/pontoon/base/static/css/light-theme.css b/pontoon/base/static/css/light-theme.css index 937ad79c4a..ec551b20c3 100644 --- a/pontoon/base/static/css/light-theme.css +++ b/pontoon/base/static/css/light-theme.css @@ -19,7 +19,8 @@ --input-background-1: #ffffff; --input-color-1: #000000; --input-color-2: #000000; - --toggle-color-1: #888888; + --toggle-color-1: #999999; + --toggle-color-2: #d0d0d0; --icon-background-1: #d0d0d0; --icon-border-1: #bbbbbb; diff --git a/pontoon/base/static/img/logo.png b/pontoon/base/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f5cc7e0ea03e5473835746d8c4ebd07e0a04dc52 GIT binary patch literal 2537 zcmd5;eKgZ+9RJN*#OP%=6}Oe%4<=14TBe$pdzo(PChsF}84~7YSffl=rH(Gf#pEW; zu+#N6M(0jkm$72=ZkC$OyO2A!-1T?=b`F1h&-49$pYxpaoX_+5JfH7_%NRTG_Wj!d z002AKU%CPSpddvQsjDeEj!LjH#iDWB{zfDK9722z5IW1$N6`dDUa`9XRCeo26opEl zjgt)kFfue{SXIS=B!^2jS7Sl5BQ|N*`hRR++;FtBw+*m#IFr85QoD3-Dhx99JVN+y zXllj9UbKaIL?-%v?PZUcP@zej^e;g=ObK~U+8a`5H(X54*q)=$ zM&SnL#_BEB2+^}#5xzQ}n?QwzC7BYxaE{w{zW!&0R>+&>SV=MiGc2rg!{W8w{vheb zEfkuc!!?1OwkfKJS_7mbskVF&<=wK@fKcAxanpo@S82Ig%eRM5cfmn2lR~dX-pmPR z2TH(NtWt{I=iV9a-glE4E`PyE0na?9AC094<9|~g_fK4TV_Q?&fg&K(6ZGij>_!Bz zS@4!peC>>kjro*S=YBjTuaG8fXvj5V2Oc=Te#Fhqqaweyx%rn#VhjW3-FyXSO&U9t zZ6Z&C1P2YuMAmF9^$^#J4Qbcj85nQ+Am&yhdP@-HTjrKhl1*X6s2APd^{&&?dT%xa zGMPudbWjpM&x+CXYE{o=w8jy@?RZ4nsmCaI79zZ51>Byq_+6PE1-iMV^zPHZz^r}vDK^`tLQhtZQd%Cg`4cL@+mf>o=D~L*1&uH@cnuO3owuRsYG@b!Tjad@|@1li}%daVc zqk_|xI$pKY{1s`SVK*HUiVKd6IQUv~hBE3xb~X+VGvrhzCS{?No%C!w1)c?Z^gh?K zj6QTuox0g>?A{yT5G*-87Q5sdCg*2&9OOpP>)gGLrNgtrz;(^mmXSC!-&DobJf}XM zJfF!qZN)s&y^NwtFrC)3_X8FEVEIedf)T02a9w~~qWdoxC^=o%ZsLguL?K!ww{m7Q ze2$W&zN{^sK2D@%jnv1zax+^;l1iX_oB1c+vZ?-+?xA5`^2(z}YZE-{AF3Nw;>jZo zOFfoEp)aV($VGp1yh`B-1%pUcZsRd!R*ZnA+T-q8U?_ITB#jY2)Fe+D8};Ze|Ji{u zIY1~LkJ~opW+ZmVJ1n;FhK5%A=X}0{;pPQgkEm!)toG?tJdb94FOEun`)(@N-lcfS zho9nS*|%&d=G9y@Knhdmi3lC49h_G$e6P<(6Y;ZnApKrdQ8HK}k;WYOHf4iWohmPOs~961c*y_c2;PsmA;37v_jhFS;{UZWen#RrfH z9?RIY#lKf4EBbt%N1at+Xz7Sa*+)TJYbyAv+}wh4rZ;ep!aO7YAXz4>q8?($WjCiJ xWEVDc@=H=!QL{9l2pCALYPz#A!iZem0Bg(~U{(n8D2mApa6n@&RbKE({vT}4v-|)6 literal 0 HcmV?d00001 diff --git a/pontoon/base/templates/widgets/checkbox.html b/pontoon/base/templates/widgets/checkbox.html index 1e822d27ec..7dbebf3ba2 100644 --- a/pontoon/base/templates/widgets/checkbox.html +++ b/pontoon/base/templates/widgets/checkbox.html @@ -1,5 +1,5 @@ {% macro checkbox(label, class, attribute, is_enabled=False, title=False, help=False, form_field=None) %} -
  • +
    {{ label }} @@ -10,5 +10,5 @@ {% if form_field %} {{ form_field }} {% endif %} -
  • + {% endmacro %} diff --git a/pontoon/base/templatetags/helpers.py b/pontoon/base/templatetags/helpers.py index 00f757b001..596a22d0e0 100644 --- a/pontoon/base/templatetags/helpers.py +++ b/pontoon/base/templatetags/helpers.py @@ -4,6 +4,7 @@ import re from datetime import timedelta +from urllib.parse import urljoin import markupsafe @@ -35,6 +36,13 @@ def url(viewname, *args, **kwargs): return reverse(viewname, args=args, kwargs=kwargs) +@library.global_function +def full_url(viewname, *args, **kwargs): + """Generate an absolute URL.""" + path = reverse(viewname, args=args, kwargs=kwargs) + return urljoin(settings.SITE_URL, path) + + @library.global_function def return_url(request): """Get an url of the previous page.""" @@ -72,6 +80,12 @@ def static(path): return staticfiles_storage.url(path) +@library.global_function +def full_static(path): + """Generate an absolute URL for a static file.""" + return urljoin(settings.SITE_URL, static(path)) + + @library.filter def to_json(value): return json.dumps(value, cls=DjangoJSONEncoder) diff --git a/pontoon/base/tests/models/test_user.py b/pontoon/base/tests/models/test_user.py index cdc276a7df..286867be00 100644 --- a/pontoon/base/tests/models/test_user.py +++ b/pontoon/base/tests/models/test_user.py @@ -2,6 +2,8 @@ import pytest +from notifications.models import Notification + from django.contrib.auth.models import User @@ -92,3 +94,67 @@ def test_user_status(user_a, user_b, user_c, user_d, gt_user, locale_a, project_ # System user (Google Translate) project_contact = gt_user assert gt_user.status(locale_a, project_contact)[1] == "" + + +@pytest.fixture +def user_with_subscriptions(): + """Fixture for a User with notification subscriptions.""" + user = User.objects.create(username="subscriber") + user.profile.new_string_notifications_email = True + user.profile.project_deadline_notifications_email = True + user.profile.comment_notifications_email = False + user.profile.unreviewed_suggestion_notifications_email = True + user.profile.review_notifications_email = False + user.profile.new_contributor_notifications_email = True + user.profile.save() + return user + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "category, expected", + [ + # New strings notifications + ("new_string", True), + # Project target dates notifications + ("project_deadline", True), + # Comments notifications + ("comment", False), + # New suggestions ready for review notifications + ("unreviewed_suggestion", True), + # Review actions on own suggestions notifications + ("review", False), + # New team contributors notifications + ("new_contributor", True), + # Notification send directly from the Messaging Center + ("direct_message", False), + # Fallback case + ("unknown", False), + ], +) +def test_is_subscribed_to_notification(user_with_subscriptions, category, expected): + # Create a notification object + notification = Notification(data={"category": category}) + + # Call the function and assert the result + assert ( + user_with_subscriptions.is_subscribed_to_notification(notification) == expected + ) + + +@pytest.mark.django_db +def test_is_subscribed_to_notification_no_data(user_with_subscriptions): + # Create a notification object without a data attribute + notification = Notification() + + # Call the function and assert the result + assert user_with_subscriptions.is_subscribed_to_notification(notification) is False + + +@pytest.mark.django_db +def test_is_subscribed_to_notification_no_category(user_with_subscriptions): + # Create a notification object without a category key in data + notification = Notification(data={"something": None}) + + # Call the function and assert the result + assert user_with_subscriptions.is_subscribed_to_notification(notification) is False diff --git a/pontoon/base/tests/test_helpers.py b/pontoon/base/tests/test_helpers.py index 692ae988ad..09016bda69 100644 --- a/pontoon/base/tests/test_helpers.py +++ b/pontoon/base/tests/test_helpers.py @@ -1,10 +1,13 @@ from datetime import timedelta +from unittest.mock import patch import pytest from pontoon.base.templatetags.helpers import ( format_datetime, format_timedelta, + full_static, + full_url, metric_prefix, nospam, to_json, @@ -12,6 +15,26 @@ from pontoon.base.utils import aware_datetime +@patch("pontoon.base.templatetags.helpers.reverse") +def test_full_url(mock_reverse, settings): + mock_reverse.return_value = "/some/path" + settings.SITE_URL = "https://example.com" + + result = full_url("some_view_name", arg1="value1") + + assert result == "https://example.com/some/path" + + +@patch("pontoon.base.templatetags.helpers.staticfiles_storage.url") +def test_full_static(mock_static, settings): + mock_static.return_value = "/static/assets/image.png" + settings.SITE_URL = "https://example.com" + + result = full_static("assets/image.png") + + assert result == "https://example.com/static/assets/image.png" + + def test_helper_to_json(): obj = { "a": "foo", diff --git a/pontoon/base/views.py b/pontoon/base/views.py index 983428a6c6..8c103e06bb 100755 --- a/pontoon/base/views.py +++ b/pontoon/base/views.py @@ -565,6 +565,7 @@ def _send_add_comment_notifications(user, comment, entity, locale, translation): action_object=locale, target=entity, description=comment, + category="comment", ) @@ -599,6 +600,7 @@ def _send_pin_comment_notifications(user, comment): action_object=locale, target=entity, description=comment.content, + category="comment", ) diff --git a/pontoon/contributors/static/css/settings.css b/pontoon/contributors/static/css/settings.css index 4859d52286..6c0685adea 100644 --- a/pontoon/contributors/static/css/settings.css +++ b/pontoon/contributors/static/css/settings.css @@ -11,13 +11,13 @@ } #locale-settings .label, +.double-check-box > .label, .toggle-label { color: var(--light-grey-7); float: left; font-size: 16px; font-weight: 300; - margin: 6px 10px 0 0; - padding-right: 20px; + padding: 6px 30px 6px 0; text-align: right; width: 295px; } @@ -95,7 +95,7 @@ #main .field label { display: inline-block; margin-right: 25px; - padding-top: 5px; + padding-top: 6px; text-align: right; vertical-align: top; width: 295px; @@ -129,10 +129,64 @@ font-style: italic; } +#main .check-list .header .title { + color: var(--light-grey-7); + display: inline-block; + font-size: 16px; + font-weight: 300; + padding: 8px 0; + text-align: center; + width: 147.5px; +} + #main .check-list .check-box { + display: inline-block; padding: 8px 0; } +#main .check-list .check-box.field { + padding: 0; +} + +#main .check-list .double-check-box { + font-size: 0; + + > .label { + padding-bottom: 8px; + padding-top: 8px; + } + + > .check-boxes { + display: inline-block; + font-size: 0; + } + + .check-box { + width: 147.5px; + + .check-box-wrapper { + text-align: center; + width: 100%; + + .fa { + margin: 0; + } + } + } + + .check-box:first-child:not(.enabled) + .check-box { + pointer-events: none; + + .fa:before { + color: var(--toggle-color-2); + } + } + + .check-box:last-child { + padding-bottom: 8px; + } +} + #main .appearance .field .help { margin: 0 0 -12px; } @@ -153,6 +207,10 @@ margin-right: 10px; } +#main .notifications .field .help { + margin: 0; +} + #main .field .verify { color: var(--status-translated); font-style: italic; @@ -163,3 +221,7 @@ font-style: italic; list-style: none; } + +#notification-email-frequency { + margin-top: 20px; +} diff --git a/pontoon/contributors/static/js/settings.js b/pontoon/contributors/static/js/settings.js index 3035bb2304..1e1be89942 100644 --- a/pontoon/contributors/static/js/settings.js +++ b/pontoon/contributors/static/js/settings.js @@ -1,10 +1,15 @@ $(function () { - // Toggle visibility - $('.data-visibility .toggle-button button').click(function (e) { + // Handle toggle buttons + $('.toggle-button button').click(function (e) { e.preventDefault(); const self = $(this); + // Theme toggle is handled separately + if (self.parents('.appearance').length) { + return; + } + if (self.is('.active')) { return; } @@ -25,7 +30,12 @@ $(function () { self.siblings().removeClass('active'); const label = self.parents('.field').find('.toggle-label').text(); - Pontoon.endLoader(`${label} visibility set to ${value}.`); + + let message = `${label} set to ${value}.`; + if (self.parents('section.data-visibility').length) { + message = `${label} visibility set to ${value}.`; + } + Pontoon.endLoader(message); }, error: function (request) { if (request.responseText === 'error') { @@ -37,7 +47,7 @@ $(function () { }); }); - // Toggle user profile attribute + // Handle checkboxes $('.check-box').click(function () { const self = $(this); @@ -51,10 +61,16 @@ $(function () { }, success: function () { self.toggleClass('enabled'); - const is_enabled = self.is('.enabled'); - const status = is_enabled ? 'enabled' : 'disabled'; - Pontoon.endLoader(self.find('.label').text() + ' ' + status + '.'); + // If notification type disabled, uncheck email checkbox + if (!self.is('.enabled')) { + const emailChecbox = self.next('.check-box'); + if (emailChecbox.length) { + emailChecbox.removeClass('enabled'); + } + } + + Pontoon.endLoader('Settings saved.'); }, error: function (request) { if (request.responseText === 'error') { diff --git a/pontoon/contributors/templates/contributors/settings.html b/pontoon/contributors/templates/contributors/settings.html index db8d55afc1..3bacd58cea 100644 --- a/pontoon/contributors/templates/contributors/settings.html +++ b/pontoon/contributors/templates/contributors/settings.html @@ -11,161 +11,314 @@ {% block heading %}
    - -
    Update profile picture
    - -
    + +
    Update profile picture
    + +
    -

    {{ user.first_name }}

    +

    {{ user.first_name }}

    {% endblock %} {% block bottom %}
    -
    - {% csrf_token %} - - -
    -

    Personal information

    -
    - {{ user_form.first_name.label_tag(label_suffix='') }} - {{ user_form.first_name }} - {{ user_form.first_name.errors }} -
    -
    - {{ user_profile_form.username.label_tag(label_suffix='') }} - {{ user_profile_form.username }} - {{ user_profile_form.username.errors }} -
    -
    - {{ user_profile_form.bio.label_tag(label_suffix='') }} - {{ user_profile_form.bio }} - {{ user_profile_form.bio.errors }} -

    Displayed on the Profile page

    -
    -
    - -
    -

    Email

    -
    - {{ user_profile_form.contact_email.label_tag(label_suffix='') }} - - {{ user_profile_form.contact_email.errors }} - {% if user.profile.contact_email and not user.profile.contact_email_verified %} + + {% csrf_token %} + + +
    +

    Personal information

    +
    + {{ user_form.first_name.label_tag(label_suffix='') }} + {{ user_form.first_name }} + {{ user_form.first_name.errors }} +
    +
    + {{ user_profile_form.username.label_tag(label_suffix='') }} + {{ user_profile_form.username }} + {{ user_profile_form.username.errors }} +
    +
    + {{ user_profile_form.bio.label_tag(label_suffix='') }} + {{ user_profile_form.bio }} + {{ user_profile_form.bio.errors }} +

    Displayed on the Profile page +

    +
    +
    + +
    +

    Email

    +
    + {{ user_profile_form.contact_email.label_tag(label_suffix='') }} + + {{ user_profile_form.contact_email.errors }} + {% if user.profile.contact_email and not user.profile.contact_email_verified %}

    Check your inbox to verify your email address

    - {% else %} -

    If you would like to provide a different email address from your login email for email communications, please do so here. Note, this email will also appear under your Profile page.

    - {% endif %} -
    -
      - {{ Checkbox.checkbox('Email communications', class='field email-communications-enabled', attribute='email_communications_enabled', is_enabled=user.profile.email_communications_enabled, title='Receive email updates', help=settings.EMAIL_COMMUNICATIONS_HELP_TEXT) }} -
    -
    - -
    -

    Appearance

    -
    -

    Choose if appearance should be dark, light, or follow your system’s settings

    -
    - {{ ThemeToggle.button(user) }} -
    - -
    -

    External accounts

    -
    - {{ user_profile_form.chat.label_tag(label_suffix='') }} - {{ user_profile_form.chat }} - {{ user_profile_form.chat.errors }} -
    -
    - {{ user_profile_form.github.label_tag(label_suffix='') }} - {{ user_profile_form.github }} - {{ user_profile_form.github.errors }} -
    -
    - {{ user_profile_form.bugzilla.label_tag(label_suffix='') }} - {{ user_profile_form.bugzilla }} - {{ user_profile_form.bugzilla.errors }} -
    -
    - -
    -

    Visibility of data on the Profile page

    -
    - {{ user_profile_visibility_form.visibility_email.label }} - {{ Toggle.button(field=user_profile_visibility_form.visibility_email) }} -

    Email addresses of team and project managers are visible to all logged-in users

    -
    -
    - {{ user_profile_visibility_form.visibility_external_accounts.label }} - {{ Toggle.button(field=user_profile_visibility_form.visibility_external_accounts) }} -
    -
    - {{ user_profile_visibility_form.visibility_approval.label }} - {{ Toggle.button(field=user_profile_visibility_form.visibility_approval) }} -
    -
    - {{ user_profile_visibility_form.visibility_self_approval.label }} - {{ Toggle.button(field=user_profile_visibility_form.visibility_self_approval) }} -
    -
    - -
    -

    Notification subscriptions

    -
      - {{ Checkbox.checkbox('New strings', class='new-string-notifications', attribute='new_string_notifications', is_enabled=user.profile.new_string_notifications, title='Get notified when new strings are added to your projects') }} - {{ Checkbox.checkbox('Project target dates', class='project-deadline-notifications', attribute='project_deadline_notifications', is_enabled=user.profile.project_deadline_notifications, title='Get notified when project target date approaches') }} - {{ Checkbox.checkbox('Comments', class='comment-notifications', attribute='comment_notifications', is_enabled=user.profile.comment_notifications, title='Get notified when comments are submitted to your strings') }} - {{ Checkbox.checkbox('New suggestions ready for review', class='unreviewed-suggestion-notifications', attribute='unreviewed_suggestion_notifications', is_enabled=user.profile.unreviewed_suggestion_notifications, title='Get notified when new suggestions are ready for review') }} - {{ Checkbox.checkbox('Review actions on own suggestions', class='review-notifications', attribute='review_notifications', is_enabled=user.profile.review_notifications, title='Get notified when your suggestions are approved or rejected') }} - {{ Checkbox.checkbox('New team contributors', class='new-contributor-notifications', attribute='new_contributor_notifications', is_enabled=user.profile.new_contributor_notifications, title='Get notified when new contributors make their first contributions to the team') }} -
    -
    - -
    -

    Editor

    -
      - {{ Checkbox.checkbox('Translate Toolkit checks', class='quality-checks', attribute='quality_checks', is_enabled=user.profile.quality_checks, title='Run Translate Toolkit checks before submitting translations') }} - - {% if user.can_translate_locales %} - {{ Checkbox.checkbox('Make suggestions', class='force-suggestions', attribute='force_suggestions', is_enabled=user.profile.force_suggestions, title='Save suggestions instead of translations') }} - {% endif %} -
    -
    - -
    -

    Default locales

    -
    -
    - Homepage - {{ team_selector.locale(locales, locale) }} -
    -
    - Preferred source locale - {{ team_selector.locale(preferred_locales, preferred_locale) }} + {% else %} +

    If you would like to provide a different email address from your login email for email + communications, please do so here. Note, this email will also appear under your Profile page.

    + {% endif %} +
    +
    + {{ Checkbox.checkbox( + 'News and updates', + class='field email-communications-enabled', + attribute='email_communications_enabled', + is_enabled=user.profile.email_communications_enabled, + title='Receive news and updates via email', + help=settings.EMAIL_COMMUNICATIONS_HELP_TEXT) + }} + {{ Checkbox.checkbox( + 'Monthly activity summary', + class='field monthly-activity-summary', + attribute='monthly_activity_summary', + is_enabled=user.profile.monthly_activity_summary, + title='Receive monthly activity summary email', + help="Get an email summary of your personal activity and the activity within your teams in the last month.") + }} +
    +
    + +
    +

    Notification subscriptions

    +
    +

    + Stay updated and choose which notifications to include in the daily or weekly email digest +

    +
    +
    + Enable + Include in email +
    +
    + New strings + + {{ Checkbox.checkbox( + '', + class='new-string-notifications', + attribute='new_string_notifications', + is_enabled=user.profile.new_string_notifications, + title='Get notified when new strings are added to your projects') + }} + {{ Checkbox.checkbox( + '', + class='new-string-notifications-email', + attribute='new_string_notifications_email', + is_enabled=user.profile.new_string_notifications_email, + title='Include notification in weekly or monthly email') + }} + +
    +
    + Project target dates + + {{ Checkbox.checkbox( + '', + class='project-deadline-notifications', + attribute='project_deadline_notifications', + is_enabled=user.profile.project_deadline_notifications, + title='Get notified when project target date approaches') + }} + {{ Checkbox.checkbox( + '', + class='project-deadline-notifications-email', + attribute='project_deadline_notifications_email', + is_enabled=user.profile.project_deadline_notifications_email, + title='Include notification in weekly or monthly email') + }} + +
    +
    + Comments + + {{ Checkbox.checkbox( + '', + class='comment-notifications', + attribute='comment_notifications', + is_enabled=user.profile.comment_notifications, + title='Get notified when comments are submitted to your strings') + }} + {{ Checkbox.checkbox( + '', + class='comment-notifications-email', + attribute='comment_notifications_email', + is_enabled=user.profile.comment_notifications_email, + title='Include notification in weekly or monthly email') + }} + +
    +
    + New suggestions ready for review + + {{ Checkbox.checkbox( + '', + class='unreviewed-suggestion-notifications', + attribute='unreviewed_suggestion_notifications', + is_enabled=user.profile.unreviewed_suggestion_notifications, + title='Get notified when new suggestions are ready for review') + }} + {{ Checkbox.checkbox( + '', + class='unreviewed-suggestion-notifications-email', + attribute='unreviewed_suggestion_notifications_email', + is_enabled=user.profile.unreviewed_suggestion_notifications_email, + title='Include notification in weekly or monthly email') + }} + +
    +
    + Review actions on own suggestions + + {{ Checkbox.checkbox( + '', + class='review-notifications', + attribute='review_notifications', + is_enabled=user.profile.review_notifications, + title='Get notified when your suggestions are approved or rejected') + }} + {{ Checkbox.checkbox( + '', + class='review-notifications-email', + attribute='review_notifications_email', + is_enabled=user.profile.review_notifications_email, + title='Include notification in weekly or monthly email') + }} + +
    +
    + New team contributors + + {{ Checkbox.checkbox( + '', + class='new-contributor-notifications', + attribute='new_contributor_notifications', + is_enabled=user.profile.new_contributor_notifications, + title='Get notified when new contributors make their first contributions to the team') + }} + {{ Checkbox.checkbox( + '', + class='new-contributor-notifications-email', + attribute='new_contributor_notifications_email', + is_enabled=user.profile.new_contributor_notifications_email, + title='Include notification in weekly or monthly email') + }} + +
    +
    + Email digest frequency + {{ Toggle.button(field=user_profile_toggle_form.notification_email_frequency) }} +
    +
    +
    + +
    +

    External accounts

    +
    + {{ user_profile_form.chat.label_tag(label_suffix='') }} + {{ user_profile_form.chat }} + {{ user_profile_form.chat.errors }} +
    +
    + {{ user_profile_form.github.label_tag(label_suffix='') }} + {{ user_profile_form.github }} + {{ user_profile_form.github.errors }} +
    +
    + {{ user_profile_form.bugzilla.label_tag(label_suffix='') }} + {{ user_profile_form.bugzilla }} + {{ user_profile_form.bugzilla.errors }} +
    +
    + +
    +

    Visibility of data on the Profile page

    +
    + {{ user_profile_toggle_form.visibility_email.label }} + {{ Toggle.button(field=user_profile_toggle_form.visibility_email) }} +

    Email addresses of team and project managers are visible to all logged-in users

    +
    +
    + {{ user_profile_toggle_form.visibility_external_accounts.label }} + {{ Toggle.button(field=user_profile_toggle_form.visibility_external_accounts) }} +
    +
    + {{ user_profile_toggle_form.visibility_approval.label }} + {{ Toggle.button(field=user_profile_toggle_form.visibility_approval) }} +
    +
    + {{ user_profile_toggle_form.visibility_self_approval.label }} + {{ Toggle.button(field=user_profile_toggle_form.visibility_self_approval) }} +
    +
    + +
    +

    Appearance

    +
    +

    Choose if appearance should be dark, light, or follow your system’s settings

    +
    + {{ ThemeToggle.button(user) }} +
    + +
    +

    Editor

    +
    + {{ Checkbox.checkbox( + 'Translate Toolkit checks', + class='quality-checks', + attribute='quality_checks', + is_enabled=user.profile.quality_checks, + title='Run Translate Toolkit checks before submitting translations') + }} + + {% if user.can_translate_locales %} + {{ Checkbox.checkbox( + 'Make suggestions', + class='force-suggestions', + attribute='force_suggestions', + is_enabled=user.profile.force_suggestions, + title='Save suggestions instead of translations') + }} + {% endif %} +
    +
    + +
    +

    Default locales

    +
    +
    + Homepage + {{ team_selector.locale(locales, locale) }} +
    +
    + Preferred source locale + {{ team_selector.locale(preferred_locales, preferred_locale) }} +
    +
    +
    + +
    +

    Preferred locales to get suggestions from

    + {{ multiple_team_selector.render(available_locales, selected_locales, form_field='locales_order', + sortable=True) }} +
    + +
    + Cancel +
    -
    - -
    -

    Preferred locales to get suggestions from

    - {{ multiple_team_selector.render(available_locales, selected_locales, form_field='locales_order', sortable=True) }} -
    - -
    - Cancel - -
    - +
    {% endblock %} {% block extend_css %} - {% stylesheet 'settings' %} +{% stylesheet 'settings' %} {% endblock %} {% block extend_js %} - {% javascript 'settings' %} +{% javascript 'settings' %} {% endblock %} diff --git a/pontoon/contributors/templates/contributors/widgets/notifications_menu.html b/pontoon/contributors/templates/contributors/widgets/notifications_menu.html index 0f528ef5f6..6962299fac 100644 --- a/pontoon/contributors/templates/contributors/widgets/notifications_menu.html +++ b/pontoon/contributors/templates/contributors/widgets/notifications_menu.html @@ -14,7 +14,7 @@ @@ -35,14 +35,14 @@ {% if notification.actor.slug %} {% set actor_anchor = notification.actor %} {% if "new string" in notification.verb %} - {% set actor_url = url('pontoon.translate.locale.agnostic', notification.actor.slug, "all-resources") + '?status=missing,pretranslated' %} + {% set actor_url = full_url('pontoon.translate.locale.agnostic', notification.actor.slug, "all-resources") + '?status=missing,pretranslated' %} {% else %} - {% set actor_url = url('pontoon.projects.project', notification.actor.slug) %} + {% set actor_url = full_url('pontoon.projects.project', notification.actor.slug) %} {% endif %} {% elif notification.actor.email %} {% set actor_anchor = notification.actor.name_or_email|nospam %} - {% set actor_url = url('pontoon.contributors.contributor.username', notification.actor.username) %} + {% set actor_url = full_url('pontoon.contributors.contributor.username', notification.actor.username) %} {% endif %} {% if description and (description.startswith("Unreviewed suggestions") or notification.verb == "has reviewed suggestions") %} @@ -57,7 +57,7 @@ - {% set link = url('pontoon.translate', notification.action_object.code, target.resource.project.slug, target.resource.path) %} + {% set link = full_url('pontoon.translate', notification.action_object.code, target.resource.project.slug, target.resource.path) %} {{ notification.verb }} @@ -81,7 +81,7 @@ {% if target %} - {{ target }} + {{ target }} {% endif %} diff --git a/pontoon/contributors/utils.py b/pontoon/contributors/utils.py index 7c53899ec0..b042adbc6c 100644 --- a/pontoon/contributors/utils.py +++ b/pontoon/contributors/utils.py @@ -163,6 +163,7 @@ def send_verification_email(request, token): EmailMessage( subject=mail_subject, body=mail_body, + from_email=settings.DEFAULT_FROM_EMAIL, to=[request.user.profile.contact_email], ).send() diff --git a/pontoon/contributors/views.py b/pontoon/contributors/views.py index fd65f1ea37..e89c6d0210 100644 --- a/pontoon/contributors/views.py +++ b/pontoon/contributors/views.py @@ -186,25 +186,37 @@ def toggle_user_profile_attribute(request, username): attribute = request.POST.get("attribute", None) boolean_attributes = [ + # Email settings "email_communications_enabled", + "monthly_activity_summary", + # Editor settings "quality_checks", "force_suggestions", + # In-app notifications "new_string_notifications", "project_deadline_notifications", "comment_notifications", "unreviewed_suggestion_notifications", "review_notifications", "new_contributor_notifications", + # Email notifications + "new_string_notifications_email", + "project_deadline_notifications_email", + "comment_notifications_email", + "unreviewed_suggestion_notifications_email", + "review_notifications_email", + "new_contributor_notifications_email", ] - visibility_attributes = [ + toggle_attributes = [ "visibility_email", "visibility_external_accounts", "visibility_self_approval", "visibility_approval", + "notification_email_frequency", ] - if attribute not in (boolean_attributes + visibility_attributes): + if attribute not in (boolean_attributes + toggle_attributes): return JsonResponse( {"status": False, "message": "Forbidden: Attribute not allowed"}, status=403, @@ -220,7 +232,7 @@ def toggle_user_profile_attribute(request, username): if attribute in boolean_attributes: # Convert JS Boolean to Python setattr(profile, attribute, json.loads(value)) - elif attribute in visibility_attributes: + elif attribute in toggle_attributes: setattr(profile, attribute, value) profile.save() @@ -395,9 +407,7 @@ def settings(request): "preferred_locale": preferred_source_locale, "user_form": user_form, "user_profile_form": user_profile_form, - "user_profile_visibility_form": forms.UserProfileVisibilityForm( - instance=profile - ), + "user_profile_toggle_form": forms.UserProfileToggleForm(instance=profile), }, ) diff --git a/pontoon/messaging/emails.py b/pontoon/messaging/emails.py new file mode 100644 index 0000000000..8cf485077f --- /dev/null +++ b/pontoon/messaging/emails.py @@ -0,0 +1,85 @@ +import datetime +import logging + +from collections import defaultdict + +from notifications.models import Notification + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.mail import EmailMultiAlternatives +from django.db.models import Q +from django.template.loader import get_template +from django.utils import timezone + +from pontoon.messaging.utils import html_to_plain_text_with_links + + +log = logging.getLogger(__name__) + + +def send_notification_digest(frequency="Daily"): + """ + Sends notification email digests to users based on the specified frequency (Daily or Weekly). + """ + log.info(f"Start sending { frequency } notification email digests.") + + if frequency == "Daily": + start_time = timezone.now() - datetime.timedelta(days=1) + elif frequency == "Weekly": + start_time = timezone.now() - datetime.timedelta(weeks=1) + + users = ( + User.objects + # Users with the selected notification email frequency + .filter(profile__notification_email_frequency=frequency) + # Users subscribed to at least one email notification type + .filter( + Q(profile__new_string_notifications_email=True) + | Q(profile__project_deadline_notifications_email=True) + | Q(profile__comment_notifications_email=True) + | Q(profile__unreviewed_suggestion_notifications_email=True) + | Q(profile__review_notifications_email=True) + | Q(profile__new_contributor_notifications_email=True) + ) + ) + + notifications = Notification.objects.filter( + recipient__in=users, + timestamp__gte=start_time, + ).select_related("recipient__profile") + + # Group notifications by user + notifications_map = defaultdict(list) + for notification in notifications: + recipient = notification.recipient + + # Only include notifications the user chose to receive via email + if recipient.is_subscribed_to_notification(notification): + notifications_map[recipient].append(notification) + + subject = f"{frequency} notifications summary" + template = get_template("messaging/emails/notification_digest.html") + + # Process and send email for each user + for user, user_notifications in notifications_map.items(): + body_html = template.render( + { + "notifications": user_notifications, + "subject": subject, + } + ) + body_text = html_to_plain_text_with_links(body_html) + + msg = EmailMultiAlternatives( + subject=subject, + body=body_text, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[user.contact_email], + ) + msg.attach_alternative(body_html, "text/html") + msg.send() + + recipient_count = len(notifications_map.keys()) + + log.info(f"Notification email digests sent to {recipient_count} users.") diff --git a/pontoon/messaging/management/__init__.py b/pontoon/messaging/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pontoon/messaging/management/commands/__init__.py b/pontoon/messaging/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pontoon/messaging/management/commands/send_notification_emails.py b/pontoon/messaging/management/commands/send_notification_emails.py new file mode 100644 index 0000000000..981e1cd222 --- /dev/null +++ b/pontoon/messaging/management/commands/send_notification_emails.py @@ -0,0 +1,15 @@ +from django.core.management.base import BaseCommand +from django.utils.timezone import now + +from pontoon.messaging.emails import send_notification_digest + + +class Command(BaseCommand): + help = "Send notifications in a daily and weekly email digests." + + def handle(self, *args, **options): + send_notification_digest(frequency="Daily") + + # Only send weekly digests on Saturdays + if now().isoweekday() == 6: + send_notification_digest(frequency="Weekly") diff --git a/pontoon/messaging/templates/messaging/emails/notification_digest.html b/pontoon/messaging/templates/messaging/emails/notification_digest.html new file mode 100644 index 0000000000..34320c5bb6 --- /dev/null +++ b/pontoon/messaging/templates/messaging/emails/notification_digest.html @@ -0,0 +1,164 @@ +{% import "contributors/widgets/notifications_menu.html" as Notifications with context %} + + + + + + + + + + {{ subject }} + + + + + + +

    + + + +
    + + + + + + + + + + + + + + + + +
    Hello.
    Here's a summary of the Pontoon notifications you've subscribed to.
    + {{ Notifications.list(notifications=notifications) }} +
    +
    + + + diff --git a/pontoon/messaging/templates/messaging/includes/compose.html b/pontoon/messaging/templates/messaging/includes/compose.html index 3d833a11a8..310d74f37a 100644 --- a/pontoon/messaging/templates/messaging/includes/compose.html +++ b/pontoon/messaging/templates/messaging/includes/compose.html @@ -10,7 +10,7 @@

    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(), @@ -19,7 +19,7 @@

      Message type

      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

    @@ -49,14 +49,14 @@

    Message content

    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

    diff --git a/pontoon/messaging/views.py b/pontoon/messaging/views.py index 1aec239bb8..864f05abf0 100644 --- a/pontoon/messaging/views.py +++ b/pontoon/messaging/views.py @@ -303,6 +303,7 @@ def send_message(request): target=None, description=f"{subject}

    {body}", identifier=identifier, + category="direct_message", ) log.info(f"Notifications sent to {len(recipients)} users.") diff --git a/pontoon/projects/management/commands/send_deadline_notifications.py b/pontoon/projects/management/commands/send_deadline_notifications.py index 4cc0dad080..5e2bc1b3e1 100644 --- a/pontoon/projects/management/commands/send_deadline_notifications.py +++ b/pontoon/projects/management/commands/send_deadline_notifications.py @@ -49,6 +49,11 @@ def handle(self, *args, **options): for contributor in contributors: if is_project_public or contributor.is_superuser: - notify.send(project, recipient=contributor, verb=verb) + notify.send( + project, + recipient=contributor, + verb=verb, + category="project_deadline", + ) self.stdout.write(f"Target date notifications for project {project} sent.") diff --git a/pontoon/projects/management/commands/send_review_notifications.py b/pontoon/projects/management/commands/send_review_notifications.py index f2d866361b..5b417045ec 100644 --- a/pontoon/projects/management/commands/send_review_notifications.py +++ b/pontoon/projects/management/commands/send_review_notifications.py @@ -77,6 +77,7 @@ def handle(self, *args, **options): recipient=author, verb="has reviewed suggestions", description=desc, + category="review", ) self.stdout.write(f"Sent {len(data)} review notifications.") diff --git a/pontoon/projects/management/commands/send_suggestion_notifications.py b/pontoon/projects/management/commands/send_suggestion_notifications.py index 71d407b148..ae9014edff 100644 --- a/pontoon/projects/management/commands/send_suggestion_notifications.py +++ b/pontoon/projects/management/commands/send_suggestion_notifications.py @@ -121,7 +121,11 @@ def handle(self, *args, **options): ) notify.send( - recipient, recipient=recipient, verb="", description=description + recipient, + recipient=recipient, + verb="", + description=description, + category="unreviewed_suggestion", ) self.stdout.write(f"Suggestion notifications sent to {len(recipients)} users.") diff --git a/pontoon/sync/changeset.py b/pontoon/sync/changeset.py index 5f4b4e9876..ad3925d192 100644 --- a/pontoon/sync/changeset.py +++ b/pontoon/sync/changeset.py @@ -239,7 +239,12 @@ def send_notifications(self, new_entities): ).distinct() for contributor in contributors: - notify.send(self.db_project, recipient=contributor, verb=verb) + notify.send( + self.db_project, + recipient=contributor, + verb=verb, + category="new_string", + ) log.info(f"New string notifications for project {self.db_project} sent.") diff --git a/pontoon/translations/views.py b/pontoon/translations/views.py index d717145806..2cb606cf70 100644 --- a/pontoon/translations/views.py +++ b/pontoon/translations/views.py @@ -168,6 +168,7 @@ def create_translation(request): recipient=manager, verb="has reviewed suggestions", # Triggers render of description only description=desc, + category="new_contributor", ) # Award Translation Champion Badge stats From a6195e4fa6ddfb9ed32416abc3c41305980c554d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Tue, 3 Dec 2024 19:25:22 +0100 Subject: [PATCH 16/17] Make NewContributorTooltip visible again (#3466) --- .../modules/editor/components/NewContributorTooltip.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/translate/src/modules/editor/components/NewContributorTooltip.css b/translate/src/modules/editor/components/NewContributorTooltip.css index b357bf51d3..36ed760e31 100644 --- a/translate/src/modules/editor/components/NewContributorTooltip.css +++ b/translate/src/modules/editor/components/NewContributorTooltip.css @@ -4,10 +4,11 @@ border-radius: 10px; box-shadow: 0 0 20px -2px var(--tooltip-background); box-sizing: border-box; - margin: -20px auto; + left: 0; + margin: -50px auto; padding: 20px; - position: absolute; - right: -280px; + position: fixed; + right: 0; width: 250px; z-index: 20; } From 6273ecb07a2523030ad9c898b261ba713979005e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Tue, 3 Dec 2024 21:55:15 +0100 Subject: [PATCH 17/17] Use full URLs in notifications (#3468) Some notification types (suggestions available for review, suggestions have been reviewed and new contributor has joined the team) use relative URLs. That means they won't work in emails. This patch fixes that. It also: * Moves notification content from multiline strings to HTML templates. * Moves management commands for sending notifications to the messaging module. * Moves notification template files to the messaging module. --- .../commands/send_deadline_notifications.py | 0 .../commands/send_review_notifications.py | 68 +++++++++---------- .../commands/send_suggestion_notifications.py | 2 +- .../notifications/new_contributor.html | 7 ++ .../notifications/suggestions_reviewed.html | 11 +++ .../notifications/suggestions_submitted.html | 11 +++ pontoon/projects/management/__init__.py | 0 .../projects/management/commands/__init__.py | 0 .../projects/suggestion_notification.jinja | 6 -- pontoon/translations/views.py | 42 +++--------- 10 files changed, 73 insertions(+), 74 deletions(-) rename pontoon/{projects => messaging}/management/commands/send_deadline_notifications.py (100%) rename pontoon/{projects => messaging}/management/commands/send_review_notifications.py (59%) rename pontoon/{projects => messaging}/management/commands/send_suggestion_notifications.py (98%) create mode 100644 pontoon/messaging/templates/messaging/notifications/new_contributor.html create mode 100644 pontoon/messaging/templates/messaging/notifications/suggestions_reviewed.html create mode 100644 pontoon/messaging/templates/messaging/notifications/suggestions_submitted.html delete mode 100644 pontoon/projects/management/__init__.py delete mode 100644 pontoon/projects/management/commands/__init__.py delete mode 100644 pontoon/projects/templates/projects/suggestion_notification.jinja diff --git a/pontoon/projects/management/commands/send_deadline_notifications.py b/pontoon/messaging/management/commands/send_deadline_notifications.py similarity index 100% rename from pontoon/projects/management/commands/send_deadline_notifications.py rename to pontoon/messaging/management/commands/send_deadline_notifications.py diff --git a/pontoon/projects/management/commands/send_review_notifications.py b/pontoon/messaging/management/commands/send_review_notifications.py similarity index 59% rename from pontoon/projects/management/commands/send_review_notifications.py rename to pontoon/messaging/management/commands/send_review_notifications.py index 5b417045ec..22103ea277 100644 --- a/pontoon/projects/management/commands/send_review_notifications.py +++ b/pontoon/messaging/management/commands/send_review_notifications.py @@ -5,7 +5,7 @@ from django.core.management.base import BaseCommand from django.db.models import Q -from django.urls import reverse +from django.template.loader import render_to_string from django.utils import timezone from pontoon.base.models import Translation @@ -14,37 +14,6 @@ class Command(BaseCommand): help = "Notify translators about their newly reviewed suggestions" - def get_description(self, notifyData): - desc = "Your suggestions have been reviewed:\n
      " - - for (locale, project), (approved, rejected) in notifyData.items(): - url = reverse( - "pontoon.translate", - kwargs={ - "locale": locale.code, - "project": project.slug, - "resource": "all-resources", - }, - ) - list = map(str, approved + rejected) - url += "?list=" + ",".join(list) - - # Filter out rejections where the author's own suggestion replaced the previous - rejected = [x for x in rejected if x not in approved] - - if len(approved) == 0: - msg = f"{len(rejected)} Rejected" - else: - msg = f"{len(approved)} Approved" - if len(rejected) > 0: - msg += f", {len(rejected)} Rejected" - - desc += ( - f'\n
    • {project.name} ({locale.code}): {msg}
    • ' - ) - - return desc + "\n
    " - def handle(self, *args, **options): """ This command sends notifications about newly reviewed @@ -57,6 +26,7 @@ def handle(self, *args, **options): # (author) -> (locale, project) -> (approved, rejected) data = defaultdict(lambda: defaultdict(lambda: (list(), list()))) start = timezone.now() - timedelta(days=1) + for suggestion in Translation.objects.filter( (Q(approved_date__gt=start) | Q(rejected_date__gt=start)) & Q(user__profile__review_notifications=True) @@ -70,13 +40,41 @@ def handle(self, *args, **options): elif suggestion.rejected and suggestion.rejected_user != author: data[author][(locale, project)][1].append(suggestion.entity.pk) - for author, notifyData in data.items(): - desc = self.get_description(notifyData) + for author, notification_data in data.items(): + notifications = [] + + for (locale, project), (approved, rejected) in notification_data.items(): + # Filter out rejections where the author's own suggestion replaced the previous + rejected = [id for id in rejected if id not in approved] + + if len(approved) == 0: + msg = f"{len(rejected)} Rejected" + else: + msg = f"{len(approved)} Approved" + if len(rejected) > 0: + msg += f", {len(rejected)} Rejected" + + notifications.append( + { + "locale": locale, + "project": project, + "ids": ",".join(map(str, approved + rejected)), + "msg": msg, + } + ) + + description = render_to_string( + "messaging/notifications/suggestions_reviewed.html", + { + "notifications": notifications, + }, + ) + notify.send( sender=author, recipient=author, verb="has reviewed suggestions", - description=desc, + description=description, category="review", ) diff --git a/pontoon/projects/management/commands/send_suggestion_notifications.py b/pontoon/messaging/management/commands/send_suggestion_notifications.py similarity index 98% rename from pontoon/projects/management/commands/send_suggestion_notifications.py rename to pontoon/messaging/management/commands/send_suggestion_notifications.py index ae9014edff..a4b8ce29cc 100644 --- a/pontoon/projects/management/commands/send_suggestion_notifications.py +++ b/pontoon/messaging/management/commands/send_suggestion_notifications.py @@ -116,7 +116,7 @@ def handle(self, *args, **options): project_locales = data[recipient] description = render_to_string( - "projects/suggestion_notification.jinja", + "messaging/notifications/suggestions_submitted.html", {"project_locales": project_locales}, ) diff --git a/pontoon/messaging/templates/messaging/notifications/new_contributor.html b/pontoon/messaging/templates/messaging/notifications/new_contributor.html new file mode 100644 index 0000000000..5ce098268d --- /dev/null +++ b/pontoon/messaging/templates/messaging/notifications/new_contributor.html @@ -0,0 +1,7 @@ +{{ user.name_or_email }} has +made their first contribution to {{ locale.name }} ({{ + locale.code }}). + +Please welcome them to the team, and make sure to review + their suggestions. diff --git a/pontoon/messaging/templates/messaging/notifications/suggestions_reviewed.html b/pontoon/messaging/templates/messaging/notifications/suggestions_reviewed.html new file mode 100644 index 0000000000..01f1b95efe --- /dev/null +++ b/pontoon/messaging/templates/messaging/notifications/suggestions_reviewed.html @@ -0,0 +1,11 @@ +Your suggestions have been reviewed: +
      + {% for notification in notifications %} + {% set locale = notification.locale %} + {% set project = notification.project %} + {% set ids = notification.ids %} + {% set msg = notification.msg %} +
    • {{ + project.name }} ({{ locale.code }}): {{ msg }}
    • + {% endfor %} +
    diff --git a/pontoon/messaging/templates/messaging/notifications/suggestions_submitted.html b/pontoon/messaging/templates/messaging/notifications/suggestions_submitted.html new file mode 100644 index 0000000000..285a7916ba --- /dev/null +++ b/pontoon/messaging/templates/messaging/notifications/suggestions_submitted.html @@ -0,0 +1,11 @@ +Unreviewed suggestions have been submitted for the following projects: + diff --git a/pontoon/projects/management/__init__.py b/pontoon/projects/management/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pontoon/projects/management/commands/__init__.py b/pontoon/projects/management/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pontoon/projects/templates/projects/suggestion_notification.jinja b/pontoon/projects/templates/projects/suggestion_notification.jinja deleted file mode 100644 index 7110606a76..0000000000 --- a/pontoon/projects/templates/projects/suggestion_notification.jinja +++ /dev/null @@ -1,6 +0,0 @@ -Unreviewed suggestions have been submitted for the following projects: - diff --git a/pontoon/translations/views.py b/pontoon/translations/views.py index 2cb606cf70..726441cc03 100644 --- a/pontoon/translations/views.py +++ b/pontoon/translations/views.py @@ -5,7 +5,7 @@ from django.db import transaction from django.http import JsonResponse from django.shortcuts import get_object_or_404 -from django.urls import reverse +from django.template.loader import render_to_string from django.utils import timezone from django.utils.datastructures import MultiValueDictKeyError from django.views.decorators.http import require_POST @@ -128,36 +128,14 @@ def create_translation(request): # When user makes their first contribution to the team, notify team managers first_contribution = not project.system_project and user.is_new_contributor(locale) if first_contribution: - desc = """ - {user} has made their first contribution to - {locale} ({locale_code}). - Please welcome them to the team, and make sure to - review their suggestions. - """.format( - user=user.name_or_email, - user_href=reverse( - "pontoon.contributors.contributor.username", - kwargs={ - "username": user.username, - }, - ), - locale=locale.name, - locale_code=locale.code, - locale_href=reverse( - "pontoon.teams.team", - kwargs={ - "locale": locale.code, - }, - ), - review_href=reverse( - "pontoon.translate", - kwargs={ - "locale": locale.code, - "project": project.slug, - "resource": entity.resource.path, - }, - ) - + f"?string={entity.pk}", + description = render_to_string( + "messaging/notifications/new_contributor.html", + { + "entity": entity, + "locale": locale, + "project": project, + "user": user, + }, ) for manager in locale.managers_group.user_set.filter( @@ -167,7 +145,7 @@ def create_translation(request): sender=manager, recipient=manager, verb="has reviewed suggestions", # Triggers render of description only - description=desc, + description=description, category="new_contributor", )