From 918059fd4c74b2bb43ce6c6a4d29b553ac42dde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Fri, 11 Aug 2023 13:44:20 +0200 Subject: [PATCH] models: add component categorization - add model for storing categories - browsing of project categories - support maximal depth of 3 categories rigth now Fixes #263 --- docs/admin/projects.rst | 15 +- docs/api.rst | 75 ++++++ docs/changes.rst | 1 + weblate/api/serializers.py | 76 +++++- weblate/api/tests.py | 107 +++++++- weblate/api/urls.py | 2 + weblate/api/views.py | 95 ++++++- weblate/auth/permissions.py | 22 +- weblate/metrics/models.py | 101 +++++++- weblate/metrics/templatetags/metrics.py | 16 +- .../static/icons/folder-multiple-outline.svg | 1 + weblate/static/icons/folder-outline.svg | 1 + weblate/templates/category-project.html | 124 +++++++++ weblate/templates/category.html | 239 +++++++++++++++++ weblate/templates/project.html | 24 +- weblate/templates/snippets/list-objects.html | 36 ++- .../trans/delete-category-language.html | 24 ++ weblate/templates/trans/delete-category.html | 41 +++ weblate/templates/trans/delete-project.html | 12 +- weblate/trans/defines.py | 3 + weblate/trans/discovery.py | 1 + weblate/trans/forms.py | 112 ++++++-- .../0182_category_component_category.py | 86 +++++++ .../0183_alter_component_unique_together.py | 20 ++ .../migrations/0184_alter_change_action.py | 91 +++++++ weblate/trans/mixins.py | 41 +++ weblate/trans/models/__init__.py | 2 + weblate/trans/models/category.py | 148 +++++++++++ weblate/trans/models/change.py | 19 ++ weblate/trans/models/component.py | 52 ++-- weblate/trans/tasks.py | 22 ++ weblate/trans/templatetags/translations.py | 32 ++- weblate/trans/tests/test_categories.py | 167 ++++++++++++ weblate/trans/tests/test_create.py | 5 +- weblate/trans/tests/test_manage.py | 3 +- weblate/trans/views/basic.py | 191 +++++++++++++- weblate/trans/views/create.py | 17 +- weblate/trans/views/files.py | 43 +++- weblate/trans/views/search.py | 70 ++--- weblate/trans/views/settings.py | 48 +++- weblate/urls.py | 5 + weblate/utils/stats.py | 241 ++++++++++++++++++ weblate/utils/urls.py | 6 +- weblate/utils/views.py | 67 +++-- 44 files changed, 2355 insertions(+), 149 deletions(-) create mode 100644 weblate/static/icons/folder-multiple-outline.svg create mode 100644 weblate/static/icons/folder-outline.svg create mode 100644 weblate/templates/category-project.html create mode 100644 weblate/templates/category.html create mode 100644 weblate/templates/trans/delete-category-language.html create mode 100644 weblate/templates/trans/delete-category.html create mode 100644 weblate/trans/migrations/0182_category_component_category.py create mode 100644 weblate/trans/migrations/0183_alter_component_unique_together.py create mode 100644 weblate/trans/migrations/0184_alter_change_action.py create mode 100644 weblate/trans/models/category.py create mode 100644 weblate/trans/tests/test_categories.py diff --git a/docs/admin/projects.rst b/docs/admin/projects.rst index 6c072b2e2cfa..30286c2f5314 100644 --- a/docs/admin/projects.rst +++ b/docs/admin/projects.rst @@ -4,12 +4,17 @@ Translation projects Translation organization ------------------------ -Weblate organizes translatable VCS content of project/components into a tree-like structure. +Weblate organizes translatable VCS content of project/components into a +tree-like structure. You can additionally organize components within a project +using categories. * The bottom level object is :ref:`project`, which should hold all translations belonging together (for example translation of an application in several versions and/or accompanying documentation). +* The middle level is optionally created by :ref:`category`. The categories can + be nested to achieve more complex structure. + * On the level above, :ref:`component`, which is actually the component to translate, you define the VCS repository to use, and the mask of files to translate. @@ -947,6 +952,14 @@ Glossary color Display color for a glossary used when showing word matches. +.. _category: + +Category +-------- + +Categories are there to give structure to components within a project. You can +nest them to achieve a more complex structure. + .. _markup: Template markup diff --git a/docs/api.rst b/docs/api.rst index 911e8ab26e3d..d42b36d86782 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -156,6 +156,17 @@ form submission (:mimetype:`application/x-www-form-urlencoded`) or as JSON -H "Authorization: Token TOKEN" \ http://example.com/api/components/hello/weblate/repository/ +.. _api-category: + +Components and categories +~~~~~~~~~~~~~~~~~~~~~~~~~ + +To access a component which is nested inside a :ref:`category`, you need to URL +encode the category name into a component name separated with a slash. For +example ``usage`` placed in a ``docs`` category needs to be used as +``docs%2Fusage``. Full URL in this case would be for example +``https://example.com/api/components/hello/docs%2Fusage/repository/``. + .. _api-rate: API rate limiting @@ -1125,6 +1136,15 @@ Projects Returned attributes are described in :ref:`api-statistics`. +.. http:get:: /api/projects/(string:project)/categories/ + + .. versionadded:: 5.0 + + Returns statistics for a project. See :http:get:`/api/categories/(int:id)/` for field definitions. + + :param project: Project URL slug + :type project: string + Components ++++++++++ @@ -2472,6 +2492,61 @@ Search :>json str url: Web URL of the matched item. :>json str category: Category of the matched item. +Categories +++++++++++ + +.. http:get:: /api/categories/ + + .. versionadded:: 5.0 + + Lists available categories. See :http:get:`/api/categories/(int:id)/` for field definitions. + +.. http:post:: /api/categories/ + + .. versionadded:: 5.0 + + Creates a new category. See :http:get:`/api/categories/(int:id)/` for field definitions. + +.. http:get:: /api/categories/(int:id)/ + + .. versionadded:: 5.0 + + :param id: Category ID + :type id: int + :>json str name: Name of category. + :>json str slug: Slug of category. + :>json str project: Link to a project. + :>json str category: Link to a parent category. + +.. http:patch:: /api/categories/(int:id)/ + + .. versionadded:: 5.0 + + Edit partial information about cateogry. + + :param id: Category ID + :type id: int + :>json object configuration: Optional cateogry configuration + +.. http:put:: /api/categories/(int:id)/ + + .. versionadded:: 5.0 + + Edit full information about cateogry. + + :param id: Category ID + :type id: int + :>json object configuration: Optional cateogry configuration + +.. http:delete:: /api/categories/(int:id)/ + + .. versionadded:: 5.0 + + Delete cateogry. + + :param id: Category ID + :type id: int + .. _hooks: Notification hooks diff --git a/docs/changes.rst b/docs/changes.rst index d077c9362395..b754d38a7c50 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -6,6 +6,7 @@ Not yet released. **New features** * :doc:`/formats/markdown` support, thanks to Anders Kaplan. +* :ref:`category` can now organize components within a project. * :doc:`/formats/fluent` now has better syntax checks thanks to Henry Wilkes. * Inviting users now works with all authentication methods. * Docker container supports file backed secrets, see :ref:`docker-secrets`. diff --git a/weblate/api/serializers.py b/weblate/api/serializers.py index b08414a70efc..1efc085d4b2b 100644 --- a/weblate/api/serializers.py +++ b/weblate/api/serializers.py @@ -18,6 +18,7 @@ from weblate.trans.defines import BRANCH_LENGTH, LANGUAGE_NAME_LENGTH, REPO_LENGTH from weblate.trans.models import ( AutoComponentList, + Category, Change, Component, ComponentList, @@ -55,13 +56,21 @@ def get_url(self, obj, view_name, request, format): return None kwargs = {} + was_slug = False for lookup in self.lookup_field: value = obj for key in lookup.split("__"): # NULL value if value is None: return None + previous = value value = getattr(value, key) + if key == "slug": + if was_slug and previous.category: + value = "%2F".join( + (*previous.category.get_url_path()[1:], value) + ) + was_slug = True if self.strip_parts: lookup = "__".join(lookup.split("__")[self.strip_parts :]) kwargs[lookup] = value @@ -348,6 +357,9 @@ class ProjectSerializer(serializers.ModelSerializer): statistics_url = serializers.HyperlinkedIdentityField( view_name="api:project-statistics", lookup_field="slug" ) + categories_url = serializers.HyperlinkedIdentityField( + view_name="api:project-categories", lookup_field="slug" + ) languages_url = serializers.HyperlinkedIdentityField( view_name="api:project-languages", lookup_field="slug" ) @@ -364,6 +376,7 @@ class Meta: "components_list_url", "repository_url", "statistics_url", + "categories_url", "changes_list_url", "languages_url", "translation_review", @@ -452,6 +465,13 @@ class ComponentSerializer(RemovableSerializer): enforced_checks = serializers.JSONField(required=False) + category = serializers.HyperlinkedRelatedField( + view_name="api:category-detail", + queryset=Category.objects.none(), + required=False, + allow_null=True, + ) + task_url = RelatedTaskField(lookup_field="background_task_id") addons = serializers.HyperlinkedIdentityField( @@ -525,6 +545,7 @@ class Meta: "is_glossary", "glossary_color", "disable_autoshare", + "category", ) extra_kwargs = { "url": { @@ -533,6 +554,11 @@ class Meta: } } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if isinstance(self.instance, Component): + self.fields["category"].queryset = self.instance.project.category_set.all() + def validate_enforced_checks(self, value): if not isinstance(value, list): raise serializers.ValidationError("Enforced checks has to be a list.") @@ -602,9 +628,14 @@ def validate(self, attrs): if self.instance and getattr(self.instance, field) == attrs[field]: continue # Look for existing components - if attrs["project"].component_set.filter(**{field: attrs[field]}).exists(): + project = attrs["project"] + field_filter = {field: attrs[field]} + if ( + project.component_set.filter(**field_filter).exists() + or project.category_set.filter(**field_filter).exists() + ): raise serializers.ValidationError( - {field: f"Component with this {field} already exists."} + {field: f"Component or category with this {field} already exists."} ) # Handle uploaded files @@ -1086,6 +1117,47 @@ def as_kwargs(self, data=None): } +class CategorySerializer(RemovableSerializer): + project = serializers.HyperlinkedRelatedField( + view_name="api:project-detail", + lookup_field="slug", + queryset=Project.objects.none(), + required=True, + ) + category = serializers.HyperlinkedRelatedField( + view_name="api:category-detail", + queryset=Category.objects.none(), + required=False, + ) + + class Meta: + model = Category + fields = ( + "name", + "slug", + "project", + "category", + "url", + ) + extra_kwargs = {"url": {"view_name": "api:category-detail"}} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + user = self.context["request"].user + self.fields["project"].queryset = user.managed_projects + + def validate(self, attrs): + # Call model validation here, DRF does not do that + if self.instance: + instance = copy(self.instance) + for key, value in attrs.items(): + setattr(instance, key, value) + else: + instance = Category(**attrs) + instance.clean() + return attrs + + class ScreenshotSerializer(RemovableSerializer): translation = MultiFieldHyperlinkedIdentityField( view_name="api:translation-detail", diff --git a/weblate/api/tests.py b/weblate/api/tests.py index 2eb39d843b1c..2a88a20cfea0 100644 --- a/weblate/api/tests.py +++ b/weblate/api/tests.py @@ -100,7 +100,7 @@ def do_request( format: str = "multipart", ): self.authenticate(superuser) - url = reverse(name, kwargs=kwargs) + url = name if name.startswith(("http:", "/")) else reverse(name, kwargs=kwargs) response = getattr(self.client, method)(url, request, format) content = response.content if hasattr(response, "content") else "" @@ -1187,6 +1187,23 @@ def test_create_component(self): component = Component.objects.get(slug="other", project__slug="test") self.assertTrue(component.manage_units) self.assertTrue(response.data["manage_units"]) + # Creating duplicate + response = self.do_request( + "api:project-components", + self.project_kwargs, + method="post", + code=400, + superuser=True, + request={ + "name": "Other", + "slug": "other", + "repo": self.format_local_path(self.git_repo_path), + "filemask": "android/values-*/strings.xml", + "file_format": "aresource", + "template": "android/values/strings.xml", + "new_lang": "none", + }, + ) def test_create_component_autoshare(self): repo = self.component.repo @@ -3830,3 +3847,91 @@ def test_edit(self): request={"configuration": expected}, ) self.assertEqual(self.component.addon_set.get().configuration, expected) + + +class CategoryAPITest(APIBaseTest): + def create_category(self): + return self.do_request( + "api:category-list", + method="post", + superuser=True, + request={ + "name": "Category Test", + "slug": "category-test", + "project": reverse("api:project-detail", kwargs=self.project_kwargs), + }, + code=201, + ) + + def list_categories(self): + return self.do_request( + "api:category-list", + method="get", + superuser=True, + ) + + def test_create(self): + response = self.list_categories() + self.assertEqual(response.data["count"], 0) + self.create_category() + response = self.list_categories() + self.assertEqual(response.data["count"], 1) + request = self.do_request("api:project-categories", self.project_kwargs) + self.assertEqual(request.data["count"], 1) + + def test_delete(self): + response = self.create_category() + category_url = response.data["url"] + response = self.do_request( + category_url, + method="delete", + code=403, + ) + response = self.do_request( + category_url, + method="delete", + superuser=True, + code=204, + ) + response = self.list_categories() + self.assertEqual(response.data["count"], 0) + + def test_rename(self): + response = self.create_category() + category_url = response.data["url"] + response = self.do_request( + category_url, + method="patch", + code=403, + ) + response = self.do_request( + category_url, + method="patch", + superuser=True, + request={"slug": "test"}, + code=400, + ) + response = self.do_request( + category_url, + method="patch", + superuser=True, + request={"slug": "test-unused"}, + ) + + def test_component(self): + response = self.create_category() + category_url = response.data["url"] + component_url = reverse("api:component-detail", kwargs=self.component_kwargs) + response = self.do_request( + component_url, + request={"category": category_url}, + method="patch", + superuser=True, + ) + # Old URL should no longer work + self.do_request(component_url, code=404) + # But new one should + response = self.do_request(response.data["url"]) + self.assertIn("category-test%252Ftest", response.data["url"]) + component = Component.objects.get(pk=self.component.pk) + self.assertEqual(component.get_url_path(), ("test", "category-test", "test")) diff --git a/weblate/api/urls.py b/weblate/api/urls.py index 377b5abeddb9..4ba44924cd9b 100644 --- a/weblate/api/urls.py +++ b/weblate/api/urls.py @@ -7,6 +7,7 @@ from weblate.api.routers import WeblateRouter from weblate.api.views import ( AddonViewSet, + CategoryViewSet, ChangeViewSet, ComponentListViewSet, ComponentViewSet, @@ -40,6 +41,7 @@ router.register("screenshots", ScreenshotViewSet) router.register("tasks", TasksViewSet, "task") router.register("addons", AddonViewSet) +router.register("categories", CategoryViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/weblate/api/views.py b/weblate/api/views.py index d344dd9a7927..0c1e9b836812 100644 --- a/weblate/api/views.py +++ b/weblate/api/views.py @@ -5,6 +5,7 @@ from __future__ import annotations import os.path +from urllib.parse import unquote from celery.result import AsyncResult from django.conf import settings @@ -44,6 +45,7 @@ AddonSerializer, BasicUserSerializer, BilingualUnitSerializer, + CategorySerializer, ChangeSerializer, ComponentListSerializer, ComponentSerializer, @@ -77,6 +79,7 @@ from weblate.trans.exceptions import FileParseError from weblate.trans.forms import AutoForm from weblate.trans.models import ( + Category, Change, Component, ComponentList, @@ -85,7 +88,12 @@ Translation, Unit, ) -from weblate.trans.tasks import auto_translate, component_removal, project_removal +from weblate.trans.tasks import ( + auto_translate, + category_removal, + component_removal, + project_removal, +) from weblate.trans.views.files import download_multi from weblate.utils.celery import get_queue_stats, get_task_progress, is_task_ready from weblate.utils.docs import get_doc_url @@ -156,7 +164,33 @@ def get_object(self): queryset = self.get_queryset() # Apply any filter backends queryset = self.filter_queryset(queryset) - lookup = {field: self.kwargs[field] for field in self.lookup_fields} + # Generate lookup + lookup = {} + category_path = "" + for field in reversed(self.lookup_fields): + if field not in ("category__slug", "slug"): + lookup[field] = self.kwargs[field] + else: + category_prefix = field[:-4] + was_category = False + was_slug = False + # Fetch component part for possible category + for category in reversed(unquote(self.kwargs[field]).split("/")): + if not was_slug: + # Component filter + lookup[field] = category + was_slug = True + else: + # Strip "slug" from category field + category_path = f"category__{category_path}" + lookup[f"{category_prefix}{category_path}slug"] = category + was_category = True + if not was_category: + # No category + lookup[ + f"{category_prefix}{'__' if category_prefix else ''}category" + ] = None + # Lookup the object return get_object_or_404(queryset, **lookup) @@ -684,6 +718,17 @@ def components(self, request, **kwargs): return self.get_paginated_response(serializer.data) + @action(detail=True, methods=["get"]) + def categories(self, request, **kwargs): + obj = self.get_object() + + queryset = obj.category_set.order_by("id") + page = self.paginate_queryset(queryset) + + serializer = CategorySerializer(page, many=True, context={"request": request}) + + return self.get_paginated_response(serializer.data) + @action(detail=True, methods=["get"]) def statistics(self, request, **kwargs): obj = self.get_object() @@ -1556,6 +1601,52 @@ def delete_components(self, request, slug, component_slug): return Response(status=HTTP_204_NO_CONTENT) +class CategoryViewSet(viewsets.ModelViewSet): + """Category API.""" + + queryset = Category.objects.none() + serializer_class = CategorySerializer + lookup_field = "pk" + + def get_queryset(self): + return Category.objects.filter( + project__in=self.request.user.allowed_projects + ).order_by("id") + + def perm_check(self, request, instance): + if not request.user.has_perm("project.edit", instance): + self.permission_denied(request, "Can not manage categories") + + def update(self, request, *args, **kwargs): + self.perm_check(request, self.get_object()) + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perm_check(request, instance) + category_removal.delay(instance.pk, request.user.pk) + return Response(status=HTTP_204_NO_CONTENT) + + def perform_create(self, serializer): + if not self.request.user.has_perm( + "project.edit", serializer.validated_data["project"] + ): + self.permission_denied( + self.request, "Can not manage categories in this project" + ) + serializer.save() + + def perform_update(self, serializer): + if not self.request.user.has_perm( + "project.edit", + serializer.validated_data.get("project", serializer.instance.project), + ): + self.permission_denied( + self.request, "Can not manage categories in this project" + ) + serializer.save() + + class Metrics(APIView): """Metrics view for monitoring.""" diff --git a/weblate/auth/permissions.py b/weblate/auth/permissions.py index 821a92d4f647..b957165505c7 100644 --- a/weblate/auth/permissions.py +++ b/weblate/auth/permissions.py @@ -7,6 +7,7 @@ from weblate.lang.models import Language from weblate.trans.models import ( + Category, Component, ComponentList, ContributorAgreement, @@ -14,7 +15,7 @@ Translation, Unit, ) -from weblate.utils.stats import ProjectLanguage +from weblate.utils.stats import CategoryLanguage, ProjectLanguage SPECIALS = {} @@ -59,6 +60,10 @@ def check_permission(user, permission, obj): return True if isinstance(obj, ProjectLanguage): obj = obj.project + if isinstance(obj, CategoryLanguage): + obj = obj.category.project + if isinstance(obj, Category): + obj = obj.project if isinstance(obj, Project): return any( permission in permissions @@ -125,8 +130,10 @@ def check_can_edit(user, permission, obj, is_vote=False): project = component.project elif isinstance(obj, Project): project = obj - elif isinstance(obj, ProjectLanguage): + elif isinstance(obj, (ProjectLanguage, Category)): project = obj.project + elif isinstance(obj, CategoryLanguage): + project = obj.category.project else: raise TypeError(f"Unknown object for permission check: {obj.__class__}") @@ -195,7 +202,16 @@ def check_unit_review(user, permission, obj, skip_enabled=False): return Denied(gettext("Source string reviews are not enabled.")) return Denied(gettext("Translation reviews are not enabled.")) else: - if isinstance(obj, (Component, ProjectLanguage)): + if isinstance(obj, CategoryLanguage): + project = obj.category.project + elif isinstance( + obj, + ( + Component, + ProjectLanguage, + Category, + ), + ): project = obj.project else: project = obj diff --git a/weblate/metrics/models.py b/weblate/metrics/models.py index 32ecbd56d162..63660b50653b 100644 --- a/weblate/metrics/models.py +++ b/weblate/metrics/models.py @@ -19,9 +19,21 @@ from weblate.lang.models import Language from weblate.memory.models import Memory from weblate.screenshots.models import Screenshot -from weblate.trans.models import Change, Component, ComponentList, Project, Translation +from weblate.trans.models import ( + Category, + Change, + Component, + ComponentList, + Project, + Translation, +) from weblate.utils.decorators import disable_for_loaddata -from weblate.utils.stats import GlobalStats, ProjectLanguage, prefetch_stats +from weblate.utils.stats import ( + CategoryLanguage, + GlobalStats, + ProjectLanguage, + prefetch_stats, +) BASIC_KEYS = { "all", @@ -191,6 +203,12 @@ def calculate_changes( changes = obj.change_set.all() elif isinstance(obj, ComponentList): changes = Change.objects.filter(component__in=obj.components.all()) + elif isinstance(obj, CategoryLanguage): + changes = Change.objects.for_category(obj.category).filter( + translation__language=obj.language + ) + elif isinstance(obj, Category): + changes = Change.objects.for_category(obj.category) elif isinstance(obj, ProjectLanguage): changes = obj.project.change_set.filter(translation__language=obj.language) elif isinstance(obj, Language): @@ -215,10 +233,14 @@ def collect_auto(self, obj): return self.collect_component(obj) if isinstance(obj, Project): return self.collect_project(obj) + if isinstance(obj, Category): + return self.collect_category(obj) if isinstance(obj, ComponentList): return self.collect_component_list(obj) if isinstance(obj, ProjectLanguage): return self.collect_project_language(obj) + if isinstance(obj, CategoryLanguage): + return self.collect_category_language(obj) if isinstance(obj, Language): return self.collect_language(obj) raise ValueError(f"Unsupported type for metrics: {obj!r}") @@ -272,6 +294,61 @@ def collect_project_language(self, project_language: ProjectLanguage): project_language.language.pk, ) + def collect_category_language(self, category_language: CategoryLanguage): + category = category_language.category + changes = category.project.change_set.for_category(category).filter( + translation__language=category_language.language + ) + + data = { + "changes": changes.filter( + timestamp__date=timezone.now().date() - datetime.timedelta(days=1), + ).count(), + "contributors": changes.filter( + timestamp__date__gte=timezone.now().date() + - datetime.timedelta(days=30), + ) + .values("user") + .distinct() + .count(), + } + + return self.create_metrics( + data, + category_language.stats, + SOURCE_KEYS, + Metric.SCOPE_CATEGORY_LANGUAGE, + category.project.pk, + category_language.language.pk, + ) + + def collect_category(self, category: Category): + languages = prefetch_stats( + [CategoryLanguage(category, language) for language in category.languages] + ) + for category_language in languages: + self.collect_category_language(category_language) + changes = Change.objects.for_category(category) + data = { + "components": category.component_set.count(), + "translations": Translation.objects.filter( + component__category=category + ).count(), + "changes": changes.filter( + timestamp__date=timezone.now().date() - datetime.timedelta(days=1) + ).count(), + "contributors": changes.filter( + timestamp__date__gte=timezone.now().date() - datetime.timedelta(days=30) + ) + .values("user") + .distinct() + .count(), + } + + return self.create_metrics( + data, category.stats, SOURCE_KEYS, Metric.SCOPE_CATEGORY, category.pk + ) + def collect_project(self, project: Project): languages = prefetch_stats( [ProjectLanguage(project, language) for language in project.languages] @@ -427,6 +504,8 @@ class Metric(models.Model): SCOPE_COMPONENT_LIST = 5 SCOPE_PROJECT_LANGUAGE = 6 SCOPE_LANGUAGE = 7 + SCOPE_CATEGORY = 8 + SCOPE_CATEGORY_LANGUAGE = 9 id = models.BigAutoField(primary_key=True) # noqa: A003 date = models.DateField(default=datetime.date.today) @@ -466,6 +545,15 @@ def create_metrics_project(sender, instance, created=False, **kwargs): ) +@receiver(post_save, sender=Category) +@disable_for_loaddata +def create_metrics_category(sender, instance, created=False, **kwargs): + if created: + Metric.objects.initialize_metrics( + scope=Metric.SCOPE_CATEGORY, relation=instance.pk + ) + + @receiver(post_save, sender=Component) @disable_for_loaddata def create_metrics_component(sender, instance, created=False, **kwargs): @@ -491,6 +579,15 @@ def create_metrics_user(sender, instance, created=False, **kwargs): Metric.objects.initialize_metrics(scope=Metric.SCOPE_USER, relation=instance.pk) +@receiver(post_delete, sender=Category) +@disable_for_loaddata +def delete_metrics_category(sender, instance, **kwargs): + Metric.objects.filter( + scope__in=(Metric.SCOPE_CATEGORY_LANGUAGE, Metric.SCOPE_CATEGORY), + relation=instance.pk, + ).delete() + + @receiver(post_delete, sender=Project) @disable_for_loaddata def delete_metrics_project(sender, instance, **kwargs): diff --git a/weblate/metrics/templatetags/metrics.py b/weblate/metrics/templatetags/metrics.py index d637f1c670e7..355a15aa1c6a 100644 --- a/weblate/metrics/templatetags/metrics.py +++ b/weblate/metrics/templatetags/metrics.py @@ -8,8 +8,14 @@ from weblate.lang.models import Language from weblate.metrics.models import Metric from weblate.metrics.wrapper import MetricsWrapper -from weblate.trans.models import Component, ComponentList, Project, Translation -from weblate.utils.stats import ProjectLanguage +from weblate.trans.models import ( + Category, + Component, + ComponentList, + Project, + Translation, +) +from weblate.utils.stats import CategoryLanguage, ProjectLanguage register = template.Library() @@ -30,6 +36,12 @@ def metrics(obj): return MetricsWrapper( obj, Metric.SCOPE_PROJECT_LANGUAGE, obj.project.id, obj.language.id ) + if isinstance(obj, Category): + return MetricsWrapper(obj, Metric.SCOPE_CATEGORY, obj.pk) + if isinstance(obj, CategoryLanguage): + return MetricsWrapper( + obj, Metric.SCOPE_CATEGORY_LANGUAGE, obj.category.id, obj.language.id + ) if isinstance(obj, Language): return MetricsWrapper(obj, Metric.SCOPE_LANGUAGE, obj.id) if isinstance(obj, User): diff --git a/weblate/static/icons/folder-multiple-outline.svg b/weblate/static/icons/folder-multiple-outline.svg new file mode 100644 index 000000000000..8e93a9f61a40 --- /dev/null +++ b/weblate/static/icons/folder-multiple-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/weblate/static/icons/folder-outline.svg b/weblate/static/icons/folder-outline.svg new file mode 100644 index 000000000000..eecc6bccf8ba --- /dev/null +++ b/weblate/static/icons/folder-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/weblate/templates/category-project.html b/weblate/templates/category-project.html new file mode 100644 index 000000000000..ee1b6c4b9d82 --- /dev/null +++ b/weblate/templates/category-project.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} +{% load i18n %} +{% load translations %} +{% load crispy_forms_tags %} +{% load metrics %} + +{% block breadcrumbs %} +{% path_object_breadcrumbs path_object %} +{% endblock %} + +{% block content %} + + + +
+ +
+{% include "snippets/list-objects.html" with objects=translations name_source="component_name" label=_("Component") %} +
+ +
+ {% include "snippets/info.html" with language=None project=category.project stats=language_stats metrics=object|metrics %} +
+ +
+{% include "last-changes-content.html" %} +{% trans "Browse all changes for this language" %} +
+ + + +{% if delete_form %} +{% include "trans/delete-form.html" %} +{% endif %} + +{% if replace_form %} +
+
+
+

