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/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/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/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/forms.py b/pontoon/base/forms.py index 23998a3286..a7f0605c5b 100644 --- a/pontoon/base/forms.py +++ b/pontoon/base/forms.py @@ -269,7 +269,7 @@ def clean_github(self): return github_username -class UserProfileVisibilityForm(forms.ModelForm): +class UserProfileToggleForm(forms.ModelForm): """ Form is responsible for controlling user profile visibility. """ @@ -281,6 +281,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/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: diff --git a/pontoon/base/models/user.py b/pontoon/base/models/user.py index 2080d7b926..d07f28ae15 100644 --- a/pontoon/base/models/user.py +++ b/pontoon/base/models/user.py @@ -452,6 +452,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""" @@ -504,5 +523,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 7ed364dc40..7d6da909c6 100644 --- a/pontoon/base/static/css/dark-theme.css +++ b/pontoon/base/static/css/dark-theme.css @@ -19,7 +19,9 @@ --popup-background-1: #333941; --input-background-1: #333941; --input-color-1: #aaaaaa; - --toggle-color-1: #777777; + --input-color-2: #ffffff; + --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 a480f34520..ec551b20c3 100644 --- a/pontoon/base/static/css/light-theme.css +++ b/pontoon/base/static/css/light-theme.css @@ -18,7 +18,9 @@ --popup-background-1: #ffffff; --input-background-1: #ffffff; --input-color-1: #000000; - --toggle-color-1: #888888; + --input-color-2: #000000; + --toggle-color-1: #999999; + --toggle-color-2: #d0d0d0; --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..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; } @@ -1424,6 +1425,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/base/static/img/logo.png b/pontoon/base/static/img/logo.png new file mode 100644 index 0000000000..f5cc7e0ea0 Binary files /dev/null and b/pontoon/base/static/img/logo.png differ 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/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 05886ef56d..596a22d0e0 100644 --- a/pontoon/base/templatetags/helpers.py +++ b/pontoon/base/templatetags/helpers.py @@ -1,8 +1,10 @@ import datetime import html import json +import re from datetime import timedelta +from urllib.parse import urljoin import markupsafe @@ -18,7 +20,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 @@ -32,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.""" @@ -69,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) @@ -288,3 +305,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/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_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/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/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 %} 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 81e05a1885..8c6aa0563a 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" or notification.verb == "ignore") %} @@ -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 929957e895..85212d65ab 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/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/projects/management/__init__.py b/pontoon/messaging/management/__init__.py similarity index 100% rename from pontoon/projects/management/__init__.py rename to pontoon/messaging/management/__init__.py diff --git a/pontoon/projects/management/commands/__init__.py b/pontoon/messaging/management/commands/__init__.py similarity index 100% rename from pontoon/projects/management/commands/__init__.py rename to pontoon/messaging/management/commands/__init__.py diff --git a/pontoon/projects/management/commands/send_deadline_notifications.py b/pontoon/messaging/management/commands/send_deadline_notifications.py similarity index 89% rename from pontoon/projects/management/commands/send_deadline_notifications.py rename to pontoon/messaging/management/commands/send_deadline_notifications.py index 4cc0dad080..5e2bc1b3e1 100644 --- a/pontoon/projects/management/commands/send_deadline_notifications.py +++ b/pontoon/messaging/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/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/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 f2d866361b..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,42 @@ 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", ) self.stdout.write(f"Sent {len(data)} review notifications.") diff --git a/pontoon/projects/management/commands/send_suggestion_notifications.py b/pontoon/messaging/management/commands/send_suggestion_notifications.py similarity index 94% rename from pontoon/projects/management/commands/send_suggestion_notifications.py rename to pontoon/messaging/management/commands/send_suggestion_notifications.py index 71d407b148..a4b8ce29cc 100644 --- a/pontoon/projects/management/commands/send_suggestion_notifications.py +++ b/pontoon/messaging/management/commands/send_suggestion_notifications.py @@ -116,12 +116,16 @@ 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}, ) 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/messaging/static/css/messaging.css b/pontoon/messaging/static/css/messaging.css index 56d2a44f5a..cd61180873 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; } } @@ -378,6 +412,7 @@ li { color: var(--light-grey-6); + font-weight: normal; } li:hover { 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/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 098abb9cf3..310d74f37a 100644 --- a/pontoon/messaging/templates/messaging/includes/compose.html +++ b/pontoon/messaging/templates/messaging/includes/compose.html @@ -6,10 +6,11 @@
    {% csrf_token %} {{ form.send_to_myself }} + {{ form.recipient_ids }}

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

    @@ -48,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

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

    Message type

    - - + + + +
    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/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..864f05abf0 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 @@ -99,33 +101,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 +170,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 +208,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 +248,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, @@ -256,16 +303,16 @@ def send_message(request): target=None, description=f"{subject}

    {body}", identifier=identifier, + category="direct_message", ) - 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: + 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 "" @@ -273,9 +320,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 +337,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) 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 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/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/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. diff --git a/pontoon/settings/base.py b/pontoon/settings/base.py index e5a24db09d..c7b0fc61fb 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: @@ -495,6 +498,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", }, @@ -666,6 +670,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", }, @@ -1138,3 +1143,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/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/teams/static/css/translation_memory.css b/pontoon/teams/static/css/translation_memory.css new file mode 100644 index 0000000000..1f07561778 --- /dev/null +++ b/pontoon/teams/static/css/translation_memory.css @@ -0,0 +1,273 @@ +.translation-memory { + .upload-wrapper { + float: right; + + .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; + } + } + + 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); + + .fa { + margin-right: 2px; + } + } + + .button.edit:hover { + background: var(--status-translated); + } + + .button.save { + display: none; + } + + .button.save:hover { + background: var(--status-translated); + } + + .button.delete { + margin-left: 5px; + } + + .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..a02bab27ee --- /dev/null +++ b/pontoon/teams/static/js/translation_memory.js @@ -0,0 +1,219 @@ +$(function () { + 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 .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 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.', 'error'); + 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', '.translation-memory .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', '.translation-memory .button.cancel', function () { + const row = $(this).parents('tr'); + row.removeClass('deleting editing'); + }); + + // Edit TM entries + $('body').on('click', '.translation-memory .button.edit', function () { + const row = $(this).parents('tr'); + row.addClass('editing'); + row.find('.target textarea').focus(); + }); + $('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(); + + $.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.', 'error'); + }, + complete: function () { + row.removeClass('editing'); + }, + }); + }); + + // Delete TM entries + $('body').on('click', '.translation-memory .button.delete', function () { + const row = $(this).parents('tr'); + row.addClass('deleting'); + }); + $('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.', 'error'); + }, + complete: function () { + row.removeClass('deleting'); + }, + }); + }, + ); + + const uploadWrapper = '.translation-memory .upload-wrapper'; + + // Upload TM entries + $('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; + } + + 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 new file mode 100644 index 0000000000..47812902e0 --- /dev/null +++ b/pontoon/teams/templates/teams/includes/translation_memory.html @@ -0,0 +1,45 @@ +
    + +
    + + +
    + +
    + + + + + +
    + +

    + 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. +

    +
    + + + + + + + + + + + {% include "teams/widgets/translation_memory_entries.html" with context %} + +
    SourceTranslationActions
    +
    diff --git a/pontoon/teams/templates/teams/team.html b/pontoon/teams/templates/teams/team.html index dd6232deb4..bd9fa60d1c 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_translate_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..99394ad2ad --- /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..474cd28ad7 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,30 @@ 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", + ), + # 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 2c14d92484..e4bac2c2b3 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 @@ -6,30 +9,40 @@ 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 from pontoon.teams.forms import LocaleRequestForm +log = logging.getLogger(__name__) + + def teams(request): """List all active localization teams.""" locales = Locale.objects.available().prefetch_related( @@ -255,6 +268,270 @@ def ajax_permissions(request, locale): ) +@require_AJAX +@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() + + 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) + + # 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="," + ), + ) + ) + + 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) + + # 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" in request.GET + else "teams/includes/translation_memory.html" + ) + + return render( + request, + template, + { + "locale": locale, + "search_query": search_query, + "tm_entries": combined_entries, + "has_next": paginator.num_pages > page_number, + }, + ) + + +@require_AJAX +@require_POST +@permission_required_or_403("base.can_translate_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_translate_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") + + +@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): 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 """ + +
    +
    +""" diff --git a/pontoon/translations/views.py b/pontoon/translations/views.py index 7a67a6f150..a625811d01 100644 --- a/pontoon/translations/views.py +++ b/pontoon/translations/views.py @@ -6,7 +6,6 @@ from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django.template.loader import render_to_string -from django.urls import reverse from django.utils import timezone from django.utils.datastructures import MultiValueDictKeyError from django.views.decorators.http import require_POST @@ -157,36 +156,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( @@ -196,7 +173,8 @@ def create_translation(request): sender=manager, recipient=manager, verb="has reviewed suggestions", # Triggers render of description only - description=desc, + description=description, + category="new_contributor", ) response_data = { 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 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 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 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; } 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); }