Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/models/category.py: 65%
130 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7from typing import TYPE_CHECKING
9from django.core.exceptions import ValidationError
10from django.db import models
11from django.db.models import Q
12from django.utils.functional import cached_property
13from django.utils.translation import gettext, gettext_lazy
15from weblate.lang.models import Language
16from weblate.trans.actions import ActionEvents
17from weblate.trans.defines import CATEGORY_DEPTH, COMPONENT_NAME_LENGTH
18from weblate.trans.mixins import (
19 CacheKeyMixin,
20 ComponentCategoryMixin,
21 LockMixin,
22 PathMixin,
23)
24from weblate.utils.stats import CategoryStats
25from weblate.utils.validators import validate_slug
27if TYPE_CHECKING: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 from collections.abc import Iterable
30 from weblate.auth.models import User
31 from weblate.trans.models import Component
34class CategoryQuerySet(models.QuerySet):
35 def search(self, query: str):
36 return self.filter(
37 Q(name__icontains=query) | Q(slug__icontains=query)
38 ).select_related(
39 "project",
40 "category__project",
41 "category__category",
42 "category__category__project",
43 "category__category__category",
44 "category__category__category__project",
45 )
47 def order(self):
48 return self.order_by("name")
51class Category(
52 models.Model, PathMixin, CacheKeyMixin, ComponentCategoryMixin, LockMixin
53):
54 name = models.CharField(
55 verbose_name=gettext_lazy("Category name"),
56 max_length=COMPONENT_NAME_LENGTH,
57 help_text=gettext_lazy("Display name"),
58 )
59 slug = models.SlugField(
60 verbose_name=gettext_lazy("URL slug"),
61 max_length=COMPONENT_NAME_LENGTH,
62 help_text=gettext_lazy("Name used in URLs and filenames."),
63 validators=[validate_slug],
64 )
65 project = models.ForeignKey(
66 "trans.Project",
67 verbose_name=gettext_lazy("Project"),
68 on_delete=models.deletion.CASCADE,
69 )
70 category = models.ForeignKey(
71 "trans.Category",
72 verbose_name=gettext_lazy("Category"),
73 on_delete=models.deletion.CASCADE,
74 null=True,
75 blank=True,
76 related_name="category_set",
77 )
79 remove_permission = "project.edit"
80 settings_permission = "project.edit"
82 objects = CategoryQuerySet.as_manager()
84 class Meta:
85 app_label = "trans"
86 verbose_name = "Category"
87 verbose_name_plural = "Categories"
88 constraints = [ # noqa: RUF012
89 models.UniqueConstraint(
90 name="category_slug_unique",
91 fields=["project", "category", "slug"],
92 nulls_distinct=False,
93 ),
94 models.UniqueConstraint(
95 name="category_name_unique",
96 fields=["project", "category", "name"],
97 nulls_distinct=False,
98 ),
99 ]
101 def __str__(self) -> str:
102 return f"{self.category or self.project}/{self.name}"
104 def __init__(self, *args, **kwargs) -> None:
105 super().__init__(*args, **kwargs)
106 self.stats = CategoryStats(self)
107 self.acting_user: User | None = None
109 def save(self, *args, **kwargs) -> None:
110 old = None
111 if self.id:
112 old = Category.objects.get(pk=self.id)
113 self.generate_changes(old)
114 self.check_rename(old)
115 self.create_path()
116 super().save(*args, **kwargs)
117 if old:
118 # Update linked repository references
119 if ( 119 ↛ 124line 119 didn't jump to line 124 because the condition on line 119 was never true
120 old.slug != self.slug
121 or old.project != self.project
122 or old.category != self.category
123 ):
124 for component in self.all_components.exclude(component=None):
125 component.linked_children.update(repo=component.get_repo_link_url())
126 # Move to a different project
127 if old.project != self.project: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 self.move_to_project(self.project)
130 def move_to_project(self, project) -> None:
131 """Trigger save with changed project on categories and components."""
132 for category in self.category_set.all():
133 category.project = project
134 category.save()
135 for component in self.component_set.all():
136 component.project = project
137 component.save()
139 def get_url_path(self):
140 parent = self.category or self.project
141 return (*parent.get_url_path(), self.slug)
143 def _get_children_depth(self):
144 return 1 + max(
145 (
146 child._get_children_depth() # noqa: SLF001
147 for child in self.category_set.all()
148 ),
149 default=0,
150 )
152 def _get_parents_depth(self):
153 depth = 0
154 current = self
155 while current.category: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true
156 depth += 1
157 current = current.category
158 return depth
160 def _get_category_depth(self):
161 return (
162 self._get_children_depth() if self.pk else 1
163 ) + self._get_parents_depth()
165 @property
166 def can_add_category(self):
167 return self._get_parents_depth() + 1 < CATEGORY_DEPTH
169 def clean(self) -> None:
170 # Validate maximal nesting depth
171 depth = self._get_category_depth()
173 if depth > CATEGORY_DEPTH: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 raise ValidationError(
175 {"category": gettext("Too deep nesting of categories!")}
176 )
178 if self.category and self.category.project != self.project: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 raise ValidationError(
180 {"category": gettext("Parent category has to be in the same project!")}
181 )
183 if self.category and self == self.category: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 raise ValidationError(
185 {"category": gettext("Parent category has to be different!")}
186 )
188 # Validate category/component name uniqueness at given level
189 self.clean_unique_together()
191 if self.id:
192 old = Category.objects.get(pk=self.id)
193 self.check_rename(old, validate=True)
195 def get_child_components_access(self, user: User):
196 """List child components."""
197 return self.component_set.filter_access(user).prefetch().order()
199 @cached_property
200 def languages(self):
201 """Return list of all languages used in project."""
202 return (
203 Language.objects.filter(
204 translation__component_id__in=self.all_component_ids
205 )
206 .distinct()
207 .order()
208 )
210 @cached_property
211 def all_components(self) -> Iterable[Component]:
212 from weblate.trans.models import Component
214 return Component.objects.filter(
215 Q(category=self)
216 | Q(category__category=self)
217 | Q(category__category__category=self)
218 )
220 @property
221 def all_repo_components(self) -> Iterable[Component]:
222 included = set()
223 # Yield all components with repo
224 for component in self.all_components:
225 if not component.linked_component_id:
226 included.add(component.pk)
227 yield component
228 # Include possibly linked components outside the category
229 for component in self.all_components:
230 if (
231 component.linked_component_id
232 and component.linked_component_id not in included
233 ):
234 yield component.linked_component
236 @cached_property
237 def all_component_ids(self):
238 return set(self.all_components.values_list("pk", flat=True))
240 def generate_changes(self, old) -> None:
241 def getvalue(base, attribute):
242 result = getattr(base, attribute)
243 if result is None:
244 return ""
245 # Use slug for Category instances
246 return getattr(result, "slug", result)
248 tracked = (
249 ("slug", ActionEvents.RENAME_CATEGORY),
250 ("category", ActionEvents.MOVE_CATEGORY),
251 ("project", ActionEvents.MOVE_CATEGORY),
252 )
253 for attribute, action in tracked:
254 old_value = getvalue(old, attribute)
255 current_value = getvalue(self, attribute)
257 if old_value != current_value: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 self.project.change_set.create(
259 action=action,
260 old=old_value,
261 target=current_value,
262 user=self.acting_user,
263 )
265 @cached_property
266 def source_language_ids(self):
267 return set(
268 self.all_components.values_list("source_language_id", flat=True).distinct()
269 )
271 def get_widgets_url(self) -> str:
272 """Return absolute URL for widgets."""
273 return self.project.get_widgets_url()