Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/models/suggestion.py: 32%

137 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 copy import copy 

8from typing import TYPE_CHECKING 

9 

10from django.conf import settings 

11from django.db import models, transaction 

12from django.db.models import Q, Sum 

13from django.utils.translation import gettext 

14 

15from weblate.checks.models import CHECKS, Check 

16from weblate.trans.actions import ActionEvents 

17from weblate.trans.autofixes import fix_target 

18from weblate.trans.exceptions import SuggestionSimilarToTranslationError 

19from weblate.trans.mixins import UserDisplayMixin 

20from weblate.trans.util import join_plural, split_plural 

21from weblate.utils import messages 

22from weblate.utils.antispam import report_spam 

23from weblate.utils.request import get_ip_address, get_user_agent_raw 

24from weblate.utils.state import STATE_TRANSLATED 

25 

26if TYPE_CHECKING: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true

27 from weblate.auth.models import AuthenticatedHttpRequest, User 

28 from weblate.trans.models.unit import Unit 

29 

30 

31class SuggestionManager(models.Manager["Suggestion"]): 

32 def add( 

33 self, 

34 unit: Unit, 

35 target: list[str], 

36 request: AuthenticatedHttpRequest | None, 

37 vote: bool = False, 

38 user: User | None = None, 

39 raise_exception: bool = True, 

40 ): 

41 """Create new suggestion for this unit.""" 

42 from weblate.auth.models import get_anonymous 

43 

44 # Apply fixups 

45 fixups: list[str] = [] 

46 if not unit.translation.is_template: 

47 target, fixups = fix_target(target, unit) 

48 

49 target_merged = join_plural(target) 

50 

51 if user is None: 

52 user = request.user if request else get_anonymous() 

53 

54 if unit.translated and unit.target == target_merged: 

55 if raise_exception: 

56 raise SuggestionSimilarToTranslationError 

57 return False 

58 

59 same_suggestions = self.filter(target=target_merged, unit=unit) 

60 # Do not rely on the SQL as MySQL compares strings case insensitive 

61 for same in same_suggestions: 

62 if same.target == target_merged: 

63 if same.user == user or not vote: 

64 return False 

65 same.add_vote(request, Vote.POSITIVE) 

66 return False 

67 

68 # Create the suggestion 

69 suggestion = self.create( 

70 target=target_merged, 

71 unit=unit, 

72 user=user, 

73 userdetails={ 

74 "address": get_ip_address(request), 

75 "agent": get_user_agent_raw(request), 

76 }, 

77 ) 

78 suggestion.fixups = fixups 

79 

80 # Record in change 

81 change = unit.generate_change( 

82 user, user, ActionEvents.SUGGESTION, check_new=False, save=False 

83 ) 

84 change.suggestion = suggestion 

85 change.target = target_merged 

86 change.save() 

87 

88 # Add unit vote 

89 if vote: 

90 suggestion.add_vote(request, Vote.POSITIVE) 

91 

92 # Update suggestion stats 

93 if user is not None: 

94 user.profile.increase_count("suggested") 

95 

96 unit.invalidate_related_cache() 

97 

98 return suggestion 

99 

100 

101class SuggestionQuerySet(models.QuerySet): 

102 def order(self): 

103 return self.order_by("-timestamp") 

104 

105 def filter_access(self, user: User): 

106 result = self 

107 if user.needs_project_filter: 

108 result = result.filter( 

109 unit__translation__component__project__in=user.allowed_projects 

110 ) 

111 if user.needs_component_restrictions_filter: 

112 result = result.filter( 

113 Q(unit__translation__component__restricted=False) 

114 | Q(unit__translation__component_id__in=user.component_permissions) 

115 ) 

116 return result 

117 

118 

119class Suggestion(models.Model, UserDisplayMixin): 

120 unit = models.ForeignKey("trans.Unit", on_delete=models.deletion.CASCADE) 

121 target = models.TextField() 

122 user = models.ForeignKey( 

123 settings.AUTH_USER_MODEL, 

124 null=True, 

125 blank=True, 

126 on_delete=models.deletion.CASCADE, 

127 ) 

128 userdetails = models.JSONField(default=dict) 

129 timestamp = models.DateTimeField(auto_now_add=True) 

130 

131 votes = models.ManyToManyField( 

132 settings.AUTH_USER_MODEL, through="Vote", related_name="user_votes" 

133 ) 

134 

135 objects = SuggestionManager.from_queryset(SuggestionQuerySet)() 

