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

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

2# 

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

4 

5from __future__ import annotations 

6 

7import os 

8from typing import ClassVar, NoReturn 

9 

10from django.core.exceptions import ValidationError 

11from django.urls import reverse 

12from django.utils.functional import cached_property 

13from django.utils.translation import gettext 

14 

15from weblate.accounts.avatar import get_user_display 

16from weblate.logger import LOGGER 

17from weblate.utils.data import data_dir 

18 

19 

20class BaseURLMixin: 

21 def get_url_path(self) -> NoReturn: 

22 raise NotImplementedError 

23 

24 @cached_property 

25 def full_slug(self): 

26 return "/".join(self.get_url_path()) 

27 

28 

29class URLMixin(BaseURLMixin): 

30 """Mixin for models providing standard shortcut API for few standard URLs.""" 

31 

32 def get_absolute_url(self) -> str: 

33 return reverse("show", kwargs={"path": self.get_url_path()}) 

34 

35 

36class LoggerMixin(BaseURLMixin): 

37 """Mixin for models with logging.""" 

38 

39 def log_hook(self, level: str, msg: str, *args) -> None: 

40 return 

41 

42 def log_debug(self, msg, *args): 

43 self.log_hook("DEBUG", msg, *args) 

44 return LOGGER.debug(f"{self.full_slug}: {msg}", *args) 

45 

46 def log_info(self, msg, *args): 

47 self.log_hook("INFO", msg, *args) 

48 return LOGGER.info(f"{self.full_slug}: {msg}", *args) 

49 

50 def log_warning(self, msg, *args): 

51 self.log_hook("WARNING", msg, *args) 

52 return LOGGER.warning(f"{self.full_slug}: {msg}", *args) 

53 

54 def log_error(self, msg, *args): 

55 self.log_hook("ERROR", msg, *args) 

56 return LOGGER.error(f"{self.full_slug}: {msg}", *args) 

57 

58 

59class PathMixin(LoggerMixin, URLMixin): 

60 """Mixin for models with path manipulations.""" 

61 

62 def _get_path(self): 

63 """Actual calculation of path.""" 

64 return os.path.join(data_dir("vcs"), *self.get_url_path()) 

65 

66 @cached_property 

67 def full_path(self): 

68 return self._get_path() 

69 

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"] 

73 

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 

79 

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 

84 

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 

91 

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) 

96 

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) 

102 

103 

104class UserDisplayMixin: 

105 def get_user_display(self, icon: bool = True): 

106 return get_user_display(self.user, icon, link=True) 

107 

108 def get_user_text_display(self): 

109 return get_user_display(self.user, icon=False, link=True) 

110 

111 

112class CacheKeyMixin: 

113 @cached_property 

114 def cache_key(self) -> str: 

115 return f"{self.__class__.__name__}-{self.id}" 

116 

117 

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 ) 

130 

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) 

136 

137 if matching_categories.exists() or matching_components.exists(): 

138 raise ValidationError({field: msg}) 

139 

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 ) 

155 

156 

157class LockMixin: 

158 is_lockable: ClassVar[bool] = False 

159 lockable_count: ClassVar[bool] = False 

160 

161 @property 

162 def locked(self) -> bool: 

163 return False 

164 

165 def can_unlock(self) -> bool: 

166 return self.locked 

167 

168 def can_lock(self) -> bool: 

169 return not self.locked