Skip to content

Commit

Permalink
models: add component categorization
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
nijel committed Aug 14, 2023
1 parent c4c3a71 commit 9c97b5f
Show file tree
Hide file tree
Showing 39 changed files with 486 additions and 114 deletions.
15 changes: 14 additions & 1 deletion docs/admin/projects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 strucutre.

.. _markup:

Template markup
Expand Down
11 changes: 11 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Not yet released.
* :doc:`/formats/markdown` support, thanks to Anders Kaplan.
* :doc:`/formats/fluent` now has better syntax checks thanks to Henry Wilkes.
* Inviting users now works with all authentication methods.
* :ref:`category` can now organize components within a project.

**Improvements**

Expand Down
68 changes: 66 additions & 2 deletions weblate/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
)
Expand All @@ -364,6 +368,7 @@ class Meta:
"components_list_url",
"repository_url",
"statistics_url",
"categories_url",
"changes_list_url",
"languages_url",
"translation_review",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -525,6 +537,7 @@ class Meta:
"is_glossary",
"glossary_color",
"disable_autoshare",
"category",
)
extra_kwargs = {
"url": {
Expand All @@ -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.")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions weblate/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions weblate/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from weblate.api.routers import WeblateRouter
from weblate.api.views import (
AddonViewSet,
CategoryViewSet,
ChangeViewSet,
ComponentListViewSet,
ComponentViewSet,
Expand Down Expand Up @@ -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.
Expand Down
81 changes: 80 additions & 1 deletion weblate/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,6 +45,7 @@
AddonSerializer,
BasicUserSerializer,
BilingualUnitSerializer,
CategorySerializer,
ChangeSerializer,
ComponentListSerializer,
ComponentSerializer,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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."""

Expand Down
3 changes: 1 addition & 2 deletions weblate/templates/addons/addon_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
{% load permissions %}

{% block breadcrumbs %}
<li><a href="{{ object.project.get_absolute_url }}">{{ object.project }}</a></li>
{% include "snippets/component-breadcrumb.html" %}
{% path_object_breadcrumbs object %}
<li><a href="{% url 'addons' path=object.get_url_path %}">{% trans "Add-ons" %}</a></li>
<li>
{% if instance %}
Expand Down
3 changes: 1 addition & 2 deletions weblate/templates/addons/addon_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
{% load permissions %}

{% block breadcrumbs %}
<li><a href="{{ object.project.get_absolute_url }}">{{ object.project }}</a></li>
{% include "snippets/component-breadcrumb.html" %}
{% path_object_breadcrumbs object %}
<li><a href="{% url 'addons' path=object.get_url_path %}">{% trans "Add-ons" %}</a></li>
{% endblock %}

Expand Down
7 changes: 2 additions & 5 deletions weblate/templates/browse.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,8 @@
{% endblock %}

{% block breadcrumbs %}
<li><a href="{{ project.get_absolute_url }}">{{ project }}</a></li>
{% if object.component.slug != "-" %}
{% include "snippets/component-breadcrumb.html" with object=object.component %}
{% endif %}
{% include "snippets/translation-breadcrumb.html" %}
{% path_object_breadcrumbs object %}

<li><a href="{% url 'browse' path=object.get_url_path %}">{% trans "Browse" %}</a></li>
<a class="pull-right flip" href="{{ object.get_widgets_url }}">
<img src="{% url 'widget-image' path=object.get_url_path widget='svg' color='badge' extension='svg' %}?native=1" />
Expand Down
Loading

0 comments on commit 9c97b5f

Please sign in to comment.