Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/mixins.py: 77%
96 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
7import os
8from typing import ClassVar, NoReturn
10from django.core.exceptions import ValidationError
11from django.urls import reverse
12from django.utils.functional import cached_property
13from django.utils.translation import gettext
15from weblate.accounts.avatar import get_user_display
16from weblate.logger import LOGGER
17from weblate.utils.data import data_dir
20class BaseURLMixin:
21 def get_url_path(self) -> NoReturn:
22 raise NotImplementedError
24 @cached_property
25 def full_slug(self):
26 return "/".join(self.get_url_path())
29class URLMixin(BaseURLMixin):
30 """Mixin for models providing standard shortcut API for few standard URLs."""
32 def get_absolute_url(self) -> str:
33 return reverse("show", kwargs={"path": self.get_url_path()})
36class LoggerMixin(BaseURLMixin):
37 """Mixin for models with logging."""
39 def log_hook(self, level: str, msg: str, *args) -> None:
40 return
42 def log_debug(self, msg, *args):
43 self.log_hook("DEBUG", msg, *args)
44 return LOGGER.debug(f"{self.full_slug}: {msg}", *args)
46 def log_info(self, msg, *args):
47 self.log_hook("INFO", msg, *args)
48 return LOGGER.info(f"{self.full_slug}: {msg}", *args)
50 def log_warning(self, msg, *args):
51 self.log_hook("WARNING", msg, *args)
52 return LOGGER.warning(f"{self.full_slug}: {msg}", *args)
54 def log_error(self, msg, *args):
55 self.log_hook("ERROR", msg, *args)
56 return LOGGER.error(f"{self.full_slug}: {msg}", *args)
59class PathMixin(LoggerMixin, URLMixin):
60 """Mixin for models with path manipulations."""
62 def _get_path(self):
63 """Actual calculation of path."""
64 return os.path.join(data_dir("vcs"), *self.get_url_path())
66 @cached_property
67 def full_path(self):
68 return self._get_path()
70 def invalidate_path_cache(self) -> None:
71 if "full_path" in self.__dict__: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 del self.__dict__["full_path"]
74 def check_rename(self, old, validate=False) -> None:
75 """Detect slug changes and possibly renames underlying directory."""
76 # No moving for links
77 if getattr(self, "is_repo_link", False) or getattr(old, "is_repo_link", False): 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 return
80 old_path = old.full_path
81 # Invalidate path cache (otherwise we would still get old path)
82 self.invalidate_path_cache()
83 new_path = self.full_path
85 if old_path != new_path: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 if validate:
87 # Patch using old path for validation
88 # the actual rename happens only on save
89 self.__dict__["full_path"] = old_path
90 return
92 self.log_info("path changed from %s to %s", old_path, new_path)
93 if os.path.exists(old_path) and not os.path.exists(new_path):
94 self.log_info('renaming "%s" to "%s"', old_path, new_path)
95 os.rename(old_path, new_path)
97 def create_path(self) -> None:
98 """Create filesystem directory for storing data."""
99 path = self.full_path
100 if not os.path.exists(path):
101 os.makedirs(path)
104class UserDisplayMixin:
105 def get_user_display(self, icon: bool = True):
106 return get_user_display(self.user, icon, link=True)
108 def get_user_text_display(self):
109 return get_user_display(self.user, icon=False, link=True)
112class CacheKeyMixin:
113 @cached_property
114 def cache_key(self) -> str:
115 return f"{self.__class__.__name__}-{self.id}"
118class ComponentCategoryMixin:
119 def _clean_unique_together(self, field: str, msg: str, lookup: str) -> None:
120 if self.category: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 matching_components = self.category.component_set.filter(**{field: lookup})
122 matching_categories = self.category.category_set.filter(**{field: lookup})
123 else:
124 matching_components = self.project.component_set.filter(
125 category=None, **{field: lookup}
126 )
127 matching_categories = self.project.category_set.filter(
128 category=None, **{field: lookup}
129 )
131 if self.id:
132 if self.__class__.__name__ == "Component":
133 matching_components = matching_components.exclude(pk=self.id)
134 else:
135 matching_categories = matching_categories.exclude(pk=self.id)
137 if matching_categories.exists() or matching_components.exists():
138 raise ValidationError({field: msg})
140 def clean_unique_together(self) -> None:
141 self._clean_unique_together(
142 "slug",
143 gettext(
144 "Component or category with the same URL slug already exists at this level."
145 ),
146 self.slug,
147 )
148 self._clean_unique_together(
149 "name",
150 gettext(
151 "Component or category with the same name already exists at this level."
152 ),
153 self.name,
154 )
157class LockMixin:
158 is_lockable: ClassVar[bool] = False
159 lockable_count: ClassVar[bool] = False
161 @property
162 def locked(self) -> bool:
163 return False
165 def can_unlock(self) -> bool:
166 return self.locked
168 def can_lock(self) -> bool:
169 return not self.locked