+ {% documentation_icon 'user/translating' 'search-replace' right=True %} + {% trans "Search and replace" %} +

+
+{% crispy replace_form %} +
+ +
+
+
+{% endif %} + +{% if bulk_state_form %} +
+
+
+

+ {% documentation_icon 'user/translating' 'bulk-edit' right=True %} + {% trans "Bulk edit" %} +

+
+{% crispy bulk_state_form %} +
+ +
+
+
+{% endif %} + + + + +
+ +{% endblock %} diff --git a/weblate/templates/category.html b/weblate/templates/category.html new file mode 100644 index 000000000000..f38c60209a50 --- /dev/null +++ b/weblate/templates/category.html @@ -0,0 +1,239 @@ +{% extends "base.html" %} +{% load i18n %} +{% load translations %} +{% load permissions %} +{% load crispy_forms_tags %} +{% load metrics %} + +{% block breadcrumbs %} +{% path_object_breadcrumbs path_object %} + + + + +{% endblock %} + +{% block content %} + +{% announcements project=object.project %} + +{% include "snippets/project/state.html" with object=object.project %} + +{% include "snippets/watch-dropdown.html" with project=object %} + +{% perm 'project.edit' object as user_can_edit_project %} +{% perm 'reports.view' object as user_can_view_reports %} + + + +
+
+ +{% include "snippets/list-objects.html" with objects=components list_categories=categories name_source="name" label=_("Component") add_link="component" %} + +{% include "paginator.html" with page_obj=components %} + +
+ +
+ {% include "snippets/info.html" with project=object.project stats=object.stats metrics=object|metrics show_source=True %} +
+ +
+ +{% include "snippets/list-objects.html" with objects=language_stats name_source="language" label=_("Language") project=object global_base=object.stats %} + +
+ +
+{% include "last-changes-content.html" %} +{% trans "Browse all project changes" %} +
+ +{% if last_announcements %} +
+{% include "last-changes-content.html" with last_changes=last_announcements %} +{% trans "Browse all project changes" %} +
+{% endif %} + + + +{% if replace_form %} +
+
+
+

