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
- document adjusted  API lookups (category in component, %2F)
  • Loading branch information
nijel committed Aug 12, 2023
1 parent 08a5c15 commit 2d3a31d
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 34 deletions.
25 changes: 24 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 @@ -156,7 +157,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 @@ -761,6 +782,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 +1026,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
3 changes: 3 additions & 0 deletions weblate/trans/defines.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@
VARIANT_REGEX_LENGTH = 190
# Needed for unique index on MySQL
VARIANT_KEY_LENGTH = 576

# Maximal categories depth
CATEGORY_DEPTH = 3
86 changes: 86 additions & 0 deletions weblate/trans/migrations/0182_category_component_category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Copyright © Michal Čihař <[email protected]>
#
# 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",
),
),
]
32 changes: 32 additions & 0 deletions weblate/trans/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,3 +110,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,
)
2 changes: 2 additions & 0 deletions weblate/trans/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +28,7 @@

__all__ = [
"Project",
"Category",
"Component",
"Translation",
"Unit",
Expand Down
63 changes: 63 additions & 0 deletions weblate/trans/models/category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Copyright © Michal Čihař <[email protected]>
#
# 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.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="subcategory_set",
)

def __str__(self):
return f"{self.category or self.project}/{self.name}"

def _get_childs_depth(self):
return 1 + max(
(child._get_childs_depth() for child in self.subcategory_set.all()),
default=0,
)

def clean(self):
# Validate maximal nesting depth
depth = self._get_childs_depth()
current = self
while current.category:
depth += 1
current = current.category

if depth > CATEGORY_DEPTH:
raise ValidationError(gettext("Too deep nesting of categories!"))

# Validate category/component name uniqueness at given level
self.clean_unique_together()
31 changes: 11 additions & 20 deletions weblate/trans/models/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -2769,13 +2776,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.
Expand Down Expand Up @@ -2821,16 +2821,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:
Expand Down
12 changes: 11 additions & 1 deletion weblate/trans/templatetags/translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from weblate.trans.filter import FILTERS, get_filter_choice
from weblate.trans.models import (
Announcement,
Category,
Component,
ContributorAgreement,
Project,
Expand Down Expand Up @@ -1156,8 +1157,17 @@ 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(), path_object.name
yield path_object.get_absolute_url(), path_object.name
elif isinstance(path_object, Category):
if path_object.category:
yield from get_breadcrumbs(path_object.category)
else:
yield from get_breadcrumbs(path_object.project)
elif isinstance(path_object, Project):
yield path_object.get_absolute_url(), path_object.name
elif isinstance(path_object, Language):
Expand Down
3 changes: 2 additions & 1 deletion weblate/trans/tests/test_manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)


Expand Down
Loading

0 comments on commit 2d3a31d

Please sign in to comment.