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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7from typing import TYPE_CHECKING 

8 

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 

14 

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 

26 

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 

29 

30 from weblate.auth.models import User 

31 from weblate.trans.models import Component 

32 

33 

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 ) 

46 

47 def order(self): 

48 return self.order_by("name") 

49 

50 

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 ) 

78 

79 remove_permission = "project.edit" 

80 settings_permission = "project.edit" 

81 

82 objects = CategoryQuerySet.as_manager() 

83 

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 ] 

100 

101 def __str__(self) -> str: 

102 return f"{self.category or self.project}/{self.name}" 

103 

104 def __init__(self, *args, **kwargs) -> None: 

105 super().__init__(*args, **kwargs) 

106 self.stats = CategoryStats(self) 

107 self.acting_user: User | None = None 

108 

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) 

129 

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() 

138 

139 def get_url_path(self): 

140 parent = self.category or self.project 

141 return (*parent.get_url_path(), self.slug) 

142 

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 ) 

151 

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 

159 

160 def _get_category_depth(self): 

161 return ( 

162 self._get_children_depth() if self.pk else 1 

163 ) + self._get_parents_depth() 

164 

165 @property 

166 def can_add_category(self): 

167 return self._get_parents_depth() + 1 < CATEGORY_DEPTH 

168 

169 def clean(self) -> None: 

170 # Validate maximal nesting depth 

171 depth = self._get_category_depth() 

172 

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 ) 

177 

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 ) 

182 

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 ) 

187 

188 # Validate category/component name uniqueness at given level 

189 self.clean_unique_together() 

190 

191 if self.id: 

192 old = Category.objects.get(pk=self.id) 

193 self.check_rename(old, validate=True) 

194 

195 def get_child_components_access(self, user: User): 

196 """List child components.""" 

197 return self.component_set.filter_access(user).prefetch().order() 

198 

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 ) 

209 

210 @cached_property 

211 def all_components(self) -> Iterable[Component]: 

212 from weblate.trans.models import Component 

213 

214 return Component.objects.filter( 

215 Q(category=self) 

216 | Q(category__category=self) 

217 | Q(category__category__category=self) 

218 ) 

219 

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 

235 

236 @cached_property 

237 def all_component_ids(self): 

238 return set(self.all_components.values_list("pk", flat=True)) 

239 

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) 

247 

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) 

256 

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 ) 

264 

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 ) 

270 

271 def get_widgets_url(self) -> str: 

272 """Return absolute URL for widgets.""" 

273 return self.project.get_widgets_url()