136 

137 class Meta: 

138 app_label = "trans" 

139 verbose_name = "string suggestion" 

140 verbose_name_plural = "string suggestions" 

141 

142 def __str__(self) -> str: 

143 return "suggestion for {} by {}".format( 

144 self.unit, self.user.username if self.user else "unknown" 

145 ) 

146 

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

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

149 self.fixups = [] 

150 

151 @transaction.atomic 

152 def accept( 

153 self, 

154 request: AuthenticatedHttpRequest, 

155 permission: str = "suggestion.accept", 

156 state=STATE_TRANSLATED, 

157 ) -> None: 

158 if not request.user.has_perm(permission, self.unit): 

159 messages.error(request, gettext("Could not accept suggestion!")) 

160 return 

161 

162 # Skip if there is no change 

163 if self.unit.target != self.target or self.unit.state < STATE_TRANSLATED: 

164 if self.user and not self.user.is_anonymous: 

165 author = self.user 

166 else: 

167 author = request.user 

168 self.unit.translate( 

169 request.user, 

170 split_plural(self.target), 

171 state, 

172 author=author, 

173 change_action=ActionEvents.ACCEPT, 

174 ) 

175 

176 # Delete the suggestion 

177 self.delete() 

178 

179 def delete_log( 

180 self, 

181 user: User, 

182 change=ActionEvents.SUGGESTION_DELETE, 

183 is_spam: bool = False, 

184 rejection_reason: str = "", 

185 old: str = "", 

186 ) -> None: 

187 """Delete with logging change.""" 

188 if is_spam and self.userdetails: 

189 report_spam( 

190 self.userdetails["address"], self.userdetails["agent"], self.target 

191 ) 

192 self.unit.change_set.create( 

193 action=change, 

194 user=user, 

195 target=self.target, 

196 author=user, 

197 details={"rejection_reason": rejection_reason}, 

198 old=old, 

199 ) 

200 self.delete() 

201 

202 def delete(self, using=None, keep_parents=False): 

203 result = super().delete(using=using, keep_parents=keep_parents) 

204 self.unit.invalidate_related_cache() 

205 return result 

206 

207 def get_num_votes(self): 

208 """Return number of votes.""" 

209 return self.vote_set.aggregate(Sum("value"))["value__sum"] or 0 

210 

211 def add_vote(self, request: AuthenticatedHttpRequest | None, value: int) -> None: 

212 """Add (or updates) vote for a suggestion.""" 

213 if request is None or not request.user.is_authenticated: 

214 return 

215 

216 vote, created = Vote.objects.get_or_create( 

217 suggestion=self, user=request.user, defaults={"value": value} 

218 ) 

219 if not created or vote.value != value: 

220 vote.value = value 

221 vote.save() 

222 

223 # Automatic accepting 

224 required_votes = self.unit.translation.suggestion_autoaccept 

225 if required_votes and self.get_num_votes() >= required_votes: 

226 self.accept(request, "suggestion.vote") 

227 

228 def get_checks(self): 

229 # Build fake unit to run checks 

230 fake_unit = copy(self.unit) 

231 fake_unit.target = self.target 

232 fake_unit.state = STATE_TRANSLATED 

233 source = fake_unit.get_source_plurals() 

234 target = fake_unit.get_target_plurals() 

235 

236 result = [] 

237 for check, check_obj in CHECKS.target.items(): 

238 if check_obj.skip_suggestions: 

239 continue 

240 if check_obj.check_target(source, target, fake_unit): 

241 result.append(Check(unit=fake_unit, dismissed=False, name=check)) 

242 return result 

243 

244 

245class Vote(models.Model): 

246 """Suggestion voting.""" 

247 

248 suggestion = models.ForeignKey( 

249 Suggestion, on_delete=models.deletion.CASCADE, db_index=False 

250 ) 

251 user = models.ForeignKey( 

252 settings.AUTH_USER_MODEL, on_delete=models.deletion.CASCADE 

253 ) 

254 value = models.SmallIntegerField(default=0) 

255 

256 POSITIVE = 1 

257 NEGATIVE = -1 

258 

259 class Meta: 

260 unique_together = [("suggestion", "user")] # noqa: RUF012 

261 app_label = "trans" 

262 verbose_name = "suggestion vote" 

263 verbose_name_plural = "suggestion votes" 

264 

265 def __str__(self) -> str: 

266 return f"{self.value:+d} for {self.suggestion} by {self.user.username}"