+ {% documentation_icon 'user/translating' 'search-replace' right=True %} + {% trans "Search and replace" %} +

+
+{% crispy replace_form %} +
+ +
+
+
+{% endif %} + +{% if bulk_state_form %} +
+
+
+

+ {% documentation_icon 'user/translating' 'bulk-edit' right=True %} + {% trans "Bulk edit" %} +

+
+{% crispy bulk_state_form %} +
+ +
+
+
+{% endif %} + + +{% if delete_form %} +{% include "trans/delete-form.html" %} +{% endif %} + +{% if user_can_edit_project %} +
+ +
+
+

{% trans "Add a category" %}

+
+
+
+{% crispy add_form %} +
+ +
+
+ +
+{% endif %} + +{% if rename_form %} +
+ +
+
+

{% trans "Rename category" %}

+
+
+

{% trans "Renaming the category will change all URLs, users will have to update bookmarks or references in cloned repositories!" %}

+
+{% crispy rename_form %} +
+ +
+
+ +
+
+

{% trans "Move category" %}

+
+
+

{% trans "Moving the category will change all its URLs, users will have to update bookmarks or references in cloned repositories!" %}

+
+{% crispy move_form %} +
+ +
+
+ +
+{% endif %} + +
+ +{% endblock %} diff --git a/weblate/templates/project.html b/weblate/templates/project.html index a3546c8e17c7..04a2e7dd1fc5 100644 --- a/weblate/templates/project.html +++ b/weblate/templates/project.html @@ -85,6 +85,7 @@
  • {% trans "Post announcement" %}
  • {% endif %} {% if user_can_edit_project %} +
  • {% trans "Add new category" %}
  • {% trans "Add new translation component" %}
  • {% if offer_hosting %} {% for billing in object.billings %} @@ -140,7 +141,7 @@
    -{% include "snippets/list-objects.html" with objects=components name_source="name" label=_("Component") add_link="component" %} +{% include "snippets/list-objects.html" with objects=components list_categories=categories name_source="name" label=_("Component") add_link="component" %} {% include "paginator.html" with page_obj=components %} @@ -241,6 +242,27 @@ {% include "trans/delete-form.html" %} {% endif %} +{% if user_can_edit_project %} +
    + +
    +
    +

    {% trans "Add a category" %}

    +
    +
    +
    +{% crispy add_form %} +
    + +
    +
    + +
    +{% endif %} + + {% if rename_form %}
    diff --git a/weblate/templates/snippets/list-objects.html b/weblate/templates/snippets/list-objects.html index ed13046440d9..419427945149 100644 --- a/weblate/templates/snippets/list-objects.html +++ b/weblate/templates/snippets/list-objects.html @@ -6,13 +6,13 @@ {% init_unique_row_id %} -{% if objects %} +{% if objects or list_categories %} {% if objects.paginator.num_pages > 1 %} {% else %}
    {% endif %} - {% if not hide_details %} + {% if not hide_details and not hide_header %} +{% for category in list_categories %} + {% get_unique_row_id object as row_id %} + + + + {% include "snippets/list-objects-percent.html" with percent=category.stats.translated_percent value=category.stats.translated query="q=state:>=translated" all=category.stats.all %} + {% if not hide_details %} + {% include "snippets/list-objects-number.html" with value=category.stats.todo query="q=state: + + + +{% endfor %} {% for object in objects %} {% get_translate_url object as translate_url %} {% get_browse_url object as browse_url %} @@ -149,7 +173,7 @@ {% if translate_url %} {% icon "pencil.svg" %} {% else %} - {% icon "folder-search-outline.svg" %} + {% icon "folder-outline.svg" %} {% endif %} {% endif %} @@ -230,7 +254,11 @@ {% endif %} {% elif add_link == "component" %} {% if user_can_edit_project %} - {% trans "Add new translation component" %} + {% if object.project %} + {% trans "Add new translation component" %} + {% else %} + {% trans "Add new translation component" %} + {% endif %} {% endif %} {% elif add_link == "language" %} {% if user_can_add_language %} diff --git a/weblate/templates/trans/delete-category-language.html b/weblate/templates/trans/delete-category-language.html new file mode 100644 index 000000000000..c27ad46b3b1d --- /dev/null +++ b/weblate/templates/trans/delete-category-language.html @@ -0,0 +1,24 @@ +{% load i18n %} +{% load icons %} + +

    {% blocktrans %}This action cannot be undone. This will permanently delete {{ object }} translations and all related content.{% endblocktrans %}

    + +

    {% trans "The following translations will be removed:" %}

    +
      + {% for translation in object.translation_set %} +
    • {{ translation }}
    • + {% endfor %} +
    + +
    + +
    +
    +
    + + + {{ object.full_slug }} +
    +
    +
    +
    diff --git a/weblate/templates/trans/delete-category.html b/weblate/templates/trans/delete-category.html new file mode 100644 index 000000000000..979d8a0f11d0 --- /dev/null +++ b/weblate/templates/trans/delete-category.html @@ -0,0 +1,41 @@ +{% load i18n %} +{% load icons %} + +

    +{% blocktrans %}This action cannot be undone. This will permanently delete the {{ object }} project and all related content.{% endblocktrans %} +{% trans "This includes, but is not limited to, translation memory, glossaries, or user permissions." %} +

    + +{% with components=object.component_set.order %} +{% if components %} +

    {% trans "The following translation components will be removed:" %}

    + +{% endif %} +{% endwith %} +{% with categories=object.category_set.order %} +{% if categories %} +

    {% trans "The following translation categories will be removed:" %}

    + +{% endif %} +{% endwith %} + +
    + +
    +
    +
    + + + {{ object.full_slug }} +
    +
    +
    +
    diff --git a/weblate/templates/trans/delete-project.html b/weblate/templates/trans/delete-project.html index 6255e98dd3e2..3ef5e8ea4d0a 100644 --- a/weblate/templates/trans/delete-project.html +++ b/weblate/templates/trans/delete-project.html @@ -2,7 +2,7 @@ {% load icons %}

    -{% blocktrans %}This action cannot be undone. This will permanently delete the {{ object }} project and all related content.{% endblocktrans %} +{% blocktrans %}This action cannot be undone. This will permanently delete the {{ object }} category and all related content.{% endblocktrans %} {% trans "This includes, but is not limited to, translation memory, glossaries, or user permissions." %}

    @@ -16,6 +16,16 @@ {% endif %} {% endwith %} +{% with categories=object.category_set.order %} +{% if categories %} +

    {% trans "The following translation categories will be removed:" %}

    + +{% endif %} +{% endwith %}
    diff --git a/weblate/trans/defines.py b/weblate/trans/defines.py index 15ef1d708a72..7c6c9a7ec984 100644 --- a/weblate/trans/defines.py +++ b/weblate/trans/defines.py @@ -31,3 +31,6 @@ VARIANT_REGEX_LENGTH = 190 # Needed for unique index on MySQL VARIANT_KEY_LENGTH = 576 + +# Maximal categories depth +CATEGORY_DEPTH = 3 diff --git a/weblate/trans/discovery.py b/weblate/trans/discovery.py index 4722e1a306eb..4237faa29acf 100644 --- a/weblate/trans/discovery.py +++ b/weblate/trans/discovery.py @@ -43,6 +43,7 @@ "edit_template", "manage_units", "variant_regex", + "category", ) diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index 5085b607deb2..d48866c30b11 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -48,7 +48,15 @@ REPO_LENGTH, ) from weblate.trans.filter import FILTERS, get_filter_choice -from weblate.trans.models import Announcement, Change, Component, Label, Project, Unit +from weblate.trans.models import ( + Announcement, + Category, + Change, + Component, + Label, + Project, + Unit, +) from weblate.trans.specialchars import RTL_CHARS_DATA, get_special_chars from weblate.trans.util import check_upload_method_permissions, is_repo_link from weblate.trans.validators import validate_check_flags @@ -1611,6 +1619,7 @@ class Meta: model = Component fields = [ "project", + "category", "name", "slug", "vcs", @@ -1724,6 +1733,13 @@ class ComponentProjectForm(ComponentNameForm): project = forms.ModelChoiceField( queryset=Project.objects.none(), label=gettext_lazy("Project") ) + category = forms.ModelChoiceField( + queryset=Category.objects.all(), + label=gettext_lazy("Category"), + widget=forms.HiddenInput, + blank=True, + required=False, + ) source_language = forms.ModelChoiceField( widget=SortedSelect, label=Component.source_language.field.verbose_name, @@ -1745,17 +1761,16 @@ def __init__(self, request, *args, **kwargs): def clean(self): if "project" not in self.cleaned_data: return + project = self.cleaned_data["project"] - name = self.cleaned_data.get("name") - if name and project.component_set.filter(name__iexact=name).exists(): - raise ValidationError( - {"name": gettext("A component with the same name already exists.")} - ) - slug = self.cleaned_data.get("slug") - if slug and project.component_set.filter(slug__iexact=slug).exists(): - raise ValidationError( - {"slug": gettext("A component with the same name already exists.")} - ) + name = self.cleaned_data.get("name", "") + slug = self.cleaned_data.get("slug", "") + category = self.cleaned_data.get("category") + + fake = Component(project=project, category=category, name=name, slug=slug) + fake.clean_unique_together() + # Check if category is from this project + fake.clean_category() class ComponentScratchCreateForm(ComponentProjectForm): @@ -1800,16 +1815,8 @@ def __init__(self, *args, **kwargs): class ComponentInitCreateForm(CleanRepoMixin, ComponentProjectForm): - """ - Component creation form. - - This is mostly copied from the Component model. Probably should be extracted to a - standalone Repository model… - """ + """Component creation form.""" - project = forms.ModelChoiceField( - queryset=Project.objects.none(), label=gettext_lazy("Project") - ) vcs = forms.ChoiceField( label=Component.vcs.field.verbose_name, help_text=Component.vcs.field.help_text, @@ -1844,7 +1851,9 @@ def clean_instance(self, data): ) ) instance.validate_unique() + instance.clean_unique_together() instance.clean_repo() + instance.clean_category() self.instance = instance # Create linked repos automatically @@ -1946,16 +1955,57 @@ class Meta: fields = ["slug"] +class CategoryRenameForm(SettingsBaseForm): + """Category rename form.""" + + class Meta: + model = Category + fields = ["name", "slug"] + + +class AddCategoryForm(SettingsBaseForm): + class Meta: + model = Category + fields = ["name", "slug"] + + def __init__(self, request, parent, *args, **kwargs): + self.parent = parent + super().__init__(request, *args, **kwargs) + + def clean(self): + if isinstance(self.parent, Category): + self.instance.category = self.parent + self.instance.project = self.parent.project + else: + self.instance.project = self.parent + + +class CategoryMoveForm(SettingsBaseForm): + """Category rename form.""" + + class Meta: + model = Category + fields = ["project", "category"] + + def __init__(self, request, *args, **kwargs): + super().__init__(request, *args, **kwargs) + self.fields["project"].queryset = request.user.managed_projects + self.fields["category"].queryset = self.instance.project.category_set.exclude( + pk=self.instance.pk + ) + + class ComponentMoveForm(SettingsBaseForm, ComponentDocsMixin): """Component renaming form.""" class Meta: model = Component - fields = ["project"] + fields = ["project", "category"] def __init__(self, request, *args, **kwargs): super().__init__(request, *args, **kwargs) self.fields["project"].queryset = request.user.managed_projects + self.fields["category"].queryset = self.instance.project.category_set.all() class ProjectSettingsForm(SettingsBaseForm, ProjectDocsMixin, ProjectAntispamMixin): @@ -2553,6 +2603,15 @@ class ProjectDeleteForm(BaseDeleteForm): warning_template = "trans/delete-project.html" +class CategoryDeleteForm(BaseDeleteForm): + confirm = forms.CharField( + label=gettext_lazy("Removal confirmation"), + help_text=gettext_lazy("Please type in the slug of the category to confirm."), + required=True, + ) + warning_template = "trans/delete-category.html" + + class ProjectLanguageDeleteForm(BaseDeleteForm): confirm = forms.CharField( label=gettext_lazy("Removal confirmation"), @@ -2564,6 +2623,17 @@ class ProjectLanguageDeleteForm(BaseDeleteForm): warning_template = "trans/delete-project-language.html" +class CategoryLanguageDeleteForm(BaseDeleteForm): + confirm = forms.CharField( + label=gettext_lazy("Removal confirmation"), + help_text=gettext_lazy( + "Please type in the slug of the category and language to confirm." + ), + required=True, + ) + warning_template = "trans/delete-category-language.html" + + class AnnouncementForm(forms.ModelForm): """Announcement posting form.""" diff --git a/weblate/trans/migrations/0182_category_component_category.py b/weblate/trans/migrations/0182_category_component_category.py new file mode 100644 index 000000000000..1f54bb78243d --- /dev/null +++ b/weblate/trans/migrations/0182_category_component_category.py @@ -0,0 +1,86 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generated by Django 4.2.3 on 2023-08-12 04:26 + +import django.db.models.deletion +from django.db import migrations, models + +import weblate.trans.mixins +import weblate.utils.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("trans", "0181_change_trans_chang_user_id_b1b554_idx"), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text="Display name", + max_length=100, + verbose_name="Category name", + ), + ), + ( + "slug", + models.SlugField( + help_text="Name used in URLs and filenames.", + max_length=100, + validators=[weblate.utils.validators.validate_slug], + verbose_name="URL slug", + ), + ), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="category_set", + to="trans.category", + verbose_name="Category", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="trans.project", + verbose_name="Project", + ), + ), + ], + bases=( + models.Model, + weblate.trans.mixins.PathMixin, + weblate.trans.mixins.CacheKeyMixin, + ), + ), + migrations.AddField( + model_name="component", + name="category", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="trans.category", + verbose_name="Category", + ), + ), + ] diff --git a/weblate/trans/migrations/0183_alter_component_unique_together.py b/weblate/trans/migrations/0183_alter_component_unique_together.py new file mode 100644 index 000000000000..15acff3cbb88 --- /dev/null +++ b/weblate/trans/migrations/0183_alter_component_unique_together.py @@ -0,0 +1,20 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generated by Django 4.2.3 on 2023-08-23 13:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("trans", "0182_category_component_category"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="component", + unique_together=set(), + ), + ] diff --git a/weblate/trans/migrations/0184_alter_change_action.py b/weblate/trans/migrations/0184_alter_change_action.py new file mode 100644 index 000000000000..7cbf975dc2af --- /dev/null +++ b/weblate/trans/migrations/0184_alter_change_action.py @@ -0,0 +1,91 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generated by Django 4.2.3 on 2023-08-23 15:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("trans", "0183_alter_component_unique_together"), + ] + + operations = [ + migrations.AlterField( + model_name="change", + name="action", + field=models.IntegerField( + choices=[ + (0, "Resource update"), + (1, "Translation completed"), + (2, "Translation changed"), + (5, "New translation"), + (3, "Comment added"), + (4, "Suggestion added"), + (6, "Automatic translation"), + (7, "Suggestion accepted"), + (8, "Translation reverted"), + (9, "Translation uploaded"), + (13, "New source string"), + (14, "Component locked"), + (15, "Component unlocked"), + (17, "Committed changes"), + (18, "Pushed changes"), + (19, "Reset repository"), + (20, "Merged repository"), + (21, "Rebased repository"), + (22, "Failed merge on repository"), + (23, "Failed rebase on repository"), + (28, "Failed push on repository"), + (24, "Parse error"), + (25, "Removed translation"), + (26, "Suggestion removed"), + (27, "Search and replace"), + (29, "Suggestion removed during cleanup"), + (30, "Source string changed"), + (31, "New string added"), + (32, "Bulk status change"), + (33, "Changed visibility"), + (34, "Added user"), + (35, "Removed user"), + (36, "Translation approved"), + (37, "Marked for edit"), + (38, "Removed component"), + (39, "Removed project"), + (41, "Renamed project"), + (42, "Renamed component"), + (43, "Moved component"), + (44, "New string to translate"), + (45, "New contributor"), + (46, "New announcement"), + (47, "New alert"), + (48, "Added new language"), + (49, "Requested new language"), + (50, "Created project"), + (51, "Created component"), + (52, "Invited user"), + (53, "Received repository notification"), + (54, "Replaced file by upload"), + (55, "License changed"), + (56, "Contributor agreement changed"), + (57, "Screnshot added"), + (58, "Screnshot uploaded"), + (59, "String updated in the repository"), + (60, "Add-on installed"), + (61, "Add-on configuration changed"), + (62, "Add-on uninstalled"), + (63, "Removed string"), + (64, "Removed comment"), + (65, "Resolved comment"), + (66, "Explanation updated"), + (67, "Removed category"), + (68, "Renamed category"), + (69, "Moved category"), + ], + db_index=True, + default=2, + ), + ), + ] diff --git a/weblate/trans/mixins.py b/weblate/trans/mixins.py index 66a69e851823..80c7dcc63ea4 100644 --- a/weblate/trans/mixins.py +++ b/weblate/trans/mixins.py @@ -6,8 +6,10 @@ import os +from django.core.exceptions import ValidationError from django.urls import reverse from django.utils.functional import cached_property +from django.utils.translation import gettext from weblate.accounts.avatar import get_user_display from weblate.logger import LOGGER @@ -110,3 +112,42 @@ class CacheKeyMixin: @cached_property def cache_key(self): return f"{self.__class__.__name__}-{self.pk}" + + +class ComponentCategoryMixin: + def _clean_unique_together(self, field: str, msg: str, lookup: str): + if self.category: + matching_components = self.category.component_set.filter(**{field: lookup}) + matching_categories = self.category.category_set.filter(**{field: lookup}) + else: + matching_components = self.project.component_set.filter( + category=None, **{field: lookup} + ) + matching_categories = self.project.category_set.filter( + category=None, **{field: lookup} + ) + + if self.id: + if self.__class__.__name__ == "Component": + matching_components = matching_components.exclude(pk=self.id) + else: + matching_categories = matching_categories.exclude(pk=self.id) + + if matching_categories.exists() or matching_components.exists(): + raise ValidationError({field: msg}) + + def clean_unique_together(self): + self._clean_unique_together( + "slug", + gettext( + "Component or category with the same URL slug already exists at this level." + ), + self.slug, + ) + self._clean_unique_together( + "name", + gettext( + "Component or category with the same name already exists at this level." + ), + self.name, + ) diff --git a/weblate/trans/models/__init__.py b/weblate/trans/models/__init__.py index 6ae814b54537..20730e88fe99 100644 --- a/weblate/trans/models/__init__.py +++ b/weblate/trans/models/__init__.py @@ -11,6 +11,7 @@ from weblate.trans.models.agreement import ContributorAgreement from weblate.trans.models.alert import Alert from weblate.trans.models.announcement import Announcement +from weblate.trans.models.category import Category from weblate.trans.models.change import Change from weblate.trans.models.comment import Comment from weblate.trans.models.component import Component @@ -27,6 +28,7 @@ __all__ = [ "Project", + "Category", "Component", "Translation", "Unit", diff --git a/weblate/trans/models/category.py b/weblate/trans/models/category.py new file mode 100644 index 000000000000..474a602f1723 --- /dev/null +++ b/weblate/trans/models/category.py @@ -0,0 +1,148 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils.functional import cached_property +from django.utils.translation import gettext, gettext_lazy + +from weblate.lang.models import Language +from weblate.trans.defines import CATEGORY_DEPTH, COMPONENT_NAME_LENGTH +from weblate.trans.mixins import CacheKeyMixin, ComponentCategoryMixin, PathMixin +from weblate.trans.models.change import Change +from weblate.utils.stats import CategoryStats +from weblate.utils.validators import validate_slug + + +class Category(models.Model, PathMixin, CacheKeyMixin, ComponentCategoryMixin): + name = models.CharField( + verbose_name=gettext_lazy("Category name"), + max_length=COMPONENT_NAME_LENGTH, + help_text=gettext_lazy("Display name"), + ) + slug = models.SlugField( + verbose_name=gettext_lazy("URL slug"), + max_length=COMPONENT_NAME_LENGTH, + help_text=gettext_lazy("Name used in URLs and filenames."), + validators=[validate_slug], + ) + project = models.ForeignKey( + "Project", + verbose_name=gettext_lazy("Project"), + on_delete=models.deletion.CASCADE, + ) + category = models.ForeignKey( + "Category", + verbose_name=gettext_lazy("Category"), + on_delete=models.deletion.CASCADE, + null=True, + blank=True, + related_name="category_set", + ) + + is_lockable = False + remove_permission = "project.edit" + settings_permission = "project.edit" + + def __str__(self): + return f"{self.category or self.project}/{self.name}" + + def save(self, *args, **kwargs): + if self.id: + old = Category.objects.get(pk=self.id) + self.generate_changes(old) + self.check_rename(old) + self.create_path() + super().save(*args, **kwargs) + + def __init__(self, *args, **kwargs): + """Constructor to initialize some cache properties.""" + super().__init__(*args, **kwargs) + self.stats = CategoryStats(self) + + def get_url_path(self): + parent = self.category or self.project + return (*parent.get_url_path(), self.slug) + + def _get_childs_depth(self): + return 1 + max( + (child._get_childs_depth() for child in self.category_set.all()), + default=0, + ) + + def clean(self): + # Validate maximal nesting depth + depth = self._get_childs_depth() if self.pk else 1 + current = self + while current.category: + depth += 1 + current = current.category + + if depth > CATEGORY_DEPTH: + raise ValidationError( + {"category": gettext("Too deep nesting of categories!")} + ) + + if self.category and self.category.project != self.project: + raise ValidationError( + {"category": gettext("Parent category has to be in the same project!")} + ) + + if self.category and self == self.category: + raise ValidationError( + {"category": gettext("Parent category has to be different!")} + ) + + # Validate category/component name uniqueness at given level + self.clean_unique_together() + + if self.id: + old = Category.objects.get(pk=self.id) + self.check_rename(old, validate=True) + + def get_child_components_access(self, user): + """Lists child components.""" + return self.component_set.filter_access(user).order() + + @cached_property + def languages(self): + """Return list of all languages used in project.""" + return ( + Language.objects.filter( + Q(translation__component__category=self) + | Q(translation__component__category__category=self) + | Q(translation__component__category__category__category=self) + ) + .distinct() + .order() + ) + + def generate_changes(self, old): + def getvalue(base, attribute): + result = getattr(base, attribute) + if result is None: + return "" + # Use slug for Category instances + return getattr(result, "slug", result) + + tracked = ( + ("slug", Change.ACTION_RENAME_CATEGORY), + ("category", Change.ACTION_MOVE_CATEGORY), + ("project", Change.ACTION_MOVE_CATEGORY), + ) + for attribute, action in tracked: + old_value = getvalue(old, attribute) + current_value = getvalue(self, attribute) + + if old_value != current_value: + Change.objects.create( + action=action, + old=old_value, + target=current_value, + project=self.project, + user=self.acting_user, + ) diff --git a/weblate/trans/models/change.py b/weblate/trans/models/change.py index 6a7366031539..aa9a4964be52 100644 --- a/weblate/trans/models/change.py +++ b/weblate/trans/models/change.py @@ -37,6 +37,16 @@ def content(self, prefetch=False): base = base.prefetch() return base.filter(action__in=Change.ACTIONS_CONTENT) + def for_category(self, category): + return self.filter( + Q(component__category=category) + | Q(component__category__category=category) + | Q(component__category__category__category=category) + ) + + def filter_announcements(self): + return self.filter(action=Change.ACTION_ANNOUNCEMENT) + def count_stats(self, days: int, step: int, dtstart: datetime): """Count the number of changes in a given period grouped by step days.""" # Count number of changes @@ -265,6 +275,9 @@ class Change(models.Model, UserDisplayMixin): ACTION_COMMENT_DELETE = 64 ACTION_COMMENT_RESOLVE = 65 ACTION_EXPLANATION = 66 + ACTION_REMOVE_CATEGORY = 67 + ACTION_RENAME_CATEGORY = 68 + ACTION_MOVE_CATEGORY = 69 ACTION_CHOICES = ( # Translators: Name of event in the history @@ -398,6 +411,12 @@ class Change(models.Model, UserDisplayMixin): ), # Translators: Name of event in the history (ACTION_EXPLANATION, gettext_lazy("Explanation updated")), + # Translators: Name of event in the history + (ACTION_REMOVE_CATEGORY, gettext_lazy("Removed category")), + # Translators: Name of event in the history + (ACTION_RENAME_CATEGORY, gettext_lazy("Renamed category")), + # Translators: Name of event in the history + (ACTION_MOVE_CATEGORY, gettext_lazy("Moved category")), ) ACTIONS_DICT = dict(ACTION_CHOICES) ACTION_STRINGS = { diff --git a/weblate/trans/models/component.py b/weblate/trans/models/component.py index d3baefbd9b95..3fe4179d075f 100644 --- a/weblate/trans/models/component.py +++ b/weblate/trans/models/component.py @@ -47,7 +47,7 @@ ) from weblate.trans.exceptions import FileParseError, InvalidTemplateError from weblate.trans.fields import RegexField -from weblate.trans.mixins import CacheKeyMixin, PathMixin +from weblate.trans.mixins import CacheKeyMixin, ComponentCategoryMixin, PathMixin from weblate.trans.models.alert import ALERTS, ALERTS_IMPORT from weblate.trans.models.change import Change from weblate.trans.models.translation import Translation @@ -284,7 +284,7 @@ def search(self, query: str): ).select_related("project") -class Component(models.Model, PathMixin, CacheKeyMixin): +class Component(models.Model, PathMixin, CacheKeyMixin, ComponentCategoryMixin): name = models.CharField( verbose_name=gettext_lazy("Component name"), max_length=COMPONENT_NAME_LENGTH, @@ -301,6 +301,13 @@ class Component(models.Model, PathMixin, CacheKeyMixin): verbose_name=gettext_lazy("Project"), on_delete=models.deletion.CASCADE, ) + category = models.ForeignKey( + "Category", + verbose_name=gettext_lazy("Category"), + on_delete=models.deletion.CASCADE, + null=True, + blank=True, + ) vcs = models.CharField( verbose_name=gettext_lazy("Version control system"), max_length=20, @@ -713,13 +720,12 @@ class Component(models.Model, PathMixin, CacheKeyMixin): settings_permission = "component.edit" class Meta: - unique_together = (("project", "name"), ("project", "slug")) app_label = "trans" verbose_name = "Component" verbose_name_plural = "Components" def __str__(self): - return f"{self.project}/{self.name}" + return f"{self.category or self.project}/{self.name}" def save(self, *args, **kwargs): """ @@ -839,13 +845,16 @@ def __init__(self, *args, **kwargs): def generate_changes(self, old): def getvalue(base, attribute): result = getattr(base, attribute) - # Use slug for Project instances + if result is None: + return "" + # Use slug for Category/Project instances return getattr(result, "slug", result) tracked = ( ("license", Change.ACTION_LICENSE_CHANGE), ("agreement", Change.ACTION_AGREEMENT_CHANGE), ("slug", Change.ACTION_RENAME_COMPONENT), + ("category", Change.ACTION_MOVE_COMPONENT), ("project", Change.ACTION_MOVE_COMPONENT), ) for attribute, action in tracked: @@ -1155,7 +1164,8 @@ def append(text: str | None): return re.compile(f"^{regex}$") def get_url_path(self): - return (*self.project.get_url_path(), self.slug) + parent = self.category or self.project + return (*parent.get_url_path(), self.slug) def get_widgets_url(self): """Return absolute URL for widgets.""" @@ -2515,6 +2525,16 @@ def set_default_branch(self): if not self.branch and not self.is_repo_link: self.branch = VCS_REGISTRY[self.vcs].get_remote_branch(self.repo) + def clean_category(self): + if self.category: + if self.category.project != self.project: + raise ValidationError( + {"category": gettext("Category does not belong to this project.")} + ) + if self.links.exists(): + message = gettext("Categorized component can not be shared.") + raise ValidationError({"category": message, "links": message}) + def clean_repo_link(self): """Validate repository link.""" if self.is_repo_link: @@ -2770,13 +2790,6 @@ def clean_repo(self): msg = gettext("Could not update repository: %s") % text raise ValidationError({"repo": msg}) - def clean_unique_together(self, field: str, msg: str, lookup: str): - matching = Component.objects.filter(project=self.project, **{field: lookup}) - if self.id: - matching = matching.exclude(pk=self.id) - if matching.exists(): - raise ValidationError({field: msg}) - def clean(self): """ Validator fetches repository. @@ -2822,22 +2835,15 @@ def clean(self): } ) - self.clean_unique_together( - "slug", - gettext("Component with this URL slug already exists in the project."), - self.slug, - ) - self.clean_unique_together( - "name", - gettext("Component with this name already exists in the project."), - self.name, - ) + self.clean_unique_together() # Check repo if config was changes if changed_git: self.drop_repository_cache() self.clean_repo() + self.clean_category() + # Template validation self.clean_template() diff --git a/weblate/trans/tasks.py b/weblate/trans/tasks.py index 2449b72e0827..043407e3d04c 100644 --- a/weblate/trans/tasks.py +++ b/weblate/trans/tasks.py @@ -26,6 +26,7 @@ from weblate.trans.autotranslate import AutoTranslate from weblate.trans.exceptions import FileParseError from weblate.trans.models import ( + Category, Change, Comment, Component, @@ -348,6 +349,27 @@ def component_removal(pk, uid): return +@app.task(trail=False) +def category_removal(pk, uid): + user = User.objects.get(pk=uid) + try: + category = Category.objects.get(pk=pk) + except Category.DoesNotExist: + return + for child in category.category_set.all(): + category_removal(child.pk, uid) + for component in category.component_set.all(): + component_removal(component.pk, uid) + Change.objects.create( + project=category.project, + action=Change.ACTION_REMOVE_CATEGORY, + target=category.slug, + user=user, + author=user, + ) + category.delete() + + @app.task(trail=False) def project_removal(pk: int, uid: int | None): user = get_anonymous() if uid is None else User.objects.get(pk=uid) diff --git a/weblate/trans/templatetags/translations.py b/weblate/trans/templatetags/translations.py index 71fdf5c0351c..a9de0e142fd5 100644 --- a/weblate/trans/templatetags/translations.py +++ b/weblate/trans/templatetags/translations.py @@ -29,6 +29,7 @@ from weblate.trans.filter import FILTERS, get_filter_choice from weblate.trans.models import ( Announcement, + Category, Component, ContributorAgreement, Project, @@ -43,7 +44,12 @@ from weblate.utils.hash import hash_to_checksum from weblate.utils.markdown import render_markdown from weblate.utils.messages import get_message_kind as get_message_kind_impl -from weblate.utils.stats import BaseStats, GhostProjectLanguageStats, ProjectLanguage +from weblate.utils.stats import ( + BaseStats, + CategoryLanguage, + GhostProjectLanguageStats, + ProjectLanguage, +) from weblate.utils.views import SORT_CHOICES register = template.Library() @@ -1152,7 +1158,22 @@ def get_breadcrumbs(path_object): yield from get_breadcrumbs(path_object.component) yield path_object.get_absolute_url(), path_object.language elif isinstance(path_object, Component): - yield from get_breadcrumbs(path_object.project) + if path_object.category: + yield from get_breadcrumbs(path_object.category) + else: + yield from get_breadcrumbs(path_object.project) + yield path_object.get_absolute_url(), format_html( + "{}{}", + path_object.name, + render_to_string( + "snippets/component-glossary-badge.html", {"object": path_object} + ), + ) + elif isinstance(path_object, Category): + if path_object.category: + yield from get_breadcrumbs(path_object.category) + else: + yield from get_breadcrumbs(path_object.project) yield path_object.get_absolute_url(), path_object.name elif isinstance(path_object, Project): yield path_object.get_absolute_url(), path_object.name @@ -1162,6 +1183,13 @@ def get_breadcrumbs(path_object): elif isinstance(path_object, ProjectLanguage): yield f"{path_object.project.get_absolute_url()}#languages", path_object.project.name yield path_object.get_absolute_url(), path_object.language + elif isinstance(path_object, CategoryLanguage): + if path_object.category.category: + yield from get_breadcrumbs(path_object.category.category) + else: + yield from get_breadcrumbs(path_object.category.project) + yield f"{path_object.category.get_absolute_url()}#languages", path_object.category.name + yield path_object.get_absolute_url(), path_object.language else: raise TypeError(f"No breadcrumbs for {path_object}") diff --git a/weblate/trans/tests/test_categories.py b/weblate/trans/tests/test_categories.py new file mode 100644 index 000000000000..9d70bf24af84 --- /dev/null +++ b/weblate/trans/tests/test_categories.py @@ -0,0 +1,167 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Test for categories.""" + +import os + +from django.urls import reverse + +from weblate.trans.models import Category, Component, Project +from weblate.trans.tests.test_views import ViewTestCase + + +class CategoriesTest(ViewTestCase): + def setUp(self): + super().setUp() + self.project.add_user(self.user, "Administration") + + def test_add_move(self): + response = self.client.post( + reverse("add-category", kwargs={"path": self.project.get_url_path()}), + {"name": "Test category", "slug": "test-cat"}, + follow=True, + ) + category_url = reverse( + "show", kwargs={"path": [*self.project.get_url_path(), "test-cat"]} + ) + category = Category.objects.get() + self.assertRedirects(response, category_url) + self.assertContains(response, "Nothing to list here.") + response = self.client.post( + reverse("move", kwargs=self.kw_component), + {"project": self.project.pk, "category": category.pk}, + follow=True, + ) + new_component_url = reverse( + "show", + kwargs={ + "path": [*self.project.get_url_path(), "test-cat", self.component.slug] + }, + ) + self.assertRedirects(response, new_component_url) + self.client.get(category_url) + self.assertNotContains(response, "Nothing to list here.") + + # Category/language view + response = self.client.get( + reverse( + "show", + kwargs={"path": [*self.project.get_url_path(), "test-cat", "-", "cs"]}, + ) + ) + self.assertContains(response, "Test category") + + response = self.client.post( + reverse("rename", kwargs={"path": category.get_url_path()}), + {"name": "Other", "slug": "renamed"}, + follow=True, + ) + self.assertNotContains(response, "Nothing to list here.") + category = Category.objects.get() + self.assertEqual(category.name, "Other") + self.assertEqual(category.slug, "renamed") + + # Add nested + response = self.client.post( + reverse("add-category", kwargs={"path": category.get_url_path()}), + {"name": "Test category", "slug": "test-cat"}, + follow=True, + ) + self.assertContains(response, "Nothing to list here.") + # Another toplevel with same name + response = self.client.post( + reverse("add-category", kwargs={"path": self.project.get_url_path()}), + {"name": "Test category", "slug": "test-cat"}, + follow=True, + ) + self.assertContains(response, "Nothing to list here.") + new_category = self.project.category_set.get(category=None, slug="test-cat") + + # Move to other category + response = self.client.post( + reverse("move", kwargs={"path": category.get_url_path()}), + {"project": self.project.pk, "category": new_category.pk}, + follow=True, + ) + self.assertContains(response, "Test category") + self.assertNotContains(response, "Nothing to list here.") + + # Delete + response = self.client.post( + reverse("remove", kwargs={"path": new_category.get_url_path()}), + {"confirm": new_category.full_slug}, + follow=True, + ) + self.assertRedirects(response, self.project.get_absolute_url()) + self.assertEqual(Component.objects.count(), 0) + + def test_move_project(self): + project = Project.objects.create(name="other", slug="other") + category = Category.objects.create( + name="Test category", slug="oc", project=self.project + ) + # Move to other project + response = self.client.post( + reverse("move", kwargs={"path": category.get_url_path()}), + {"project": project.pk, "category": ""}, + follow=True, + ) + self.assertContains(response, "Test category") + self.assertContains(response, "Nothing to list here.") + + def test_move_wrong(self): + project = Project.objects.create(name="other", slug="other") + category = Category.objects.create(name="other", slug="oc", project=project) + response = self.client.post( + reverse("move", kwargs=self.kw_component), + {"project": self.project.pk, "category": category.pk}, + follow=True, + ) + self.assertContains( + response, "Error in parameter category: Select a valid choice." + ) + + def test_paths(self): + old_path = self.component.full_path + self.assertTrue(os.path.exists(old_path)) + self.assertTrue( + os.path.exists( + self.component.translation_set.get(language_code="cs").get_filename() + ) + ) + + # Add it to category + category = Category.objects.create( + project=self.project, name="Category test", slug="testcat" + ) + old_category_path = category.full_path + self.assertTrue(os.path.exists(old_category_path)) + self.component.category = category + self.component.save() + category_path = self.component.full_path + self.assertFalse(os.path.exists(old_path)) + self.assertTrue(os.path.exists(category_path)) + self.assertTrue( + os.path.exists( + self.component.translation_set.get(language_code="cs").get_filename() + ) + ) + + # Rename category + category.slug = "other" + category.acting_user = self.user + category.save() + self.assertFalse(os.path.exists(old_category_path)) + self.assertTrue(os.path.exists(category.full_path)) + + component = Component.objects.get(pk=self.component.pk) + self.assertFalse(os.path.exists(old_path)) + self.assertFalse(os.path.exists(category_path)) + self.assertTrue(os.path.exists(component.full_path)) + self.assertTrue( + os.path.exists( + component.translation_set.get(language_code="cs").get_filename() + ) + ) diff --git a/weblate/trans/tests/test_create.py b/weblate/trans/tests/test_create.py index 2893ccdf4eb3..cb924f245621 100644 --- a/weblate/trans/tests/test_create.py +++ b/weblate/trans/tests/test_create.py @@ -354,7 +354,10 @@ def create(): self.assertContains(response, "Test/Create Component") response = create() - self.assertContains(response, "A component with the same name already exists.") + self.assertContains( + response, + "Component or category with the same URL slug already exists at this level.", + ) @modify_settings(INSTALLED_APPS={"remove": "weblate.billing"}) def test_create_scratch_android(self): diff --git a/weblate/trans/tests/test_manage.py b/weblate/trans/tests/test_manage.py index edbba91cf808..1743dfca4045 100644 --- a/weblate/trans/tests/test_manage.py +++ b/weblate/trans/tests/test_manage.py @@ -175,7 +175,8 @@ def test_rename_component_conflict(self): reverse("rename", kwargs=self.kw_component), {"slug": "test2"}, follow=True ) self.assertContains( - response, "Component with this URL slug already exists in the project." + response, + "Component or category with the same URL slug already exists at this level.", ) diff --git a/weblate/trans/views/basic.py b/weblate/trans/views/basic.py index cdece57425c0..454620caf0f7 100644 --- a/weblate/trans/views/basic.py +++ b/weblate/trans/views/basic.py @@ -19,9 +19,14 @@ from weblate.lang.models import Language from weblate.trans.exceptions import FileParseError from weblate.trans.forms import ( + AddCategoryForm, AnnouncementForm, AutoForm, BulkEditForm, + CategoryDeleteForm, + CategoryLanguageDeleteForm, + CategoryMoveForm, + CategoryRenameForm, ComponentDeleteForm, ComponentMoveForm, ComponentRenameForm, @@ -38,7 +43,14 @@ get_new_unit_form, get_upload_form, ) -from weblate.trans.models import Change, Component, ComponentList, Project, Translation +from weblate.trans.models import ( + Category, + Change, + Component, + ComponentList, + Project, + Translation, +) from weblate.trans.models.component import prefetch_tasks from weblate.trans.models.project import prefetch_project_flags from weblate.trans.models.translation import GhostTranslation @@ -46,6 +58,7 @@ from weblate.utils import messages from weblate.utils.ratelimit import reset_rate_limit, session_ratelimit_post from weblate.utils.stats import ( + CategoryLanguage, GhostProjectLanguageStats, ProjectLanguage, prefetch_stats, @@ -162,14 +175,31 @@ def show_engage(request, path): @never_cache def show(request, path): - obj = parse_path(request, path, (Translation, Component, Project, ProjectLanguage)) + obj = parse_path( + request, + path, + ( + Translation, + Component, + Project, + ProjectLanguage, + Category, + CategoryLanguage, + ), + ) if isinstance(obj, Project): return show_project(request, obj) if isinstance(obj, Component): return show_component(request, obj) if isinstance(obj, ProjectLanguage): return show_project_language(request, obj) - return show_translation(request, obj) + if isinstance(obj, Category): + return show_category(request, obj) + if isinstance(obj, CategoryLanguage): + return show_category_language(request, obj) + if isinstance(obj, Translation): + return show_translation(request, obj) + raise TypeError(f"Not supported show: {obj}") def show_project_language(request, obj): @@ -230,18 +260,73 @@ def show_project_language(request, obj): ) -def show_project(request, obj): - obj.stats.ensure_basic() +def show_category_language(request, obj): + language_object = obj.language + category_object = obj.category user = request.user - last_changes = obj.change_set.prefetch().order()[:10].preload() - last_announcements = ( - obj.change_set.prefetch() - .order() - .filter(action=Change.ACTION_ANNOUNCEMENT)[:10] + last_changes = ( + Change.objects.for_category(category_object) + .last_changes(user) + .filter(language=language_object)[:10] .preload() ) + translations = list(obj.translation_set) + + # Add ghost translations + if user.is_authenticated: + existing = {translation.component.slug for translation in translations} + for component in category_object.component_set.all(): + if component.slug in existing: + continue + if component.can_add_new_language(user, fast=True): + translations.append(GhostTranslation(component, language_object)) + + return render( + request, + "category-project.html", + { + "allow_index": True, + "language": language_object, + "category": category_object, + "object": obj, + "path_object": obj, + "last_changes": last_changes, + "translations": translations, + "title": f"{category_object} - {language_object}", + "search_form": SearchForm(user, language=language_object), + "licenses": obj.category.get_child_components_access(user) + .exclude(license="") + .order_by("license"), + "language_stats": category_object.stats.get_single_language_stats( + language_object + ), + "delete_form": optional_form( + CategoryLanguageDeleteForm, user, "translation.delete", obj, obj=obj + ), + "replace_form": optional_form(ReplaceForm, user, "unit.edit", obj), + "bulk_state_form": optional_form( + BulkEditForm, + user, + "translation.auto", + obj, + user=user, + obj=obj, + project=obj.category.project, + ), + }, + ) + + +def show_project(request, obj): + obj.stats.ensure_basic() + user = request.user + + all_changes = obj.change_set.prefetch().order() + last_changes = all_changes[:10].preload() + last_announcements = all_changes.filter_announcements()[:10].preload() + all_components = prefetch_stats(obj.get_child_components_access(user).prefetch()) all_components = get_paginator(request, all_components) for component in all_components: @@ -285,6 +370,7 @@ def show_project(request, obj): "announcement_form": optional_form( AnnouncementForm, user, "project.edit", obj ), + "add_form": AddCategoryForm(request, obj), "delete_form": optional_form( ProjectDeleteForm, user, "project.edit", obj, obj=obj ), @@ -307,6 +393,91 @@ def show_project(request, obj): project=obj, ), "components": components, + "categories": obj.category_set.filter(category=None), + "licenses": sorted( + (component for component in all_components if component.license), + key=lambda component: component.license, + ), + }, + ) + + +def show_category(request, obj): + obj.stats.ensure_basic() + user = request.user + + all_changes = Change.objects.for_category(obj).prefetch().order() + last_changes = all_changes[:10].preload() + last_announcements = all_changes.filter_announcements()[:10].preload() + + all_components = prefetch_stats(obj.get_child_components_access(user).prefetch()) + all_components = get_paginator(request, all_components) + + language_stats = obj.stats.get_language_stats() + # Show ghost translations for user languages + component = None + for component in all_components: + if component.can_add_new_language(user, fast=True): + break + if component: + add_ghost_translations( + component, + user, + language_stats, + GhostProjectLanguageStats, + ) + + language_stats = sort_unicode( + language_stats, + lambda x: f"{user.profile.get_translation_order(x)}-{x.language}", + ) + + components = prefetch_tasks(all_components) + + return render( + request, + "category.html", + { + "allow_index": True, + "object": obj, + "path_object": obj, + "project": obj, + "add_form": AddCategoryForm(request, obj), + "last_changes": last_changes, + "last_announcements": last_announcements, + "language_stats": [stat.obj or stat for stat in language_stats], + "search_form": SearchForm(user), + "delete_form": optional_form( + CategoryDeleteForm, user, "project.edit", obj, obj=obj + ), + "rename_form": optional_form( + CategoryRenameForm, + user, + "project.edit", + obj, + request=request, + instance=obj, + ), + "move_form": optional_form( + CategoryMoveForm, + user, + "project.edit", + obj, + request=request, + instance=obj, + ), + "replace_form": optional_form(ReplaceForm, user, "unit.edit", obj), + "bulk_state_form": optional_form( + BulkEditForm, + user, + "translation.auto", + obj, + user=user, + obj=obj, + project=obj.project, + ), + "components": components, + "categories": obj.category_set.all(), "licenses": sorted( (component for component in all_components if component.license), key=lambda component: component.license, diff --git a/weblate/trans/views/create.py b/weblate/trans/views/create.py index e0e64956fad5..41998957b69a 100644 --- a/weblate/trans/views/create.py +++ b/weblate/trans/views/create.py @@ -33,7 +33,7 @@ ProjectImportCreateForm, ProjectImportForm, ) -from weblate.trans.models import Component, Project +from weblate.trans.models import Category, Component, Project from weblate.trans.tasks import perform_update from weblate.trans.util import get_clean_env from weblate.utils import messages @@ -203,7 +203,8 @@ class CreateComponent(BaseCreateView): model = Component projects = None stage = None - selected_project = "" + selected_project = None + selected_category = None basic_fields = ("repo", "name", "slug", "vcs", "source_language") empty_form = False form_class = ComponentInitCreateForm @@ -304,7 +305,9 @@ def get_form(self, form_class=None, empty=False): form = super().get_form(form_class) if "project" in form.fields: project_field = form.fields["project"] + category_field = form.fields["category"] project_field.queryset = self.projects + category_field.queryset = Category.objects.filter(project__in=self.projects) project_field.empty_label = None if self.selected_project: project_field.initial = self.selected_project @@ -312,6 +315,8 @@ def get_form(self, form_class=None, empty=False): form.fields["source_language"].initial = Component.objects.filter( project=self.selected_project )[0].source_language_id + if self.selected_category: + category_field.initial = self.selected_category self.empty_form = False return form @@ -327,7 +332,13 @@ def fetch_params(self, request): request.POST.get("project", request.GET.get("project", "")) ) except ValueError: - self.selected_project = "" + self.selected_project = None + try: + self.selected_category = int( + request.POST.get("category", request.GET.get("category", "")) + ) + except ValueError: + self.selected_category = None if request.user.is_superuser: self.projects = Project.objects.order() elif self.has_billing: diff --git a/weblate/trans/views/files.py b/weblate/trans/views/files.py index 2d3952f174a8..e0ea19366675 100644 --- a/weblate/trans/views/files.py +++ b/weblate/trans/views/files.py @@ -5,17 +5,24 @@ import os from django.core.exceptions import PermissionDenied +from django.db.models import Q from django.shortcuts import get_object_or_404, redirect from django.utils.translation import gettext, ngettext from django.views.decorators.http import require_POST from weblate.trans.exceptions import FailedCommitError, PluralFormsMismatchError from weblate.trans.forms import DownloadForm, get_upload_form -from weblate.trans.models import Component, ComponentList, Project, Translation +from weblate.trans.models import ( + Category, + Component, + ComponentList, + Project, + Translation, +) from weblate.utils import messages from weblate.utils.data import data_dir from weblate.utils.errors import report_error -from weblate.utils.stats import ProjectLanguage +from weblate.utils.stats import CategoryLanguage, ProjectLanguage from weblate.utils.views import ( download_translation_file, parse_path, @@ -73,7 +80,11 @@ def download_component_list(request, name): def download(request, path): """Handling of translation uploads.""" - obj = parse_path(request, path, (Translation, Component, Project, ProjectLanguage)) + obj = parse_path( + request, + path, + (Translation, Component, Project, ProjectLanguage, Category, CategoryLanguage), + ) if not request.user.has_perm("translation.download", obj): raise PermissionDenied @@ -106,6 +117,32 @@ def download(request, path): request.GET.get("format"), name=obj.slug, ) + if isinstance(obj, CategoryLanguage): + components = obj.category.project.component_set.filter_access( + request.user + ).filter( + Q(category=obj.category) + | Q(category__category=obj.category) + | Q(category__category__category=obj.category) + ) + return download_multi( + Translation.objects.filter(component__in=components, language=obj.language), + [obj.category.project], + request.GET.get("format"), + name=f"{obj.category.slug}-{obj.language.code}", + ) + if isinstance(obj, Category): + components = obj.project.component_set.filter_access(request.user).filter( + Q(category=obj) + | Q(category__category=obj) + | Q(category__category__category=obj) + ) + return download_multi( + Translation.objects.filter(component__in=components), + [obj], + request.GET.get("format"), + name=obj.slug, + ) if isinstance(obj, Component): return download_multi( obj.translation_set.all(), diff --git a/weblate/trans/views/search.py b/weblate/trans/views/search.py index 8c3f0fbc7abf..4dc1d8c35ad1 100644 --- a/weblate/trans/views/search.py +++ b/weblate/trans/views/search.py @@ -18,16 +18,15 @@ ReplaceForm, SearchForm, ) -from weblate.trans.models import Change, Component, Project, Translation, Unit +from weblate.trans.models import Category, Change, Component, Project, Translation, Unit from weblate.trans.util import render from weblate.utils import messages from weblate.utils.ratelimit import check_rate_limit -from weblate.utils.stats import ProjectLanguage +from weblate.utils.stats import CategoryLanguage, ProjectLanguage from weblate.utils.views import ( get_paginator, get_sort_name, import_message, - parse_path, parse_path_units, show_form_errors, ) @@ -37,7 +36,9 @@ @require_POST def search_replace(request, path): obj, unit_set, context = parse_path_units( - request, path, (Translation, Component, Project, ProjectLanguage) + request, + path, + (Translation, Component, Project, ProjectLanguage, Category, CategoryLanguage), ) if not request.user.has_perm("unit.edit", obj): @@ -121,29 +122,23 @@ def search(request, path=None): is_ratelimited = not check_rate_limit("search", request) search_form = SearchForm(user=request.user, data=request.GET) sort = get_sort_name(request) - context = {"search_form": search_form} - obj = parse_path( + obj, unit_set, context = parse_path_units( request, path, - (Component, Project, ProjectLanguage, Translation, Language, None), + ( + Component, + Project, + ProjectLanguage, + Translation, + Category, + CategoryLanguage, + Language, + None, + ), ) + + context["search_form"] = search_form context["back_url"] = obj.get_absolute_url() if obj is not None else None - if isinstance(obj, Component): - context["component"] = obj - context["project"] = obj.project - elif isinstance(obj, ProjectLanguage): - context["project"] = obj.project - elif isinstance(obj, Translation): - context["component"] = obj.component - context["project"] = obj.component.project - elif isinstance(obj, Project): - context["project"] = obj - elif isinstance(obj, Language): - context["language"] = obj - elif obj is None: - pass - else: - raise TypeError(f"Not implemented search for {obj}") if not is_ratelimited and request.GET and search_form.is_valid(): # This is ugly way to hide query builder when showing results @@ -151,27 +146,12 @@ def search(request, path=None): user=request.user, data=request.GET, show_builder=False ) search_form.is_valid() - # Filter results by ACL - units = Unit.objects.prefetch_full().prefetch() - if isinstance(obj, Translation): - units = units.filter(translation=obj) - elif isinstance(obj, Component): - units = units.filter(translation__component=obj) - elif isinstance(obj, Project): - units = units.filter(translation__component__project=obj) - elif isinstance(obj, ProjectLanguage): - units = units.filter( - translation__component__project=obj.project, - translation__language=obj.language, + units = ( + unit_set.prefetch_full() + .prefetch() + .search( + search_form.cleaned_data.get("q", ""), project=context.get("project") ) - elif isinstance(obj, Language): - units = units.filter_access(request.user).filter(translation__language=obj) - elif obj is None: - units = units.filter_access(request.user) - else: - raise TypeError(f"Not implemented search for {obj}") - units = units.search( - search_form.cleaned_data.get("q", ""), project=context.get("project") ) units = get_paginator( @@ -210,7 +190,9 @@ def search(request, path=None): @never_cache def bulk_edit(request, path): obj, unit_set, context = parse_path_units( - request, path, (Translation, Component, Project, ProjectLanguage) + request, + path, + (Translation, Component, Project, ProjectLanguage, Category, CategoryLanguage), ) if not request.user.has_perm("translation.auto", obj) or not request.user.has_perm( diff --git a/weblate/trans/views/settings.py b/weblate/trans/views/settings.py index 48a29a242122..ea7e0a42be53 100644 --- a/weblate/trans/views/settings.py +++ b/weblate/trans/views/settings.py @@ -15,23 +15,27 @@ from django.views.generic import TemplateView, View from weblate.trans.forms import ( + AddCategoryForm, AnnouncementForm, BaseDeleteForm, + CategoryMoveForm, + CategoryRenameForm, ComponentMoveForm, ComponentRenameForm, ComponentSettingsForm, ProjectRenameForm, ProjectSettingsForm, ) -from weblate.trans.models import Announcement, Component, Project, Translation +from weblate.trans.models import Announcement, Category, Component, Project, Translation from weblate.trans.tasks import ( + category_removal, component_removal, create_project_backup, project_removal, ) from weblate.trans.util import redirect_param, render from weblate.utils import messages -from weblate.utils.stats import ProjectLanguage +from weblate.utils.stats import CategoryLanguage, ProjectLanguage from weblate.utils.views import parse_path, show_form_errors @@ -125,7 +129,11 @@ def dismiss_alert(request, path): @login_required @require_POST def remove(request, path): - obj = parse_path(request, path, (Translation, Component, Project, ProjectLanguage)) + obj = parse_path( + request, + path, + (Translation, Component, Project, ProjectLanguage, CategoryLanguage, Category), + ) if not request.user.has_perm(obj.remove_permission, obj): raise PermissionDenied @@ -140,11 +148,15 @@ def remove(request, path): obj.remove(request.user) messages.success(request, gettext("The translation has been removed.")) elif isinstance(obj, Component): - parent = obj.project + parent = obj.category or obj.project component_removal.delay(obj.pk, request.user.pk) messages.success( request, gettext("The translation component was scheduled for removal.") ) + elif isinstance(obj, Category): + parent = obj.category or obj.project + category_removal.delay(obj.pk, request.user.pk) + messages.success(request, gettext("The category was scheduled for removal.")) elif isinstance(obj, Project): parent = reverse("home") project_removal.delay(obj.pk, request.user.pk) @@ -155,6 +167,12 @@ def remove(request, path): translation.remove(request.user) messages.success(request, gettext("A language in the project was removed.")) + elif isinstance(obj, CategoryLanguage): + parent = obj.project + for translation in obj.translation_set: + translation.remove(request.user) + + messages.success(request, gettext("A language in the category was removed.")) return redirect(parent) @@ -194,19 +212,37 @@ def perform_rename(form_cls, request, obj, perm: str): @login_required @require_POST def move(request, path): - obj = parse_path(request, path, (Component,)) + obj = parse_path(request, path, (Component, Category)) + if isinstance(obj, Category): + return perform_rename(CategoryMoveForm, request, obj, "project.edit") return perform_rename(ComponentMoveForm, request, obj, "project.edit") @login_required @require_POST def rename(request, path): - obj = parse_path(request, path, (Component, Project)) + obj = parse_path(request, path, (Component, Project, Category)) if isinstance(obj, Component): return perform_rename(ComponentRenameForm, request, obj, "component.edit") + if isinstance(obj, Category): + return perform_rename(CategoryRenameForm, request, obj, "project.edit") return perform_rename(ProjectRenameForm, request, obj, "project.edit") +@login_required +@require_POST +def add_category(request, path): + obj = parse_path(request, path, (Project, Category)) + if not request.user.has_perm("project.edit", obj): + raise PermissionDenied + form = AddCategoryForm(request, obj, request.POST) + if not form.is_valid(): + show_form_errors(request, form) + return redirect_param(obj, "#rename") + form.save() + return redirect(form.instance) + + @login_required @require_POST def announcement(request, path): diff --git a/weblate/urls.py b/weblate/urls.py index ae62358ba49e..3a920999d396 100644 --- a/weblate/urls.py +++ b/weblate/urls.py @@ -408,6 +408,11 @@ "rename//", weblate.trans.views.settings.rename, name="rename" ), path("move//", weblate.trans.views.settings.move, name="move"), + path( + "category/add//", + weblate.trans.views.settings.add_category, + name="add-category", + ), # Alerts dismiss path( "alerts//dismiss/", diff --git a/weblate/utils/stats.py b/weblate/utils/stats.py index 01ee2562ffee..de513a573aae 100644 --- a/weblate/utils/stats.py +++ b/weblate/utils/stats.py @@ -621,6 +621,11 @@ def calculate_source(self, stats_obj, stats): def prefetch_source(self): return + @cached_property + def category_set(self): + # Used in CategoryLanguageStats + return [] + def _prefetch_basic(self): stats = zero_stats(self.basic_keys) for translation in self.translation_set: @@ -629,6 +634,12 @@ def _prefetch_basic(self): for item in BASIC_KEYS: aggregate(stats, item, stats_obj) self.calculate_source(stats_obj, stats) + for category in self.category_set: + stats_obj = category.stats + stats_obj.ensure_basic() + for item in BASIC_KEYS: + aggregate(stats, item, stats_obj) + self.calculate_source(stats_obj, stats) for key, value in stats.items(): self.store(key, value) @@ -641,6 +652,8 @@ def calculate_item(self, item): result = 0 for translation in self.translation_set: result += getattr(translation.stats, item) + for category in self.category_set: + result += getattr(category.stats, item) self.store(item, result) @@ -699,6 +712,8 @@ def get_invalidate_keys( result = super().get_invalidate_keys(language, childs, parents) if parents: result.update(self._object.project.stats.get_invalidate_keys(language)) + if self._object.category: + result.update(self._object.category.stats.get_invalidate_keys(language)) for clist in self._object.componentlist_set.iterator(): result.update(clist.stats.get_invalidate_keys()) if childs: @@ -823,6 +838,12 @@ def component_set(self): return self._project_stats.component_set return prefetch_stats(self.project.component_set.prefetch_source_stats()) + @cached_property + def category_set(self): + if self._project_stats: + return self._project_stats.category_set + return prefetch_stats(self.project.category_set.all()) + @cached_property def translation_set(self): return prefetch_stats( @@ -839,6 +860,11 @@ def prefetch_source(self): chars += stats_obj.all_chars words += stats_obj.all_words strings += stats_obj.all + for category in self.category_set: + stats_obj = category.stats + chars += stats_obj.all_chars + words += stats_obj.all_words + strings += stats_obj.all self.store("source_chars", chars) self.store("source_words", words) self.store("source_strings", strings) @@ -858,6 +884,209 @@ def is_source(self): ) +class CategoryLanguage(BaseURLMixin): + """Wrapper class used in category-language listings and stats.""" + + remove_permission = "translation.delete" + + def __init__(self, category, language: Language): + self.category = category + self.language = language + self.component = ProjectLanguageComponent(self) + + def __str__(self): + return f"{self.category} - {self.language}" + + @property + def code(self): + return self.language.code + + @cached_property + def stats(self): + return CategoryLanguageStats(self) + + @cached_property + def pk(self): + return f"{self.category.pk}-{self.language.pk}" + + @cached_property + def cache_key(self): + return f"{self.category.cache_key}-{self.language.pk}" + + def get_url_path(self): + return [*self.category.get_url_path(), "-", self.language.code] + + def get_absolute_url(self): + return reverse("show", kwargs={"path": self.get_url_path()}) + + def get_translate_url(self): + return reverse("translate", kwargs={"path": self.get_url_path()}) + + @cached_property + def translation_set(self): + result = self.language.translation_set.filter( + component__category=self.category + ).prefetch() + for item in result: + item.is_shared = ( + None + if item.component.project == self.category.project + else item.component.project + ) + return sorted( + result, + key=lambda trans: (trans.component.priority, trans.component.name.lower()), + ) + + @cached_property + def is_source(self): + return all( + self.language.id == component.source_language_id + for component in self.category.component_set.all() + ) + + @cached_property + def change_set(self): + return self.language.change_set.filter(component__category=self.category) + + +class CategoryLanguageStats(LanguageStats): + def __init__(self, obj: CategoryLanguage, category_stats=None): + self.language = obj.language + self.category = obj.category + self._category_stats = category_stats + super().__init__(obj) + obj.stats = self + + @cached_property + def has_review(self): + return ( + self.category.project.source_review + or self.category.project.translation_review + ) + + @cached_property + def component_set(self): + if self._category_stats: + return self._category_stats.component_set + return prefetch_stats(self.category.component_set.prefetch_source_stats()) + + @cached_property + def category_set(self): + if self._category_stats: + return self._category_stats.category_set + return prefetch_stats(self.category.category_set.all()) + + @cached_property + def translation_set(self): + return prefetch_stats( + self.language.translation_set.filter(component__in=self.component_set) + ) + + def calculate_source(self, stats_obj, stats): + return + + def prefetch_source(self): + chars = words = strings = 0 + for component in self.component_set: + stats_obj = component.source_translation.stats + chars += stats_obj.all_chars + words += stats_obj.all_words + strings += stats_obj.all + for category in self.category_set: + stats_obj = category.stats + chars += stats_obj.all_chars + words += stats_obj.all_words + strings += stats_obj.all + self.store("source_chars", chars) + self.store("source_words", words) + self.store("source_strings", strings) + + def _prefetch_basic(self): + super()._prefetch_basic() + self.store("languages", 1) + + def get_single_language_stats(self, language): + return self + + @cached_property + def is_source(self): + return all( + self.language.id == component.source_language_id + for component in self.category.component_set.all() + ) + + +class CategoryStats(BaseStats): + basic_keys = SOURCE_KEYS + + def get_invalidate_keys( + self, + language: Language | None = None, + childs: bool = False, + parents: bool = True, + ): + result = super().get_invalidate_keys(language, childs, parents) + if parents: + result.update(self._object.project.stats.get_invalidate_keys(language)) + if self._object.category: + result.update(self._object.category.stats.get_invalidate_keys(language)) + if language: + result.update( + self.get_single_language_stats(language).get_invalidate_keys() + ) + else: + for lang in self._object.languages: + result.update( + self.get_single_language_stats(lang).get_invalidate_keys() + ) + return result + + @cached_property + def component_set(self): + return prefetch_stats(self._object.component_set.prefetch_source_stats()) + + @cached_property + def category_set(self): + return prefetch_stats(self._object.category_set.all()) + + def _prefetch_basic(self): + stats = zero_stats(self.basic_keys) + for component in self.component_set: + stats_obj = component.stats + stats_obj.ensure_basic() + for item in self.basic_keys: + aggregate(stats, item, stats_obj) + for category in self.category_set: + stats_obj = category.stats + stats_obj.ensure_basic() + for item in self.basic_keys: + aggregate(stats, item, stats_obj) + for key, value in stats.items(): + self.store(key, value) + + def calculate_item(self, item): + """Calculate stats for translation.""" + result = 0 + for component in self.component_set: + result += getattr(component.stats, item) + for category in self.category_set: + result += getattr(category.stats, item) + self.store(item, result) + + def get_single_language_stats(self, language): + return CategoryLanguageStats( + CategoryLanguage(self._object, language), category_stats=self + ) + + def get_language_stats(self): + result = [ + self.get_single_language_stats(language) + for language in self._object.languages + ] + return prefetch_stats(result) + + class ProjectStats(BaseStats): basic_keys = SOURCE_KEYS @@ -884,6 +1113,10 @@ def get_invalidate_keys( ) return result + @cached_property + def category_set(self): + return prefetch_stats(self._object.category_set.filter(category=None).all()) + @cached_property def component_set(self): return prefetch_stats(self._object.component_set.prefetch_source_stats()) @@ -908,6 +1141,12 @@ def _prefetch_basic(self): for item in self.basic_keys: aggregate(stats, item, stats_obj) + for category in self.category_set: + stats_obj = category.stats + stats_obj.ensure_basic() + for item in self.basic_keys: + aggregate(stats, item, stats_obj) + for key, value in stats.items(): self.store(key, value) @@ -918,6 +1157,8 @@ def calculate_item(self, item): result = 0 for component in self.component_set: result += getattr(component.stats, item) + for category in self.category_set: + result += getattr(category.stats, item) self.store(item, result) diff --git a/weblate/utils/urls.py b/weblate/utils/urls.py index 71398a5027d2..f0e522115da5 100644 --- a/weblate/utils/urls.py +++ b/weblate/utils/urls.py @@ -5,6 +5,10 @@ from django.urls import register_converter from django.urls.converters import PathConverter, StringConverter +from weblate.trans.defines import CATEGORY_DEPTH + +URL_DEPTH = CATEGORY_DEPTH + 3 + class WeblateSlugConverter(StringConverter): regex = "[^/]+" @@ -23,7 +27,7 @@ class WidgetExtensionConverter(StringConverter): class ObjectPathConverter(PathConverter): - regex = "[^/]+(/[^/]+){0,3}" + regex = f"[^/]+(/[^/]+){{0,{URL_DEPTH}}}" def to_python(self, value): return value.split("/") diff --git a/weblate/utils/views.py b/weblate/utils/views.py index decc9eb140a9..f78c64e4daf0 100644 --- a/weblate/utils/views.py +++ b/weblate/utils/views.py @@ -7,12 +7,14 @@ from __future__ import annotations import os +from contextlib import suppress from time import mktime from typing import Any from zipfile import ZipFile from django.conf import settings from django.core.paginator import EmptyPage, Paginator +from django.db.models import Q from django.http import FileResponse, Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils.http import http_date @@ -22,10 +24,10 @@ from weblate.formats.models import EXPORTERS, FILE_FORMATS from weblate.lang.models import Language -from weblate.trans.models import Component, Project, Translation, Unit +from weblate.trans.models import Category, Component, Project, Translation, Unit from weblate.utils import messages from weblate.utils.errors import report_error -from weblate.utils.stats import ProjectLanguage +from weblate.utils.stats import CategoryLanguage, ProjectLanguage from weblate.vcs.git import LocalRepository SORT_KEYS = { @@ -169,27 +171,39 @@ def _parse_path(request, path: tuple[str], *, skip_acl: bool = False): return ProjectLanguage(project, language) # Component/category structure - parent = project - component = None + current = project + category_args = {"category": None} while path: slug = path.pop(0) - try: - component = parent.component_set.get(slug=slug) - except Component.DoesNotExist as error: - raise Http404(f"Object {slug} not found in {parent}") from error - else: + + # Category/language special case + if slug == "-" and len(path) == 1: + language = get_object_or_404(Language, code=path[0]) + return CategoryLanguage(current, language) + + # Try component first + with suppress(Component.DoesNotExist): + current = current.component_set.get(slug=slug, **category_args) if not skip_acl: - request.user.check_access_component(component) - component.acting_user = request.user + request.user.check_access_component(current) + current.acting_user = request.user break + # Try category + with suppress(Category.DoesNotExist): + current = current.category_set.get(slug=slug, **category_args) + current.acting_user = request.user + category_args = {} + continue + + # Nothing more to try + raise Http404(f"Object {slug} not found in {current}") + # Nothing left, return current object if not path: - return component + return current - translation = get_object_or_404( - component.translation_set, language__code=path.pop(0) - ) + translation = get_object_or_404(current.translation_set, language__code=path.pop(0)) if not path: return translation @@ -241,6 +255,29 @@ def parse_path_units(request, path: list[str], types: tuple[Any]): ) context["project"] = obj.project context["language"] = obj.language + elif isinstance(obj, Category): + unit_set = Unit.objects.filter( + Q(translation__component__category=obj) + | Q(translation__component__category__category=obj) + | Q(translation__component__category__category__category=obj) + ) + context["project"] = obj.project + elif isinstance(obj, CategoryLanguage): + unit_set = Unit.objects.filter( + Q(translation__component__category=obj.category) + | Q(translation__component__category__category=obj.category) + | Q(translation__component__category__category__category=obj.category), + translation__language=obj.language, + ) + context["project"] = obj.category.project + context["language"] = obj.language + elif isinstance(obj, Language): + unit_set = Unit.objects.filter_access(request.user).filter( + translation__language=obj + ) + context["language"] = obj + elif obj is None: + unit_set = Unit.objects.filter_access(request.user) else: raise TypeError("Unsupported result: {obj}")
    @@ -128,6 +128,30 @@
    + {% icon "folder-multiple-outline.svg" %} + + {{ category.name }} +
    {% translation_progress category.stats %}