Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/models/alert.py: 67%
424 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 collections import defaultdict
9from datetime import timedelta
10from typing import TYPE_CHECKING, Any
12import sentry_sdk
13from django.conf import settings
14from django.db import models
15from django.db.models import Count, Q
16from django.template.loader import render_to_string
17from django.utils import timezone
18from django.utils.functional import cached_property
19from django.utils.translation import gettext_lazy
20from weblate_language_data.ambiguous import AMBIGUOUS
21from weblate_language_data.countries import DEFAULT_LANGS
23from weblate.formats.models import FILE_FORMATS
24from weblate.trans.actions import ActionEvents
25from weblate.utils.requests import get_uri_error
26from weblate.utils.state import STATE_TRANSLATED
27from weblate.vcs.models import VCS_REGISTRY
29if TYPE_CHECKING: 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true
30 from django_stubs_ext import StrOrPromise
32 from weblate.auth.models import User
33 from weblate.trans.models.component import Component, ComponentQuerySet
34 from weblate.trans.models.translation import Translation, TranslationQuerySet
37ALERTS: dict[str, type[BaseAlert]] = {}
38ALERTS_IMPORT: set[str] = set()
41def register(cls: type[BaseAlert]) -> type[BaseAlert]:
42 name = cls.__name__
43 ALERTS[name] = cls
44 if cls.on_import:
45 ALERTS_IMPORT.add(name)
46 return cls
49def update_alerts(component: Component, alerts: set[str] | None = None) -> None:
50 for name, alert in ALERTS.items():
51 if alerts and name not in alerts: 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true
52 continue
53 with sentry_sdk.start_span(op="alerts.update", name=f"ALERT {name}"):
54 result = alert.check_component(component)
55 if result is None:
56 continue
57 if isinstance(result, dict):
58 component.add_alert(alert.__name__, **result)
59 elif result:
60 component.add_alert(alert.__name__)
61 else:
62 component.delete_alert(alert.__name__)
65class Alert(models.Model):
66 component = models.ForeignKey(
67 "trans.Component", on_delete=models.deletion.CASCADE, db_index=False
68 )
69 timestamp = models.DateTimeField(auto_now_add=True)
70 updated = models.DateTimeField(auto_now=True)
71 name = models.CharField(max_length=150)
72 dismissed = models.BooleanField(default=False, db_index=True)
73 details = models.JSONField(default=dict)
75 class Meta:
76 unique_together = [("component", "name")] # noqa: RUF012
77 verbose_name = "component alert"
78 verbose_name_plural = "component alerts"
80 def __str__(self) -> str:
81 return str(self.obj.verbose)
83 def save(self, *args, **kwargs) -> None:
84 is_new = not self.id
85 super().save(*args, **kwargs)
86 if is_new:
87 self.component.change_set.create(
88 action=ActionEvents.ALERT,
89 alert=self,
90 details={"alert": self.name},
91 )
93 @cached_property
94 def obj(self) -> BaseAlert:
95 return ALERTS[self.name](self, **self.details)
97 def render(self, user: User) -> str:
98 return self.obj.render(user)
101class BaseAlert:
102 verbose: StrOrPromise = ""
103 on_import = False
104 link_wide = False
105 project_wide = False
106 dismissable = False
107 doc_page = ""
108 doc_anchor = ""
110 def __init__(self, instance: Alert) -> None:
111 self.instance = instance
113 def get_analysis(self) -> dict[str, Any]:
114 return {}
116 def get_context(self, user: User) -> dict[str, Any]:
117 result = {
118 "alert": self.instance,
119 "component": self.instance.component,
120 "timestamp": self.instance.timestamp,
121 "details": self.instance.details,
122 "analysis": self.get_analysis(),
123 "user": user,
124 }
125 result.update(self.instance.details)
126 return result
128 def render(self, user: User) -> str:
129 return render_to_string(
130 f"trans/alert/{self.__class__.__name__.lower()}.html",
131 self.get_context(user),
132 )
134 @staticmethod
135 def check_component(component: Component) -> bool | dict | None: # noqa: ARG004
136 return None
139class ErrorAlert(BaseAlert):
140 def __init__(self, instance: Alert, error: str) -> None:
141 super().__init__(instance)
142 self.error = error
145class MultiAlert(BaseAlert):
146 occurrences_limit = 100
148 def __init__(self, instance: Alert, occurrences: list[dict[str, str]]) -> None:
149 super().__init__(instance)
150 self.occurrences = self.process_occurrences(
151 occurrences[: self.occurrences_limit]
152 )
153 self.total_occurrences = len(occurrences)
154 self.missed_occurrences = self.total_occurrences > self.occurrences_limit
156 def get_context(self, user: User) -> dict[str, Any]:
157 result = super().get_context(user)
158 result["occurrences"] = self.occurrences
159 result["total_occurrences"] = self.total_occurrences
160 result["missed_occurrences"] = self.missed_occurrences
161 return result
163 def process_occurrences(
164 self, occurrences: list[dict[str, str]]
165 ) -> list[dict[str, Any]]:
166 from weblate.lang.models import Language
167 from weblate.trans.models import Unit
169 processors = (
170 ("language_code", "language", Language.objects.all(), "code"),
171 ("unit_pk", "unit", Unit.objects.prefetch().prefetch_full(), "pk"),
172 )
173 for key, target, base, lookup in processors:
174 # Extract list to fetch
175 updates = defaultdict(list)
176 for occurrence in occurrences:
177 if key not in occurrence:
178 continue
180 updates[occurrence[key]].append(occurrence)
182 if not updates:
183 continue
185 result = base.filter(**{f"{lookup}__in": updates.keys()})
186 for match in result:
187 for occurrence in updates[getattr(match, lookup)]:
188 occurrence[target] = match
190 return occurrences
193@register
194class DuplicateString(MultiAlert):
195 # Translators: Name of an alert
196 verbose = gettext_lazy("Duplicated string found in the file.")
197 on_import = True
199 # Note: The removal of this alert can be also done in Translation.delete_unit
202@register
203class DuplicateLanguage(MultiAlert):
204 # Translators: Name of an alert
205 verbose = gettext_lazy("Duplicated translation.")
206 on_import = True
208 def get_analysis(self) -> dict[str, Any]:
209 component = self.instance.component
210 result = {"monolingual": bool(component.template)}
211 source = component.source_language.code
212 for occurrence in self.occurrences:
213 if occurrence["language_code"] == source:
214 result["source_language"] = True
215 codes = {
216 code.strip().replace("-", "_").lower()
217 for code in occurrence["codes"].split(",")
218 }
219 if codes.intersection(DEFAULT_LANGS):
220 result["default_country"] = True
221 return result
224@register
225class DuplicateFilemask(BaseAlert):
226 # Translators: Name of an alert
227 verbose = gettext_lazy("Duplicated file mask.")
228 link_wide = True
229 doc_page = "admin/projects"
230 doc_anchor = "component-filemask"
232 def __init__(self, instance: Alert, duplicates: list[str]) -> None:
233 super().__init__(instance)
234 self.duplicates = duplicates
236 @staticmethod
237 def get_translations(component: Component) -> TranslationQuerySet:
238 from weblate.trans.models import Translation
240 return Translation.objects.filter(
241 Q(component=component) | Q(component__linked_component=component)
242 )
244 @classmethod
245 def check_component(cls, component: Component) -> bool | dict | None:
246 if component.is_repo_link: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
247 return False
249 translations = set(
250 cls.get_translations(component)
251 .values_list("filename")
252 .annotate(count=Count("id"))
253 .filter(count__gt=1)
254 .values_list("filename", flat=True)
255 )
256 translations.discard("")
257 if translations: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 return {"duplicates": sorted(translations)}
259 return False
261 def resolve_filename(
262 self, filename: str
263 ) -> ComponentQuerySet | TranslationQuerySet:
264 if "*" in filename:
265 # Legacy path for old alerts
266 # TODO: Remove in Weblate 6.0
267 return self.instance.component.component_set.filter(filemask=filename)
268 return self.get_translations(self.instance.component).filter(filename=filename)
270 def get_analysis(self) -> dict[str, Any]:
271 return {
272 "duplicates_resolved": [
273 (filename, self.resolve_filename(filename))
274 for filename in self.duplicates
275 ]
276 }
279@register
280class MergeFailure(ErrorAlert):
281 # Translators: Name of an alert
282 verbose = gettext_lazy("Could not merge the repository.")
283 link_wide = True
284 doc_page = "faq"
285 doc_anchor = "merge"
288class BaseGitFailure(ErrorAlert):
289 link_wide = True
290 behind_messages = (
291 "The tip of your current branch is behind its remote counterpart",
292 "fetch first",
293 )
294 terminal_message = "terminal prompts disabled"
295 not_found_messages = (
296 "Repository not found.",
297 "HTTP Error 404: Not Found",
298 "Repository was archived so is read-only",
299 "does not appear to be a git repository",
300 )
301 temporary_messages = (
302 "Empty reply from server",
303 "no suitable response from remote hg",
304 "cannot lock ref",
305 "Too many retries",
306 "Connection timed out",
307 )
308 permission_messages = (
309 "denied to",
310 "The repository exists, but forking is disabled.",
311 "protected branch hook declined",
312 "GH006:",
313 )
314 gerrit_messages = (
315 "is not registered in your account, and you lack 'forge",
316 "prohibited by Gerrit",
317 )
319 def get_analysis(self) -> dict[str, Any]:
320 terminal_disabled = self.terminal_message in self.error
321 repo_suggestion = None
322 force_push_suggestion = False
323 component = self.instance.component
325 # Missing credentials
326 if terminal_disabled:
327 if component.push:
328 if component.push.startswith("https://github.com/"):
329 repo_suggestion = f"git@github.com:{component.push[19:]}"
330 elif component.repo.startswith("https://github.com/"):
331 repo_suggestion = f"git@github.com:{component.repo[19:]}"
333 # Missing commits
334 behind = any(message in self.error for message in self.behind_messages)
335 if behind:
336 force_push_suggestion = (
337 component.vcs == "git"
338 and component.merge_style == "rebase"
339 and bool(component.push_branch)
340 )
342 return {
343 "terminal": terminal_disabled,
344 "behind": behind,
345 "repo_suggestion": repo_suggestion,
346 "force_push_suggestion": force_push_suggestion,
347 "not_found": any(
348 message in self.error for message in self.not_found_messages
349 ),
350 "permission": any(
351 message in self.error for message in self.permission_messages
352 ),
353 "gerrit": any(message in self.error for message in self.gerrit_messages),
354 "temporary": any(
355 message in self.error for message in self.temporary_messages
356 ),
357 }
360@register
361class PushFailure(BaseGitFailure):
362 # Translators: Name of an alert
363 verbose = gettext_lazy("Could not push the repository.")
365 @staticmethod
366 def check_component(component: Component) -> bool | dict | None:
367 if not component.can_push(): 367 ↛ 370line 367 didn't jump to line 370 because the condition on line 367 was always true
368 return False
369 # We do not trigger it here, just remove stale alert
370 return None
373@register
374class UpdateFailure(BaseGitFailure):
375 # Translators: Name of an alert
376 verbose = gettext_lazy("Could not update the repository.")
377 link_wide = True
378 doc_page = "admin/projects"
379 doc_anchor = "component-repo"
382@register
383class ParseError(MultiAlert):
384 # Translators: Name of an alert
385 verbose = gettext_lazy("Could not parse translation files.")
386 on_import = True
389@register
390class BillingLimit(BaseAlert):
391 # Translators: Name of an alert
392 verbose = gettext_lazy("Your billing plan has exceeded its limits.")
395@register
396class RepositoryOutdated(BaseAlert):
397 # Translators: Name of an alert
398 verbose = gettext_lazy("Repository outdated.")
399 link_wide = True
402@register
403class RepositoryChanges(BaseAlert):
404 # Translators: Name of an alert
405 verbose = gettext_lazy("Repository has changes.")
406 link_wide = True
407 dismissable = True
410@register
411class MissingLicense(BaseAlert):
412 # Translators: Name of an alert
413 verbose = gettext_lazy("License info missing.")
414 doc_page = "admin/projects"
415 doc_anchor = "component-license"
417 @staticmethod
418 def check_component(component: Component) -> bool | dict | None:
419 return component.project.needs_license() and not component.license
422@register
423class AddonScriptError(MultiAlert):
424 # Translators: Name of an alert
425 verbose = gettext_lazy("Could not run add-on.")
426 doc_page = "adons"
429@register
430class CDNAddonError(MultiAlert):
431 # Translators: Name of an alert
432 verbose = gettext_lazy("Could not run add-on.")
433 doc_page = "adons"
434 doc_anchor = "addon-weblate-cdn-cdnjs"
437@register
438class MsgmergeAddonError(MultiAlert):
439 # Translators: Name of an alert
440 verbose = gettext_lazy("Could not run add-on.")
441 doc_page = "adons"
442 doc_anchor = "addon-weblate-gettext-msgmerge"
445@register
446class MonolingualTranslation(BaseAlert):
447 # Translators: Name of an alert
448 verbose = gettext_lazy("Misconfigured monolingual translation.")
449 doc_page = "formats"
450 doc_anchor = "bimono"
452 @staticmethod
453 def check_component(component: Component) -> bool | dict | None:
454 if (
455 component.is_glossary
456 or component.template
457 or not component.source_language.uses_whitespace()
458 ):
459 return False
461 # Pick translation with translated strings except source one
462 translation: Translation | None = None
463 for current in ( 463 ↛ 468line 463 didn't jump to line 468 because the loop on line 463 never started
464 component.translation_set.filter(unit__state__gte=STATE_TRANSLATED)
465 .exclude(language_id=component.source_language_id)
466 .select_related("language")
467 ):
468 if not current.language.uses_whitespace():
469 continue
470 translation = current
472 # Bail out if there is no suitable translation
473 if translation is None: 473 ↛ 476line 473 didn't jump to line 476 because the condition on line 473 was always true
474 return False
476 allunits = translation.unit_set
478 source_space = allunits.filter(source__contains=" ")
479 target_space = allunits.filter(
480 state__gte=STATE_TRANSLATED, target__contains=" "
481 )
482 return (
483 allunits.count() > 3 and not source_space.exists() and target_space.exists()
484 )
487@register
488class UnsupportedConfiguration(BaseAlert):
489 # Translators: Name of an alert
490 verbose = gettext_lazy("Unsupported component configuration")
491 doc_page = "admin/projects"
492 doc_anchor = "component"
494 def __init__(self, instance: Alert, vcs: str, file_format: str) -> None:
495 super().__init__(instance)
496 self.vcs = vcs
497 self.file_format = file_format
499 @staticmethod
500 def check_component(component: Component) -> bool | dict | None:
501 vcs = component.vcs not in VCS_REGISTRY
502 file_format = component.file_format not in FILE_FORMATS
503 if vcs or file_format: 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true
504 return {"file_format": file_format, "vcs": vcs}
505 return False
508@register
509class BrokenBrowserURL(BaseAlert):
510 # Translators: Name of an alert
511 verbose = gettext_lazy("Broken repository browser URL")
512 dismissable = True
513 doc_page = "admin/projects"
514 doc_anchor = "component-repoweb"
516 def __init__(self, instance: Alert, link: str, error: str) -> None:
517 super().__init__(instance)
518 self.link = link
519 self.error = error
521 @staticmethod
522 def check_component(component: Component) -> bool | dict | None:
523 location_error = None
524 location_link = None
525 if component.repoweb: 525 ↛ 527line 525 didn't jump to line 527 because the condition on line 525 was never true
526 # Pick random translation with translated strings except source one
527 translation = (
528 component.translation_set.filter(unit__state__gte=STATE_TRANSLATED)
529 .exclude(language_id=component.source_language_id)
530 .first()
531 )
533 if translation:
534 allunits = translation.unit_set
535 else:
536 allunits = component.source_translation.unit_set
538 unit = allunits.exclude(location="").first()
539 if unit:
540 for _location, filename, line in unit.get_locations():
541 location_link = component.get_repoweb_link(filename, line)
542 if location_link is None:
543 continue
544 # We only test first link
545 location_error = get_uri_error(location_link)
546 break
547 if location_error: 547 ↛ 548line 547 didn't jump to line 548 because the condition on line 547 was never true
548 return {"link": location_link, "error": location_error}
549 return False
552@register
553class BrokenProjectURL(BaseAlert):
554 # Translators: Name of an alert
555 verbose = gettext_lazy("Broken project website URL")
556 dismissable = True
557 doc_page = "admin/projects"
558 doc_anchor = "project-web"
559 project_wide = True
561 def __init__(self, instance: Alert, error: str | None = None) -> None:
562 super().__init__(instance)
563 self.error = error
565 @staticmethod
566 def check_component(component: Component) -> bool | dict | None:
567 if component.project.web: 567 ↛ 571line 567 didn't jump to line 571 because the condition on line 567 was always true
568 location_error = get_uri_error(component.project.web)
569 if location_error is not None:
570 return {"error": location_error}
571 return False
574@register
575class UnusedScreenshot(BaseAlert):
576 # Translators: Name of an alert
577 verbose = gettext_lazy("Unused screenshot")
578 doc_page = "admin/translating"
579 doc_anchor = "screenshots"
581 @staticmethod
582 def check_component(component: Component) -> bool | dict | None:
583 from weblate.screenshots.models import Screenshot
585 return (
586 Screenshot.objects.filter(translation__component=component)
587 .annotate(Count("units"))
588 .filter(units__count=0)
589 .exists()
590 )
593@register
594class AmbiguousLanguage(BaseAlert):
595 # Translators: Name of an alert
596 verbose = gettext_lazy("Ambiguous language code.")
597 dismissable = True
598 doc_page = "admin/languages"
599 doc_anchor = "ambiguous-languages"
601 def get_context(self, user: User) -> dict[str, Any]:
602 result = super().get_context(user)
603 ambgiuous = self.instance.component.get_ambiguous_translations().values_list(
604 "language__code", flat=True
605 )
606 result["ambiguous"] = {code: AMBIGUOUS[code] for code in ambgiuous}
607 return result
609 @staticmethod
610 def check_component(component: Component) -> bool | dict | None:
611 return component.get_ambiguous_translations().exists()
614@register
615class NoLibreConditions(BaseAlert):
616 # Translators: Name of an alert
617 verbose = gettext_lazy("Does not meet Libre hosting conditions.")
619 @staticmethod
620 def check_component(component: Component) -> bool | dict | None:
621 return (
622 settings.OFFER_HOSTING
623 and component.project.billings
624 and component.project.billing.plan.price == 0
625 and not component.project.billing.valid_libre
626 )
629@register
630class UnusedEnforcedCheck(BaseAlert):
631 verbose = gettext_lazy("Unused enforced checks.")
632 doc_page = "admin/checks"
633 doc_anchor = "enforcing-checks"
635 @staticmethod
636 def check_component(component: Component) -> bool | dict | None:
637 return any(component.get_unused_enforcements())
640@register
641class NoMaskMatches(BaseAlert):
642 verbose = gettext_lazy("No file mask matches.")
643 doc_page = "admin/projects"
644 doc_anchor = "component-filemask"
646 def get_analysis(self) -> dict[str, Any]:
647 return {
648 "can_add": self.instance.component.can_add_new_language(None, fast=True),
649 }
651 @staticmethod
652 def check_component(component: Component) -> bool | dict | None:
653 return (
654 not component.is_glossary
655 and component.translation_set.count() <= 1
656 and not component.intermediate
657 )
660@register
661class InexistantFiles(BaseAlert):
662 verbose = gettext_lazy("Inexistent files.")
663 doc_page = "admin/projects"
664 doc_anchor = "component-template"
666 def __init__(self, instance: Alert, files: list[str]) -> None:
667 super().__init__(instance)
668 self.files = files
670 @staticmethod
671 def check_component(component: Component) -> bool | dict | None:
672 missing_files = [
673 name
674 for name in (component.template, component.intermediate, component.new_base)
675 if name and not os.path.exists(os.path.join(component.full_path, name))
676 ]
677 if missing_files: 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true
678 return {"files": missing_files}
679 return False
682@register
683class UnusedComponent(BaseAlert):
684 verbose = gettext_lazy("Component seems unused.")
685 doc_page = "devel/community"
687 def get_analysis(self) -> dict[str, Any]:
688 return {"days": settings.UNUSED_ALERT_DAYS}
690 @staticmethod
691 def check_component(component: Component) -> bool | dict | None:
692 if settings.UNUSED_ALERT_DAYS == 0: 692 ↛ 693line 692 didn't jump to line 693 because the condition on line 692 was never true
693 return False
694 if component.is_glossary:
695 # Auto created glossaries can live without being used
696 return False
697 if component.stats.all == component.stats.translated:
698 # Allow fully translated ones
699 return False
700 last_changed = component.stats.last_changed
701 cutoff = timezone.now() - timedelta(days=settings.UNUSED_ALERT_DAYS)
702 if last_changed is not None: 702 ↛ 705line 702 didn't jump to line 705 because the condition on line 702 was always true
703 # If last content change is present, use it to decide
704 return last_changed < cutoff
705 oldest_change = component.change_set.order_by("timestamp").first()
706 # Weird, each component should have change
707 return oldest_change is None or oldest_change.timestamp < cutoff
710@register
711class MonolingualGlossary(BaseAlert):
712 verbose = gettext_lazy("Glossary using monolingual files.")
713 doc_page = "user/glossary"
714 dismissable = True
716 @staticmethod
717 def check_component(component: Component) -> bool | dict | None:
718 return component.is_glossary and bool(component.template)