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
« 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 copy import copy
8from typing import TYPE_CHECKING
10from django.conf import settings
11from django.db import models, transaction
12from django.db.models import Q, Sum
13from django.utils.translation import gettext
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
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
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
44 # Apply fixups
45 fixups: list[str] = []
46 if not unit.translation.is_template:
47 target, fixups = fix_target(target, unit)
49 target_merged = join_plural(target)
51 if user is None:
52 user = request.user if request else get_anonymous()
54 if unit.translated and unit.target == target_merged:
55 if raise_exception:
56 raise SuggestionSimilarToTranslationError
57 return False
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
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
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()
88 # Add unit vote
89 if vote:
90 suggestion.add_vote(request, Vote.POSITIVE)
92 # Update suggestion stats
93 if user is not None:
94 user.profile.increase_count("suggested")
96 unit.invalidate_related_cache()
98 return suggestion
101class SuggestionQuerySet(models.QuerySet):
102 def order(self):
103 return self.order_by("-timestamp")
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
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)
131 votes = models.ManyToManyField(
132 settings.AUTH_USER_MODEL, through="Vote", related_name="user_votes"
133 )
135 objects = SuggestionManager.from_queryset(SuggestionQuerySet)()
137 class Meta:
138 app_label = "trans"
139 verbose_name = "string suggestion"
140 verbose_name_plural = "string suggestions"
142 def __str__(self) -> str:
143 return "suggestion for {} by {}".format(
144 self.unit, self.user.username if self.user else "unknown"
145 )
147 def __init__(self, *args, **kwargs) -> None:
148 super().__init__(*args, **kwargs)
149 self.fixups = []
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
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 )
176 # Delete the suggestion
177 self.delete()
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()
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
207 def get_num_votes(self):
208 """Return number of votes."""
209 return self.vote_set.aggregate(Sum("value"))["value__sum"] or 0
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
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()
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")
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()
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
245class Vote(models.Model):
246 """Suggestion voting."""
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)
256 POSITIVE = 1
257 NEGATIVE = -1
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"
265 def __str__(self) -> str:
266 return f"{self.value:+d} for {self.suggestion} by {self.user.username}"