From 1dcd7382221f7b943b9b743ee32322f7233f6a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matja=C5=BE=20Horvat?= Date: Thu, 22 Feb 2024 21:41:50 +0100 Subject: [PATCH] Optimize tags dashboards (#3101) The functionality should remain identical, but the views should be significantly faster. In the case of Firefox Tag dashboards, the load times were in the 20-30 second range (or timing out), now these pages take max. 2-3 seconds to load. The patch also: * adds an extra DB index, which significantly impacts Tags tab performance * drops the unused code (which represents the vast majority of changes in this patch) * moves remaining files from tags/utils to tags/admin --- .../0053_alter_translation_index_together.py | 27 +++ pontoon/base/models.py | 39 ++-- pontoon/localizations/views.py | 11 +- .../templates/projects/includes/teams.html | 4 + pontoon/projects/views.py | 10 +- pontoon/tags/admin/__init__.py | 10 + pontoon/tags/{utils => admin}/base.py | 48 ----- pontoon/tags/{ => admin}/exceptions.py | 0 pontoon/tags/admin/forms.py | 2 +- pontoon/tags/{utils => admin}/resources.py | 3 +- pontoon/tags/{utils => admin}/tag.py | 26 +-- pontoon/tags/{utils => admin}/tags.py | 34 --- pontoon/tags/templates/tags/tag.html | 2 +- .../tags/tests/{utils => admin}/__init__.py | 0 .../tags/tests/{utils => admin}/test_base.py | 2 +- pontoon/tags/tests/{ => admin}/test_forms.py | 0 .../tests/{utils => admin}/test_resources.py | 10 +- .../tags/tests/{utils => admin}/test_tag.py | 104 +-------- pontoon/tags/tests/admin/test_tags.py | 100 +++++++++ pontoon/tags/tests/admin/test_views.py | 133 ++++++++++++ pontoon/tags/tests/test_utils.py | 76 +++++++ pontoon/tags/tests/test_views.py | 142 +----------- pontoon/tags/tests/utils/test_stats.py | 195 ----------------- pontoon/tags/tests/utils/test_tagged.py | 198 ----------------- pontoon/tags/tests/utils/test_tags.py | 203 ------------------ pontoon/tags/tests/utils/test_translations.py | 147 ------------- pontoon/tags/utils.py | 115 ++++++++++ pontoon/tags/utils/__init__.py | 21 -- pontoon/tags/utils/chart.py | 49 ----- pontoon/tags/utils/latest_activity.py | 73 ------- pontoon/tags/utils/stats.py | 63 ------ pontoon/tags/utils/tagged.py | 64 ------ pontoon/tags/utils/translations.py | 67 ------ pontoon/tags/views.py | 14 +- 34 files changed, 515 insertions(+), 1477 deletions(-) create mode 100644 pontoon/base/migrations/0053_alter_translation_index_together.py rename pontoon/tags/{utils => admin}/base.py (68%) rename pontoon/tags/{ => admin}/exceptions.py (100%) rename pontoon/tags/{utils => admin}/resources.py (98%) rename pontoon/tags/{utils => admin}/tag.py (72%) rename pontoon/tags/{utils => admin}/tags.py (56%) rename pontoon/tags/tests/{utils => admin}/__init__.py (100%) rename pontoon/tags/tests/{utils => admin}/test_base.py (96%) rename pontoon/tags/tests/{ => admin}/test_forms.py (100%) rename pontoon/tags/tests/{utils => admin}/test_resources.py (96%) rename pontoon/tags/tests/{utils => admin}/test_tag.py (54%) create mode 100644 pontoon/tags/tests/admin/test_tags.py create mode 100644 pontoon/tags/tests/admin/test_views.py create mode 100644 pontoon/tags/tests/test_utils.py delete mode 100644 pontoon/tags/tests/utils/test_stats.py delete mode 100644 pontoon/tags/tests/utils/test_tagged.py delete mode 100644 pontoon/tags/tests/utils/test_tags.py delete mode 100644 pontoon/tags/tests/utils/test_translations.py create mode 100644 pontoon/tags/utils.py delete mode 100644 pontoon/tags/utils/__init__.py delete mode 100644 pontoon/tags/utils/chart.py delete mode 100644 pontoon/tags/utils/latest_activity.py delete mode 100644 pontoon/tags/utils/stats.py delete mode 100644 pontoon/tags/utils/tagged.py delete mode 100644 pontoon/tags/utils/translations.py diff --git a/pontoon/base/migrations/0053_alter_translation_index_together.py b/pontoon/base/migrations/0053_alter_translation_index_together.py new file mode 100644 index 0000000000..8f6bde78f1 --- /dev/null +++ b/pontoon/base/migrations/0053_alter_translation_index_together.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.15 on 2024-02-20 10:51 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("base", "0052_rename_logged_in_users"), + ] + + operations = [ + migrations.AlterIndexTogether( + name="translation", + index_together={ + ("locale", "user", "entity"), + ("entity", "user", "approved", "pretranslated"), + ("entity", "locale", "fuzzy"), + ("date", "locale"), + ("entity", "locale", "approved"), + ("entity", "locale", "pretranslated"), + ("approved_date", "locale"), + }, + ), + ] diff --git a/pontoon/base/models.py b/pontoon/base/models.py index 43b7ccb3ce..18fc784bcc 100644 --- a/pontoon/base/models.py +++ b/pontoon/base/models.py @@ -531,6 +531,25 @@ class AggregatedStats(models.Model): class Meta: abstract = True + @classmethod + def get_chart_dict(cls, obj): + """Get chart data dictionary""" + if obj.total_strings: + return { + "total_strings": obj.total_strings, + "approved_strings": obj.approved_strings, + "pretranslated_strings": obj.pretranslated_strings, + "strings_with_errors": obj.strings_with_errors, + "strings_with_warnings": obj.strings_with_warnings, + "unreviewed_strings": obj.unreviewed_strings, + "approved_share": round(obj.approved_percent), + "pretranslated_share": round(obj.pretranslated_percent), + "errors_share": round(obj.errors_percent), + "warnings_share": round(obj.warnings_percent), + "unreviewed_share": round(obj.unreviewed_percent), + "completion_percent": int(math.floor(obj.completed_percent)), + } + @classmethod def get_stats_sum(cls, qs): """ @@ -1837,25 +1856,6 @@ def get_chart(cls, self, extra=None): return chart - @classmethod - def get_chart_dict(cls, obj): - """Get chart data dictionary""" - if obj.total_strings: - return { - "total_strings": obj.total_strings, - "approved_strings": obj.approved_strings, - "pretranslated_strings": obj.pretranslated_strings, - "strings_with_errors": obj.strings_with_errors, - "strings_with_warnings": obj.strings_with_warnings, - "unreviewed_strings": obj.unreviewed_strings, - "approved_share": round(obj.approved_percent), - "pretranslated_share": round(obj.pretranslated_percent), - "errors_share": round(obj.errors_percent), - "warnings_share": round(obj.warnings_percent), - "unreviewed_share": round(obj.unreviewed_percent), - "completion_percent": int(math.floor(obj.completed_percent)), - } - def aggregate_stats(self): TranslatedResource.objects.filter( resource__project=self.project, @@ -3284,6 +3284,7 @@ class Meta: ("entity", "locale", "fuzzy"), ("locale", "user", "entity"), ("date", "locale"), + ("approved_date", "locale"), ) constraints = [ models.UniqueConstraint( diff --git a/pontoon/localizations/views.py b/pontoon/localizations/views.py index 7c9b7b46bf..4e92b25378 100644 --- a/pontoon/localizations/views.py +++ b/pontoon/localizations/views.py @@ -1,5 +1,4 @@ import math -from operator import attrgetter from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db.models import Q @@ -20,7 +19,7 @@ ) from pontoon.contributors.views import ContributorsMixin from pontoon.insights.utils import get_insights -from pontoon.tags.utils import TagsTool +from pontoon.tags.utils import Tags def localization(request, code, slug): @@ -159,13 +158,7 @@ def ajax_tags(request, code, slug): if not project.tags_enabled: raise Http404 - tags_tool = TagsTool( - locales=[locale], - projects=[project], - priority=True, - ) - - tags = sorted(tags_tool, key=attrgetter("priority"), reverse=True) + tags = Tags(project=project, locale=locale).get() return render( request, diff --git a/pontoon/projects/templates/projects/includes/teams.html b/pontoon/projects/templates/projects/includes/teams.html index e07ac191ef..2e8d053965 100644 --- a/pontoon/projects/templates/projects/includes/teams.html +++ b/pontoon/projects/templates/projects/includes/teams.html @@ -19,10 +19,12 @@ {% set locale_projects = project.available_locales_list() %} {% for locale in locales %} + {% if not tag %} {% set main_link = url('pontoon.projects.project', project.slug) %} {% set chart_link = url('pontoon.translate', locale.code, project.slug, 'all-resources') %} {% set latest_activity = locale.get_latest_activity(project) %} {% set chart = locale.get_chart(project) %} + {% endif %} {% if locale.code in locale_projects %} {% set class = 'limited' %} {% if chart %} @@ -35,6 +37,8 @@ {% if tag %} {% set main_link = url('pontoon.projects.project', project.slug) + '?tag=' + tag.slug %} {% set chart_link = url('pontoon.translate', locale.code, project.slug, 'all-resources') + '?tag=' + tag.slug %} + {% set latest_activity = locale.latest_activity %} + {% set chart = locale.chart %} {% if chart %} {% set main_link = chart_link %} {% endif %} diff --git a/pontoon/projects/views.py b/pontoon/projects/views.py index 11bf330f02..ec8a0b9c22 100644 --- a/pontoon/projects/views.py +++ b/pontoon/projects/views.py @@ -1,5 +1,4 @@ import uuid -from operator import attrgetter from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -19,7 +18,7 @@ from pontoon.contributors.views import ContributorsMixin from pontoon.insights.utils import get_insights from pontoon.projects import forms -from pontoon.tags.utils import TagsTool +from pontoon.tags.utils import Tags def projects(request): @@ -109,12 +108,7 @@ def ajax_tags(request, slug): if not project.tags_enabled: raise Http404 - tags_tool = TagsTool( - projects=[project], - priority=True, - ) - - tags = sorted(tags_tool, key=attrgetter("priority"), reverse=True) + tags = Tags(project=project).get() return render( request, diff --git a/pontoon/tags/admin/__init__.py b/pontoon/tags/admin/__init__.py index e69de29bb2..492a55b3cf 100644 --- a/pontoon/tags/admin/__init__.py +++ b/pontoon/tags/admin/__init__.py @@ -0,0 +1,10 @@ +from .resources import TagsResourcesTool +from .tags import TagsTool +from .tag import TagTool + + +__all__ = ( + "TagsResourcesTool", + "TagsTool", + "TagTool", +) diff --git a/pontoon/tags/utils/base.py b/pontoon/tags/admin/base.py similarity index 68% rename from pontoon/tags/utils/base.py rename to pontoon/tags/admin/base.py index 7226bb38f2..7ba6f6240c 100644 --- a/pontoon/tags/utils/base.py +++ b/pontoon/tags/admin/base.py @@ -1,6 +1,5 @@ from collections import OrderedDict -from django.db.models import Q from django.utils.functional import cached_property from pontoon.base.models import ( @@ -137,50 +136,3 @@ def translation_manager(self): @property def tr_manager(self): return TranslatedResource.objects - - -class TagsTRTool(TagsDataTool): - """Data Tool from the perspective of TranslatedResources""" - - clone_kwargs = TagsDataTool.clone_kwargs + ("annotations", "groupby") - - @property - def data_manager(self): - return self.tr_manager - - def filter_locales(self, trs): - return trs.filter(locale__in=self.locales) if self.locales else trs - - def filter_path(self, trs): - return ( - trs.filter(resource__path__contains=self.path).distinct() - if self.path - else trs - ) - - def filter_projects(self, trs): - return trs.filter(resource__project__in=self.projects) if self.projects else trs - - def filter_tag(self, trs): - """Filters on tag.slug and tag.priority""" - - q = Q() - if not self.slug: - # if slug is not specified, then just remove all resources - # that have no tag - q &= ~Q(resource__tag__isnull=True) - - if self.slug: - q &= Q(resource__tag__slug__contains=self.slug) - - if self.priority is not None: - if self.priority is False: - # if priority is False, exclude tags with priority - q &= Q(resource__tag__priority__isnull=True) - elif self.priority is True: - # if priority is True show only tags with priority - q &= Q(resource__tag__priority__isnull=False) - elif isinstance(self.priority, int): - # if priority is an int, filter on that priority - q &= Q(resource__tag__priority=self.priority) - return trs.filter(q) diff --git a/pontoon/tags/exceptions.py b/pontoon/tags/admin/exceptions.py similarity index 100% rename from pontoon/tags/exceptions.py rename to pontoon/tags/admin/exceptions.py diff --git a/pontoon/tags/admin/forms.py b/pontoon/tags/admin/forms.py index f25aade1d4..2aa29ab52b 100644 --- a/pontoon/tags/admin/forms.py +++ b/pontoon/tags/admin/forms.py @@ -2,7 +2,7 @@ from django.utils.functional import cached_property from pontoon.base.models import Resource -from pontoon.tags.utils import TagsTool +from .tags import TagsTool class LinkTagResourcesAdminForm(forms.Form): diff --git a/pontoon/tags/utils/resources.py b/pontoon/tags/admin/resources.py similarity index 98% rename from pontoon/tags/utils/resources.py rename to pontoon/tags/admin/resources.py index 0623467870..1af3d97f54 100644 --- a/pontoon/tags/utils/resources.py +++ b/pontoon/tags/admin/resources.py @@ -1,8 +1,7 @@ from django.db.models import Q -from pontoon.tags.exceptions import InvalidProjectError - from .base import TagsDataTool +from .exceptions import InvalidProjectError class TagsResourcesTool(TagsDataTool): diff --git a/pontoon/tags/utils/tag.py b/pontoon/tags/admin/tag.py similarity index 72% rename from pontoon/tags/utils/tag.py rename to pontoon/tags/admin/tag.py index 22146c8613..f7c2084c19 100644 --- a/pontoon/tags/utils/tag.py +++ b/pontoon/tags/admin/tag.py @@ -1,9 +1,7 @@ from django.utils.functional import cached_property -from .tagged import Tagged, TaggedLocale - -class TagTool(Tagged): +class TagTool: """This wraps ``Tag`` model kwargs providing an API for efficient retrieval of related information, eg tagged ``Resources``, ``Locales`` and ``Projects``, and methods for managing linked @@ -29,15 +27,6 @@ def linked_resources(self): """``Resources`` that are linked to this ``Tag``""" return self.resource_tool.get_linked_resources(self.slug).order_by("path") - @property - def locale_stats(self): - return self.tags_tool.stat_tool(slug=self.slug, groupby="locale").data - - @cached_property - def locale_latest(self): - """A cached property containing latest locale changes""" - return self.tags_tool.translation_tool(slug=self.slug, groupby="locale").data - @cached_property def object(self): """Returns the ``Tag`` model object for this tag. @@ -61,19 +50,6 @@ def resource_tool(self): else self.tags_tool.resource_tool ) - def iter_locales(self, project=None): - """Iterate ``Locales`` that are associated with this tag - (given any filtering in ``self.tags_tool``) - - yields a ``TaggedLocale`` that can be used to get eg chart data - """ - for locale in self.locale_stats: - yield TaggedLocale( - project=project, - latest_translation=self.locale_latest.get(locale["locale"]), - **locale - ) - def link_resources(self, resources): """Link Resources to this tag, raises an Error if the tag's Project is set, and the requested resource is not in that Project diff --git a/pontoon/tags/utils/tags.py b/pontoon/tags/admin/tags.py similarity index 56% rename from pontoon/tags/utils/tags.py rename to pontoon/tags/admin/tags.py index 6466ce0187..bc401eceb7 100644 --- a/pontoon/tags/utils/tags.py +++ b/pontoon/tags/admin/tags.py @@ -4,9 +4,7 @@ from .base import Clonable from .resources import TagsResourcesTool -from .stats import TagsStatsTool from .tag import TagTool -from .translations import TagsLatestTranslationsTool class TagsTool(Clonable): @@ -17,19 +15,11 @@ class TagsTool(Clonable): tag_class = TagTool resources_class = TagsResourcesTool - translations_class = TagsLatestTranslationsTool - stats_class = TagsStatsTool clone_kwargs = ("locales", "projects", "priority", "path", "slug") def __getitem__(self, k): return self(slug=k) - def __iter__(self): - return self.iter_tags(self.stat_tool.data) - - def __len__(self): - return len(self.stat_tool.data) - @property def tag_manager(self): return Tag.objects @@ -40,22 +30,6 @@ def resource_tool(self): projects=self.projects, locales=self.locales, slug=self.slug, path=self.path ) - @cached_property - def stat_tool(self): - return self.stats_class( - slug=self.slug, - locales=self.locales, - projects=self.projects, - priority=self.priority, - path=self.path, - ) - - @cached_property - def translation_tool(self): - return self.translations_class( - slug=self.slug, locales=self.locales, projects=self.projects - ) - def get(self, slug=None): """Get the first tag by iterating self, or by slug if set""" if slug is None: @@ -70,11 +44,3 @@ def get_tags(self, slug=None): if slug: return tags.filter(slug=slug) return tags - - def iter_tags(self, tags): - """Iterate associated tags, and create TagTool objects for - each, adding latest translation data - """ - for tag in tags: - latest_translation = self.translation_tool.data.get(tag["resource__tag"]) - yield self.tag_class(self, latest_translation=latest_translation, **tag) diff --git a/pontoon/tags/templates/tags/tag.html b/pontoon/tags/templates/tags/tag.html index b4499e3e0a..04364d6c74 100644 --- a/pontoon/tags/templates/tags/tag.html +++ b/pontoon/tags/templates/tags/tag.html @@ -54,7 +54,7 @@

{{ HeadingInfo.progress_chart() }} - {{ HeadingInfo.progress_chart_legend(tag) }} + {{ HeadingInfo.progress_chart_legend(tag.chart) }} {% endblock %} diff --git a/pontoon/tags/tests/utils/__init__.py b/pontoon/tags/tests/admin/__init__.py similarity index 100% rename from pontoon/tags/tests/utils/__init__.py rename to pontoon/tags/tests/admin/__init__.py diff --git a/pontoon/tags/tests/utils/test_base.py b/pontoon/tags/tests/admin/test_base.py similarity index 96% rename from pontoon/tags/tests/utils/test_base.py rename to pontoon/tags/tests/admin/test_base.py index ddbb406ed2..881800d8c6 100644 --- a/pontoon/tags/tests/utils/test_base.py +++ b/pontoon/tags/tests/admin/test_base.py @@ -8,8 +8,8 @@ Translation, ) +from pontoon.tags.admin.base import Clonable, TagsDataTool from pontoon.tags.models import Tag -from pontoon.tags.utils.base import Clonable, TagsDataTool def test_util_clonable(): diff --git a/pontoon/tags/tests/test_forms.py b/pontoon/tags/tests/admin/test_forms.py similarity index 100% rename from pontoon/tags/tests/test_forms.py rename to pontoon/tags/tests/admin/test_forms.py diff --git a/pontoon/tags/tests/utils/test_resources.py b/pontoon/tags/tests/admin/test_resources.py similarity index 96% rename from pontoon/tags/tests/utils/test_resources.py rename to pontoon/tags/tests/admin/test_resources.py index a556eec46b..651f8acca1 100644 --- a/pontoon/tags/tests/utils/test_resources.py +++ b/pontoon/tags/tests/admin/test_resources.py @@ -10,9 +10,9 @@ ResourceFactory, TagFactory, ) -from pontoon.tags.exceptions import InvalidProjectError +from pontoon.tags.admin import TagsResourcesTool +from pontoon.tags.admin.exceptions import InvalidProjectError from pontoon.tags.models import Tag -from pontoon.tags.utils import TagsResourcesTool @pytest.fixture @@ -161,7 +161,7 @@ def test_util_tags_resources_tool_link_bad(resource_a, tag_c, project_b): def test_util_tags_resources_tool_linked_resources(resource_a, tag_c): resource_tool = TagsResourcesTool() - _patch_ctx = patch("pontoon.tags.utils.TagsResourcesTool.get") + _patch_ctx = patch("pontoon.tags.admin.TagsResourcesTool.get") with _patch_ctx as m: values = MagicMock() values.values.return_value = 7 @@ -176,7 +176,7 @@ def test_util_tags_resources_tool_linked_resources(resource_a, tag_c): def test_util_tags_resources_tool_linkable_resources(resource_a, tag_c): resource_tool = TagsResourcesTool() - _patch_ctx = patch("pontoon.tags.utils.TagsResourcesTool.find") + _patch_ctx = patch("pontoon.tags.admin.TagsResourcesTool.find") with _patch_ctx as m: values = MagicMock() values.values.return_value = 7 @@ -245,7 +245,7 @@ def test_util_tag_resources_tool_get(): resource_tool = TagsResourcesTool() _patch_ctx = patch( - "pontoon.tags.utils.TagsResourcesTool.filtered_data", + "pontoon.tags.admin.TagsResourcesTool.filtered_data", new_callable=PropertyMock(), ) with _patch_ctx as p: diff --git a/pontoon/tags/tests/utils/test_tag.py b/pontoon/tags/tests/admin/test_tag.py similarity index 54% rename from pontoon/tags/tests/utils/test_tag.py rename to pontoon/tags/tests/admin/test_tag.py index 71ba19d750..8b7f3c9eb3 100644 --- a/pontoon/tags/tests/utils/test_tag.py +++ b/pontoon/tags/tests/admin/test_tag.py @@ -1,89 +1,9 @@ -import types from unittest.mock import patch, MagicMock, PropertyMock -import pytest - -from pontoon.tags.utils import TagsTool, TagTool - - -@pytest.mark.parametrize( - "kwargs", - [ - dict( - tags_tool=None, name=None, pk=None, priority=None, project=None, slug=None - ), - dict( - tags_tool=1, - name=2, - pk=3, - priority=4, - project=5, - slug=6, - latest_translation=7, - total_strings=8, - approved_strings=9, - ), - ], -) -def test_util_tag_tool_init(kwargs): - # Test the TagTool can be instantiated with/out args - tags_tool = TagTool(**kwargs) - for k, v in kwargs.items(): - assert getattr(tags_tool, k) == v - - -@patch("pontoon.tags.utils.tags.TagsTool.stat_tool", new_callable=PropertyMock()) -def test_util_tag_tool_locale_stats(stats_mock): - stats_mock.configure_mock(**{"return_value.data": 23}) - tag_tool = TagTool( - TagsTool(), name=None, pk=None, priority=None, project=None, slug=7 - ) - - # locale_stats returns self.tags_tool.stats_tool().data - assert tag_tool.locale_stats == 23 - - # stats_tool was called with slug and groupby - assert list(stats_mock.call_args) == [(), {"groupby": "locale", "slug": 7}] +from pontoon.tags.admin import TagsTool, TagTool -@patch("pontoon.tags.utils.tag.TagTool.locale_stats", new_callable=PropertyMock) -@patch("pontoon.tags.utils.tag.TagTool.locale_latest", new_callable=PropertyMock) -@patch("pontoon.tags.utils.tag.TaggedLocale") -def test_util_tag_tool_iter_locales(locale_mock, latest_mock, stats_mock): - tag_tool = TagTool( - TagsTool(), name=None, pk=None, priority=None, project=None, slug=None - ) - - # Set mocks - locale_mock.return_value = "X" - latest_mock.configure_mock(**{"return_value.get.return_value": 23}) - stats_mock.return_value = [ - dict(foo=1, locale=1), - dict(foo=2, locale=2), - dict(foo=3, locale=3), - ] - - # iter_locales - should generate 3 of 'X' - locales = tag_tool.iter_locales() - assert isinstance(locales, types.GeneratorType) - assert list(locales) == ["X"] * 3 - assert len(locale_mock.call_args_list) == 3 - assert stats_mock.called - - # locale_latest is called with each of the locales - assert list(list(a) for a in latest_mock.return_value.get.call_args_list) == [ - [(1,), {}], - [(2,), {}], - [(3,), {}], - ] - - # TaggedLocale is called with locale data - for i, args in enumerate(locale_mock.call_args_list): - assert args[1]["foo"] == i + 1 - assert args[1]["latest_translation"] == 23 - - -@patch("pontoon.tags.utils.TagTool.resource_tool", new_callable=PropertyMock) +@patch("pontoon.tags.admin.TagTool.resource_tool", new_callable=PropertyMock) def test_util_tag_tool_linked_resources(resources_mock): tag_tool = TagTool( TagsTool(), name=None, pk=None, priority=None, project=None, slug=7 @@ -108,7 +28,7 @@ def test_util_tag_tool_linked_resources(resources_mock): assert list(order_by_mock.call_args) == [("path",), {}] -@patch("pontoon.tags.utils.TagTool.resource_tool", new_callable=PropertyMock) +@patch("pontoon.tags.admin.TagTool.resource_tool", new_callable=PropertyMock) def test_util_tag_tool_linkable_resources(resources_mock): tag_tool = TagTool( TagsTool(), name=None, pk=None, priority=None, project=None, slug=7 @@ -133,7 +53,7 @@ def test_util_tag_tool_linkable_resources(resources_mock): assert list(order_by_mock.call_args) == [("path",), {}] -@patch("pontoon.tags.utils.TagTool.resource_tool", new_callable=PropertyMock) +@patch("pontoon.tags.admin.TagTool.resource_tool", new_callable=PropertyMock) def test_util_tag_tool_link_resources(resources_mock): tag_tool = TagTool( TagsTool(), name=None, pk=None, priority=None, project=None, slug=7 @@ -147,7 +67,7 @@ def test_util_tag_tool_link_resources(resources_mock): assert list(resources_mock.return_value.link.call_args) == [(7,), {"resources": 13}] -@patch("pontoon.tags.utils.TagTool.resource_tool", new_callable=PropertyMock) +@patch("pontoon.tags.admin.TagTool.resource_tool", new_callable=PropertyMock) def test_util_tag_tool_unlink_resources(resources_mock): tag_tool = TagTool( TagsTool(), name=None, pk=None, priority=None, project=None, slug=7 @@ -164,7 +84,7 @@ def test_util_tag_tool_unlink_resources(resources_mock): ] -@patch("pontoon.tags.utils.TagsTool.tag_manager", new_callable=PropertyMock) +@patch("pontoon.tags.admin.TagsTool.tag_manager", new_callable=PropertyMock) def test_util_tag_tool_object(tag_mock): tag_mock.configure_mock( **{"return_value.select_related" ".return_value.get.return_value": 23} @@ -183,7 +103,7 @@ def test_util_tag_tool_object(tag_mock): ] -@patch("pontoon.tags.utils.TagsTool.resource_tool", new_callable=PropertyMock) +@patch("pontoon.tags.admin.TagsTool.resource_tool", new_callable=PropertyMock) def test_util_tag_tool_resource_tool(resources_mock): tool_mock = MagicMock(return_value=23) resources_mock.return_value = tool_mock @@ -202,13 +122,3 @@ def test_util_tag_tool_resource_tool(resources_mock): # tool was called with project as args assert tag_tool.resource_tool == 23 assert list(tool_mock.call_args) == [(), {"projects": [43]}] - - -@patch("pontoon.tags.utils.TagsTool.translation_tool") -def test_util_tag_tool_locale_latest(trans_mock): - trans_mock.configure_mock(**{"return_value.data": 23}) - tag_tool = TagTool( - TagsTool(), name=None, pk=None, priority=None, project=None, slug=17 - ) - assert tag_tool.locale_latest == 23 - assert list(trans_mock.call_args) == [(), {"groupby": "locale", "slug": 17}] diff --git a/pontoon/tags/tests/admin/test_tags.py b/pontoon/tags/tests/admin/test_tags.py new file mode 100644 index 0000000000..acaccfaa89 --- /dev/null +++ b/pontoon/tags/tests/admin/test_tags.py @@ -0,0 +1,100 @@ +from unittest.mock import MagicMock, patch, PropertyMock + +import pytest + +from pontoon.tags.admin import ( + TagsResourcesTool, + TagsTool, + TagTool, +) +from pontoon.tags.admin.base import Clonable +from pontoon.tags.models import Tag + + +def test_util_tags_tool(): + # test tags tool instantiation + tags_tool = TagsTool() + assert tags_tool.tag_class is TagTool + assert tags_tool.resources_class is TagsResourcesTool + assert tags_tool.locales is None + assert tags_tool.projects is None + assert tags_tool.priority is None + assert tags_tool.slug is None + assert tags_tool.path is None + assert tags_tool.tag_manager == Tag.objects + + +@pytest.mark.parametrize( + "kwargs", + [ + dict(slug=None, locales=None, projects=None, path=None), + dict(slug=1, locales=2, projects=3, path=4), + ], +) +@patch("pontoon.tags.admin.TagsTool.resources_class") +def test_util_tags_tool_resources(resources_mock, kwargs): + # tests instantiation of tag.resources_tool with different args + tags_tool = TagsTool(**kwargs) + resources_mock.return_value = 23 + assert tags_tool.resource_tool == 23 + assert resources_mock.call_args[1] == kwargs + + +@patch("pontoon.tags.admin.TagsTool.tag_class") +@patch("pontoon.tags.admin.TagsTool.get_tags") +def test_util_tags_tool_get(tags_mock, class_mock): + # tests getting a TagTool from TagsTool + tags_tool = TagsTool() + class_mock.return_value = 23 + + # calling with slug creates a TagTool instance + assert tags_tool.get(113) == 23 + assert list(class_mock.call_args) == [(tags_tool,), {}] + assert list(tags_mock.call_args) == [(), {"slug": 113}] + + +def test_util_tags_tool_call_and_clone(): + # tests cloning a TagsTool + tags_tool = TagsTool() + cloned = tags_tool() + assert cloned is not tags_tool + assert isinstance(tags_tool, Clonable) + assert isinstance(cloned, Clonable) + + +@patch("pontoon.tags.admin.TagsTool.__call__") +def test_util_tags_tool_getitem(call_mock): + # test that calling __getitem__ calls __call__ with slug + tags_tool = TagsTool() + slugs = ["foo", "bar"] + for slug in slugs: + tags_tool[slug] + assert call_mock.call_args_list[0][1] == dict(slug=slugs[0]) + assert call_mock.call_args_list[1][1] == dict(slug=slugs[1]) + + +@patch("pontoon.tags.admin.TagsTool.tag_manager", new_callable=PropertyMock) +def test_util_tags_tool_get_tags(tag_mock): + filter_mock = MagicMock(**{"filter.return_value": 23}) + tag_mock.configure_mock( + **{"return_value.filter.return_value.values.return_value": filter_mock} + ) + tags_tool = TagsTool() + + # no slug provided, returns `values` + assert tags_tool.get_tags() is filter_mock + assert not filter_mock.called + assert list(tag_mock.return_value.filter.return_value.values.call_args) == [ + ("pk", "name", "slug", "priority", "project"), + {}, + ] + + tag_mock.reset_mock() + + # slug provided, `values` is filtered + assert tags_tool.get_tags("FOO") == 23 + assert list(filter_mock.filter.call_args) == [(), {"slug": "FOO"}] + assert list(tag_mock.return_value.filter.return_value.values.call_args) == [ + ("pk", "name", "slug", "priority", "project"), + {}, + ] diff --git a/pontoon/tags/tests/admin/test_views.py b/pontoon/tags/tests/admin/test_views.py new file mode 100644 index 0000000000..0b68669189 --- /dev/null +++ b/pontoon/tags/tests/admin/test_views.py @@ -0,0 +1,133 @@ +from unittest.mock import patch + +import pytest + +from django.urls import reverse + + +@pytest.mark.django_db +@patch("pontoon.tags.admin.views.ProjectTagAdminAjaxView.get_form_class") +def test_view_project_tag_admin_ajax_form( + form_mock, + client, + admin, + project_a, + tag_a, +): + form_mock.configure_mock( + **{ + "return_value.return_value.is_valid.return_value": True, + "return_value.return_value.save.return_value": [7, 23], + "return_value.return_value.errors": [], + } + ) + client.force_login(admin) + url = reverse( + "pontoon.admin.project.ajax.tag", + kwargs=dict( + project=project_a.slug, + tag=tag_a.slug, + ), + ) + + response = client.post( + url, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert form_mock.return_value.return_value.is_valid.called + assert form_mock.return_value.return_value.save.called + assert response.json() == {"data": [7, 23]} + + +@pytest.mark.django_db +@patch("pontoon.tags.admin.views.ProjectTagAdminAjaxView.get_form_class") +def test_view_project_tag_admin_ajax_form_bad( + form_mock, + client, + admin, + project_a, + tag_a, +): + form_mock.configure_mock( + **{ + "return_value.return_value.is_valid.return_value": False, + "return_value.return_value.errors": ["BIG PROBLEM"], + } + ) + client.force_login(admin) + url = reverse( + "pontoon.admin.project.ajax.tag", + kwargs=dict( + project=project_a.slug, + tag=tag_a.slug, + ), + ) + + response = client.post( + url, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response.status_code == 400 + assert form_mock.return_value.call_args[1]["project"] == project_a + assert dict(form_mock.return_value.call_args[1]["data"]) == dict(tag=["tag"]) + assert form_mock.return_value.return_value.is_valid.called + assert not form_mock.return_value.return_value.save.called + assert response.json() == {"errors": ["BIG PROBLEM"]} + + form_mock.return_value.reset_mock() + response = client.post( + url, + data=dict(foo="bar", bar="baz"), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + assert response.status_code == 400 + assert form_mock.return_value.call_args[1]["project"] == project_a + assert dict(form_mock.return_value.call_args[1]["data"]) == dict( + foo=["bar"], + bar=["baz"], + tag=["tag"], + ) + assert form_mock.return_value.return_value.is_valid.called + assert not form_mock.return_value.return_value.save.called + assert response.json() == {"errors": ["BIG PROBLEM"]} + + +@pytest.mark.django_db +@patch("pontoon.tags.admin.views.ProjectTagAdminAjaxView.get_form") +def test_view_project_tag_admin_ajax(form_mock, member, project_a, tag_a): + form_mock.configure_mock(**{"return_value.save.return_value": 23}) + url = reverse( + "pontoon.admin.project.ajax.tag", + kwargs=dict( + project=project_a.slug, + tag=tag_a.slug, + ), + ) + + # no `get` here + response = member.client.get(url) + assert response.status_code == 404 + + # need xhr headers + response = member.client.post(url) + assert response.status_code == 400 + + # must be superuser! + response = member.client.post( + url, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + if not response.wsgi_request.user.is_superuser: + assert not form_mock.called + assert not form_mock.return_value.is_valid.called + if response.wsgi_request.user.is_anonymous: + assert response.status_code == 404 + else: + assert response.status_code == 403 + return + + # Form.get_form was called + assert form_mock.called + assert form_mock.return_value.is_valid.called + assert response.status_code == 200 + assert response.json() == {"data": 23} diff --git a/pontoon/tags/tests/test_utils.py b/pontoon/tags/tests/test_utils.py new file mode 100644 index 0000000000..6e5454e457 --- /dev/null +++ b/pontoon/tags/tests/test_utils.py @@ -0,0 +1,76 @@ +import pytest + +from pontoon.tags.utils import Tags + + +@pytest.fixture +def chart_0(): + return { + "total_strings": 0, + "approved_strings": 0, + "pretranslated_strings": 0, + "strings_with_errors": 0, + "strings_with_warnings": 0, + "unreviewed_strings": 0, + "approved_share": 0, + "pretranslated_share": 0, + "errors_share": 0, + "warnings_share": 0, + "unreviewed_share": 0, + "completion_percent": 0, + } + + +@pytest.mark.django_db +def test_tags_get_no_project(): + tags = Tags().get() + assert len(tags) == 0 + + +@pytest.mark.django_db +def test_tags_get( + chart_0, + project_a, + tag_a, + translation_a, +): + tags = Tags(project=project_a).get() + tag = tags[0] + + assert tag.name == tag_a.name + assert tag.slug == tag_a.slug + assert tag.priority == tag_a.priority + assert tag.latest_activity == translation_a.latest_activity + + chart = chart_0 + chart["total_strings"] = 1 + chart["unreviewed_strings"] = 1 + chart["unreviewed_share"] = 100.0 + assert tag.chart == chart + + +@pytest.mark.django_db +def test_tags_get_tag_locales( + chart_0, + project_a, + project_locale_a, + tag_a, +): + tags = Tags(project=project_a, slug=tag_a.slug) + tag = tags.get_tag_locales() + + assert tag.name == tag_a.name + assert tag.priority == tag_a.priority + assert tag.locales.count() == project_a.locales.all().count() + assert tag.locales.first() == project_a.locales.all().first() + + with pytest.raises(AttributeError): + tag.latest_activity + + chart = chart_0 + chart["total_strings"] = 1 + assert tag.chart == chart + + locale = tag.locales.first() + assert locale.latest_activity is None + assert locale.chart == chart diff --git a/pontoon/tags/tests/test_views.py b/pontoon/tags/tests/test_views.py index 5e2e3144ea..5b18f97504 100644 --- a/pontoon/tags/tests/test_views.py +++ b/pontoon/tags/tests/test_views.py @@ -1,139 +1,8 @@ -from unittest.mock import patch - import pytest from django.urls import reverse from pontoon.base.models import Priority -from pontoon.tags.utils import TaggedLocale, TagTool - - -@pytest.mark.django_db -@patch("pontoon.tags.admin.views.ProjectTagAdminAjaxView.get_form_class") -def test_view_project_tag_admin_ajax_form( - form_mock, - client, - admin, - project_a, - tag_a, -): - form_mock.configure_mock( - **{ - "return_value.return_value.is_valid.return_value": True, - "return_value.return_value.save.return_value": [7, 23], - "return_value.return_value.errors": [], - } - ) - client.force_login(admin) - url = reverse( - "pontoon.admin.project.ajax.tag", - kwargs=dict( - project=project_a.slug, - tag=tag_a.slug, - ), - ) - - response = client.post( - url, - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - assert form_mock.return_value.return_value.is_valid.called - assert form_mock.return_value.return_value.save.called - assert response.json() == {"data": [7, 23]} - - -@pytest.mark.django_db -@patch("pontoon.tags.admin.views.ProjectTagAdminAjaxView.get_form_class") -def test_view_project_tag_admin_ajax_form_bad( - form_mock, - client, - admin, - project_a, - tag_a, -): - form_mock.configure_mock( - **{ - "return_value.return_value.is_valid.return_value": False, - "return_value.return_value.errors": ["BIG PROBLEM"], - } - ) - client.force_login(admin) - url = reverse( - "pontoon.admin.project.ajax.tag", - kwargs=dict( - project=project_a.slug, - tag=tag_a.slug, - ), - ) - - response = client.post( - url, - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - assert response.status_code == 400 - assert form_mock.return_value.call_args[1]["project"] == project_a - assert dict(form_mock.return_value.call_args[1]["data"]) == dict(tag=["tag"]) - assert form_mock.return_value.return_value.is_valid.called - assert not form_mock.return_value.return_value.save.called - assert response.json() == {"errors": ["BIG PROBLEM"]} - - form_mock.return_value.reset_mock() - response = client.post( - url, - data=dict(foo="bar", bar="baz"), - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - assert response.status_code == 400 - assert form_mock.return_value.call_args[1]["project"] == project_a - assert dict(form_mock.return_value.call_args[1]["data"]) == dict( - foo=["bar"], - bar=["baz"], - tag=["tag"], - ) - assert form_mock.return_value.return_value.is_valid.called - assert not form_mock.return_value.return_value.save.called - assert response.json() == {"errors": ["BIG PROBLEM"]} - - -@pytest.mark.django_db -@patch("pontoon.tags.admin.views.ProjectTagAdminAjaxView.get_form") -def test_view_project_tag_admin_ajax(form_mock, member, project_a, tag_a): - form_mock.configure_mock(**{"return_value.save.return_value": 23}) - url = reverse( - "pontoon.admin.project.ajax.tag", - kwargs=dict( - project=project_a.slug, - tag=tag_a.slug, - ), - ) - - # no `get` here - response = member.client.get(url) - assert response.status_code == 404 - - # need xhr headers - response = member.client.post(url) - assert response.status_code == 400 - - # must be superuser! - response = member.client.post( - url, - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - if not response.wsgi_request.user.is_superuser: - assert not form_mock.called - assert not form_mock.return_value.is_valid.called - if response.wsgi_request.user.is_anonymous: - assert response.status_code == 404 - else: - assert response.status_code == 403 - return - - # Form.get_form was called - assert form_mock.called - assert form_mock.return_value.is_valid.called - assert response.status_code == 200 - assert response.json() == {"data": 23} @pytest.mark.django_db @@ -144,14 +13,11 @@ def test_view_project_tag_locales(client, project_a, tag_a): ) # tag is not associated with project + project_a.tag_set.remove(tag_a) response = client.get(url) assert response.status_code == 404 - # tag has no priority so still wont show up... project_a.tag_set.add(tag_a) - response = client.get(url) - assert response.status_code == 404 - tag_a.priority = Priority.NORMAL tag_a.save() response = client.get(url) @@ -165,12 +31,11 @@ def test_view_project_tag_locales(client, project_a, tag_a): assert response.context_data["project"] == project_a res_tag = response.context_data["tag"] - assert isinstance(res_tag, TagTool) - assert res_tag.object == tag_a + assert res_tag == tag_a @pytest.mark.django_db -def test_view_project_tag_locales_ajax(client, project_a, project_locale_a, tag_a): +def test_view_project_tag_locales_ajax(client, project_a, tag_a): url = reverse( "pontoon.tags.ajax.teams", kwargs=dict(project=project_a.slug, tag=tag_a.slug), @@ -192,7 +57,6 @@ def test_view_project_tag_locales_ajax(client, project_a, project_locale_a, tag_ for i, locale in enumerate(locales): locale = response.context_data["locales"][i] - assert isinstance(locale, TaggedLocale) assert locale.code == locales[i].locale.code assert locale.name == locales[i].locale.name diff --git a/pontoon/tags/tests/utils/test_stats.py b/pontoon/tags/tests/utils/test_stats.py deleted file mode 100644 index 6fe79d43d6..0000000000 --- a/pontoon/tags/tests/utils/test_stats.py +++ /dev/null @@ -1,195 +0,0 @@ -from unittest.mock import MagicMock, patch, PropertyMock - -import pytest - -from django.db.models import QuerySet - -from pontoon.base.models import TranslatedResource -from pontoon.tags.utils import TagsStatsTool - - -def test_util_tags_stats_tool(tag_data_init_kwargs): - # tests instantiation of stats tool - kwargs = tag_data_init_kwargs - stats_tool = TagsStatsTool(**kwargs) - for k, v in kwargs.items(): - assert getattr(stats_tool, k) == v - assert stats_tool.tr_manager == TranslatedResource.objects - - -def test_util_tags_stats_tool_annotations(): - # tests annotations can be overridden - stats_tool = TagsStatsTool() - assert stats_tool.get_annotations() == stats_tool.default_annotations - - anno = dict(foo="foo0", bar="bar0") - stats_tool = TagsStatsTool(annotations=anno) - assert stats_tool.get_annotations() != stats_tool.default_annotations - assert stats_tool.get_annotations() != anno - anno.update(stats_tool.default_annotations) - assert stats_tool.get_annotations() == anno - - -@patch("pontoon.tags.utils.TagsStatsTool.get_data") -def test_util_tags_stats_tool_data(data_mock): - # tests coalescing and caching of data - stats_tool = TagsStatsTool() - data_mock.return_value = (1, 2, 3) - result = stats_tool.data - assert result == [1, 2, 3] - assert data_mock.called - data_mock.reset_mock() - data_mock.return_value = (4, 5, 6) - result = stats_tool.data - assert not data_mock.called - assert result == [1, 2, 3] - del stats_tool.__dict__["data"] - result = stats_tool.data - assert data_mock.called - assert result == [4, 5, 6] - - -@patch( - "pontoon.tags.utils.TagsStatsTool.data", - new_callable=PropertyMock, -) -def test_util_tags_stats_tool_len(data_pmock): - # tests len(stats) is taken from data - stats_tool = TagsStatsTool() - data_pmock.return_value = [7, 23] - result = len(stats_tool) - assert data_pmock.called - assert result == 2 - - -@patch("pontoon.tags.utils.TagsStatsTool.data", new_callable=PropertyMock) -def test_util_tags_stats_tool_iter(data_pmock): - # tests iter(stats) iterates the data - stats_tool = TagsStatsTool() - data_pmock.return_value = [7, 23] - result = list(stats_tool) - assert data_pmock.called - assert result == [7, 23] - - -def test_util_tags_stats_tool_filters(): - # tests stats tool has expected filters - stats_tool = TagsStatsTool() - assert stats_tool.filters == [ - getattr(stats_tool, "filter_%s" % f) for f in stats_tool.filter_methods - ] - - -@patch( - "pontoon.tags.utils.TagsStatsTool.tr_manager", - new_callable=PropertyMock, -) -@patch("pontoon.tags.utils.TagsStatsTool.filter_tag") -@patch("pontoon.tags.utils.TagsStatsTool.filter_projects") -@patch("pontoon.tags.utils.TagsStatsTool.filter_locales") -@patch("pontoon.tags.utils.TagsStatsTool.filter_path") -def test_util_tags_stats_tool_fitered_data( - m_path, - m_locales, - m_proj, - m_tag, - trs_mock, -): - # tests all filter functions are called when filtering data - # and that they are called with the result of previous - - stats_tool = TagsStatsTool() - m = m_tag, m_proj, m_locales, m_path - - # mock trs for translated_resources.all() - _m = MagicMock() - _m.all.return_value = 0 - trs_mock.return_value = _m - - for i, _m in enumerate(m): - if i >= len(m) - 1: - _m.return_value = 23 - else: - _m.return_value = i - - # get the filtered_data - result = stats_tool.filtered_data - assert result == 23 - for i, _m in enumerate(m): - assert _m.called - if i > 0: - assert _m.call_args[0][0] == i - 1 - - -@pytest.mark.django_db -def test_util_tags_stats_tool_get_data_empty(calculate_tags, assert_tags): - # tests stats tool and test calculation doesnt break if there is no data - stats_tool = TagsStatsTool() - data = stats_tool.get_data() - assert isinstance(data, QuerySet) - assert list(data) == [] - assert_tags( - calculate_tags(), - data, - ) - - -@pytest.mark.django_db -def test_util_tags_stats_tool_get_data_matrix( - tag_matrix, - calculate_tags, - assert_tags, - tag_test_kwargs, -): - # for different parametrized kwargs, tests that the calculated stat data - # matches expectations from long-hand calculation - name, kwargs = tag_test_kwargs - stats_tool = TagsStatsTool(**kwargs) - data = stats_tool.get_data() - assert isinstance(data, QuerySet) - _tags = calculate_tags(**kwargs) - assert_tags(_tags, data) - - if name.endswith("_exact"): - assert len(data) == 1 - elif name.endswith("_no_match"): - assert len(data) == 0 - elif name.endswith("_match"): - assert len(data) > 0 - elif name.endswith("_contains"): - assert 1 < len(data) < len(tag_matrix["tags"]) - elif name == "empty": - pass - else: - raise ValueError(f"Unsupported assertion type: {name}") - - if name.startswith("slug_") and "slug" in kwargs: - for result in data: - assert kwargs["slug"] in result["slug"] - - -@pytest.mark.django_db -def test_util_tags_stats_tool_groupby_locale( - tag_matrix, - calculate_tags, - assert_tags, - tag_test_kwargs, -): - name, kwargs = tag_test_kwargs - - # this is only used with slug set to a unique slug, and doesnt work - # correctly without - if name == "slug_contains" or not kwargs.get("slug"): - kwargs["slug"] = tag_matrix["tags"][0].slug - - stats_tool = TagsStatsTool(groupby="locale", **kwargs) - data = stats_tool.get_data() - # assert isinstance(data, QuerySet) - exp = calculate_tags(groupby="locale", **kwargs) - data = stats_tool.coalesce(data) - assert len(data) == len(exp) - for locale in data: - locale_exp = exp[locale["locale"]] - assert locale_exp["total_strings"] == locale["total_strings"] - assert locale_exp["pretranslated_strings"] == locale["pretranslated_strings"] - assert locale_exp["approved_strings"] == locale["approved_strings"] diff --git a/pontoon/tags/tests/utils/test_tagged.py b/pontoon/tags/tests/utils/test_tagged.py deleted file mode 100644 index 2797ad34dd..0000000000 --- a/pontoon/tags/tests/utils/test_tagged.py +++ /dev/null @@ -1,198 +0,0 @@ -from unittest.mock import patch - -import pytest - -from pontoon.tags.utils import LatestActivity, LatestActivityUser, TaggedLocale -from pontoon.tags.utils.chart import TagChart -from pontoon.tags.utils.tagged import Tagged - - -def test_util_tag_tagged(): - # Tests the base Tagged class - - # called with no args - defaults - tagged = Tagged() - assert tagged.latest_activity is None - assert tagged.chart is None - assert tagged.kwargs == {} - - # called with random arg - added to kwargs - tagged = Tagged(foo="bar") - assert tagged.latest_activity is None - assert tagged.chart is None - assert tagged.kwargs == dict(foo="bar") - - # called with total_strings - chart added - tagged = Tagged(total_strings=23) - assert tagged.latest_activity is None - assert isinstance(tagged.chart, TagChart) - assert tagged.chart.total_strings == 23 - - # called with latest_translation - latest activity added - tagged = Tagged(latest_translation=23) - with patch("pontoon.tags.utils.tagged.LatestActivity") as m: - m.return_value = "y" - assert tagged.latest_activity == "y" - - assert tagged.chart is None - assert tagged.kwargs == {} - - -def test_util_tag_tagged_locale(): - # Tests instantiation of TaggedLocale wrapper - - # defaults - tagged = TaggedLocale() - assert tagged.code is None - assert tagged.name is None - assert tagged.total_strings is None - assert tagged.latest_activity is None - assert tagged.tag is None - assert tagged.project is None - assert tagged.population is None - assert tagged.kwargs == {} - - # call with locale data - tagged = TaggedLocale( - slug="bar", pk=23, code="foo", name="A foo", project=43, population=113 - ) - assert tagged.tag == "bar" - assert tagged.code == "foo" - assert tagged.name == "A foo" - assert tagged.total_strings is None - assert tagged.latest_activity is None - assert tagged.kwargs == dict( - slug="bar", pk=23, code="foo", name="A foo", project=43, population=113 - ) - - # call with latest_translation and stat data - tagged = TaggedLocale(latest_translation=7, total_strings=23) - with patch("pontoon.tags.utils.tagged.LatestActivity") as m: - m.return_value = "y" - assert tagged.latest_activity == "y" - assert isinstance(tagged.chart, TagChart) - assert tagged.chart.total_strings == 23 - - -def test_util_latest_activity(): - # Tests instantiating the latest_activity wrapper - - # call with random activity - defaults - activity = LatestActivity(dict(foo="bar")) - assert activity.activity == dict(foo="bar") - assert activity.type == "submitted" - assert activity.translation == dict(string="") - assert activity.user is None - - # check approved_date - activity = LatestActivity(dict(approved_date=7)) - assert activity.approved_date == 7 - - # check date - activity = LatestActivity(dict(date=23)) - assert activity.date == 23 - activity = LatestActivity(dict(date=23, approved_date=113)) - assert activity.date == 113 - - # check type is approved - activity = LatestActivity(dict(date=23, approved_date=113)) - assert activity.type == "approved" - - # check user is created if present - activity = LatestActivity(dict(user__email=43)) - assert isinstance(activity.user, LatestActivityUser) - assert activity.user.activity == {"user__email": 43} - - # check translation is created if present - activity = LatestActivity(dict(string="foo")) - assert activity.translation == {"string": "foo"} - - -@patch("pontoon.tags.utils.latest_activity.user_gravatar_url") -def test_util_latest_activity_user(avatar_mock): - # Tests instantiating a latest activity user wrapper - - avatar_mock.return_value = 113 - - # call with random user data - defaults - user = LatestActivityUser( - dict(foo="bar"), - "submitted", - ) - assert user.prefix == "" - assert user.email is None - assert user.first_name is None - assert user.name_or_email is None - assert user.gravatar_url(23) is None - - # call with email - user data added - user = LatestActivityUser( - dict(user__email="bar@ba.z"), - "submitted", - ) - assert user.prefix == "" - assert user.email == "bar@ba.z" - assert user.first_name is None - assert user.name_or_email == "bar@ba.z" - assert user.gravatar_url(23) == 113 - assert list(avatar_mock.call_args) == [(user, 23), {}] - - avatar_mock.reset_mock() - - # call with email and first_name - correct first_name - user = LatestActivityUser( - dict(user__email="bar@ba.z", user__first_name="FOOBAR"), - "submitted", - ) - assert user.prefix == "" - assert user.email == "bar@ba.z" - assert user.first_name == "FOOBAR" - assert user.name_or_email == "FOOBAR" - assert user.gravatar_url(23) == 113 - assert list(avatar_mock.call_args) == [(user, 23), {}] - - # call with approved user and activity type - correct prefix - user = LatestActivityUser( - dict( - approved_user__email="bar@ba.z", - approved_user__first_name="FOOBAR", - user__email="foo.bar@ba.z", - user__first_name="FOOBARBAZ", - ), - "approved", - ) - assert user.prefix == "approved_" - assert user.email == "bar@ba.z" - assert user.first_name == "FOOBAR" - assert user.name_or_email == "FOOBAR" - assert user.gravatar_url(23) == 113 - assert list(avatar_mock.call_args) == [(user, 23), {}] - - -def test_util_tag_chart(): - - chart = TagChart() - assert chart.approved_strings is None - assert chart.pretranslated_strings is None - assert chart.total_strings is None - assert chart.unreviewed_strings is None - - # `total_strings` should be set - otherwise TagChart throws - # errors - with pytest.raises(TypeError): - chart.approved_share - - with pytest.raises(TypeError): - chart._share(23) - - chart = TagChart( - total_strings=73, - pretranslated_strings=7, - approved_strings=13, - strings_with_warnings=0, - unreviewed_strings=23, - ) - assert chart.approved_share == 18.0 - assert chart.pretranslated_share == 10.0 - assert chart.unreviewed_share == 32.0 - assert chart.completion_percent == 27 diff --git a/pontoon/tags/tests/utils/test_tags.py b/pontoon/tags/tests/utils/test_tags.py deleted file mode 100644 index 5ea14fb9a0..0000000000 --- a/pontoon/tags/tests/utils/test_tags.py +++ /dev/null @@ -1,203 +0,0 @@ -from unittest.mock import MagicMock, patch, PropertyMock - -import pytest - -from pontoon.tags.models import Tag -from pontoon.tags.utils import ( - TagsLatestTranslationsTool, - TagsResourcesTool, - TagsStatsTool, - TagsTool, - TagTool, -) -from pontoon.tags.utils.base import Clonable - - -def test_util_tags_tool(): - # test tags tool instantiation - tags_tool = TagsTool() - assert tags_tool.tag_class is TagTool - assert tags_tool.resources_class is TagsResourcesTool - assert tags_tool.translations_class is TagsLatestTranslationsTool - assert tags_tool.stats_class is TagsStatsTool - assert tags_tool.locales is None - assert tags_tool.projects is None - assert tags_tool.priority is None - assert tags_tool.slug is None - assert tags_tool.path is None - assert tags_tool.tag_manager == Tag.objects - - -@patch("pontoon.tags.utils.TagsTool.stats_class") -def test_util_tags_tool_stats(stats_mock, tag_init_kwargs): - # tests instantiation of tag.stats_tool with different args - tags_tool = TagsTool(**tag_init_kwargs) - stats_mock.return_value = 23 - assert tags_tool.stat_tool == 23 - assert stats_mock.call_args[1] == tag_init_kwargs - - -@pytest.mark.parametrize( - "kwargs", - [ - dict(slug=None, locales=None, projects=None, path=None), - dict(slug=1, locales=2, projects=3, path=4), - ], -) -@patch("pontoon.tags.utils.TagsTool.resources_class") -def test_util_tags_tool_resources(resources_mock, kwargs): - # tests instantiation of tag.resources_tool with different args - tags_tool = TagsTool(**kwargs) - resources_mock.return_value = 23 - assert tags_tool.resource_tool == 23 - assert resources_mock.call_args[1] == kwargs - - -@pytest.mark.parametrize( - "kwargs", - [dict(slug=None, locales=None, projects=None), dict(slug=1, locales=2, projects=3)], -) -@patch("pontoon.tags.utils.TagsTool.translations_class") -def test_util_tags_tool_translations(trans_mock, kwargs): - # tests instantiation of tag.translations_tool with different args - tags_tool = TagsTool(**kwargs) - trans_mock.return_value = 23 - assert tags_tool.translation_tool == 23 - assert trans_mock.call_args[1] == kwargs - - -@patch("pontoon.tags.utils.TagsTool.tag_class") -@patch("pontoon.tags.utils.TagsTool.get_tags") -@patch("pontoon.tags.utils.TagsTool.__len__") -@patch("pontoon.tags.utils.TagsTool.__iter__") -def test_util_tags_tool_get(iter_mock, len_mock, tags_mock, class_mock): - # tests getting a TagTool from TagsTool - tags_tool = TagsTool() - class_mock.return_value = 23 - len_mock.return_value = 7 - iter_mock.return_value = iter([3, 17, 73]) - - # with no slug returns first result from iter(self) - assert tags_tool.get() == 3 - assert not class_mock.called - assert not tags_mock.called - assert len_mock.called - assert iter_mock.called - len_mock.reset_mock() - iter_mock.reset_mock() - - # calling with slug creates a TagTool instance - # and doesnt call iter(self) at all - assert tags_tool.get(113) == 23 - assert not len_mock.called - assert not iter_mock.called - assert list(class_mock.call_args) == [(tags_tool,), {}] - assert list(tags_mock.call_args) == [(), {"slug": 113}] - - -def test_util_tags_tool_call_and_clone(): - # tests cloning a TagsTool - tags_tool = TagsTool() - cloned = tags_tool() - assert cloned is not tags_tool - assert isinstance(tags_tool, Clonable) - assert isinstance(cloned, Clonable) - - -@patch("pontoon.tags.utils.TagsTool.__call__") -def test_util_tags_tool_getitem(call_mock): - # test that calling __getitem__ calls __call__ with slug - tags_tool = TagsTool() - slugs = ["foo", "bar"] - for slug in slugs: - tags_tool[slug] - assert call_mock.call_args_list[0][1] == dict(slug=slugs[0]) - assert call_mock.call_args_list[1][1] == dict(slug=slugs[1]) - - -@patch("pontoon.tags.utils.TagsTool.iter_tags") -@patch("pontoon.tags.utils.TagsTool.stat_tool", new_callable=PropertyMock) -def test_util_tags_tool_iter(stats_mock, iter_mock): - # tests that when you iter it calls iter_tags with - # stats data - tags_tool = TagsTool() - stats_mock.configure_mock(**{"return_value.data": [7, 23]}) - iter_mock.return_value = iter([]) - assert list(tags_tool) == [] - assert stats_mock.called - assert list(iter_mock.call_args) == [([7, 23],), {}] - - -@patch("pontoon.tags.utils.TagsTool.stat_tool", new_callable=PropertyMock) -def test_util_tags_tool_len(stats_mock): - # tests that when you len() you get the len - # of the stats data - m_len = MagicMock() - m_len.__len__.return_value = 23 - stats_mock.configure_mock(**{"return_value.data": m_len}) - tags_tool = TagsTool() - assert len(tags_tool) == 23 - assert m_len.__len__.called - - -@patch("pontoon.tags.utils.TagsTool.translation_tool", new_callable=PropertyMock) -@patch("pontoon.tags.utils.TagsTool.tag_class") -def test_util_tags_tool_iter_tags(tag_mock, trans_mock): - # tests that iter_tags calls instantiates a TagTool with - # stat data and latest_translation data - - trans_mock.configure_mock(**{"return_value.data.get.return_value": 23}) - tags_tool = TagsTool() - list( - tags_tool.iter_tags( - [ - dict(resource__tag=1, foo="bar"), - dict(resource__tag=2, foo="bar"), - dict(resource__tag=3, foo="bar"), - ] - ) - ) - - # translation_tool.data.get() was called 3 times with tag pks - assert [x[0][0] for x in trans_mock.return_value.data.get.call_args_list] == [ - 1, - 2, - 3, - ] - - # TagTool was called 3 times with the tags tool as arg - assert [x[0][0] for x in tag_mock.call_args_list] == [tags_tool] * 3 - - # and stat + translation data as kwargs - assert [x[1] for x in tag_mock.call_args_list] == [ - {"resource__tag": 1, "latest_translation": 23, "foo": "bar"}, - {"resource__tag": 2, "latest_translation": 23, "foo": "bar"}, - {"resource__tag": 3, "latest_translation": 23, "foo": "bar"}, - ] - - -@patch("pontoon.tags.utils.TagsTool.tag_manager", new_callable=PropertyMock) -def test_util_tags_tool_get_tags(tag_mock): - filter_mock = MagicMock(**{"filter.return_value": 23}) - tag_mock.configure_mock( - **{"return_value.filter.return_value.values.return_value": filter_mock} - ) - tags_tool = TagsTool() - - # no slug provided, returns `values` - assert tags_tool.get_tags() is filter_mock - assert not filter_mock.called - assert list(tag_mock.return_value.filter.return_value.values.call_args) == [ - ("pk", "name", "slug", "priority", "project"), - {}, - ] - - tag_mock.reset_mock() - - # slug provided, `values` is filtered - assert tags_tool.get_tags("FOO") == 23 - assert list(filter_mock.filter.call_args) == [(), {"slug": "FOO"}] - assert list(tag_mock.return_value.filter.return_value.values.call_args) == [ - ("pk", "name", "slug", "priority", "project"), - {}, - ] diff --git a/pontoon/tags/tests/utils/test_translations.py b/pontoon/tags/tests/utils/test_translations.py deleted file mode 100644 index 0b395e4674..0000000000 --- a/pontoon/tags/tests/utils/test_translations.py +++ /dev/null @@ -1,147 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -from pontoon.base.models import Translation -from pontoon.tags.utils import TagsLatestTranslationsTool - - -def test_util_tags_stats_tool(tag_data_init_kwargs): - # tests instantiation of translations tool - kwargs = tag_data_init_kwargs - tr_tool = TagsLatestTranslationsTool(**kwargs) - for k, v in kwargs.items(): - assert getattr(tr_tool, k) == v - - -@pytest.mark.django_db -def test_util_tags_translation_tool_get_data( - tag_matrix, - calculate_tags_latest, - tag_test_kwargs, -): - # for different parametrized kwargs, tests that the calculated - # latest data matches expectations from long-hand calculation - name, kwargs = tag_test_kwargs - - # calculate expectations - exp = calculate_tags_latest(**kwargs) - - # get the data, and coalesce to translations dictionary - tr_tool = TagsLatestTranslationsTool(**kwargs) - data = tr_tool.coalesce(tr_tool.get_data()) - - # get a pk dictionary of all translations - translations = Translation.objects.select_related("user").in_bulk() - - assert len(data) == len(exp) - - for k, (pk, date) in exp.items(): - assert data[k]["date"] == date - assert data[k]["string"] == translations.get(pk).string - - if name.endswith("_exact"): - assert len(data) == 1 - elif name.endswith("_no_match"): - assert len(data) == 0 - elif name.endswith("_match"): - assert len(data) > 0 - elif name.endswith("_contains"): - assert 1 < len(data) < len(tag_matrix["tags"]) - elif name == "empty": - pass - else: - raise ValueError(f"Unsupported assertion type: {name}") - - -@patch("pontoon.tags.utils.TagsLatestTranslationsTool.get_data") -def test_util_tags_translation_tool_data(data_mock): - # ensures latest translation data is coalesced and cached - # correctly - tr_tool = TagsLatestTranslationsTool() - - # set up mock return for get_data that can be used like - # qs.iterator() - data_m = [ - dict(entity__resource__tag="foo"), - dict(entity__resource__tag="bar"), - ] - data_m2 = [dict(entity__resource__tag="baz")] - iterator_m = MagicMock() - iterator_m.iterator.return_value = data_m - data_mock.return_value = iterator_m - - # get data from the tool - result = tr_tool.data - - # we got back data from data_m coalesced to a dictionary - # with the groupby fields as keys - assert result == dict(foo=data_m[0], bar=data_m[1]) - assert iterator_m.iterator.called - - # lets reset the mock and change the return value - iterator_m.reset_mock() - iterator_m.iterator.return_value = data_m2 - - # and get the data again - result = tr_tool.data - - # which was cached, so nothing changed - assert not iterator_m.iterator.called - assert result == dict(foo=data_m[0], bar=data_m[1]) - - # after deleting the cache... - del tr_tool.__dict__["data"] - - # ...we get the new value - result = tr_tool.data - assert iterator_m.iterator.called - assert result == dict(baz=data_m2[0]) - - -@pytest.mark.django_db -def test_util_tags_translation_tool_groupby( - tag_matrix, - tag_test_kwargs, - calculate_tags_latest, - user_a, - user_b, -): - name, kwargs = tag_test_kwargs - - # hmm, translations have no users - # - set first 3rd to user_a, and second 3rd to user_b - total = Translation.objects.count() - first_third_users = Translation.objects.all()[: total / 3].values_list("pk") - second_third_users = Translation.objects.all()[ - total / 3 : 2 * total / 3 - ].values_list("pk") - (Translation.objects.filter(pk__in=first_third_users).update(user=user_a)) - (Translation.objects.filter(pk__in=second_third_users).update(user=user_b)) - - # calculate expectations grouped by locale - exp = calculate_tags_latest(groupby="locale", **kwargs) - - # calculate data from tool grouped by locale - tr_tool = TagsLatestTranslationsTool(groupby="locale", **kwargs) - data = tr_tool.coalesce(tr_tool.get_data()) - - # get a pk dictionary of all translations - translations = Translation.objects.select_related("user").in_bulk() - - assert len(data) == len(exp) - - for k, (pk, date) in exp.items(): - # check all of the expected values are correct for the - # translation and user - translation = translations.get(pk) - assert data[k]["date"] == date - assert data[k]["string"] == translation.string - assert data[k]["approved_date"] == translation.approved_date - user = translation.user - if user: - assert data[k]["user__email"] == user.email - assert data[k]["user__first_name"] == user.first_name - else: - assert data[k]["user__email"] is None - assert data[k]["user__first_name"] is None diff --git a/pontoon/tags/utils.py b/pontoon/tags/utils.py new file mode 100644 index 0000000000..e455198525 --- /dev/null +++ b/pontoon/tags/utils.py @@ -0,0 +1,115 @@ +from django.db.models import Q, Max, Sum + +from pontoon.base.models import TranslatedResource, Translation +from pontoon.tags.models import Tag + + +class Tags: + """This provides an API for retrieving related ``Tags`` for given filters, + providing statistical information and latest activity data. + """ + + def __init__(self, **kwargs): + self.project = kwargs.get("project") + self.locale = kwargs.get("locale") + self.slug = kwargs.get("slug") + self.tag = Tag.objects.filter(project=self.project, slug=self.slug).first() + + def get(self): + tags = ( + Tag.objects.filter(project=self.project, resources__isnull=False) + .distinct() + .order_by("-priority", "name") + ) + + chart = self.chart(Q(), "resource__tag") + latest_activity = self.latest_activity(Q(), "resource__tag") + for tag in tags: + tag.chart = chart.get(tag.pk) + tag.latest_activity = latest_activity.get(tag.pk) + + return tags + + def get_tag_locales(self): + tag = self.tag + + if tag is None: + return None + + chart = self.chart(Q(resource__tag=self.tag), "resource__tag") + tag.chart = chart.get(tag.pk) + tag.locales = self.project.locales.all() + + locale_chart = self.chart(Q(resource__tag=self.tag), "locale") + locale_latest_activity = self.latest_activity( + Q(resource__tag=self.tag), "locale" + ) + for locale in tag.locales: + locale.chart = locale_chart.get(locale.pk) + locale.latest_activity = locale_latest_activity.get(locale.pk) + + return tag + + def chart(self, query, group_by): + trs = ( + self.translated_resources.filter(query) + .values(group_by) + .annotate( + total_strings=Sum("resource__total_strings"), + approved_strings=Sum("approved_strings"), + pretranslated_strings=Sum("pretranslated_strings"), + strings_with_errors=Sum("strings_with_errors"), + strings_with_warnings=Sum("strings_with_warnings"), + unreviewed_strings=Sum("unreviewed_strings"), + ) + ) + + return { + tr[group_by]: TranslatedResource.get_chart_dict( + TranslatedResource(**{key: tr[key] for key in list(tr.keys())[1:]}) + ) + for tr in trs + } + + def latest_activity(self, query, group_by): + latest_activity = {} + dates = {} + translations = Translation.objects.none() + + trs = ( + self.translated_resources.exclude(latest_translation__isnull=True) + .filter(query) + .values(group_by) + .annotate( + date=Max("latest_translation__date"), + approved_date=Max("latest_translation__approved_date"), + ) + ) + + for tr in trs: + date = max(tr["date"], tr["approved_date"] or tr["date"]) + dates[date] = tr[group_by] + prefix = "entity__" if group_by == "resource__tag" else "" + + # Find translations with matching date and tag/locale + translations |= Translation.objects.filter( + Q(**{"date": date, f"{prefix}{group_by}": tr[group_by]}) + ).prefetch_related("user", "approved_user") + + for t in translations: + key = dates[t.latest_activity["date"]] + latest_activity[key] = t.latest_activity + + return latest_activity + + @property + def translated_resources(self): + trs = TranslatedResource.objects + + if self.project is not None: + trs = trs.filter(resource__project=self.project) + + if self.locale is not None: + trs = trs.filter(locale=self.locale) + + return trs diff --git a/pontoon/tags/utils/__init__.py b/pontoon/tags/utils/__init__.py deleted file mode 100644 index 44d19cf824..0000000000 --- a/pontoon/tags/utils/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .latest_activity import LatestActivity, LatestActivityUser -from .resources import TagsResourcesTool -from .stats import TagsStatsTool -from .tagged import TaggedLocale -from .tag import TagTool -from .tags import TagsTool -from .translations import TagsLatestTranslationsTool - - -__all__ = ( - "LatestActivity", - "LatestActivityTranslation", - "LatestActivityUser", - "TagChart", - "TaggedLocale", - "TagsLatestTranslationsTool", - "TagsResourcesTool", - "TagsStatsTool", - "TagsTool", - "TagTool", -) diff --git a/pontoon/tags/utils/chart.py b/pontoon/tags/utils/chart.py deleted file mode 100644 index 7b091a4472..0000000000 --- a/pontoon/tags/utils/chart.py +++ /dev/null @@ -1,49 +0,0 @@ -import math - - -class TagChart: - def __init__(self, **kwargs): - self.kwargs = kwargs - self.approved_strings = kwargs.get("approved_strings") - self.pretranslated_strings = kwargs.get("pretranslated_strings") - self.strings_with_warnings = kwargs.get("strings_with_warnings") - self.strings_with_errors = kwargs.get("strings_with_errors") - self.total_strings = kwargs.get("total_strings") - self.unreviewed_strings = kwargs.get("unreviewed_strings") - - @property - def completion_percent(self): - return int( - math.floor( - ( - self.approved_strings - + self.pretranslated_strings - + self.strings_with_warnings - ) - / float(self.total_strings) - * 100 - ) - ) - - @property - def approved_share(self): - return self._share(self.approved_strings) - - @property - def pretranslated_share(self): - return self._share(self.pretranslated_strings) - - @property - def warnings_share(self): - return self._share(self.strings_with_warnings) - - @property - def errors_share(self): - return self._share(self.strings_with_errors) - - @property - def unreviewed_share(self): - return self._share(self.unreviewed_strings) - - def _share(self, item): - return round(item / float(self.total_strings) * 100) or 0 diff --git a/pontoon/tags/utils/latest_activity.py b/pontoon/tags/utils/latest_activity.py deleted file mode 100644 index 159c10e44a..0000000000 --- a/pontoon/tags/utils/latest_activity.py +++ /dev/null @@ -1,73 +0,0 @@ -# The classes here provide similar functionality to -# ProjectLocale.get_latest_activity in mangling latest activity data, -# although they use queryset `values` rather than objects -from pontoon.base.models import user_gravatar_url - - -class LatestActivityUser: - def __init__(self, activity, activity_type): - self.activity = activity - self.type = activity_type - - @property - def prefix(self): - return "approved_" if self.type == "approved" else "" - - @property - def email(self): - return self.activity.get(self.prefix + "user__email") - - @property - def first_name(self): - return self.activity.get(self.prefix + "user__first_name") - - @property - def name_or_email(self): - return self.first_name or self.email - - @property - def display_name(self): - return self.first_name or self.email.split("@")[0] - - @property - def username(self): - return self.activity.get(self.prefix + "user__username") - - def gravatar_url(self, *args): - if self.email: - return user_gravatar_url(self, *args) - - -class LatestActivity: - def __init__(self, activity): - self.activity = activity - - @property - def approved_date(self): - return self.activity.get("approved_date") - - @property - def date(self): - if self.type == "approved": - return self.approved_date - return self.activity.get("date") - - @property - def translation(self): - return dict(string=self.activity.get("string", "")) - - @property - def user(self): - return ( - LatestActivityUser(self.activity, self.type) - if "user__email" in self.activity or "approved_user__email" in self.activity - else None - ) - - @property - def type(self): - if self.approved_date is not None and self.approved_date > self.activity.get( - "date" - ): - return "approved" - return "submitted" diff --git a/pontoon/tags/utils/stats.py b/pontoon/tags/utils/stats.py deleted file mode 100644 index 5660a6ec9c..0000000000 --- a/pontoon/tags/utils/stats.py +++ /dev/null @@ -1,63 +0,0 @@ -# The classes here provide similar functionality to -# TranslatedResource.stats in mangling stats data, -# although they use queryset `values` rather than objects - -from django.db.models import F, Sum, Value -from django.db.models.functions import Coalesce - -from .base import TagsTRTool - - -class TagsStatsTool(TagsTRTool): - """Creates aggregated stat data for tags according to - filters - """ - - coalesce = list - - filter_methods = ("tag", "projects", "locales", "path") - - # from the perspective of translated resources - _default_annotations = ( - ("total_strings", Coalesce(Sum("resource__total_strings"), Value(0))), - ("pretranslated_strings", Coalesce(Sum("pretranslated_strings"), Value(0))), - ("strings_with_warnings", Coalesce(Sum("strings_with_warnings"), Value(0))), - ("strings_with_errors", Coalesce(Sum("strings_with_errors"), Value(0))), - ("approved_strings", Coalesce(Sum("approved_strings"), Value(0))), - ("unreviewed_strings", Coalesce(Sum("unreviewed_strings"), Value(0))), - ) - - def get_data(self): - """Stats can be generated either grouping by tag or by locale - - Once the tags/locales are found a second query is made to get - their data - - """ - if self.get_groupby()[0] == "resource__tag": - stats = { - stat["resource__tag"]: stat - for stat in super(TagsStatsTool, self).get_data() - } - - # get the found tags as values - tags = self.tag_manager.filter(pk__in=stats.keys()) - tags = tags.values("pk", "slug", "name", "priority", "project") - tags = tags.annotate(resource__tag=F("pk")) - for tag in tags: - # update the stats with tag data - tag.update(stats[tag["pk"]]) - return tags - elif self.get_groupby()[0] == "locale": - result = list(super().get_data()) - # get the found locales as values - locales = { - loc["pk"]: loc - for loc in self.locale_manager.filter( - pk__in=(r["locale"] for r in result) - ).values("pk", "name", "code", "population") - } - for r in result: - # update the stats with locale data - r.update(locales[r["locale"]]) - return sorted(result, key=lambda r: r["name"]) diff --git a/pontoon/tags/utils/tagged.py b/pontoon/tags/utils/tagged.py deleted file mode 100644 index b9fc64a6d6..0000000000 --- a/pontoon/tags/utils/tagged.py +++ /dev/null @@ -1,64 +0,0 @@ -from .latest_activity import LatestActivity -from .chart import TagChart - - -class Tagged: - """Base class for wrapping `values` dictionaries of related - tag information - """ - - def __init__(self, **kwargs): - self._latest_translation = kwargs.pop("latest_translation", None) - self.approved_strings = kwargs.get("approved_strings") - self.pretranslated_strings = kwargs.get("pretranslated_strings") - self.strings_with_warnings = kwargs.get("strings_with_warnings") - self.strings_with_errors = kwargs.get("strings_with_errors") - self.total_strings = kwargs.get("total_strings") - self.unreviewed_strings = kwargs.get("unreviewed_strings") - self.kwargs = kwargs - - @property - def chart(self): - """Generate a dict of chart information""" - return TagChart(**self.kwargs) if self.total_strings else None - - @property - def latest_translation(self): - return self._latest_translation - - @property - def latest_activity(self): - """Returns wrapped LatestActivity data if available""" - return ( - LatestActivity(self.latest_translation) if self.latest_translation else None - ) - - @property - def tag(self): - return self.kwargs.get("slug") - - def get_latest_activity(self, x): - return self.latest_activity - - def get_chart(self, x): - return self.chart - - -class TaggedLocale(Tagged): - """Wraps a Locale to provide stats and latest information""" - - @property - def code(self): - return self.kwargs.get("code") - - @property - def name(self): - return self.kwargs.get("name") - - @property - def population(self): - return self.kwargs.get("population") - - @property - def project(self): - return self.kwargs.get("project") diff --git a/pontoon/tags/utils/translations.py b/pontoon/tags/utils/translations.py deleted file mode 100644 index 68bc125638..0000000000 --- a/pontoon/tags/utils/translations.py +++ /dev/null @@ -1,67 +0,0 @@ -from django.db.models import Max, Q - -from .base import TagsTRTool - - -class TagsLatestTranslationsTool(TagsTRTool): - """For given filters this tool will find the latest ``Translations`` - for a ``Tag``. It uses TranslatedResources to find the translations - but returns translations. - """ - - filter_methods = ("tag", "projects", "latest", "locales", "path") - - _default_annotations = ( - ("date", Max("latest_translation__date")), - ("approved_date", Max("latest_translation__approved_date")), - ) - - @property - def groupby_prefix(self): - # as we find latest_translations for translated_resources - # and use that to retrieve the translations, we need to map the groupby - # field here - groupby = list(self.get_groupby()) - if groupby == ["resource__tag"]: - return "entity__resource__tag" - elif groupby == ["locale"]: - return "locale" - - def coalesce(self, data): - return { - translation[self.groupby_prefix]: translation - for translation in data.iterator() - } - - def get_data(self): - _translations = self.translation_manager.none() - stats = super().get_data() - - for tr in stats.iterator(): - if tr["approved_date"] is not None and tr["approved_date"] > tr["date"]: - key = "approved_date" - else: - key = "date" - - # find translations with matching date and tag/locale - _translations |= self.translation_manager.filter( - Q(**{key: tr[key], self.groupby_prefix: tr[self.get_groupby()[0]]}) - ) - - return _translations.values( - *( - "string", - "date", - "approved_date", - "approved_user__email", - "approved_user__first_name", - "approved_user__username", - "user__email", - "user__first_name", - "user__username", - ) - + (self.groupby_prefix,) - ) - - def filter_latest(self, qs): - return qs.exclude(latest_translation__isnull=True) diff --git a/pontoon/tags/views.py b/pontoon/tags/views.py index 5f79403ac4..cde2ca847f 100644 --- a/pontoon/tags/views.py +++ b/pontoon/tags/views.py @@ -1,8 +1,8 @@ from django.http import Http404 -from .utils import TagsTool from pontoon.base.models import Project from pontoon.base.utils import is_ajax +from pontoon.tags.utils import Tags from django.views.generic import DetailView @@ -29,18 +29,16 @@ def get_AJAX(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): - try: - tag = TagsTool( - projects=[self.object], - priority=True, - )[self.kwargs["tag"]].get() - except IndexError: + tags = Tags(project=self.object, slug=self.kwargs["tag"]) + tag = tags.get_tag_locales() + + if not tag: raise Http404 if is_ajax(self.request): return dict( project=self.object, - locales=list(tag.iter_locales()), + locales=tag.locales, tag=tag, )