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

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 collections import defaultdict 

9from datetime import timedelta 

10from typing import TYPE_CHECKING, Any 

11 

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 

22 

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 

28 

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 

31 

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 

35 

36 

37ALERTS: dict[str, type[BaseAlert]] = {} 

38ALERTS_IMPORT: set[str] = set() 

39 

40 

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 

47 

48 

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__) 

63 

64 

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) 

74 

75 class Meta: 

76 unique_together = [("component", "name")] # noqa: RUF012 

77 verbose_name = "component alert" 

78 verbose_name_plural = "component alerts" 

79 

80 def __str__(self) -> str: 

81 return str(self.obj.verbose) 

82 

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 ) 

92 

93 @cached_property 

94 def obj(self) -> BaseAlert: 

95 return ALERTS[self.name](self, **self.details) 

96 

97 def render(self, user: User) -> str: 

98 return self.obj.render(user) 

99 

100 

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

109 

110 def __init__(self, instance: Alert) -> None: 

111 self.instance = instance 

112 

113 def get_analysis(self) -> dict[str, Any]: 

114 return {} 

115 

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 

127 

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 ) 

133 

134 @staticmethod 

135 def check_component(component: Component) -> bool | dict | None: # noqa: ARG004 

136 return None 

137 

138 

139class ErrorAlert(BaseAlert): 

140 def __init__(self, instance: Alert, error: str) -> None: 

141 super().__init__(instance) 

142 self.error = error 

143 

144 

145class MultiAlert(BaseAlert): 

146 occurrences_limit = 100 

147 

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 

155 

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 

162 

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 

168 

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 

179 

180 updates[occurrence[key]].append(occurrence) 

181 

182 if not updates: 

183 continue 

184 

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 

189 

190 return occurrences 

191 

192 

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 

198 

199 # Note: The removal of this alert can be also done in Translation.delete_unit 

200 

201 

202@register 

203class DuplicateLanguage(MultiAlert): 

204 # Translators: Name of an alert 

205 verbose = gettext_lazy("Duplicated translation.") 

206 on_import = True 

207 

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 

222 

223 

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" 

231 

232 def __init__(self, instance: Alert, duplicates: list[str]) -> None: 

233 super().__init__(instance) 

234 self.duplicates = duplicates 

235 

236 @staticmethod 

237 def get_translations(component: Component) -> TranslationQuerySet: 

238 from weblate.trans.models import Translation 

239 

240 return Translation.objects.filter( 

241 Q(component=component) | Q(component__linked_component=component) 

242 ) 

243 

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 

248 

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 

260 

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) 

269 

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 } 

277 

278 

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" 

286 

287 

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 ) 

318 

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 

324 

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

332 

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 ) 

341 

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 } 

358 

359 

360@register 

361class PushFailure(BaseGitFailure): 

362 # Translators: Name of an alert 

363 verbose = gettext_lazy("Could not push the repository.") 

364 

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 

371 

372 

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" 

380 

381 

382@register 

383class ParseError(MultiAlert): 

384 # Translators: Name of an alert 

385 verbose = gettext_lazy("Could not parse translation files.") 

386 on_import = True 

387 

388 

389@register 

390class BillingLimit(BaseAlert): 

391 # Translators: Name of an alert 

392 verbose = gettext_lazy("Your billing plan has exceeded its limits.") 

393 

394 

395@register 

396class RepositoryOutdated(BaseAlert): 

397 # Translators: Name of an alert 

398 verbose = gettext_lazy("Repository outdated.") 

399 link_wide = True 

400 

401 

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 

408 

409 

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" 

416 

417 @staticmethod 

418 def check_component(component: Component) -> bool | dict | None: 

419 return component.project.needs_license() and not component.license 

420 

421 

422@register 

423class AddonScriptError(MultiAlert): 

424 # Translators: Name of an alert 

425 verbose = gettext_lazy("Could not run add-on.") 

426 doc_page = "adons" 

427 

428 

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" 

435 

436 

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" 

443 

444 

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" 

451 

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 

460 

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 

471 

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 

475 

476 allunits = translation.unit_set 

477 

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 ) 

485 

486 

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" 

493 

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 

498 

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 

506 

507 

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" 

515 

516 def __init__(self, instance: Alert, link: str, error: str) -> None: 

517 super().__init__(instance) 

518 self.link = link 

519 self.error = error 

520 

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 ) 

532 

533 if translation: 

534 allunits = translation.unit_set 

535 else: 

536 allunits = component.source_translation.unit_set 

537 

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 

550 

551 

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 

560 

561 def __init__(self, instance: Alert, error: str | None = None) -> None: 

562 super().__init__(instance) 

563 self.error = error 

564 

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 

572 

573 

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" 

580 

581 @staticmethod 

582 def check_component(component: Component) -> bool | dict | None: 

583 from weblate.screenshots.models import Screenshot 

584 

585 return ( 

586 Screenshot.objects.filter(translation__component=component) 

587 .annotate(Count("units")) 

588 .filter(units__count=0) 

589 .exists() 

590 ) 

591 

592 

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" 

600 

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 

608 

609 @staticmethod 

610 def check_component(component: Component) -> bool | dict | None: 

611 return component.get_ambiguous_translations().exists() 

612 

613 

614@register 

615class NoLibreConditions(BaseAlert): 

616 # Translators: Name of an alert 

617 verbose = gettext_lazy("Does not meet Libre hosting conditions.") 

618 

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 ) 

627 

628 

629@register 

630class UnusedEnforcedCheck(BaseAlert): 

631 verbose = gettext_lazy("Unused enforced checks.") 

632 doc_page = "admin/checks" 

633 doc_anchor = "enforcing-checks" 

634 

635 @staticmethod 

636 def check_component(component: Component) -> bool | dict | None: 

637 return any(component.get_unused_enforcements()) 

638 

639 

640@register 

641class NoMaskMatches(BaseAlert): 

642 verbose = gettext_lazy("No file mask matches.") 

643 doc_page = "admin/projects" 

644 doc_anchor = "component-filemask" 

645 

646 def get_analysis(self) -> dict[str, Any]: 

647 return { 

648 "can_add": self.instance.component.can_add_new_language(None, fast=True), 

649 } 

650 

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 ) 

658 

659 

660@register 

661class InexistantFiles(BaseAlert): 

662 verbose = gettext_lazy("Inexistent files.") 

663 doc_page = "admin/projects" 

664 doc_anchor = "component-template" 

665 

666 def __init__(self, instance: Alert, files: list[str]) -> None: 

667 super().__init__(instance) 

668 self.files = files 

669 

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 

680 

681 

682@register 

683class UnusedComponent(BaseAlert): 

684 verbose = gettext_lazy("Component seems unused.") 

685 doc_page = "devel/community" 

686 

687 def get_analysis(self) -> dict[str, Any]: 

688 return {"days": settings.UNUSED_ALERT_DAYS} 

689 

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 

708 

709 

710@register 

711class MonolingualGlossary(BaseAlert): 

712 verbose = gettext_lazy("Glossary using monolingual files.") 

713 doc_page = "user/glossary" 

714 dismissable = True 

715 

716 @staticmethod 

717 def check_component(component: Component) -> bool | dict | None: 

718 return component.is_glossary and bool(component.template)