From 1c4eb51292c7e3e0cec701d1eb85dc199adbbbcb 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 - support maximal depth of 3 categories rigth now Fixes #263 TODO: - add UI to: - add a category - delete a category - move component between categories - add documentation - add tests - add API - tests - documentation --- docs/admin/projects.rst | 15 +- docs/api.rst | 11 + docs/changes.rst | 1 + weblate/api/serializers.py | 68 +++++- weblate/api/tests.py | 17 ++ weblate/api/urls.py | 2 + weblate/api/views.py | 81 +++++++- weblate/templates/snippets/list-objects.html | 2 +- weblate/trans/defines.py | 3 + weblate/trans/discovery.py | 1 + .../0182_category_component_category.py | 86 ++++++++ weblate/trans/mixins.py | 32 +++ weblate/trans/models/__init__.py | 2 + weblate/trans/models/category.py | 90 ++++++++ weblate/trans/models/component.py | 34 ++- weblate/trans/templatetags/translations.py | 18 +- weblate/trans/tests/test_manage.py | 3 +- weblate/trans/views/basic.py | 18 +- weblate/utils/stats.py | 196 ++++++++++++++++++ weblate/utils/urls.py | 6 +- weblate/utils/views.py | 34 +-- 21 files changed, 675 insertions(+), 45 deletions(-) create mode 100644 weblate/trans/migrations/0182_category_component_category.py create mode 100644 weblate/trans/models/category.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..d0b49f66a271 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 diff --git a/docs/changes.rst b/docs/changes.rst index e1b6e5155cc9..d9c0b2f7dd9e 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..8bdb90ec9018 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, @@ -348,6 +349,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 +368,7 @@ class Meta: "components_list_url", "repository_url", "statistics_url", + "categories_url", "changes_list_url", "languages_url", "translation_review", @@ -452,6 +457,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 +537,7 @@ class Meta: "is_glossary", "glossary_color", "disable_autoshare", + "category", ) extra_kwargs = { "url": { @@ -533,6 +546,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 +620,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 +1109,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..7598a88d54ab 100644 --- a/weblate/api/tests.py +++ b/weblate/api/tests.py @@ -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 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..ebdf6da57167 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, @@ -156,7 +159,27 @@ 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 != self.category_field: + object_name, attr_name = field.rsplit("__", 1) + lookup[f"{object_name}__{category_path}{attr_name}"] = self.kwargs[ + field + ] + else: + # Fetch component part for possible category + for category in reversed(unquote(self.kwargs[field]).split("/")): + if not category_path: + # Component filter + lookup[field] = category + else: + # Strip "slug" from category field + category_prefix = self.category_field[:-4] + category_path = f"category__{category_path}" + lookup[f"{category_prefix}{category_path}__slug"] = category + # Lookup the object return get_object_or_404(queryset, **lookup) @@ -684,6 +707,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() @@ -761,6 +795,7 @@ class ComponentViewSet( queryset = Component.objects.none() serializer_class = ComponentSerializer lookup_fields = ("project__slug", "slug") + category_field = "slug" def get_queryset(self): return ( @@ -1004,6 +1039,7 @@ class TranslationViewSet(MultipleFieldMixin, WeblateViewSet, DestroyModelMixin): queryset = Translation.objects.none() serializer_class = TranslationSerializer lookup_fields = ("component__project__slug", "component__slug", "language__code") + category_field = "component__slug" raw_urls = ("translation-file",) def get_queryset(self): @@ -1556,6 +1592,49 @@ 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): + self.perm_check(request, self.get_object()) + return super().destroy(request, *args, **kwargs) + + 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["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/templates/snippets/list-objects.html b/weblate/templates/snippets/list-objects.html index ed13046440d9..932df0b11020 100644 --- a/weblate/templates/snippets/list-objects.html +++ b/weblate/templates/snippets/list-objects.html @@ -12,7 +12,7 @@ {% else %} {% endif %} - {% if not hide_details %} + {% if not hide_details and not hide_header %}
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/migrations/0182_category_component_category.py b/weblate/trans/migrations/0182_category_component_category.py new file mode 100644 index 000000000000..9f391a9961e2 --- /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="subcategory_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/mixins.py b/weblate/trans/mixins.py index 66a69e851823..0099d37ed5e3 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,33 @@ 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): + parent = self.category if self.category else self.project + matching_components = parent.component_set.filter(**{field: lookup}) + matching_categories = parent.category_set.filter(**{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 this URL slug already exists at this level." + ), + self.slug, + ) + self._clean_unique_together( + "name", + gettext( + "Component or category with this 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..c5194437cedb --- /dev/null +++ b/weblate/trans/models/category.py @@ -0,0 +1,90 @@ +# 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.utils.translation import gettext, gettext_lazy + +from weblate.trans.defines import CATEGORY_DEPTH, COMPONENT_NAME_LENGTH +from weblate.trans.mixins import CacheKeyMixin, ComponentCategoryMixin, PathMixin +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", + ) + + 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.check_rename(old) + 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!")} + ) + + # 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) diff --git a/weblate/trans/models/component.py b/weblate/trans/models/component.py index dd466eb1f856..089bcaf8219a 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, @@ -719,7 +726,7 @@ class Meta: 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): """ @@ -1150,7 +1157,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.""" @@ -2765,13 +2773,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. @@ -2817,16 +2818,7 @@ 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: diff --git a/weblate/trans/templatetags/translations.py b/weblate/trans/templatetags/translations.py index 59962ca192cc..568a724063ee 100644 --- a/weblate/trans/templatetags/translations.py +++ b/weblate/trans/templatetags/translations.py @@ -30,6 +30,7 @@ from weblate.trans.filter import FILTERS, get_filter_choice from weblate.trans.models import ( Announcement, + Category, Component, ContributorAgreement, Project, @@ -1154,7 +1155,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 diff --git a/weblate/trans/tests/test_manage.py b/weblate/trans/tests/test_manage.py index d34f7a141d67..2717502e9638 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 this URL slug already exists at this level.", ) diff --git a/weblate/trans/views/basic.py b/weblate/trans/views/basic.py index cdece57425c0..5fdef4dbd003 100644 --- a/weblate/trans/views/basic.py +++ b/weblate/trans/views/basic.py @@ -38,7 +38,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 @@ -162,13 +169,15 @@ 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)) 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) + if isinstance(obj, Category): + return show_category(request, obj) return show_translation(request, obj) @@ -315,6 +324,11 @@ def show_project(request, obj): ) +def show_category(request, obj): + # TODO: implement + return render(request, "category.html", {}) + + def show_component(request, obj): obj.stats.ensure_basic() user = request.user diff --git a/weblate/utils/stats.py b/weblate/utils/stats.py index 01ee2562ffee..9ef68a4bc5a5 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 None + 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) @@ -858,6 +871,175 @@ 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.project.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.prefetch_source_stats()) + + @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.source_translation.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 + + @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.prefetch_source_stats()) + + 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) + + class ProjectStats(BaseStats): basic_keys = SOURCE_KEYS @@ -884,6 +1066,12 @@ def get_invalidate_keys( ) return result + @cached_property + def category_set(self): + return prefetch_stats( + self._object.category_set.filter(category=None).prefetch_source_stats() + ) + @cached_property def component_set(self): return prefetch_stats(self._object.component_set.prefetch_source_stats()) @@ -908,6 +1096,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 +1112,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 c768a4de0ae0..3e4c3b3254f8 100644 --- a/weblate/utils/views.py +++ b/weblate/utils/views.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +from contextlib import suppress from time import mktime from typing import Any from zipfile import ZipFile @@ -22,7 +23,7 @@ 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 @@ -169,27 +170,32 @@ 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: + # 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) + category_args = {} + continue + + # Nothing more to try + raise Http404(f"Object {slug} not found in {current}") from None + # 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