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

1936 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 

8import re 

9import time 

10from collections import defaultdict 

11from glob import glob 

12from itertools import chain 

13from typing import TYPE_CHECKING, Any, TypedDict, cast 

14from urllib.parse import quote as urlquote 

15from urllib.parse import urlparse 

16 

17import sentry_sdk 

18from celery import current_task 

19from celery.result import AsyncResult 

20from django.conf import settings 

21from django.core.cache import cache 

22from django.core.exceptions import ( 

23 ImproperlyConfigured, 

24 ObjectDoesNotExist, 

25 ValidationError, 

26) 

27from django.core.validators import MaxValueValidator 

28from django.db import IntegrityError, connection, models, transaction 

29from django.db.models import Count, F, Q 

30from django.db.models.signals import m2m_changed 

31from django.dispatch import receiver 

32from django.utils.functional import cached_property 

33from django.utils.html import format_html, format_html_join 

34from django.utils.safestring import mark_safe 

35from django.utils.text import format_lazy 

36from django.utils.timezone import localtime 

37from django.utils.translation import gettext, gettext_lazy, ngettext, pgettext 

38from weblate_language_data.ambiguous import AMBIGUOUS 

39 

40from weblate.checks.flags import Flags 

41from weblate.checks.models import CHECKS 

42from weblate.formats.models import FILE_FORMATS 

43from weblate.lang.models import Language, get_default_lang 

44from weblate.memory.tasks import import_memory 

45from weblate.trans.actions import ActionEvents 

46from weblate.trans.defines import ( 

47 BRANCH_LENGTH, 

48 COMPONENT_NAME_LENGTH, 

49 FILENAME_LENGTH, 

50 PROJECT_NAME_LENGTH, 

51 REPO_LENGTH, 

52) 

53from weblate.trans.exceptions import FileParseError, InvalidTemplateError 

54from weblate.trans.fields import RegexField 

55from weblate.trans.file_format_params import FILE_FORMATS_PARAMS 

56from weblate.trans.mixins import ( 

57 CacheKeyMixin, 

58 ComponentCategoryMixin, 

59 LockMixin, 

60 PathMixin, 

61) 

62from weblate.trans.models.alert import ALERTS, ALERTS_IMPORT, Alert, update_alerts 

63from weblate.trans.models.change import Change 

64from weblate.trans.models.pending import PendingUnitChange 

65from weblate.trans.models.translation import Translation 

66from weblate.trans.models.unit import Unit 

67from weblate.trans.models.variant import Variant 

68from weblate.trans.signals import ( 

69 component_post_update, 

70 translation_post_add, 

71 vcs_post_commit, 

72 vcs_post_push, 

73 vcs_post_update, 

74 vcs_pre_push, 

75 vcs_pre_update, 

76) 

77from weblate.trans.util import ( 

78 PRIORITY_CHOICES, 

79 cleanup_path, 

80 cleanup_repo_url, 

81 count_words, 

82 is_repo_link, 

83 path_separator, 

84) 

85from weblate.trans.validators import ( 

86 validate_autoaccept, 

87 validate_check_flags, 

88 validate_file_format_parameters, 

89 validate_filemask, 

90 validate_language_code, 

91) 

92from weblate.utils import messages 

93from weblate.utils.celery import get_task_progress 

94from weblate.utils.colors import ColorChoices 

95from weblate.utils.decorators import disable_for_loaddata 

96from weblate.utils.errors import report_error 

97from weblate.utils.fields import EmailField 

98from weblate.utils.html import format_html_join_comma, list_to_tuples 

99from weblate.utils.licenses import ( 

100 get_license_choices, 

101 get_license_name, 

102 get_license_url, 

103 is_libre, 

104) 

105from weblate.utils.lock import WeblateLock 

106from weblate.utils.random import get_random_identifier 

107from weblate.utils.render import ( 

108 render_template, 

109 validate_render_addon, 

110 validate_render_commit, 

111 validate_render_component, 

112 validate_repoweb, 

113) 

114from weblate.utils.site import get_site_url 

115from weblate.utils.state import ( 

116 STATE_APPROVED, 

117 STATE_FUZZY, 

118 STATE_READONLY, 

119 STATE_TRANSLATED, 

120) 

121from weblate.utils.stats import ComponentStats 

122from weblate.utils.validators import ( 

123 validate_filename, 

124 validate_re_nonempty, 

125 validate_slug, 

126) 

127from weblate.vcs.base import RepositoryError 

128from weblate.vcs.git import GitMergeRequestBase, LocalRepository 

129from weblate.vcs.models import VCS_REGISTRY 

130from weblate.vcs.ssh import add_host_key 

131 

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

133 from collections.abc import Iterable 

134 from datetime import datetime 

135 

136 from weblate.addons.models import Addon 

137 from weblate.auth.models import AuthenticatedHttpRequest, User 

138 from weblate.checks.base import BaseCheck 

139 from weblate.formats.base import TranslationFormat 

140 from weblate.trans.models.unit import UnitAttributesDict 

141 from weblate.vcs.base import Repository 

142 

143NEW_LANG_CHOICES = ( 

144 # Translators: Action when adding new translation 

145 ("contact", gettext_lazy("Contact maintainers")), 

146 # Translators: Action when adding new translation 

147 ("url", gettext_lazy("Point to translation instructions URL")), 

148 # Translators: Action when adding new translation 

149 ("add", gettext_lazy("Create new language file")), 

150 # Translators: Action when adding new translation 

151 ("none", gettext_lazy("Disable adding new translations")), 

152) 

153LANGUAGE_CODE_STYLE_CHOICES = ( 

154 ("", gettext_lazy("Default based on the file format")), 

155 ("posix", gettext_lazy("POSIX style using underscore as a separator")), 

156 ( 

157 "posix_lowercase", 

158 gettext_lazy("POSIX style using underscore as a separator, lower cased"), 

159 ), 

160 ("bcp", gettext_lazy("BCP style using hyphen as a separator")), 

161 ( 

162 "posix_long", 

163 gettext_lazy( 

164 "POSIX style using underscore as a separator, including country code" 

165 ), 

166 ), 

167 ( 

168 "posix_long_lowercase", 

169 gettext_lazy( 

170 "POSIX style using underscore as a separator, including country code, lower cased" 

171 ), 

172 ), 

173 ( 

174 "bcp_long", 

175 gettext_lazy("BCP style using hyphen as a separator, including country code"), 

176 ), 

177 ( 

178 "bcp_legacy", 

179 gettext_lazy("BCP style using hyphen as a separator, legacy language codes"), 

180 ), 

181 ("bcp_lower", gettext_lazy("BCP style using hyphen as a separator, lower cased")), 

182 ("android", gettext_lazy("Android style")), 

183 ("appstore", gettext_lazy("Apple App Store metadata style")), 

184 ("googleplay", gettext_lazy("Google Play metadata style")), 

185 ("linux", gettext_lazy("Linux style")), 

186 ("linux_lowercase", gettext_lazy("Linux style, lower cased")), 

187) 

188 

189MERGE_CHOICES = ( 

190 ("merge", gettext_lazy("Merge")), 

191 ("rebase", gettext_lazy("Rebase")), 

192 ("merge_noff", gettext_lazy("Merge without fast-forward")), 

193) 

194 

195LOCKING_ALERTS = {"MergeFailure", "UpdateFailure", "PushFailure", "ParseError"} 

196 

197BITBUCKET_GIT_REPOS_REGEXP = [ 

198 r"(?:ssh|https):\/\/(?:(?:git@|)bitbucket.org)\/([^/]*)\/([^/]*)", 

199 r"git@bitbucket.org:([^/]*)\/([^/]*)", 

200] 

201 

202GITHUB_REPOS_REGEXP = [ 

203 r"(?:git|https):\/\/(?:github.com)\/([^/]*)\/([^/]*)", 

204 r"git@github.com:([^/]*)\/([^/]*)", 

205] 

206 

207PAGURE_REPOS_REGEXP = [r"(?:ssh|https):\/\/(?:(?:git@|)pagure.io)\/([^/]*)\/([^/]*)"] 

208 

209AZURE_REPOS_REGEXP = [ 

210 r"(?:https):\/\/(?:dev.azure.com)\/([^/]*)\/([^/]*)\/(?:_git)\/([^/]*)", 

211 r"(?:https):\/\/(?:([^/]*).visualstudio.com)\/([^/]*)\/(?:_git)\/([^/]*)", 

212 r"(?:[^/]*)\@vs-ssh.visualstudio.com:v3\/([^/]*)\/([^/]*)\/([^/]*)", 

213 r"(?:git@ssh.dev.azure.com:v3)\/([^/]*)\/([^/]*)\/([^/]*)", 

214] 

215 

216 

217def perform_on_link(func): 

218 """Perform operation on repository link.""" 

219 

220 def on_link_wrapper(self, *args, **kwargs): 

221 linked = self.linked_component 

222 if linked: 222 ↛ 224line 222 didn't jump to line 224 because the condition on line 222 was never true

223 # Avoid loading project next time if matches 

224 if linked.project_id == self.project_id: 

225 linked.project = self.project 

226 # Call same method on linked component 

227 return getattr(linked, func.__name__)(*args, **kwargs) 

228 return func(self, *args, **kwargs) 

229 

230 return on_link_wrapper 

231 

232 

233def prefetch_tasks(components): 

234 """Prefetch update tasks.""" 

235 lookup = {component.update_key: component for component in components} 

236 if lookup: 

237 results_dict = cache.get_many(lookup.keys()) 

238 results: dict[str, AsyncResult] = { 

239 value: AsyncResult(value) for value in results_dict.values() if value 

240 } 

241 

242 for item, value in results_dict.items(): 

243 if not value: 

244 continue 

245 lookup[item].__dict__["background_task"] = results[value] 

246 lookup.pop(item) 

247 for component in lookup.values(): 

248 component.__dict__["background_task"] = None 

249 return components 

250 

251 

252def translation_prefetch_tasks(translations): 

253 prefetch_tasks([translation.component for translation in translations]) 

254 return translations 

255 

256 

257def prefetch_glossary_terms(components) -> None: 

258 if not components: 

259 return 

260 lookup = {component.glossary_sources_key: component for component in components} 

261 for item, value in cache.get_many(lookup.keys()).items(): 

262 lookup[item].__dict__["glossary_sources"] = value 

263 

264 

265class ComponentQuerySet(models.QuerySet): 

266 def prefetch(self, alerts: bool = True, defer: bool = True): 

267 result = self 

268 linked_component: str | models.Prefetch 

269 if defer: 

270 result = result.defer_huge() 

271 linked_component = models.Prefetch( 

272 "linked_component", queryset=Component.objects.defer_huge() 

273 ) 

274 else: 

275 linked_component = "linked_component" 

276 if alerts: 276 ↛ 285line 276 didn't jump to line 285 because the condition on line 276 was always true

277 result = result.prefetch_related( 

278 models.Prefetch( 

279 "alert_set", 

280 queryset=Alert.objects.filter(dismissed=False), 

281 to_attr="all_active_alerts", 

282 ), 

283 ) 

284 

285 return result.prefetch_related( 

286 "project", 

287 "category", 

288 "category__project", 

289 "category__category", 

290 "category__category__project", 

291 "category__category__category", 

292 "category__category__category__project", 

293 linked_component, 

294 "linked_component__project", 

295 ) 

296 

297 def defer_huge(self): 

298 return self.defer( 

299 "commit_message", 

300 "add_message", 

301 "delete_message", 

302 "merge_message", 

303 "addon_message", 

304 "pull_message", 

305 ) 

306 

307 def filter_by_path(self, path: str) -> ComponentQuerySet: 

308 try: 

309 project, *categories, component = path.split("/") 

310 except ValueError as error: 

311 raise Component.DoesNotExist from error 

312 kwargs: dict[str, str | None] = {} 

313 prefix = "" 

314 for category in reversed(categories): 

315 kwargs[f"{prefix}category__slug"] = category 

316 prefix = f"category__{prefix}" 

317 if not kwargs: 

318 kwargs["category"] = None 

319 return self.filter( 

320 slug__iexact=component, project__slug__iexact=project, **kwargs 

321 ) 

322 

323 def get_by_path(self, path: str) -> Component: 

324 return self.filter_by_path(path).get() 

325 

326 def get_linked(self, val): 

327 """Return component for linked repo.""" 

328 if not is_repo_link(val): 328 ↛ 330line 328 didn't jump to line 330 because the condition on line 328 was always true

329 return None 

330 return self.get_by_path(val[10:]) 

331 

332 def order_project(self): 

333 """Ordering in global scope by project name.""" 

334 return self.order_by("project__name", "name") 

335 

336 def order(self): 

337 """Ordering in project scope by priority.""" 

338 return self.order_by("priority", "is_glossary", "name") 

339 

340 def with_repo(self): 

341 return self.exclude(repo__startswith="weblate:") 

342 

343 def filter_access(self, user: User): 

344 result = self 

345 if user.needs_project_filter: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 result = result.filter(project__in=user.allowed_projects) 

347 if user.needs_component_restrictions_filter: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 result = result.filter( 

349 Q(restricted=False) | Q(id__in=user.component_permissions) 

350 ) 

351 return result 

352 

353 def search(self, query: str): 

354 return self.filter( 

355 Q(name__icontains=query) | Q(slug__icontains=query) 

356 ).select_related( 

357 "project", 

358 "category__project", 

359 "category__category", 

360 "category__category__project", 

361 "category__category__category", 

362 "category__category__category__project", 

363 ) 

364 

365 

366class OldComponentSettings(TypedDict): 

367 check_flags: str 

368 

369 

370class Component( 

371 models.Model, PathMixin, CacheKeyMixin, ComponentCategoryMixin, LockMixin 

372): 

373 name = models.CharField( 

374 verbose_name=gettext_lazy("Component name"), 

375 max_length=COMPONENT_NAME_LENGTH, 

376 help_text=gettext_lazy("Display name"), 

377 ) 

378 slug = models.SlugField( 

379 verbose_name=gettext_lazy("URL slug"), 

380 max_length=COMPONENT_NAME_LENGTH, 

381 help_text=gettext_lazy("Name used in URLs and filenames."), 

382 validators=[validate_slug], 

383 ) 

384 project = models.ForeignKey( 

385 "trans.Project", 

386 verbose_name=gettext_lazy("Project"), 

387 on_delete=models.deletion.CASCADE, 

388 db_index=False, 

389 ) 

390 category = models.ForeignKey( 

391 "trans.Category", 

392 verbose_name=gettext_lazy("Category"), 

393 on_delete=models.deletion.CASCADE, 

394 null=True, 

395 blank=True, 

396 ) 

397 vcs = models.CharField( 

398 verbose_name=gettext_lazy("Version control system"), 

399 max_length=20, 

400 help_text=gettext_lazy( 

401 "Version control system to use to access your " 

402 "repository containing translations. You can also choose " 

403 "additional integration with third party providers to " 

404 "submit merge requests." 

405 ), 

406 choices=VCS_REGISTRY.get_choices(), 

407 default=settings.DEFAULT_VCS, 

408 ) 

409 repo = models.CharField( 

410 verbose_name=gettext_lazy("Source code repository"), 

411 max_length=REPO_LENGTH, 

412 help_text=gettext_lazy( 

413 "URL of a repository, use weblate://project/component " 

414 "to share it with other component." 

415 ), 

416 ) 

417 linked_component = models.ForeignKey( 

418 "trans.Component", 

419 verbose_name=gettext_lazy("Project"), 

420 on_delete=models.deletion.CASCADE, 

421 null=True, 

422 editable=False, 

423 ) 

424 push = models.CharField( 

425 verbose_name=gettext_lazy("Repository push URL"), 

426 max_length=REPO_LENGTH, 

427 help_text=gettext_lazy( 

428 "URL of a push repository, pushing is turned off if empty." 

429 ), 

430 blank=True, 

431 ) 

432 repoweb = models.CharField( 

433 verbose_name=gettext_lazy("Repository browser"), 

434 max_length=200, 

435 help_text=gettext_lazy( 

436 "Link to repository browser, use {{branch}} for branch, " 

437 "{{filename}} and {{line}} as filename and line placeholders. " 

438 "You might want to strip leading directory by using {{filename|parentdir}}." 

439 ), 

440 validators=[validate_repoweb], 

441 blank=True, 

442 ) 

443 git_export = models.CharField( 

444 verbose_name=gettext_lazy("Exported repository URL"), 

445 max_length=60 + PROJECT_NAME_LENGTH + COMPONENT_NAME_LENGTH, 

446 help_text=gettext_lazy( 

447 "URL of repository where users can fetch changes from Weblate" 

448 ), 

449 blank=True, 

450 ) 

451 report_source_bugs = EmailField( 

452 verbose_name=gettext_lazy("Source string bug reporting address"), 

453 help_text=gettext_lazy( 

454 "E-mail address for reports on errors in source strings. " 

455 "Leave empty for no e-mails." 

456 ), 

457 blank=True, 

458 ) 

459 branch = models.CharField( 

460 verbose_name=gettext_lazy("Repository branch"), 

461 max_length=BRANCH_LENGTH, 

462 help_text=gettext_lazy("Repository branch to translate"), 

463 default="", 

464 blank=True, 

465 ) 

466 push_branch = models.CharField( 

467 verbose_name=gettext_lazy("Push branch"), 

468 max_length=BRANCH_LENGTH, 

469 help_text=gettext_lazy( 

470 "Branch for pushing changes, leave empty to use repository branch" 

471 ), 

472 default="", 

473 blank=True, 

474 ) 

475 filemask = models.CharField( 

476 verbose_name=gettext_lazy("File mask"), 

477 max_length=FILENAME_LENGTH, 

478 validators=[validate_filemask, validate_filename], 

479 help_text=gettext_lazy( 

480 "Path of files to translate relative to repository root," 

481 " use * instead of language code, " 

482 "for example: po/*.po or locale/*/LC_MESSAGES/django.po." 

483 ), 

484 ) 

485 screenshot_filemask = models.CharField( 

486 verbose_name=gettext_lazy("Screenshot file mask"), 

487 max_length=FILENAME_LENGTH, 

488 blank=True, 

489 validators=[validate_filemask, validate_filename], 

490 help_text=gettext_lazy( 

491 "Path of screenshots relative to repository root, " 

492 "for example: docs/screenshots/*.png." 

493 ), 

494 ) 

495 template = models.CharField( 

496 verbose_name=gettext_lazy("Monolingual base language file"), 

497 max_length=FILENAME_LENGTH, 

498 blank=True, 

499 help_text=gettext_lazy( 

500 "Filename of translation base file, containing all strings " 

501 "and their source " 

502 "for monolingual translations." 

503 ), 

504 validators=[validate_filename], 

505 ) 

506 edit_template = models.BooleanField( 

507 verbose_name=gettext_lazy("Edit base file"), 

508 default=True, 

509 help_text=gettext_lazy( 

510 "Whether users will be able to edit the base file " 

511 "for monolingual translations." 

512 ), 

513 ) 

514 intermediate = models.CharField( 

515 verbose_name=gettext_lazy("Intermediate language file"), 

516 max_length=FILENAME_LENGTH, 

517 blank=True, 

518 help_text=gettext_lazy( 

519 "Filename of intermediate translation file. In most cases " 

520 "this is a translation file provided by developers and is " 

521 "used when creating actual source strings." 

522 ), 

523 validators=[validate_filename], 

524 ) 

525 

526 new_base = models.CharField( 

527 verbose_name=gettext_lazy("Template for new translations"), 

528 max_length=FILENAME_LENGTH, 

529 blank=True, 

530 help_text=gettext_lazy( 

531 "Filename of file used for creating new translations. " 

532 "For gettext choose .pot file." 

533 ), 

534 validators=[validate_filename], 

535 ) 

536 file_format = models.CharField( 

537 verbose_name=gettext_lazy("File format"), 

538 max_length=50, 

539 choices=FILE_FORMATS.get_choices(), 

540 blank=False, 

541 ) 

542 

543 file_format_params = models.JSONField( 

544 verbose_name=gettext_lazy("File format parameters"), 

545 default=dict, 

546 blank=True, 

547 validators=[validate_file_format_parameters], 

548 ) 

549 

550 locked = models.BooleanField( 

551 verbose_name=gettext_lazy("Locked"), 

552 default=False, 

553 help_text=gettext_lazy( 

554 "Locked component will not get any translation updates." 

555 ), 

556 ) 

557 hide_glossary_matches = models.BooleanField( 

558 verbose_name=gettext_lazy("Do not show glossary matches"), 

559 default=False, 

560 help_text=gettext_lazy("Hides the glossary panel in the translation editor."), 

561 ) 

562 allow_translation_propagation = models.BooleanField( 

563 verbose_name=gettext_lazy("Allow translation propagation"), 

564 default=settings.DEFAULT_TRANSLATION_PROPAGATION, 

565 help_text=gettext_lazy( 

566 "Whether translation updates in other components " 

567 "will cause automatic translation in this one" 

568 ), 

569 ) 

570 contribute_project_tm = models.BooleanField( 

571 verbose_name=gettext_lazy("Contribute to project translation memory"), 

572 default=True, 

573 help_text=gettext_lazy( 

574 "Allow this component's translations to be added to the project-level" 

575 " translation memory." 

576 ), 

577 ) 

578 # This should match definition in WorkflowSetting 

579 enable_suggestions = models.BooleanField( 

580 verbose_name=gettext_lazy("Turn on suggestions"), 

581 default=True, 

582 help_text=gettext_lazy("Whether to allow translation suggestions at all."), 

583 ) 

584 # This should match definition in WorkflowSetting 

585 suggestion_voting = models.BooleanField( 

586 verbose_name=gettext_lazy("Suggestion voting"), 

587 default=False, 

588 help_text=gettext_lazy( 

589 "Users can only vote for suggestions and can’t make direct translations." 

590 ), 

591 ) 

592 # This should match definition in WorkflowSetting 

593 suggestion_autoaccept = models.PositiveSmallIntegerField( 

594 verbose_name=gettext_lazy("Automatically accept suggestions"), 

595 default=0, 

596 help_text=gettext_lazy( 

597 "Automatically accept suggestions with this number of votes," 

598 " use 0 to turn it off." 

599 ), 

600 validators=[validate_autoaccept], 

601 ) 

602 check_flags = models.TextField( 

603 verbose_name=gettext_lazy("Translation flags"), 

604 default="", 

605 help_text=gettext_lazy( 

606 "Additional comma-separated flags to influence Weblate behavior." 

607 ), 

608 validators=[validate_check_flags], 

609 blank=True, 

610 ) 

611 enforced_checks = models.JSONField( 

612 verbose_name=gettext_lazy("Enforced checks"), 

613 help_text=gettext_lazy("List of checks which can not be dismissed."), 

614 default=list, 

615 blank=True, 

616 ) 

617 

618 # Licensing 

619 license = models.CharField( 

620 verbose_name=gettext_lazy("Translation license"), 

621 max_length=150, 

622 blank=not settings.LICENSE_REQUIRED, 

623 default="", 

624 choices=get_license_choices(), 

625 ) 

626 agreement = models.TextField( 

627 verbose_name=gettext_lazy("Contributor license agreement"), 

628 blank=True, 

629 default="", 

630 help_text=gettext_lazy( 

631 "Contributor license agreement which needs to be approved before a user can " 

632 "translate this component." 

633 ), 

634 ) 

635 

636 # Adding new language 

637 new_lang = models.CharField( 

638 verbose_name=gettext_lazy("Adding new translation"), 

639 max_length=10, 

640 choices=NEW_LANG_CHOICES, 

641 default="add", 

642 help_text=gettext_lazy("How to handle requests for creating new translations."), 

643 ) 

644 language_code_style = models.CharField( 

645 verbose_name=gettext_lazy("Language code style"), 

646 max_length=20, 

647 choices=LANGUAGE_CODE_STYLE_CHOICES, 

648 default="", 

649 blank=True, 

650 help_text=gettext_lazy( 

651 "Customize language code used to generate the filename for " 

652 "translations created by Weblate." 

653 ), 

654 ) 

655 manage_units = models.BooleanField( 

656 verbose_name=gettext_lazy("Manage strings"), 

657 default=False, 

658 help_text=gettext_lazy( 

659 "Enables adding and removing strings straight from Weblate. If your " 

660 "strings are extracted from the source code or managed externally you " 

661 "probably want to keep it disabled." 

662 ), 

663 ) 

664 

665 # VCS config 

666 merge_style = models.CharField( 

667 verbose_name=gettext_lazy("Merge style"), 

668 max_length=10, 

669 choices=MERGE_CHOICES, 

670 default=settings.DEFAULT_MERGE_STYLE, 

671 help_text=gettext_lazy( 

672 "Define whether Weblate should merge the upstream repository " 

673 "or rebase changes onto it." 

674 ), 

675 ) 

676 commit_message = models.TextField( 

677 verbose_name=gettext_lazy("Commit message when translating"), 

678 help_text=gettext_lazy( 

679 "You can use template language for various info, " 

680 "please consult the documentation for more details." 

681 ), 

682 validators=[validate_render_commit], 

683 default=settings.DEFAULT_COMMIT_MESSAGE, 

684 ) 

685 add_message = models.TextField( 

686 verbose_name=gettext_lazy("Commit message when adding translation"), 

687 help_text=gettext_lazy( 

688 "You can use template language for various info, " 

689 "please consult the documentation for more details." 

690 ), 

691 validators=[validate_render_commit], 

692 default=settings.DEFAULT_ADD_MESSAGE, 

693 ) 

694 delete_message = models.TextField( 

695 verbose_name=gettext_lazy("Commit message when removing translation"), 

696 help_text=gettext_lazy( 

697 "You can use template language for various info, " 

698 "please consult the documentation for more details." 

699 ), 

700 validators=[validate_render_commit], 

701 default=settings.DEFAULT_DELETE_MESSAGE, 

702 ) 

703 merge_message = models.TextField( 

704 # Translators: The commit message, for when merging the translation 

705 verbose_name=gettext_lazy("Commit message when merging translation"), 

706 help_text=gettext_lazy( 

707 "You can use template language for various info, " 

708 "please consult the documentation for more details." 

709 ), 

710 validators=[validate_render_component], 

711 default=settings.DEFAULT_MERGE_MESSAGE, 

712 ) 

713 addon_message = models.TextField( 

714 verbose_name=gettext_lazy("Commit message when add-on makes a change"), 

715 help_text=gettext_lazy( 

716 "You can use template language for various info, " 

717 "please consult the documentation for more details." 

718 ), 

719 validators=[validate_render_addon], 

720 default=settings.DEFAULT_ADDON_MESSAGE, 

721 ) 

722 pull_message = models.TextField( 

723 verbose_name=gettext_lazy("Merge request message"), 

724 help_text=gettext_lazy( 

725 "You can use template language for various info, " 

726 "please consult the documentation for more details." 

727 ), 

728 validators=[validate_render_addon], 

729 default=settings.DEFAULT_PULL_MESSAGE, 

730 ) 

731 push_on_commit = models.BooleanField( 

732 verbose_name=gettext_lazy("Push on commit"), 

733 default=settings.DEFAULT_PUSH_ON_COMMIT, 

734 help_text=gettext_lazy( 

735 "Whether the repository should be pushed upstream on every commit." 

736 ), 

737 ) 

738 commit_pending_age = models.SmallIntegerField( 

739 verbose_name=gettext_lazy("Age of changes to commit"), 

740 default=settings.COMMIT_PENDING_HOURS, 

741 validators=[MaxValueValidator(2160)], 

742 help_text=gettext_lazy( 

743 "Time in hours after which any pending changes will be " 

744 "committed to the VCS." 

745 ), 

746 ) 

747 auto_lock_error = models.BooleanField( 

748 verbose_name=gettext_lazy("Lock on error"), 

749 default=settings.DEFAULT_AUTO_LOCK_ERROR, 

750 help_text=gettext_lazy( 

751 "Whether the component should be locked on repository errors." 

752 ), 

753 ) 

754 

755 source_language = models.ForeignKey( 

756 Language, 

757 verbose_name=gettext_lazy("Source language"), 

758 help_text=gettext_lazy("Language used for source strings in all components"), 

759 default=get_default_lang, 

760 on_delete=models.deletion.CASCADE, 

761 ) 

762 language_regex = RegexField( 

763 verbose_name=gettext_lazy("Language filter"), 

764 max_length=500, 

765 default="^[^.]+$", 

766 help_text=gettext_lazy( 

767 "Regular expression used to filter " 

768 "translation files when scanning for file mask." 

769 ), 

770 ) 

771 variant_regex = RegexField( 

772 verbose_name=gettext_lazy("Variants regular expression"), 

773 validators=[validate_re_nonempty], 

774 max_length=190, 

775 default="", 

776 blank=True, 

777 help_text=gettext_lazy( 

778 "Regular expression used to determine variants of a string." 

779 ), 

780 ) 

781 

782 priority = models.IntegerField( 

783 default=100, 

784 choices=PRIORITY_CHOICES, 

785 verbose_name=gettext_lazy("Priority"), 

786 help_text=gettext_lazy( 

787 "Components with higher priority are offered first to translators." 

788 ), 

789 ) 

790 restricted = models.BooleanField( 

791 verbose_name=gettext_lazy("Restricted component"), 

792 default=settings.DEFAULT_RESTRICTED_COMPONENT, 

793 db_index=True, 

794 help_text=gettext_lazy( 

795 "Restrict access to the component to only " 

796 "those explicitly given permission." 

797 ), 

798 ) 

799 

800 links = models.ManyToManyField( 

801 "trans.Project", 

802 verbose_name=gettext_lazy("Share in projects"), 

803 blank=True, 

804 related_name="shared_components", 

805 help_text=gettext_lazy( 

806 "Choose additional projects where this component will be listed." 

807 ), 

808 ) 

809 

810 # Glossary management 

811 is_glossary = models.BooleanField( 

812 verbose_name=gettext_lazy("Use as a glossary"), 

813 default=False, 

814 db_index=True, 

815 help_text=gettext_lazy( 

816 "Glossaries are different from regular translations but help keep track of and share consistent terminology." 

817 ), 

818 ) 

819 glossary_color = models.CharField( 

820 verbose_name=gettext_lazy("Glossary color"), 

821 max_length=30, 

822 choices=ColorChoices.choices, 

823 blank=False, 

824 default=ColorChoices.SILVER, 

825 ) 

826 remote_revision = models.CharField(max_length=200, default="", blank=True) 

827 local_revision = models.CharField(max_length=200, default="", blank=True) 

828 processed_revision = models.CharField(max_length=200, default="", blank=True) 

829 

830 key_filter = RegexField( 

831 verbose_name=gettext_lazy("Key filter"), 

832 max_length=500, 

833 default="", 

834 help_text=gettext_lazy( 

835 "Regular expression used to filter keys. This is only available for monolingual formats." 

836 ), 

837 blank=True, 

838 ) 

839 

840 secondary_language = models.ForeignKey( 

841 Language, 

842 verbose_name=gettext_lazy("Secondary language"), 

843 help_text=format_lazy( 

844 "{} {}", 

845 gettext_lazy( 

846 "Additional language to show together with the source language while translating." 

847 ), 

848 gettext_lazy("This setting is inherited from the project if left empty."), 

849 ), 

850 default=None, 

851 blank=True, 

852 null=True, 

853 related_name="component_secondary_languages", 

854 on_delete=models.deletion.CASCADE, 

855 ) 

856 

857 objects = ComponentQuerySet.as_manager() 

858 

859 is_lockable = True 

860 remove_permission = "component.edit" 

861 settings_permission = "component.edit" 

862 

863 class Meta: 

864 app_label = "trans" 

865 verbose_name = "Component" 

866 verbose_name_plural = "Components" 

867 indexes = [ # noqa: RUF012 

868 models.Index(fields=["project", "allow_translation_propagation"]), 

869 ] 

870 constraints = [ # noqa: RUF012 

871 models.UniqueConstraint( 

872 name="component_slug_unique", 

873 fields=["project", "category", "slug"], 

874 nulls_distinct=False, 

875 ), 

876 models.UniqueConstraint( 

877 name="component_name_unique", 

878 fields=["project", "category", "name"], 

879 nulls_distinct=False, 

880 ), 

881 ] 

882 

883 def __str__(self) -> str: 

884 return f"{self.category or self.project}/{self.name}" 

885 

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

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

888 self._file_format = None 

889 self.stats = ComponentStats(self) 

890 self.needs_cleanup = False 

891 self.alerts_trigger: dict[str, list[dict]] = {} 

892 self.updated_sources: dict[int, Unit] = {} 

893 self.old_component_settings: OldComponentSettings = { 

894 "check_flags": self.check_flags 

895 } 

896 self._sources: dict[int, Unit] = {} 

897 self._sources_prefetched = False 

898 self.logs: list[str] = [] 

899 self.translations_count: int | None = None 

900 self.translations_progress = 0 

901 self.acting_user: User | None = None 

902 self.batch_checks = False 

903 self.batched_checks: set[str] = set() 

904 self.needs_variants_update = False 

905 self._invalidate_scheduled = False 

906 self._alerts_scheduled = False 

907 self._template_check_done = False 

908 self._glossary_sync_scheduled = False 

909 self.new_lang_error_message: str | None = None 

910 

911 def save(self, *args, **kwargs) -> None: 

912 """ 

913 Save wrapper. 

914 

915 It updates the back-end repository and regenerates translation data. 

916 """ 

917 from weblate.trans.tasks import component_after_save 

918 

919 self.drop_file_format_cache() 

920 self.set_default_branch() 

921 

922 # Linked component cache 

923 self.linked_component = Component.objects.get_linked(self.repo) 

924 

925 # Detect if VCS config has changed (so that we have to pull the repo) 

926 changed_git = True 

927 changed_setup = False 

928 changed_template = False 

929 changed_variant = False 

930 changed_enforced_checks = False 

931 create = True 

932 

933 # Sets the key_filter to blank if the file format is bilingual 

934 if self.key_filter and not self.has_template(): 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true

935 self.key_filter = "" 

936 

937 update_tm = self.contribute_project_tm 

938 

939 if self.id: 

940 old = Component.objects.get(pk=self.id) 

941 changed_git = ( 

942 (old.vcs != self.vcs) 

943 or (old.repo != self.repo) 

944 or (old.branch != self.branch) 

945 or (old.filemask != self.filemask) 

946 or (old.language_regex != self.language_regex) 

947 ) 

948 changed_template = (old.intermediate != self.intermediate) or ( 

949 old.template != self.template 

950 ) 

951 changed_setup = ( 

952 (old.file_format != self.file_format) 

953 or (old.edit_template != self.edit_template) 

954 or (old.new_base != self.new_base) 

955 or changed_template 

956 or old.key_filter != self.key_filter 

957 ) 

958 if changed_setup: 

959 old.commit_pending("changed setup", None) 

960 if old.key_filter != self.key_filter: 960 ↛ 961line 960 didn't jump to line 961 because the condition on line 960 was never true

961 self.drop_key_filter_cache() 

962 

963 changed_variant = old.variant_regex != self.variant_regex 

964 # Generate change entries for changes 

965 self.generate_changes(old) 

966 # Detect slug changes and rename Git repo 

967 self.check_rename(old) 

968 # Rename linked repos 

969 if ( 969 ↛ 974line 969 didn't jump to line 974 because the condition on line 969 was never true

970 old.slug != self.slug 

971 or old.project != self.project 

972 or old.category != self.category 

973 ): 

974 old.component_set.update(repo=self.get_repo_link_url()) 

975 if changed_git: 975 ↛ 976line 975 didn't jump to line 976 because the condition on line 975 was never true

976 self.drop_repository_cache() 

977 

978 changed_enforced_checks = ( 

979 old.enforced_checks != self.enforced_checks and self.enforced_checks 

980 ) 

981 

982 create = False 

983 

984 # detect if the component had TM contribution disabled but changed to enabled 

985 update_tm = self.contribute_project_tm and not old.contribute_project_tm 

986 elif self.is_glossary: 

987 # Creating new glossary 

988 

989 # Turn on unit management for glossary and disable adding languages 

990 # as they are added automatically 

991 self.manage_units = True 

992 self.new_lang = "none" 

993 # Make sure it is listed in project glossaries now 

994 self.project.glossaries.append(self) 

995 

996 # Remove leading ./ from paths 

997 self.filemask = cleanup_path(self.filemask) 

998 self.screenshot_filemask = cleanup_path(self.screenshot_filemask) 

999 self.template = cleanup_path(self.template) 

1000 self.intermediate = cleanup_path(self.intermediate) 

1001 self.new_base = cleanup_path(self.new_base) 

1002 

1003 # Save/Create object 

1004 super().save(*args, **kwargs) 

1005 

1006 if create: 

1007 self.install_autoaddon() 

1008 

1009 # Ensure source translation is existing, otherwise we might 

1010 # be hitting race conditions between background update and frontend displaying 

1011 # the newly created component 

1012 bool(self.source_translation) 

1013 

1014 if settings.CELERY_TASK_ALWAYS_EAGER: 1014 ↛ 1015line 1014 didn't jump to line 1015 because the condition on line 1014 was never true

1015 self.after_save( 

1016 changed_git=changed_git, 

1017 changed_setup=changed_setup, 

1018 changed_template=changed_template, 

1019 changed_variant=changed_variant, 

1020 changed_enforced_checks=changed_enforced_checks, 

1021 skip_push=kwargs.get("force_insert", False), 

1022 create=create, 

1023 ) 

1024 else: 

1025 component_after_save.delay_on_commit( 

1026 self.pk, 

1027 changed_git=changed_git, 

1028 changed_setup=changed_setup, 

1029 changed_template=changed_template, 

1030 changed_variant=changed_variant, 

1031 changed_enforced_checks=changed_enforced_checks, 

1032 skip_push=kwargs.get("force_insert", False), 

1033 create=create, 

1034 ) 

1035 

1036 if self.old_component_settings["check_flags"] != self.check_flags: 1036 ↛ 1037line 1036 didn't jump to line 1037 because the condition on line 1036 was never true

1037 transaction.on_commit( 

1038 lambda: self.schedule_update_checks(update_state=True) 

1039 ) 

1040 

1041 # Invalidate source language cache just to be sure, as it is relatively 

1042 # cheap to update 

1043 self.project.invalidate_source_language_cache() 

1044 for project in self.cached_links: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the loop on line 1044 never started

1045 project.invalidate_source_language_cache() 

1046 

1047 if update_tm: 

1048 import_memory.delay_on_commit(self.project.id, self.pk) 

1049 

1050 @cached_property 

1051 def cached_links(self) -> models.QuerySet[Component]: 

1052 return self.links.all() 

1053 

1054 def generate_changes(self, old) -> None: 

1055 def getvalue(base, attribute): 

1056 result = getattr(base, attribute) 

1057 if result is None: 

1058 return "" 

1059 # Use slug for Category/Project instances 

1060 return getattr(result, "slug", result) 

1061 

1062 tracked = ( 

1063 ("license", ActionEvents.LICENSE_CHANGE), 

1064 ("agreement", ActionEvents.AGREEMENT_CHANGE), 

1065 ("slug", ActionEvents.RENAME_COMPONENT), 

1066 ("category", ActionEvents.MOVE_COMPONENT), 

1067 ("project", ActionEvents.MOVE_COMPONENT), 

1068 ) 

1069 for attribute, action in tracked: 

1070 old_value = getvalue(old, attribute) 

1071 current_value = getvalue(self, attribute) 

1072 

1073 if old_value != current_value: 1073 ↛ 1074line 1073 didn't jump to line 1074 because the condition on line 1073 was never true

1074 self.change_set.create( 

1075 action=action, 

1076 old=old_value, 

1077 target=current_value, 

1078 user=self.acting_user, 

1079 ) 

1080 

1081 def install_autoaddon(self) -> None: 

1082 """Installs automatically enabled addons from file format.""" 

1083 from weblate.addons.models import ADDONS, Addon 

1084 

1085 for name, configuration in chain( 1085 ↛ 1088line 1085 didn't jump to line 1088 because the loop on line 1085 never started

1086 self.file_format_cls.autoaddon.items(), settings.DEFAULT_ADDONS.items() 

1087 ): 

1088 try: 

1089 addon = ADDONS[name] 

1090 except KeyError: 

1091 self.log_warning("could not enable addon %s, not found", name) 

1092 continue 

1093 

1094 if ( 

1095 addon.project_scope 

1096 and Addon.objects.filter( 

1097 component__project=self.project, name=name 

1098 ).exists() 

1099 ): 

1100 self.log_warning( 

1101 "could not enable addon %s, already installed on project", name 

1102 ) 

1103 continue 

1104 

1105 component = self 

1106 if addon.repo_scope and self.linked_component: 

1107 component = self.linked_component 

1108 

1109 if component.addon_set.filter(name=name).exists(): 

1110 component.log_warning( 

1111 "could not enable addon %s, already installed", name 

1112 ) 

1113 continue 

1114 

1115 if addon.has_settings(): 

1116 form = addon.get_add_form(None, component=component, data=configuration) 

1117 if form is None: 

1118 raise TypeError 

1119 if not form.is_valid(): 

1120 component.log_warning( 

1121 "could not enable addon %s, invalid settings", name 

1122 ) 

1123 continue 

1124 

1125 if not addon.can_install(component, None): 

1126 component.log_warning("could not enable addon %s, not compatible", name) 

1127 continue 

1128 

1129 component.log_info("enabling addon %s", name) 

1130 # Running is disabled now, it is triggered in after_save 

1131 addon.create(component=component, run=False, configuration=configuration) 

1132 

1133 def create_glossary(self) -> None: 

1134 project = self.project 

1135 

1136 # Does glossary already exist? 

1137 if self.is_glossary or project.glossaries or len(project.child_components) > 2: 

1138 return 

1139 

1140 component_names = {component.name for component in project.child_components} 

1141 component_slugs = {component.slug for component in project.child_components} 

1142 if "Glossary" in component_names or "glossary" in component_slugs: 1142 ↛ 1143line 1142 didn't jump to line 1143 because the condition on line 1142 was never true

1143 return 

1144 

1145 # Create glossary component 

1146 project.scratch_create_component( 

1147 project.name if project.name not in component_names else "Glossary", 

1148 "glossary", 

1149 self.source_language, 

1150 "tbx", 

1151 is_glossary=True, 

1152 has_template=False, 

1153 allow_translation_propagation=False, 

1154 license=self.license, 

1155 ) 

1156 

1157 @cached_property 

1158 def lock(self): 

1159 return WeblateLock( 

1160 lock_path=self.project.full_path, 

1161 scope="component-update", 

1162 key=self.pk, 

1163 slug=self.slug, 

1164 cache_template="{scope}-lock-{key}", 

1165 file_template="{slug}-update.lock", 

1166 timeout=5, 

1167 origin=self.full_slug, 

1168 ) 

1169 

1170 @cached_property 

1171 def update_key(self) -> str: 

1172 return f"component-update-{self.pk}" 

1173 

1174 def delete_background_task(self) -> None: 

1175 cache.delete(self.update_key) 

1176 

1177 def store_background_task(self, task=None) -> None: 

1178 if task is None: 1178 ↛ 1182line 1178 didn't jump to line 1182 because the condition on line 1178 was always true

1179 if not current_task: 

1180 return 

1181 task = current_task.request 

1182 cache.set(self.update_key, task.id, 6 * 3600) 

1183 

1184 @cached_property 

1185 def background_task_id(self): 

1186 return cache.get(self.update_key) 

1187 

1188 @cached_property 

1189 def background_task(self): 

1190 task_id = self.background_task_id 

1191 if not task_id: 

1192 return None 

1193 return AsyncResult(task_id) 

1194 

1195 def progress_step(self, progress=None) -> None: 

1196 # No task (for example eager mode) 

1197 if not current_task or not current_task.request.id: 

1198 return 

1199 # Operate on linked component if needed 

1200 if self.translations_count == -1: 1200 ↛ 1201line 1200 didn't jump to line 1201 because the condition on line 1200 was never true

1201 if self.linked_component: 

1202 self.linked_component.progress_step(progress) 

1203 return 

1204 # Calculate progress for translations 

1205 if progress is None: 

1206 self.translations_progress += 1 

1207 progress = 100 * self.translations_progress // self.translations_count 

1208 # Store task state 

1209 current_task.update_state( 

1210 state="PROGRESS", meta={"progress": progress, "component": self.pk} 

1211 ) 

1212 

1213 def store_log(self, slug, msg, *args) -> None: 

1214 if self.translations_count == -1 and self.linked_component: 1214 ↛ 1215line 1214 didn't jump to line 1215 because the condition on line 1214 was never true

1215 self.linked_component.store_log(slug, msg, *args) 

1216 return 

1217 self.logs.append(f"{slug}: {msg % args}") 

1218 if current_task and current_task.request.id: 

1219 cache.set(f"task-log-{current_task.request.id}", self.logs, 2 * 3600) 

1220 

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

1222 if level != "DEBUG": 

1223 self.store_log(self.full_slug, msg, *args) 

1224 

1225 def get_progress(self): 

1226 task = self.background_task 

1227 if task is None: 

1228 return 100, [] 

1229 progress = get_task_progress(task) 

1230 return (progress, cache.get(f"task-log-{task.id}", [])) 

1231 

1232 def in_progress(self): 

1233 return ( 

1234 not settings.CELERY_TASK_ALWAYS_EAGER 

1235 and self.background_task is not None 

1236 and not self.background_task.ready() 

1237 ) 

1238 

1239 def get_source_translation(self): 

1240 """ 

1241 Return source translation object if it exists. 

1242 

1243 In some cases we do not want to create source translation object as 

1244 source_translation property does, but we want to utilize its cache. 

1245 """ 

1246 if "source_translation" in self.__dict__: 

1247 return self.__dict__["source_translation"] 

1248 try: 

1249 result = self.translation_set.get(language_id=self.source_language_id) 

1250 except ObjectDoesNotExist: 

1251 return None 

1252 self.__dict__["source_translation"] = result 

1253 return result 

1254 

1255 @cached_property 

1256 def source_translation(self): 

1257 # This is basically copy of get_or_create, but avoids additional 

1258 # SQL query to get source_language in case the source translation 

1259 # already exists. The source_language is only fetched in the slow 

1260 # path when creating the translation. 

1261 language = self.source_language 

1262 try: 

1263 result = self.translation_set.select_related("plural").get( 

1264 language=language 

1265 ) 

1266 except ObjectDoesNotExist: 

1267 try: 

1268 with transaction.atomic(): 

1269 return self.translation_set.create( 

1270 language=language, 

1271 check_flags="read-only", 

1272 filename=self.template, 

1273 plural=self.file_format_cls.get_plural(language), 

1274 language_code=language.code, 

1275 ) 

1276 except IntegrityError: 

1277 try: 

1278 return self.translation_set.get(language_id=self.source_language_id) 

1279 except self.translation_set.model.DoesNotExist: 

1280 pass 

1281 raise 

1282 else: 

1283 result.language = language 

1284 return result 

1285 

1286 def preload_sources(self, sources=None) -> None: 

1287 """Preload source objects to improve performance on load.""" 

1288 if sources is not None: 

1289 self._sources = sources 

1290 else: 

1291 self._sources = { 

1292 source.id_hash: source 

1293 for source in self.source_translation.unit_set.all() 

1294 } 

1295 self._sources_prefetched = True 

1296 

1297 def get_all_sources(self): 

1298 if not self._sources_prefetched: 1298 ↛ 1300line 1298 didn't jump to line 1300 because the condition on line 1298 was always true

1299 self.preload_sources() 

1300 return list(self._sources.values()) 

1301 

1302 def unload_sources(self) -> None: 

1303 self._sources = {} 

1304 self._sources_prefetched = False 

1305 

1306 def _process_new_source(self, source: Unit, *, save: bool = True) -> Change: 

1307 # Avoid fetching empty list of checks from the database 

1308 source.all_checks = [] 

1309 source.source_updated = True 

1310 change = source.generate_change( 

1311 self.acting_user, 

1312 self.acting_user, 

1313 ActionEvents.NEW_SOURCE, 

1314 check_new=False, 

1315 save=save, 

1316 ) 

1317 self.updated_sources[source.id] = source 

1318 return change 

1319 

1320 def bulk_create_sources(self, attributes_list: list[UnitAttributesDict]) -> None: 

1321 """Ensure that all sources are stored in the database.""" 

1322 # Can not bulk create with getting primary key, resort to one by one creation 

1323 if not connection.features.can_return_rows_from_bulk_insert: 1323 ↛ 1324line 1323 didn't jump to line 1324 because the condition on line 1323 was never true

1324 return 

1325 

1326 # Make sure we load all the sources 

1327 if not self._sources_prefetched: 1327 ↛ 1328line 1327 didn't jump to line 1328 because the condition on line 1327 was never true

1328 self.preload_sources() 

1329 

1330 # Filter out missing ones 

1331 missing = [ 

1332 attributes 

1333 for attributes in attributes_list 

1334 if attributes["id_hash"] not in self._sources 

1335 ] 

1336 

1337 if not missing: 

1338 return 

1339 

1340 # Bulk create 

1341 # Needs to be in sync with Unit.update_source_unit 

1342 units = [ 

1343 Unit( 

1344 translation=self.source_translation, 

1345 id_hash=attributes["id_hash"], 

1346 source=attributes["source"], 

1347 target=attributes["source"], 

1348 context=attributes["context"], 

1349 position=attributes["pos"], 

1350 note=attributes["note"], 

1351 location=attributes["location"], 

1352 explanation=attributes["explanation"], 

1353 flags=attributes["flags"].format(), 

1354 num_words=count_words(attributes["source"], self.source_language), 

1355 state=STATE_TRANSLATED 

1356 if self.template and self.edit_template 

1357 else STATE_READONLY, 

1358 ) 

1359 for attributes in missing 

1360 ] 

1361 Unit.objects.bulk_create(units) 

1362 

1363 changes: list[Change] = [] 

1364 

1365 for unit in units: 

1366 # Fill in source_unit 

1367 unit.source_unit = unit 

1368 

1369 # Track in local cache 

1370 self._sources[unit.id_hash] = unit 

1371 

1372 # Postprocess and create change 

1373 changes.append(self._process_new_source(unit, save=False)) 

1374 

1375 # Update source unit in the database 

1376 Unit.objects.bulk_update(units, fields=["source_unit"]) 

1377 

1378 # Store changes in the database 

1379 Change.objects.bulk_create(changes) 

1380 

1381 def get_source(self, id_hash, create=None): 

1382 """Get source info with caching.""" 

1383 from weblate.trans.models import Unit 

1384 

1385 # Preload sources when creating units 

1386 if not self._sources_prefetched and create: 1386 ↛ 1387line 1386 didn't jump to line 1387 because the condition on line 1386 was never true

1387 self.preload_sources() 

1388 

1389 try: 

1390 return self._sources[id_hash] 

1391 except KeyError: 

1392 source_units = self.source_translation.unit_set 

1393 if not self._sources_prefetched: 

1394 # Fetch one by one for case getting for single unit, if not prefetch 

1395 # was done, this will raise an exception in case of error 

1396 source = source_units.get(id_hash=id_hash) 

1397 elif create: 

1398 # Create in case of parsing translations 

1399 # Set correct state depending on template editing 

1400 if self.template and self.edit_template: 

1401 create["state"] = STATE_TRANSLATED 

1402 else: 

1403 create["state"] = STATE_READONLY 

1404 

1405 # Create source unit 

1406 source = Unit( 

1407 translation=self.source_translation, id_hash=id_hash, **create 

1408 ) 

1409 source.save(force_insert=True, only_save=True) 

1410 self._process_new_source(source) 

1411 else: 

1412 # We are not supposed to create new one 

1413 msg = "Could not find source unit" 

1414 raise Unit.DoesNotExist(msg) from None 

1415 

1416 self._sources[id_hash] = source 

1417 return source 

1418 

1419 @property 

1420 def filemask_re(self): 

1421 # We used to rely on fnmask.translate here, but since Python 3.9 

1422 # it became super optimized beast producing regexp with possibly 

1423 # several groups making it hard to modify later for our needs. 

1424 result: list[str] = [] 

1425 raw: list[str] = [] 

1426 

1427 def append(text: str | None) -> None: 

1428 if raw: 

1429 result.append(re.escape("".join(raw))) 

1430 raw.clear() 

1431 if text is not None: 

1432 result.append(text) 

1433 

1434 for char in self.filemask: 

1435 if char == ".": 

1436 append(r"\.") 

1437 elif char == "*": 

1438 append("([^/]*)") 

1439 else: 

1440 raw.append(char) 

1441 append(None) 

1442 regex = "".join(result) 

1443 return re.compile(f"^{regex}$") 

1444 

1445 def get_url_path(self): 

1446 parent = self.category or self.project 

1447 return (*parent.get_url_path(), self.slug) 

1448 

1449 def get_widgets_url(self) -> str: 

1450 """Return absolute URL for widgets.""" 

1451 return f"{self.project.get_widgets_url()}?component={self.pk}" 

1452 

1453 def get_share_url(self): 

1454 """Return absolute shareable URL.""" 

1455 return self.project.get_share_url() 

1456 

1457 @perform_on_link 

1458 def _get_path(self): 

1459 """Return full path to component VCS repository.""" 

1460 return super()._get_path() 

1461 

1462 @perform_on_link 

1463 def has_push_configuration(self): 

1464 return bool(self.push) 

1465 

1466 @perform_on_link 

1467 def can_push(self): 

1468 """Return true if push is possible for this component.""" 

1469 return self.has_push_configuration() or not self.repository_class.needs_push_url 

1470 

1471 @property 

1472 def is_repo_link(self): 

1473 """Check whether a repository is just a link to another one.""" 

1474 return is_repo_link(self.repo) 

1475 

1476 @property 

1477 def is_repo_local(self) -> bool: 

1478 """Check whether a repository is local-only.""" 

1479 return self.vcs == "local" 

1480 

1481 @property 

1482 def repository_class(self) -> type[Repository]: 

1483 try: 

1484 return VCS_REGISTRY[self.vcs] 

1485 except KeyError as error: 

1486 msg = f"Component using VCS {self.vcs}, but it not configured" 

1487 raise ImproperlyConfigured(msg) from error 

1488 

1489 @cached_property 

1490 def repository(self) -> Repository: 

1491 """Get VCS repository object.""" 

1492 if self.linked_component is not None: 1492 ↛ 1493line 1492 didn't jump to line 1493 because the condition on line 1492 was never true

1493 return self.linked_component.repository 

1494 return self.repository_class(self.full_path, branch=self.branch, component=self) 

1495 

1496 @perform_on_link 

1497 def get_last_remote_commit(self): 

1498 """Return latest locally known remote commit.""" 

1499 if self.is_repo_local or not self.remote_revision: 1499 ↛ 1501line 1499 didn't jump to line 1501 because the condition on line 1499 was always true

1500 return None 

1501 try: 

1502 return self.repository.get_revision_info(self.remote_revision) 

1503 except RepositoryError: 

1504 return None 

1505 

1506 def get_last_commit(self): 

1507 """Return latest locally known remote commit.""" 

1508 if self.is_repo_local or not self.local_revision: 1508 ↛ 1510line 1508 didn't jump to line 1510 because the condition on line 1508 was always true

1509 return None 

1510 try: 

1511 return self.repository.get_revision_info(self.local_revision) 

1512 except RepositoryError: 

1513 try: 

1514 self.store_local_revision() 

1515 except RepositoryError: 

1516 return None 

1517 return self.repository.get_revision_info(self.local_revision) 

1518 

1519 @perform_on_link 

1520 def get_repo_url(self): 

1521 """Return link to repository.""" 

1522 if not settings.HIDE_REPO_CREDENTIALS: 

1523 return self.repo 

1524 return cleanup_repo_url(self.repo) 

1525 

1526 @perform_on_link 

1527 def get_repo_branch(self): 

1528 """Return branch in repository.""" 

1529 return self.branch 

1530 

1531 @perform_on_link 

1532 def get_export_url(self): 

1533 """Return URL of exported VCS repository.""" 

1534 return self.git_export 

1535 

1536 def get_repoweb_link( 

1537 self, 

1538 filename: str, 

1539 line: str, 

1540 template: str | None = None, 

1541 user=None, 

1542 ): 

1543 """ 

1544 Generate link to source code browser for given file and line. 

1545 

1546 For linked repositories, it is possible to override the linked repository path 

1547 here. 

1548 """ 

1549 if not template: 

1550 if self.repoweb: 

1551 template = self.repoweb 

1552 elif user and user.has_perm("vcs.view", self): 

1553 template = getattr( 

1554 self, 

1555 f"get_{self.vcs}_repoweb_template", 

1556 self.get_git_repoweb_template, 

1557 )() 

1558 if self.linked_component is not None: 

1559 return self.linked_component.get_repoweb_link( 

1560 filename, line, template, user=self.acting_user 

1561 ) 

1562 if not template: 

1563 if filename.startswith("https://"): 

1564 return filename 

1565 return None 

1566 

1567 return render_template( 

1568 template, 

1569 filename=urlquote(filename), 

1570 line=urlquote(line), 

1571 branch=urlquote(self.branch), 

1572 component=self, 

1573 ) 

1574 

1575 def get_git_repoweb_template(self): 

1576 """Return the template link for a specific vcs.""" 

1577 repo = self.repo 

1578 if repo == "local:": 

1579 return None 

1580 

1581 parsed_url = urlparse(repo) 

1582 

1583 # Make sure this is a string 

1584 parsed_hostname = parsed_url.hostname or "" 

1585 

1586 if repo.startswith("git@bitbucket.org") or parsed_hostname == "bitbucket.org": 

1587 return self.get_bitbucket_git_repoweb_template() 

1588 

1589 if repo.startswith("git@github.com") or parsed_hostname == "github.com": 

1590 return self.get_github_repoweb_template() 

1591 

1592 if parsed_hostname == "pagure.io": 

1593 return self.get_pagure_repoweb_template() 

1594 

1595 if ( 

1596 repo.startswith("git@ssh.dev.azure.com:v3") 

1597 or parsed_hostname == "dev.azure.com" 

1598 or parsed_hostname.endswith(".visualstudio.com") 

1599 ): 

1600 return self.get_azure_repoweb_template() 

1601 

1602 return None 

1603 

1604 def get_clean_slug(self, slug): 

1605 if slug.endswith(".git"): 

1606 return slug[:-4] 

1607 return slug 

1608 

1609 def get_bitbucket_git_repoweb_template(self) -> str | None: 

1610 owner, slug, matches = None, None, None 

1611 domain = "bitbucket.org" 

1612 matches = re.match(BITBUCKET_GIT_REPOS_REGEXP[0], self.repo) 

1613 if matches is None: 

1614 matches = re.match(BITBUCKET_GIT_REPOS_REGEXP[1], self.repo) 

1615 if matches: 

1616 owner = matches.group(1) 

1617 slug = self.get_clean_slug(matches.group(2)) 

1618 if owner and slug: 

1619 return ( 

1620 f"https://{domain}/{owner}/{slug}/blob/{{branch}}/{{filename}}#{{line}}" 

1621 ) 

1622 

1623 return None 

1624 

1625 def get_github_repoweb_template(self) -> str | None: 

1626 owner, slug, matches = None, None, None 

1627 domain = "github.com" 

1628 matches = re.match(GITHUB_REPOS_REGEXP[0], self.repo) 

1629 if matches is None: 

1630 matches = re.match(GITHUB_REPOS_REGEXP[1], self.repo) 

1631 if matches: 

1632 owner = matches.group(1) 

1633 slug = self.get_clean_slug(matches.group(2)) 

1634 if owner and slug: 

1635 return f"https://{domain}/{owner}/{slug}/blob/{{branch}}/{{filename}}#L{{line}}" 

1636 

1637 return None 

1638 

1639 def get_pagure_repoweb_template(self) -> str | None: 

1640 owner, slug = None, None 

1641 domain = "pagure.io" 

1642 if matches := re.match(PAGURE_REPOS_REGEXP[0], self.repo): 

1643 owner = matches.group(1) 

1644 slug = matches.group(2) 

1645 

1646 if owner and slug: 

1647 return f"https://{domain}/{owner}/{slug}/blob/{{branch}}/f/{{filename}}/#_{{line}}" 

1648 

1649 return None 

1650 

1651 def get_azure_repoweb_template(self) -> str | None: 

1652 organization, project, repository, matches = None, None, None, None 

1653 domain = "dev.azure.com" 

1654 matches = re.match(AZURE_REPOS_REGEXP[0], self.repo) 

1655 if matches is None: 

1656 matches = re.match(AZURE_REPOS_REGEXP[1], self.repo) 

1657 if matches is None: 

1658 matches = re.match(AZURE_REPOS_REGEXP[2], self.repo) 

1659 if matches is None: 

1660 matches = re.match(AZURE_REPOS_REGEXP[3], self.repo) 

1661 

1662 if matches: 

1663 organization = matches.group(1) 

1664 project = matches.group(2) 

1665 repository = matches.group(3) 

1666 

1667 if organization and project and repository: 

1668 return f"https://{domain}/{organization}/{project}/_git/{repository}/blob/{{branch}}/{{filename}}#L{{line}}" 

1669 

1670 return None 

1671 

1672 def error_text(self, error): 

1673 """Return text message for a RepositoryError.""" 

1674 message = error.get_message() 

1675 if not settings.HIDE_REPO_CREDENTIALS: 1675 ↛ 1676line 1675 didn't jump to line 1676 because the condition on line 1675 was never true

1676 return message 

1677 return cleanup_repo_url(self.repo, message) 

1678 

1679 def add_ssh_host_key(self) -> None: 

1680 """ 

1681 Add SSH key for current repo as trusted. 

1682 

1683 This is essentially a TOFU approach. 

1684 """ 

1685 

1686 def add(repo) -> None: 

1687 self.log_info("checking for key to add for %s", repo) 

1688 parsed = urlparse(repo) 

1689 if not parsed.hostname: 

1690 parsed = urlparse(f"ssh://{repo}") 

1691 if not parsed.hostname: 

1692 return 

1693 try: 

1694 port = parsed.port 

1695 except ValueError: 

1696 port = "" 

1697 self.log_info("adding SSH key for %s:%s", parsed.hostname, port) 

1698 add_host_key(None, parsed.hostname, port) 

1699 

1700 add(self.repo) 

1701 if self.push: 

1702 add(self.push) 

1703 

1704 def handle_update_error(self, error_text, retry) -> None: 

1705 if "Host key verification failed" in error_text: 1705 ↛ 1706line 1705 didn't jump to line 1706 because the condition on line 1705 was never true

1706 if retry: 

1707 # Add ssh key and retry 

1708 self.add_ssh_host_key() 

1709 return 

1710 raise ValidationError( 

1711 { 

1712 "repo": gettext( 

1713 "Could not verify SSH host key, please add " 

1714 "them in SSH page in the admin interface." 

1715 ) 

1716 } 

1717 ) 

1718 if "terminal prompts disabled" in error_text: 1718 ↛ 1719line 1718 didn't jump to line 1719 because the condition on line 1718 was never true

1719 raise ValidationError( 

1720 { 

1721 "repo": gettext( 

1722 "The repository requires authentication, please specify " 

1723 "credentials in the URL or use SSH access instead." 

1724 ) 

1725 } 

1726 ) 

1727 raise ValidationError( 

1728 {"repo": gettext("Could not fetch the repository: %s") % error_text} 

1729 ) 

1730 

1731 @perform_on_link 

1732 def update_remote_branch(self, validate=False, retry=True): 

1733 """Pull from remote repository.""" 

1734 # Update 

1735 self.log_info("updating repository") 

1736 try: 

1737 with self.repository.lock: 

1738 start = time.monotonic() 

1739 try: 

1740 previous_revision = self.repository.last_remote_revision 

1741 except RepositoryError: 

1742 # Repository not yet configured 

1743 previous_revision = None 

1744 self.repository.update_remote() 

1745 timediff = time.monotonic() - start 

1746 self.log_info("update took %.2f seconds", timediff) 

1747 except RepositoryError as error: 

1748 report_error( 

1749 "Could not update the repository", 

1750 project=self.project, 

1751 skip_sentry=not settings.DEBUG, 

1752 ) 

1753 error_text = self.error_text(error) 

1754 if validate: 1754 ↛ 1757line 1754 didn't jump to line 1757 because the condition on line 1754 was always true

1755 self.handle_update_error(error_text, retry) 

1756 return self.update_remote_branch(True, False) 

1757 if self.id: 

1758 self.add_alert("UpdateFailure", error=error_text) 

1759 return False 

1760 

1761 for line in self.repository.last_output.splitlines(): 

1762 self.log_debug("update: %s", line) 

1763 try: 

1764 # This can actually fail without a remote repo 

1765 remote_revision = self.repository.last_remote_revision 

1766 except RepositoryError: 

1767 remote_revision = None 

1768 if previous_revision and remote_revision: 1768 ↛ 1777line 1768 didn't jump to line 1777 because the condition on line 1768 was always true

1769 if previous_revision == remote_revision: 1769 ↛ 1772line 1769 didn't jump to line 1772 because the condition on line 1769 was always true

1770 self.log_info("repository up to date at %s", previous_revision) 

1771 else: 

1772 self.log_info( 

1773 "repository updated from %s to %s", 

1774 previous_revision, 

1775 remote_revision, 

1776 ) 

1777 if self.id: 

1778 self.delete_alert("UpdateFailure") 

1779 if remote_revision and previous_revision != remote_revision: 1779 ↛ 1780line 1779 didn't jump to line 1780 because the condition on line 1779 was never true

1780 self.remote_revision = remote_revision 

1781 Component.objects.filter(pk=self.pk).update( 

1782 remote_revision=remote_revision 

1783 ) 

1784 return True 

1785 

1786 def configure_repo(self, validate=False, pull=True) -> None: 

1787 """Ensure repository is correctly set up.""" 

1788 if self.is_repo_link: 1788 ↛ 1789line 1788 didn't jump to line 1789 because the condition on line 1788 was never true

1789 return 

1790 

1791 if self.is_repo_local: 

1792 if not os.path.exists(os.path.join(self.full_path, ".git")): 

1793 if ( 1793 ↛ 1801line 1793 didn't jump to line 1801 because the condition on line 1793 was never true

1794 not self.template 

1795 and not self.file_format_cls.create_empty_bilingual 

1796 and not hasattr(self.file_format_cls, "update_bilingual") 

1797 ) or ( 

1798 self.template 

1799 and self.file_format_cls.get_new_file_content() is None 

1800 ): 

1801 raise ValidationError({"template": gettext("File does not exist.")}) 

1802 LocalRepository.from_files( 

1803 self.full_path, 

1804 {self.template: self.file_format_cls.get_new_file_content()} 

1805 if self.template 

1806 else {}, 

1807 ) 

1808 return 

1809 

1810 with self.repository.lock: 

1811 self.repository.configure_remote( 

1812 self.repo, self.push, self.branch, fast=not self.id 

1813 ) 

1814 self.repository.set_committer( 

1815 settings.DEFAULT_COMMITER_NAME, settings.DEFAULT_COMMITER_EMAIL 

1816 ) 

1817 

1818 if pull: 1818 ↛ exitline 1818 didn't jump to the function exit

1819 self.update_remote_branch(validate) 

1820 

1821 def configure_branch(self) -> None: 

1822 """Ensure local tracking branch exists and is checked out.""" 

1823 if self.is_repo_link: 1823 ↛ 1824line 1823 didn't jump to line 1824 because the condition on line 1823 was never true

1824 return 

1825 

1826 with self.repository.lock: 

1827 self.repository.configure_branch(self.branch) 

1828 

1829 def uses_changed_files(self, changed): 

1830 """Detect whether list of changed files matches configuration.""" 

1831 for filename in [self.template, self.intermediate, self.new_base]: 

1832 if filename and filename in changed: 1832 ↛ 1833line 1832 didn't jump to line 1833 because the condition on line 1832 was never true

1833 return True 

1834 return any(self.filemask_re.match(path) for path in changed) 

1835 

1836 def needs_commit_upstream(self) -> bool: 

1837 """ 

1838 Detect whether commit is needed because of upstream changes. 

1839 

1840 Inspect changed files in the upstream repository to see if any of them 

1841 would trigger parsing of translation files. In case there is none, the 

1842 repository can be merged without committing pending changes. 

1843 """ 

1844 changed = self.repository.get_changed_files() 

1845 if self.uses_changed_files(changed): 1845 ↛ 1846line 1845 didn't jump to line 1846 because the condition on line 1845 was never true

1846 return True 

1847 for component in self.linked_children: 1847 ↛ 1848line 1847 didn't jump to line 1848 because the loop on line 1847 never started

1848 if component.uses_changed_files(changed): 

1849 return True 

1850 return False 

1851 

1852 @perform_on_link 

1853 def do_update(self, request: AuthenticatedHttpRequest | None = None, method=None): 

1854 """Perform repository update.""" 

1855 self.translations_progress = 0 

1856 self.translations_count = 0 

1857 # Hold lock all time here to avoid somebody writing between commit 

1858 # and merge/rebase. 

1859 with self.repository.lock: 

1860 self.store_background_task() 

1861 self.progress_step(0) 

1862 self.configure_repo(pull=False) 

1863 

1864 # pull remote 

1865 if not self.update_remote_branch(): 1865 ↛ 1866line 1865 didn't jump to line 1866 because the condition on line 1865 was never true

1866 return False 

1867 

1868 self.configure_branch() 

1869 

1870 # do we have something to merge? 

1871 try: 

1872 needs_merge = self.repo_needs_merge() 

1873 except RepositoryError: 

1874 # Not yet configured repository 

1875 needs_merge = True 

1876 

1877 if not needs_merge and method != "rebase": 

1878 self.delete_alert("MergeFailure") 

1879 self.delete_alert("RepositoryOutdated") 

1880 return True 

1881 

1882 # commit possible pending changes if needed 

1883 if self.needs_commit_upstream(): 1883 ↛ 1884line 1883 didn't jump to line 1884 because the condition on line 1883 was never true

1884 self.commit_pending( 

1885 "update", request.user if request else None, skip_push=True 

1886 ) 

1887 

1888 # update local branch 

1889 try: 

1890 result = self.update_branch(request, method=method, skip_push=True) 

1891 except RepositoryError: 

1892 result = False 

1893 

1894 if result: 1894 ↛ 1896line 1894 didn't jump to line 1896 because the condition on line 1894 was never true

1895 # create translation objects for all files 

1896 self.create_translations(request=request) 

1897 

1898 # Push after possible merge 

1899 self.push_if_needed(do_update=False) 

1900 

1901 if not self.repo_needs_push(): 1901 ↛ 1904line 1901 didn't jump to line 1904 because the condition on line 1901 was always true

1902 self.delete_alert("RepositoryChanges") 

1903 

1904 self.progress_step(100) 

1905 self.translations_count = None 

1906 

1907 return result 

1908 

1909 @perform_on_link 

1910 def push_if_needed(self, do_update=True) -> None: 

1911 """ 

1912 Push changes to upstream if needed. 

1913 

1914 Checks for: 

1915 

1916 * Pushing on commit 

1917 * Configured push 

1918 * Whether there is something to push 

1919 """ 

1920 if not self.push_on_commit: 

1921 self.log_info("skipped push: push on commit disabled") 

1922 return 

1923 if not self.can_push(): 1923 ↛ 1926line 1923 didn't jump to line 1926 because the condition on line 1923 was always true

1924 self.log_info("skipped push: upstream not configured") 

1925 return 

1926 if not self.repo_needs_push(): 

1927 self.log_info( 

1928 "skipped push: nothing to push (%d/%d outgoing)", 

1929 self.count_repo_outgoing, 

1930 self.count_push_branch_outgoing, 

1931 ) 

1932 return 

1933 if settings.CELERY_TASK_ALWAYS_EAGER: 

1934 self.do_push(None, force_commit=False, do_update=do_update) 

1935 else: 

1936 from weblate.trans.tasks import perform_push 

1937 

1938 self.log_info("scheduling push") 

1939 perform_push.delay_on_commit( 

1940 self.pk, None, force_commit=False, do_update=do_update 

1941 ) 

1942 

1943 @perform_on_link 

1944 def push_repo(self, request: AuthenticatedHttpRequest, retry: bool = True): 

1945 """Push repository changes upstream.""" 

1946 with self.repository.lock: 

1947 self.log_info("pushing to remote repo") 

1948 try: 

1949 self.repository.push(self.push_branch) 

1950 except RepositoryError as error: 

1951 error_text = self.error_text(error) 

1952 report_error( 

1953 "Could not push the repo", 

1954 project=self.project, 

1955 skip_sentry=not settings.DEBUG, 

1956 ) 

1957 self.change_set.create( 

1958 action=ActionEvents.FAILED_PUSH, 

1959 target=error_text, 

1960 user=request.user if request else self.acting_user, 

1961 ) 

1962 if retry: 

1963 if "Host key verification failed" in error_text: 

1964 # Try adding SSH key and retry 

1965 self.add_ssh_host_key() 

1966 return self.push_repo(request, retry=False) 

1967 if "fetch first" in error_text: 

1968 # Upstream has moved, try additional update via calling do_push 

1969 return self.do_push(request, retry=False) 

1970 if ( 

1971 "shallow update not allowed" in error_text 

1972 or "expected old/new/ref, got 'shallow" in error_text 

1973 ): 

1974 try: 

1975 self.repository.unshallow() 

1976 except RepositoryError: 

1977 report_error( 

1978 "Could not unshallow the repo", 

1979 project=self.project, 

1980 skip_sentry=not settings.DEBUG, 

1981 ) 

1982 else: 

1983 return self.push_repo(request, retry=False) 

1984 messages.error( 

1985 request, 

1986 gettext("Could not push %(component)s: %(error_text)s") 

1987 % { 

1988 "component": self, 

1989 "error_text": error_text, 

1990 }, 

1991 ) 

1992 self.add_alert("PushFailure", error=error_text) 

1993 return False 

1994 self.delete_alert("RepositoryChanges") 

1995 self.delete_alert("PushFailure") 

1996 return True 

1997 

1998 @property 

1999 def pushes_to_different_location(self) -> bool: 

2000 return ( 

2001 self.branch != self.push_branch 

2002 or self.repository.pushes_to_different_location 

2003 ) 

2004 

2005 @perform_on_link 

2006 def do_push( 

2007 self, 

2008 request, 

2009 force_commit: bool = True, 

2010 do_update: bool = True, 

2011 retry: bool = True, 

2012 ) -> bool: 

2013 """Push changes to remote repo.""" 

2014 # Skip push for local only repo 

2015 if self.is_repo_local: 2015 ↛ 2019line 2015 didn't jump to line 2019 because the condition on line 2015 was always true

2016 return True 

2017 

2018 # Do we have push configured 

2019 if not self.can_push(): 

2020 messages.error(request, gettext("Push is turned off for %s.") % self) 

2021 return False 

2022 

2023 # Commit any pending changes 

2024 if force_commit: 

2025 self.commit_pending( 

2026 "push", request.user if request else None, skip_push=True 

2027 ) 

2028 

2029 # Do we have anything to push? 

2030 if not self.repo_needs_push(): 

2031 return True 

2032 

2033 if do_update: 

2034 # Update the repo 

2035 self.do_update(request) 

2036 

2037 # Were all changes merged? 

2038 if not self.pushes_to_different_location and self.repo_needs_merge(): 

2039 return False 

2040 

2041 # Send pre push signal 

2042 vcs_pre_push.send(sender=self.__class__, component=self) 

2043 for component in self.linked_children: 

2044 vcs_pre_push.send(sender=component.__class__, component=component) 

2045 

2046 # Do actual push 

2047 result = self.push_repo(request, retry=retry) 

2048 if not result: 

2049 return False 

2050 

2051 self.change_set.create( 

2052 action=ActionEvents.PUSH, 

2053 user=request.user if request else self.acting_user, 

2054 ) 

2055 

2056 vcs_post_push.send(sender=self.__class__, component=self) 

2057 for component in self.linked_children: 

2058 vcs_post_push.send(sender=component.__class__, component=component) 

2059 

2060 return True 

2061 

2062 @perform_on_link 

2063 def do_reset( 

2064 self, 

2065 request: AuthenticatedHttpRequest | None = None, 

2066 *, 

2067 keep_changes: bool = False, 

2068 ) -> bool: 

2069 """Reset repo to match remote.""" 

2070 from weblate.trans.tasks import perform_commit 

2071 

2072 with self.repository.lock: 

2073 previous_head = self.repository.last_revision 

2074 # First check we're up to date 

2075 self.update_remote_branch() 

2076 

2077 if keep_changes: 

2078 # Mark all strings as pending when keeping changes 

2079 self.do_file_sync(request, do_commit=False) 

2080 else: 

2081 # Explicitly remove all pending changes 

2082 PendingUnitChange.objects.filter( 

2083 unit__translation__component=self 

2084 ).delete() 

2085 

2086 # Do actual reset 

2087 try: 

2088 self.log_info("resetting to remote repo") 

2089 self.repository.reset() 

2090 except RepositoryError: 

2091 report_error( 

2092 "Could not reset the repository", 

2093 project=self.project, 

2094 skip_sentry=not settings.DEBUG, 

2095 ) 

2096 messages.error( 

2097 request, 

2098 gettext("Could not reset to remote branch on %s.") % self, 

2099 ) 

2100 return False 

2101 

2102 self.change_set.create( 

2103 action=ActionEvents.RESET, 

2104 user=request.user if request else self.acting_user, 

2105 details={ 

2106 "new_head": self.repository.last_revision, 

2107 "previous_head": previous_head, 

2108 }, 

2109 ) 

2110 self.delete_alert("MergeFailure") 

2111 self.delete_alert("RepositoryOutdated") 

2112 self.delete_alert("PushFailure") 

2113 

2114 if not keep_changes: 

2115 self.trigger_post_update( 

2116 previous_head=previous_head, 

2117 skip_push=False, 

2118 ) 

2119 

2120 # create translation objects for all files 

2121 self.create_translations(request=request, force=True) 

2122 

2123 if keep_changes: 

2124 # Trigger commit and scan in the background 

2125 perform_commit.delay_on_commit( 

2126 self.pk, 

2127 "reset-sync", 

2128 user_id=request.user.id if request else None, 

2129 force_scan=True, 

2130 previous_head=previous_head, 

2131 ) 

2132 return True 

2133 

2134 @perform_on_link 

2135 def do_cleanup(self, request: AuthenticatedHttpRequest | None = None) -> bool: 

2136 """Clean up the repository.""" 

2137 with self.repository.lock: 

2138 try: 

2139 self.log_info("cleaning up the repo") 

2140 self.repository.cleanup() 

2141 except RepositoryError: 

2142 report_error( 

2143 "Could not clean the repository", 

2144 project=self.project, 

2145 skip_sentry=not settings.DEBUG, 

2146 ) 

2147 messages.error( 

2148 request, 

2149 gettext("Could not clean the repository on %s.") % self, 

2150 ) 

2151 return False 

2152 

2153 return True 

2154 

2155 @perform_on_link 

2156 @transaction.atomic 

2157 def do_file_sync( 

2158 self, request: AuthenticatedHttpRequest | None = None, *, do_commit: bool = True 

2159 ) -> None: 

2160 from weblate.trans.models import Unit 

2161 from weblate.trans.tasks import perform_commit 

2162 

2163 for unit in Unit.objects.filter( 

2164 Q(translation__component=self) 

2165 | Q(translation__component__linked_component=self) 

2166 ).exclude( 

2167 Q(translation__language_id=F("translation__component__source_language_id")) 

2168 | Q(translation__filename="") 

2169 ): 

2170 PendingUnitChange.store_unit_change(unit) 

2171 

2172 self.change_set.create( 

2173 action=ActionEvents.FORCE_SYNC, 

2174 user=self.acting_user or (request.user if request else None), 

2175 ) 

2176 

2177 if do_commit: 

2178 perform_commit.delay_on_commit( 

2179 self.pk, "file-sync", user_id=request.user.id if request else None 

2180 ) 

2181 

2182 @perform_on_link 

2183 @transaction.atomic 

2184 def do_file_scan(self, request: AuthenticatedHttpRequest | None = None) -> bool: 

2185 self.commit_pending("file-scan", request.user if request else None) 

2186 self.change_set.create( 

2187 action=ActionEvents.FORCE_SCAN, 

2188 user=self.acting_user or (request.user if request else None), 

2189 ) 

2190 

2191 self.create_translations(request=request, force=True) 

2192 return True 

2193 

2194 def get_repo_link_url(self): 

2195 return "weblate://{}".format("/".join(self.get_url_path())) 

2196 

2197 @cached_property 

2198 def linked_children(self) -> ComponentQuerySet: 

2199 """Return list of components which links repository to us.""" 

2200 if self.is_repo_link: 2200 ↛ 2201line 2200 didn't jump to line 2201 because the condition on line 2200 was never true

2201 return self.component_set.none() 

2202 children = self.component_set.prefetch() 

2203 for child in children: 2203 ↛ 2204line 2203 didn't jump to line 2204 because the loop on line 2203 never started

2204 child.linked_component = self 

2205 return children 

2206 

2207 def get_linked_children_for_template(self): 

2208 return [ 

2209 { 

2210 "project_name": linked.project.name, 

2211 "name": linked.name, 

2212 "url": get_site_url(linked.get_absolute_url()), 

2213 } 

2214 for linked in self.linked_children 

2215 ] 

2216 

2217 @perform_on_link 

2218 def commit_pending( # noqa: C901 

2219 self, reason: str, user: User | None, skip_push: bool = False 

2220 ) -> bool: 

2221 """Check whether there is any translation to be committed.""" 

2222 from weblate.auth.models import User 

2223 

2224 def reuse_self(translation: Translation) -> Translation: 

2225 if translation.component_id == self.id: 2225 ↛ 2227line 2225 didn't jump to line 2227 because the condition on line 2225 was always true

2226 translation.component = self 

2227 if translation.component.linked_component_id == self.id: 2227 ↛ 2228line 2227 didn't jump to line 2228 because the condition on line 2227 was never true

2228 translation.component.linked_component = self 

2229 if translation.pk == translation.component.source_translation.pk: 2229 ↛ 2230line 2229 didn't jump to line 2230 because the condition on line 2229 was never true

2230 translation = translation.component.source_translation 

2231 return translation 

2232 

2233 if user is None: 

2234 user = User.objects.get_or_create_bot( 

2235 scope="weblate", name="commit", verbose="Background commit" 

2236 ) 

2237 

2238 # Get all translation with pending changes, source translation first 

2239 translations = sorted( 

2240 Translation.objects.filter(unit__pending_changes__isnull=False) 

2241 .filter(Q(component=self) | Q(component__linked_component=self)) 

2242 .distinct() 

2243 .prefetch_related("component"), 

2244 key=lambda translation: not translation.is_source, 

2245 ) 

2246 components = {} 

2247 skipped = set() 

2248 source_updated_components = [] 

2249 translation_pks = defaultdict(list) 

2250 was_changed: bool = False 

2251 

2252 if not translations: 

2253 return True 

2254 

2255 # Commit pending changes 

2256 with self.repository.lock: 

2257 for translation in translations: 

2258 translation = reuse_self(translation) 

2259 component = translation.component 

2260 if component.pk in skipped: 2260 ↛ 2262line 2260 didn't jump to line 2262 because the condition on line 2260 was never true

2261 # We already failed at this component 

2262 continue 

2263 if component.pk not in components: 

2264 # Validate template is valid 

2265 if component.has_template(): 2265 ↛ 2266line 2265 didn't jump to line 2266 because the condition on line 2265 was never true

2266 try: 

2267 component.template_store # noqa: B018 

2268 except FileParseError as error: 

2269 if not isinstance(error.__cause__, FileNotFoundError): 

2270 report_error( 

2271 "Could not parse template file on commit", 

2272 project=self.project, 

2273 ) 

2274 component.log_error( 

2275 "skipping commit due to error: %s", error 

2276 ) 

2277 component.update_import_alerts(delete=False) 

2278 skipped.add(component.pk) 

2279 continue 

2280 

2281 components[component.pk] = component 

2282 with self.start_sentry_span("commit_pending"): 

2283 translation_changed = translation._commit_pending(reason, user) # noqa: SLF001 

2284 was_changed |= translation_changed 

2285 if translation_changed and component.has_template(): 2285 ↛ 2286line 2285 didn't jump to line 2286 because the condition on line 2285 was never true

2286 translation_pks[component.pk].append(translation.pk) 

2287 if translation.is_source: 

2288 source_updated_components.append(component) 

2289 

2290 # Update hash of other translations, otherwise they would be seen as having change 

2291 for component in source_updated_components: 2291 ↛ 2292line 2291 didn't jump to line 2292 because the loop on line 2291 never started

2292 for translation in component.translation_set.exclude( 

2293 pk__in=translation_pks[component.pk] 

2294 ): 

2295 translation.store_hash() 

2296 

2297 if was_changed: 2297 ↛ 2309line 2297 didn't jump to line 2309 because the condition on line 2297 was always true

2298 self.store_local_revision() 

2299 

2300 # Fire postponed post commit signals 

2301 for component in components.values(): 

2302 component.send_post_commit_signal() 

2303 component.update_import_alerts(delete=False) 

2304 

2305 # Push if enabled 

2306 if not skip_push: 2306 ↛ 2309line 2306 didn't jump to line 2309 because the condition on line 2306 was always true

2307 self.push_if_needed() 

2308 

2309 return True 

2310 

2311 def commit_files( 

2312 self, 

2313 *, 

2314 template: str | None = None, 

2315 author: str | None = None, 

2316 timestamp: datetime | None = None, 

2317 files: list[str] | None = None, 

2318 signals: bool = True, 

2319 skip_push: bool = False, 

2320 extra_context: dict[str, Any] | None = None, 

2321 message: str | None = None, 

2322 component: models.Model | None = None, 

2323 store_hash: bool = True, 

2324 ): 

2325 """Commit files to the repository.""" 

2326 linked = self.linked_component 

2327 if linked: 2327 ↛ 2328line 2327 didn't jump to line 2328 because the condition on line 2327 was never true

2328 return linked.commit_files( 

2329 template=template, 

2330 author=author, 

2331 timestamp=timestamp, 

2332 files=files, 

2333 signals=signals, 

2334 skip_push=skip_push, 

2335 extra_context=extra_context, 

2336 message=message, 

2337 component=self, 

2338 ) 

2339 

2340 with self.start_sentry_span("commit_files"): 

2341 if message is None: 2341 ↛ 2354line 2341 didn't jump to line 2354 because the condition on line 2341 was always true

2342 if template is None: 2342 ↛ 2343line 2342 didn't jump to line 2343 because the condition on line 2342 was never true

2343 msg = "Missing template when message is not specified" 

2344 raise ValueError(msg) 

2345 # Handle context 

2346 context = {"component": component or self, "author": author} 

2347 if extra_context: 2347 ↛ 2351line 2347 didn't jump to line 2351 because the condition on line 2347 was always true

2348 context.update(extra_context) 

2349 

2350 # Generate commit message 

2351 message = render_template(template, **context) 

2352 

2353 # Actual commit 

2354 if not self.repository.commit(message, author, localtime(timestamp), files): 

2355 return False 

2356 

2357 # Send post commit signal 

2358 if signals: 

2359 self.send_post_commit_signal(store_hash=store_hash) 

2360 

2361 self.store_local_revision() 

2362 

2363 # Push if we should 

2364 if not skip_push: 

2365 self.push_if_needed() 

2366 

2367 return True 

2368 

2369 def send_post_commit_signal(self, store_hash: bool = True) -> None: 

2370 vcs_post_commit.send( 

2371 sender=self.__class__, component=self, store_hash=store_hash 

2372 ) 

2373 

2374 def get_parse_error_message(self, error) -> str: 

2375 error_message = getattr(error, "strerror", "") 

2376 if not error_message: 

2377 error_message = getattr(error, "message", "") 

2378 if not error_message: 

2379 error_message = str(error).replace(self.full_path, "") 

2380 return error_message 

2381 

2382 def handle_parse_error( 

2383 self, error, translation=None, filename=None, reraise: bool = True 

2384 ) -> None: 

2385 """Process parse errors.""" 

2386 error_message = self.get_parse_error_message(error) 

2387 if filename is None: 

2388 filename = self.template if translation is None else translation.filename 

2389 self.trigger_alert("ParseError", error=error_message, filename=filename) 

2390 if self.id: 

2391 self.change_set.create( 

2392 translation=translation, 

2393 action=ActionEvents.PARSE_ERROR, 

2394 details={"error_message": error_message, "filename": filename}, 

2395 user=self.acting_user, 

2396 ) 

2397 if reraise: 

2398 raise FileParseError(error_message) from error 

2399 

2400 def store_local_revision(self) -> None: 

2401 """Store current revision in the database.""" 

2402 self.local_revision = self.repository.last_revision 

2403 # Avoid using using save as that does complex things and we 

2404 # just want to update the database 

2405 Component.objects.filter(Q(pk=self.pk) | Q(linked_component=self)).update( 

2406 local_revision=self.local_revision 

2407 ) 

2408 

2409 @perform_on_link 

2410 def update_branch( 

2411 self, 

2412 request: AuthenticatedHttpRequest | None = None, 

2413 method: str | None = None, 

2414 skip_push: bool = False, 

2415 ) -> bool: 

2416 """Update current branch to match remote (if possible).""" 

2417 if method is None: 

2418 method = self.merge_style 

2419 user = request.user if request else self.acting_user 

2420 # run pre update hook 

2421 vcs_pre_update.send(sender=self.__class__, component=self) 

2422 for component in self.linked_children: 2422 ↛ 2423line 2422 didn't jump to line 2423 because the loop on line 2422 never started

2423 vcs_pre_update.send(sender=component.__class__, component=component) 

2424 

2425 # Apply logic for merge or rebase 

2426 if method == "rebase": 

2427 method_func = self.repository.rebase 

2428 error_msg = gettext("Could not rebase local branch onto remote branch %s.") 

2429 action = ActionEvents.REBASE 

2430 action_failed = ActionEvents.FAILED_REBASE 

2431 kwargs = {} 

2432 else: 

2433 method_func = self.repository.merge 

2434 error_msg = gettext("Could not merge remote branch into %s.") 

2435 action = ActionEvents.MERGE 

2436 action_failed = ActionEvents.FAILED_MERGE 

2437 kwargs = {"message": render_template(self.merge_message, component=self)} 

2438 if method == "merge_noff": 2438 ↛ 2439line 2438 didn't jump to line 2439 because the condition on line 2438 was never true

2439 kwargs["no_ff"] = True 

2440 

2441 with self.repository.lock: 

2442 try: 

2443 previous_head = self.repository.last_revision 

2444 # Try to merge it 

2445 method_func(**kwargs) 

2446 new_head = self.repository.last_revision 

2447 self.log_info( 

2448 "%s remote into repo %s..%s", method, previous_head, new_head 

2449 ) 

2450 except RepositoryError as error: 

2451 # Report error 

2452 report_error( 

2453 f"Failed {method}", 

2454 project=self.project, 

2455 skip_sentry=not settings.DEBUG, 

2456 ) 

2457 

2458 # In case merge has failure recover 

2459 error = self.error_text(error) 

2460 status = self.repository.status() 

2461 

2462 # Log error 

2463 if self.id: 

2464 self.change_set.create( 

2465 action=action_failed, 

2466 target=error, 

2467 user=user, 

2468 details={"error": error, "status": status}, 

2469 ) 

2470 self.add_alert("MergeFailure", error=error) 

2471 

2472 # Reset repo back 

2473 method_func(abort=True) 

2474 

2475 # Tell user (if there is any) 

2476 messages.error(request, error_msg % self) 

2477 

2478 raise 

2479 

2480 # Delete alerts 

2481 if self.id: 2481 ↛ 2488line 2481 didn't jump to line 2488 because the condition on line 2481 was always true

2482 self.delete_alert("MergeFailure") 

2483 self.delete_alert("RepositoryOutdated") 

2484 if not self.repo_needs_push(): 2484 ↛ 2488line 2484 didn't jump to line 2488 because the condition on line 2484 was always true

2485 self.delete_alert("PushFailure") 

2486 

2487 # New upstream matches previous local revision 

2488 if self.local_revision == new_head: 

2489 return False 

2490 

2491 if self.id: 2491 ↛ 2511line 2491 didn't jump to line 2511

2492 self.store_local_revision() 

2493 

2494 # Record change 

2495 self.change_set.create( 

2496 action=action, 

2497 user=user, 

2498 details={"new_head": new_head, "previous_head": previous_head}, 

2499 ) 

2500 

2501 # The files have been updated and the signal receivers (addons) 

2502 # might need to access the template 

2503 self.drop_template_store_cache() 

2504 

2505 # Run post update hook, this should be done with repo lock held 

2506 # to avoid possible race with another update 

2507 self.trigger_post_update( 

2508 previous_head=previous_head, 

2509 skip_push=skip_push, 

2510 ) 

2511 return True 

2512 

2513 @perform_on_link 

2514 def trigger_post_update(self, *, previous_head: str, skip_push: bool) -> None: 

2515 vcs_post_update.send( 

2516 sender=self.__class__, 

2517 component=self, 

2518 previous_head=previous_head, 

2519 skip_push=skip_push, 

2520 ) 

2521 for component in self.linked_children: 2521 ↛ 2522line 2521 didn't jump to line 2522 because the loop on line 2521 never started

2522 vcs_post_update.send( 

2523 sender=component.__class__, 

2524 component=component, 

2525 previous_head=previous_head, 

2526 skip_push=skip_push, 

2527 ) 

2528 

2529 def get_mask_matches(self) -> list[str]: 

2530 """Return files matching current mask.""" 

2531 prefix = path_separator(os.path.join(self.full_path, "")) 

2532 matches = set() 

2533 for filename in glob(os.path.join(self.full_path, self.filemask)): 

2534 path = path_separator(filename).replace(prefix, "") 

2535 code = self.get_lang_code(path) 

2536 if re.match(self.language_regex, code) and code != "source": 2536 ↛ 2539line 2536 didn't jump to line 2539 because the condition on line 2536 was always true

2537 matches.add(path) 

2538 else: 

2539 self.log_info("skipping language %s [%s]", code, path) 

2540 

2541 # Remove symlinked translations 

2542 for filename in list(matches): 

2543 resolved = self.repository.resolve_symlinks(filename) 

2544 if resolved != filename and resolved in matches: 2544 ↛ 2545line 2544 didn't jump to line 2545 because the condition on line 2544 was never true

2545 matches.discard(filename) 

2546 

2547 if self.has_template(): 2547 ↛ 2549line 2547 didn't jump to line 2549 because the condition on line 2547 was never true

2548 # We do not want to show intermediate translation standalone 

2549 if self.intermediate: 

2550 matches.discard(self.intermediate) 

2551 # We want to list template among translations as well 

2552 matches.discard(self.template) 

2553 return [self.template, *sorted(matches)] 

2554 return sorted(matches) 

2555 

2556 def update_source_checks(self) -> None: 

2557 self.log_info("running source checks for %d strings", len(self.updated_sources)) 

2558 for unit in self.updated_sources.values(): 

2559 unit.is_batch_update = True 

2560 unit.run_checks() 

2561 self.updated_sources = {} 

2562 

2563 @cached_property 

2564 def all_active_alerts(self): 

2565 result = self.alert_set.filter(dismissed=False) 

2566 list(result) 

2567 return result 

2568 

2569 @cached_property 

2570 def all_alerts(self): 

2571 return {alert.name: alert for alert in self.alert_set.all()} 

2572 

2573 @property 

2574 def lock_alerts(self): 

2575 if not self.auto_lock_error: 

2576 return [] 

2577 return [ 

2578 alert for alert in self.all_active_alerts if alert.name in LOCKING_ALERTS 

2579 ] 

2580 

2581 def trigger_alert(self, name: str, **kwargs) -> None: 

2582 if name in self.alerts_trigger: 

2583 self.alerts_trigger[name].append(kwargs) 

2584 else: 

2585 self.alerts_trigger[name] = [kwargs] 

2586 

2587 def delete_alert(self, alert: str) -> None: 

2588 if alert in self.all_alerts: 

2589 self.all_alerts[alert].delete() 

2590 del self.all_alerts[alert] 

2591 if ( 2591 ↛ 2605line 2591 didn't jump to line 2605 because the condition on line 2591 was never true

2592 self.locked 

2593 and self.auto_lock_error 

2594 and alert in LOCKING_ALERTS 

2595 and not self.alert_set.filter(name__in=LOCKING_ALERTS).exists() 

2596 and getattr( 

2597 # The object might not exist 

2598 self.change_set.filter(action=ActionEvents.LOCK) 

2599 .order_by("-id") 

2600 .first(), 

2601 "auto_status", 

2602 None, 

2603 ) 

2604 ): 

2605 self.do_lock(user=None, lock=False, auto=True) 

2606 

2607 if ALERTS[alert].link_wide: 

2608 for component in self.linked_children: 2608 ↛ 2609line 2608 didn't jump to line 2609 because the loop on line 2608 never started

2609 component.delete_alert(alert) 

2610 

2611 def add_alert(self, alert: str, noupdate: bool = False, **details) -> None: 

2612 if alert in self.all_alerts: 

2613 obj = self.all_alerts[alert] 

2614 created = False 

2615 else: 

2616 obj, created = self.alert_set.get_or_create( 

2617 name=alert, defaults={"details": details} 

2618 ) 

2619 self.all_alerts[alert] = obj 

2620 

2621 # Automatically lock on error 

2622 if created and self.auto_lock_error and alert in LOCKING_ALERTS: 2622 ↛ 2623line 2622 didn't jump to line 2623 because the condition on line 2622 was never true

2623 self.do_lock(user=None, lock=True, auto=True) 

2624 

2625 # Update details with exception of component removal 

2626 if not created and not noupdate: 

2627 obj.details = details 

2628 obj.save() 

2629 

2630 if ALERTS[alert].link_wide: 2630 ↛ 2631line 2630 didn't jump to line 2631 because the condition on line 2630 was never true

2631 for component in self.linked_children: 

2632 component.add_alert(alert, noupdate=noupdate, **details) 

2633 

2634 def update_import_alerts(self, delete: bool = True) -> None: 

2635 self.log_info("checking triggered alerts") 

2636 for alert in ALERTS_IMPORT: 

2637 if alert in self.alerts_trigger: 

2638 self.add_alert(alert, occurrences=self.alerts_trigger[alert]) 

2639 elif delete: 

2640 self.delete_alert(alert) 

2641 self.alerts_trigger = {} 

2642 

2643 def create_translations( 

2644 self, 

2645 *, 

2646 force: bool = False, 

2647 force_scan: bool = False, 

2648 langs: list[str] | None = None, 

2649 request: AuthenticatedHttpRequest | None = None, 

2650 changed_template: bool = False, 

2651 from_link: bool = False, 

2652 change: int | None = None, 

2653 ) -> bool: 

2654 """Load translations from VCS.""" 

2655 if settings.CELERY_TASK_ALWAYS_EAGER: 2655 ↛ 2659line 2655 didn't jump to line 2659 because the condition on line 2655 was never true

2656 # Asynchronous processing not available, run the update 

2657 # directly from the request processing. 

2658 # NOTE: In case the lock cannot be acquired, an error will be raised. 

2659 return self.create_translations_immediate( 

2660 force=force, 

2661 force_scan=force_scan, 

2662 langs=langs, 

2663 request=request, 

2664 changed_template=changed_template, 

2665 from_link=from_link, 

2666 change=change, 

2667 ) 

2668 

2669 from weblate.trans.tasks import perform_load 

2670 

2671 self.log_info("scheduling update in background") 

2672 perform_load.delay_on_commit( 

2673 pk=self.pk, 

2674 force=force, 

2675 force_scan=force_scan, 

2676 langs=langs, 

2677 changed_template=changed_template, 

2678 from_link=from_link, 

2679 change=change, 

2680 user_id=request.user.id if request is not None else None, 

2681 ) 

2682 return False 

2683 

2684 def create_translations_immediate( 

2685 self, 

2686 *, 

2687 force: bool = False, 

2688 force_scan: bool = False, 

2689 langs: list[str] | None = None, 

2690 request: AuthenticatedHttpRequest | None = None, 

2691 changed_template: bool = False, 

2692 from_link: bool = False, 

2693 change: int | None = None, 

2694 ) -> bool: 

2695 """ 

2696 Load translations from VCS synchronously. 

2697 

2698 Should not be called directly, except from Celery tasks. 

2699 """ 

2700 # In case the lock cannot be acquired, an error will be raised. 

2701 with self.lock, self.start_sentry_span("create_translations"): # pylint: disable=not-context-manager 

2702 return self._create_translations( 

2703 force=force, 

2704 force_scan=force_scan, 

2705 langs=langs, 

2706 request=request, 

2707 changed_template=changed_template, 

2708 from_link=from_link, 

2709 change=change, 

2710 ) 

2711 

2712 def check_template_valid(self) -> None: 

2713 if self._template_check_done: 

2714 return 

2715 if self.has_template(): 2715 ↛ 2717line 2715 didn't jump to line 2717 because the condition on line 2715 was never true

2716 # Avoid parsing if template is invalid 

2717 try: 

2718 self.template_store.check_valid() 

2719 except (ValueError, FileParseError) as exc: 

2720 raise InvalidTemplateError(info=str(exc)) from exc 

2721 self._template_check_done = True 

2722 

2723 def _create_translations( # noqa: C901,PLR0915 

2724 self, 

2725 *, 

2726 force: bool = False, 

2727 force_scan: bool = False, 

2728 langs: list[str] | None = None, 

2729 request: AuthenticatedHttpRequest | None = None, 

2730 changed_template: bool = False, 

2731 from_link: bool = False, 

2732 change: int | None = None, 

2733 ) -> bool: 

2734 """Load translations from VCS.""" 

2735 from weblate.trans.tasks import update_enforced_checks 

2736 

2737 self.store_background_task() 

2738 

2739 # Store the revision as add-ons might update it later 

2740 current_revision = self.local_revision 

2741 

2742 if ( 

2743 self.processed_revision == current_revision 

2744 and self.local_revision 

2745 and not force 

2746 and not force_scan 

2747 ): 

2748 self.log_info("this revision has been already parsed, skipping update") 

2749 self.progress_step(100) 

2750 return False 

2751 

2752 # Ensure we start from fresh template 

2753 self.drop_template_store_cache() 

2754 self.unload_sources() 

2755 self.needs_cleanup = False 

2756 self.updated_sources = {} 

2757 self.alerts_trigger = {} 

2758 self.start_batched_checks() 

2759 was_change = False 

2760 translations = {} 

2761 languages = {} 

2762 matches = self.get_mask_matches() 

2763 

2764 source_file = self.template 

2765 

2766 if not self.has_template(): 2766 ↛ 2787line 2766 didn't jump to line 2787 because the condition on line 2766 was always true

2767 # This creates the translation when necessary 

2768 translation = self.source_translation 

2769 

2770 if ( 

2771 self.file_format == "po" 

2772 and self.new_base.endswith(".pot") 

2773 and os.path.exists(self.get_new_base_filename()) 

2774 ): 

2775 # Process pot file as source to include additional metadata 

2776 matches = [self.new_base, *matches] 

2777 source_file = self.new_base 

2778 else: 

2779 # Always include source language to avoid parsing matching files 

2780 languages[self.source_language.code] = translation 

2781 translations[translation.id] = translation 

2782 

2783 # Delete old source units after change from monolingual to bilingual 

2784 if changed_template: 2784 ↛ 2785line 2784 didn't jump to line 2785 because the condition on line 2784 was never true

2785 translation.unit_set.all().delete() 

2786 

2787 if self.translations_count != -1: 2787 ↛ 2792line 2787 didn't jump to line 2792 because the condition on line 2787 was always true

2788 self.translations_progress = 0 

2789 self.translations_count = len(matches) + sum( 

2790 c.translation_set.count() for c in self.linked_children 

2791 ) 

2792 for pos, path in enumerate(matches): 

2793 if not self._sources_prefetched and path != source_file: 

2794 self.preload_sources() 

2795 with transaction.atomic(): 

2796 if path == source_file: 

2797 code = self.source_language.code 

2798 else: 

2799 code = self.get_lang_code(path) 

2800 if langs is not None and code not in langs: 2800 ↛ 2801line 2800 didn't jump to line 2801 because the condition on line 2800 was never true

2801 self.log_info("skipping %s", path) 

2802 continue 

2803 

2804 self.log_info( 

2805 "checking %s (%s) [%d/%d]", path, code, pos + 1, len(matches) 

2806 ) 

2807 lang = Language.objects.auto_get_or_create( 

2808 code=self.get_language_alias(code), 

2809 languages_cache=self.project.languages_cache, 

2810 ) 

2811 if lang.code in languages: 2811 ↛ 2812line 2811 didn't jump to line 2812 because the condition on line 2811 was never true

2812 codes = f"{code}, {languages[lang.code].language_code}" 

2813 filenames = f"{path}, {languages[lang.code].filename}" 

2814 detail = f"{lang.code} ({codes})" 

2815 self.log_warning("duplicate language found: %s", detail) 

2816 self.trigger_alert( 

2817 "DuplicateLanguage", 

2818 codes=codes, 

2819 language_code=lang.code, 

2820 filenames=filenames, 

2821 ) 

2822 continue 

2823 try: 

2824 translation = Translation.objects.check_sync( 

2825 self, 

2826 lang, 

2827 code, 

2828 path, 

2829 force, 

2830 request=request, 

2831 change=change, 

2832 ) 

2833 except InvalidTemplateError as error: 

2834 self.log_warning( 

2835 "skipping update due to error in parsing template: %s", 

2836 error.__cause__, 

2837 ) 

2838 self.handle_parse_error(error.__cause__, filename=self.template) 

2839 self.update_import_alerts() 

2840 raise error.__cause__ from error # pylint: disable=E0710 

2841 was_change |= bool(translation.reason) 

2842 translations[translation.id] = translation 

2843 languages[lang.code] = translation 

2844 # Unload the store to save memory as we won't need it again 

2845 translation.drop_store_cache() 

2846 # Remove fuzzy flag on template name change 

2847 if changed_template and self.template: 2847 ↛ 2848line 2847 didn't jump to line 2848 because the condition on line 2847 was never true

2848 translation.unit_set.filter(state=STATE_FUZZY).update( 

2849 state=STATE_TRANSLATED 

2850 ) 

2851 self.progress_step() 

2852 

2853 # Delete possibly no longer existing translations 

2854 if langs is None: 2854 ↛ 2868line 2854 didn't jump to line 2868 because the condition on line 2854 was always true

2855 todelete = self.translation_set.exclude(id__in=translations.keys()) 

2856 if todelete.exists(): 2856 ↛ 2857line 2856 didn't jump to line 2857 because the condition on line 2856 was never true

2857 self.needs_cleanup = True 

2858 with transaction.atomic(): 

2859 self.log_info( 

2860 "removing stale translations: %s", 

2861 ",".join(trans.language.code for trans in todelete), 

2862 ) 

2863 todelete.delete() 

2864 # Indicate a change to invalidate stats 

2865 was_change = True 

2866 

2867 # Update import alerts 

2868 self.update_import_alerts() 

2869 # Clean no matches alert if there are translations: 

2870 if translations: 2870 ↛ 2874line 2870 didn't jump to line 2874 because the condition on line 2870 was always true

2871 self.delete_alert("NoMaskMatches") 

2872 

2873 # Process linked repos 

2874 for pos, component in enumerate(self.linked_children): 2874 ↛ 2875line 2874 didn't jump to line 2875 because the loop on line 2874 never started

2875 self.log_info( 

2876 "updating linked project %s [%d/%d]", 

2877 component, 

2878 pos + 1, 

2879 len(self.linked_children), 

2880 ) 

2881 component.translations_count = -1 

2882 try: 

2883 # Do not run these linked repos update as other background tasks. 

2884 was_change |= component.create_translations_immediate( 

2885 force=force, langs=langs, request=request, from_link=True 

2886 ) 

2887 except FileParseError as error: 

2888 if not isinstance(error.__cause__, FileNotFoundError): 

2889 report_error("Failed linked component update", project=self.project) 

2890 continue 

2891 

2892 # Run source checks on updated source strings 

2893 if self.updated_sources: 

2894 self.update_source_checks() 

2895 

2896 # Update flags 

2897 if was_change: 2897 ↛ 2901line 2897 didn't jump to line 2901 because the condition on line 2897 was always true

2898 self.invalidate_cache() 

2899 

2900 # Schedule background cleanup if needed 

2901 if self.needs_cleanup and not self.template: 

2902 from weblate.trans.tasks import cleanup_component 

2903 

2904 cleanup_component.delay_on_commit(self.id) 

2905 

2906 if was_change: 2906 ↛ 2912line 2906 didn't jump to line 2912 because the condition on line 2906 was always true

2907 if self.needs_variants_update: 2907 ↛ 2908line 2907 didn't jump to line 2908 because the condition on line 2907 was never true

2908 self.update_variants() 

2909 component_post_update.send(sender=self.__class__, component=self) 

2910 self.schedule_sync_terminology() 

2911 

2912 self.unload_sources() 

2913 self.run_batched_checks() 

2914 

2915 # Update last processed revision 

2916 self.processed_revision = current_revision 

2917 # Avoid using save() here 

2918 Component.objects.filter(pk=self.pk).update(processed_revision=current_revision) 

2919 

2920 if self.enforced_checks: 2920 ↛ 2921line 2920 didn't jump to line 2921 because the condition on line 2920 was never true

2921 update_enforced_checks.delay_on_commit(component=self.pk) 

2922 

2923 self.log_info("updating completed") 

2924 return was_change 

2925 

2926 def start_batched_checks(self) -> None: 

2927 self.batch_checks = True 

2928 self.batched_checks = set() 

2929 

2930 def run_batched_checks(self) -> None: 

2931 # Batch checks 

2932 if self.batched_checks: 

2933 from weblate.checks.tasks import batch_update_checks 

2934 

2935 batched_checks = list(self.batched_checks) 

2936 if settings.CELERY_TASK_ALWAYS_EAGER: 2936 ↛ 2937line 2936 didn't jump to line 2937 because the condition on line 2936 was never true

2937 batch_update_checks(self.id, batched_checks, component=self) 

2938 else: 

2939 batch_update_checks.delay_on_commit(self.id, batched_checks) 

2940 self.batch_checks = False 

2941 self.batched_checks = set() 

2942 

2943 def _invalidate_trigger(self) -> None: 

2944 self._invalidate_scheduled = False 

2945 self.log_info("updating stats caches") 

2946 self.stats.update_language_stats() 

2947 self.invalidate_glossary_cache() 

2948 

2949 def invalidate_cache(self) -> None: 

2950 if self._invalidate_scheduled: 2950 ↛ 2951line 2950 didn't jump to line 2951 because the condition on line 2950 was never true

2951 return 

2952 

2953 self._invalidate_scheduled = True 

2954 transaction.on_commit(self._invalidate_trigger) 

2955 

2956 @cached_property 

2957 def glossary_sources_key(self) -> str: 

2958 return f"component-glossary-{self.pk}" 

2959 

2960 @cached_property 

2961 def glossary_sources(self): 

2962 from weblate.glossary.models import get_glossary_sources 

2963 

2964 result = cache.get(self.glossary_sources_key) 

2965 if result is None: 

2966 result = get_glossary_sources(self) 

2967 cache.set(self.glossary_sources_key, result, 24 * 3600) 

2968 return result 

2969 

2970 def invalidate_glossary_cache(self) -> None: 

2971 if not self.is_glossary: 

2972 return 

2973 cache.delete(self.glossary_sources_key) 

2974 self.project.invalidate_glossary_cache() 

2975 for project in self.cached_links: 2975 ↛ 2976line 2975 didn't jump to line 2976 because the loop on line 2975 never started

2976 project.invalidate_glossary_cache() 

2977 if "glossary_sources" in self.__dict__: 2977 ↛ 2978line 2977 didn't jump to line 2978 because the condition on line 2977 was never true

2978 del self.__dict__["glossary_sources"] 

2979 

2980 def get_lang_code(self, path, validate=False): 

2981 """Parse language code from path.""" 

2982 # Directly return source language code unless validating 

2983 if not validate and path == self.template: 2983 ↛ 2984line 2983 didn't jump to line 2984 because the condition on line 2983 was never true

2984 return self.source_language.code 

2985 # Parse filename 

2986 matches = self.filemask_re.match(path) 

2987 

2988 if not matches or not matches.lastindex: 2988 ↛ 2989line 2988 didn't jump to line 2989 because the condition on line 2988 was never true

2989 if path == self.template: 

2990 return self.source_language.code 

2991 return "" 

2992 

2993 # Use longest matched code 

2994 code = max(matches.groups(), key=len) 

2995 

2996 # Remove possible encoding part 

2997 if "." in code and (".utf" in code.lower() or ".iso" in code.lower()): 2997 ↛ 2998line 2997 didn't jump to line 2998 because the condition on line 2997 was never true

2998 return code.split(".")[0] 

2999 return code 

3000 

3001 def sync_git_repo( 

3002 self, 

3003 *, 

3004 validate: bool = False, 

3005 skip_push: bool = False, 

3006 skip_commit: bool = False, 

3007 ) -> None: 

3008 """Bring VCS repo in sync with current model.""" 

3009 if self.is_repo_link: 3009 ↛ 3010line 3009 didn't jump to line 3010 because the condition on line 3009 was never true

3010 return 

3011 if skip_push is None: 3011 ↛ 3012line 3011 didn't jump to line 3012 because the condition on line 3011 was never true

3012 skip_push = validate 

3013 self.configure_repo(validate) 

3014 if not skip_commit and self.id: 

3015 self.commit_pending("sync", None, skip_push=skip_push) 

3016 self.configure_branch() 

3017 if self.id: 

3018 # Update existing repo 

3019 self.update_branch(skip_push=skip_push) 

3020 else: 

3021 # Reset to upstream in case not yet saved model (this is called 

3022 # from the clean method only) 

3023 with self.repository.lock: 

3024 self.update_remote_branch() 

3025 self.repository.reset() 

3026 

3027 def set_default_branch(self) -> None: 

3028 """Set default VCS branch if empty.""" 

3029 if not self.branch and not self.is_repo_link: 

3030 self.branch = self.repository_class.get_remote_branch(self.repo) 

3031 

3032 def clean_category(self) -> None: 

3033 if self.category: 3033 ↛ 3034line 3033 didn't jump to line 3034 because the condition on line 3033 was never true

3034 if self.category.project != self.project: 

3035 raise ValidationError( 

3036 {"category": gettext("Category does not belong to this project.")} 

3037 ) 

3038 if self.pk and self.links.exists(): 

3039 raise ValidationError( 

3040 gettext("Categorized component can not be shared.") 

3041 ) 

3042 

3043 def clean_repo_link(self) -> None: 

3044 """Validate repository link.""" 

3045 if self.is_repo_link: 3045 ↛ 3046line 3045 didn't jump to line 3046 because the condition on line 3045 was never true

3046 try: 

3047 repo = Component.objects.get_linked(self.repo) 

3048 except (Component.DoesNotExist, ValueError) as error: 

3049 raise ValidationError( 

3050 { 

3051 "repo": gettext( 

3052 "Invalid link to a Weblate project, " 

3053 "use weblate://project/component." 

3054 ) 

3055 } 

3056 ) from error 

3057 else: 

3058 if repo is not None and repo.is_repo_link: 

3059 raise ValidationError( 

3060 { 

3061 "repo": gettext( 

3062 "Invalid link to a Weblate project, " 

3063 "cannot link to linked repository!" 

3064 ) 

3065 } 

3066 ) 

3067 if repo.pk == self.pk: 

3068 raise ValidationError( 

3069 { 

3070 "repo": gettext( 

3071 "Invalid link to a Weblate project, " 

3072 "cannot link it to itself!" 

3073 ) 

3074 } 

3075 ) 

3076 # Push repo is not used with link 

3077 for setting in ("push", "branch", "push_branch"): 

3078 if getattr(self, setting): 

3079 raise ValidationError( 

3080 { 

3081 setting: gettext( 

3082 "Option is not available for linked repositories. " 

3083 "Setting from linked component will be used." 

3084 ) 

3085 } 

3086 ) 

3087 # Make sure we are not using stale link even if link is not present 

3088 self.linked_component = Component.objects.get_linked(self.repo) 

3089 

3090 def clean_lang_codes(self, matches: list[str]) -> None: 

3091 """Validate that there are no double language codes.""" 

3092 if not matches and not self.is_valid_base_for_new(): 

3093 raise ValidationError( 

3094 {"filemask": gettext("The file mask did not match any files.")} 

3095 ) 

3096 langs: dict[str, str] = {} 

3097 existing_langs: set[str] = set() 

3098 

3099 for match in matches: 

3100 code = self.get_lang_code(match, validate=True) 

3101 lang = validate_language_code(self.get_language_alias(code), match, True) 

3102 if lang.code in langs: 3102 ↛ 3103line 3102 didn't jump to line 3103 because the condition on line 3102 was never true

3103 message = gettext( 

3104 "There is more than one file for %(language)s language: " 

3105 "%(filename1)s, %(filename2)s " 

3106 "Please adjust the file mask and use components for translating " 

3107 "different resources." 

3108 ) % { 

3109 "language": lang, 

3110 "filename1": match, 

3111 "filename2": langs[lang.code], 

3112 } 

3113 raise ValidationError({"filemask": message}) 

3114 

3115 langs[lang.code] = match 

3116 if lang.id: 3116 ↛ 3099line 3116 didn't jump to line 3099 because the condition on line 3116 was always true

3117 existing_langs.add(lang.code) 

3118 

3119 # No languages matched our definition 

3120 if not existing_langs and langs: 3120 ↛ 3121line 3120 didn't jump to line 3121 because the condition on line 3120 was never true

3121 message = gettext( 

3122 "Could not find any matching language, please check the file mask." 

3123 ) 

3124 raise ValidationError({"filemask": message}) 

3125 

3126 def clean_files(self, matches: list[str]) -> None: 

3127 """Validate that translation files can be parsed.""" 

3128 errors: list[tuple[str, Exception]] = [] 

3129 dir_path = self.full_path 

3130 for match in matches: 

3131 try: 

3132 store = self.file_format_cls( # pylint: disable=too-many-function-args,unexpected-keyword-arg 

3133 os.path.join(dir_path, match), 

3134 self.template_store, 

3135 file_format_params=self.file_format_params, 

3136 ) 

3137 store.check_valid() 

3138 except Exception as error: 

3139 errors.append((match, error)) 

3140 if errors: 

3141 if len(errors) == 1: 3141 ↛ 3142line 3141 didn't jump to line 3142 because the condition on line 3141 was never true

3142 msg = format_html( 

3143 gettext("Could not parse {file}: {error}"), 

3144 file=format_html("<code>{}</code>", errors[0][0]), 

3145 error=errors[0][1], 

3146 ) 

3147 else: 

3148 msg = format_html( 

3149 "{}<br>{}", 

3150 ngettext( 

3151 "Could not parse %d matched file.", 

3152 "Could not parse %d matched files.", 

3153 len(errors), 

3154 ) 

3155 % len(errors), 

3156 format_html_join( 

3157 mark_safe("<br>"), 

3158 "<code>{}</code>: {}", 

3159 errors, 

3160 ), 

3161 ) 

3162 raise ValidationError({"filemask": msg}) 

3163 

3164 def is_valid_base_for_new( 

3165 self, errors: list[Exception] | None = None, fast: bool = False 

3166 ) -> bool: 

3167 filename = self.get_new_base_filename() 

3168 template = self.has_template() 

3169 return self.file_format_cls.is_valid_base_for_new( 

3170 filename, 

3171 template, 

3172 errors, 

3173 fast=fast, 

3174 file_format_params=self.file_format_params, 

3175 ) 

3176 

3177 def clean_new_lang(self) -> None: 

3178 """Validate new language choices.""" 

3179 # Validate if new base is configured or language adding is set 

3180 if (not self.new_base and self.new_lang != "add") or not self.file_format: 

3181 return 

3182 # File is valid or no file is needed 

3183 errors: list[Exception] = [] 

3184 if self.is_valid_base_for_new(errors): 3184 ↛ 3187line 3184 didn't jump to line 3187 because the condition on line 3184 was always true

3185 return 

3186 # File is needed, but not present 

3187 if not self.new_base: 

3188 message = gettext( 

3189 "You have set up Weblate to add new translation " 

3190 "files, but did not provide a base file to do that." 

3191 ) 

3192 raise ValidationError({"new_base": message, "new_lang": message}) 

3193 filename = self.get_new_base_filename() 

3194 # File is present, but does not exist 

3195 if not os.path.exists(filename): 

3196 raise ValidationError({"new_base": gettext("File does not exist.")}) 

3197 # File is present, but it is not valid 

3198 if errors: 

3199 message = gettext( 

3200 "Could not parse base file for new translations: %s" 

3201 ) % format_html_join_comma("{}", list_to_tuples(errors)) 

3202 raise ValidationError({"new_base": message}) 

3203 raise ValidationError( 

3204 {"new_base": gettext("Unrecognized base file for new translations.")} 

3205 ) 

3206 

3207 def clean_template(self) -> None: 

3208 """Validate template value.""" 

3209 # Test for unexpected template usage 

3210 if ( 3210 ↛ 3215line 3210 didn't jump to line 3215 because the condition on line 3210 was never true

3211 self.template 

3212 and self.file_format 

3213 and self.file_format_cls.monolingual is False 

3214 ): 

3215 msg = gettext("You can not use a base file for bilingual translation.") 

3216 raise ValidationError({"template": msg, "file_format": msg}) 

3217 

3218 if self.edit_template and not self.file_format_cls.can_edit_base: 

3219 msg = gettext("Editing template is not supported with this file format.") 

3220 raise ValidationError({"edit_template": msg}) 

3221 

3222 # Prohibit intermediate usage without template 

3223 if self.intermediate and not self.template: 3223 ↛ 3224line 3223 didn't jump to line 3224 because the condition on line 3223 was never true

3224 msg = gettext( 

3225 "An intermediate language file can not be used " 

3226 "without an editing template." 

3227 ) 

3228 raise ValidationError({"template": msg, "intermediate": msg}) 

3229 if self.intermediate and self.intermediate == self.template: 3229 ↛ 3230line 3229 didn't jump to line 3230 because the condition on line 3229 was never true

3230 raise ValidationError( 

3231 { 

3232 # Translators: it refers to the intermediate field in the form 

3233 "intermediate": gettext( 

3234 "An intermediate language file has to be different from " 

3235 "monolingual base language file. You can probably keep it " 

3236 "empty." 

3237 ) 

3238 } 

3239 ) 

3240 if self.intermediate and not self.edit_template: 3240 ↛ 3241line 3240 didn't jump to line 3241 because the condition on line 3240 was never true

3241 msg = gettext( 

3242 "An intermediate language file can not be used " 

3243 "without an editing template." 

3244 ) 

3245 raise ValidationError({"edit_template": msg, "intermediate": msg}) 

3246 

3247 # Special case for Gettext 

3248 if self.template.endswith(".pot") and self.filemask.endswith(".po"): 3248 ↛ 3249line 3248 didn't jump to line 3249 because the condition on line 3248 was never true

3249 msg = gettext("Using a .pot file as base file is unsupported.") 

3250 raise ValidationError({"template": msg}) 

3251 

3252 if not self.file_format: 3252 ↛ 3253line 3252 didn't jump to line 3253 because the condition on line 3252 was never true

3253 return 

3254 

3255 # Validate template loading 

3256 if self.has_template(): 3256 ↛ 3257line 3256 didn't jump to line 3257 because the condition on line 3256 was never true

3257 self.create_template_if_missing() 

3258 full_path = os.path.join(self.full_path, self.template) 

3259 if not os.path.exists(full_path): 

3260 raise ValidationError({"template": gettext("File does not exist.")}) 

3261 

3262 try: 

3263 self.template_store.check_valid() 

3264 except (FileParseError, ValueError) as error: 

3265 msg = gettext("Could not parse translation base file: %s") % str(error) 

3266 raise ValidationError({"template": msg}) from error 

3267 

3268 code = self.get_lang_code(self.template, validate=True) 

3269 lang = validate_language_code( 

3270 self.get_language_alias(code), self.template, required=False 

3271 ) 

3272 if lang: 

3273 lang_code = lang.base_code 

3274 if lang_code != self.source_language.base_code: 

3275 msg = gettext( 

3276 "Template language ({0}) does not match source language ({1})!" 

3277 ).format(lang_code, self.source_language.code) 

3278 raise ValidationError({"template": msg, "source_language": msg}) 

3279 

3280 elif self.file_format_cls.monolingual: 

3281 msg = gettext( 

3282 "You can not use a monolingual translation without a base file." 

3283 ) 

3284 raise ValidationError({"template": msg}) 

3285 

3286 def clean_repo(self) -> None: 

3287 self.clean_repo_link() 

3288 

3289 # Baild out on failed repo validation 

3290 if self.repo is None: 3290 ↛ 3291line 3290 didn't jump to line 3291 because the condition on line 3290 was never true

3291 return 

3292 

3293 if not self.is_repo_local and self.repo == "local:": 

3294 raise ValidationError( 

3295 {"vcs": gettext("Choose No remote repository for local: URL.")} 

3296 ) 

3297 

3298 if self.is_repo_local and self.push: 

3299 raise ValidationError( 

3300 {"push": gettext("Push URL is not used without a remote repository.")} 

3301 ) 

3302 

3303 # Validate VCS repo 

3304 try: 

3305 self.set_default_branch() 

3306 

3307 self.sync_git_repo(validate=True, skip_push=True) 

3308 except RepositoryError as error: 

3309 text = self.error_text(error) 

3310 if "terminal prompts disabled" in text: 3310 ↛ 3311line 3310 didn't jump to line 3311 because the condition on line 3310 was never true

3311 raise ValidationError( 

3312 { 

3313 "repo": gettext( 

3314 "Your push URL seems to miss credentials. Either provide " 

3315 "them in the URL or use SSH with key based authentication." 

3316 ) 

3317 } 

3318 ) from error 

3319 msg = gettext("Could not update repository: %s") % text 

3320 raise ValidationError({"repo": msg}) from error 

3321 

3322 if ( 3322 ↛ 3327line 3322 didn't jump to line 3327 because the condition on line 3322 was never true

3323 issubclass(self.repository_class, GitMergeRequestBase) 

3324 and self.repo == self.push 

3325 and self.branch == self.push_branch 

3326 ): 

3327 msg = gettext( 

3328 "Pull and push branches cannot be the same when using merge requests." 

3329 ) 

3330 raise ValidationError({"push_branch": msg}) 

3331 

3332 def clean_file_format_params(self) -> None: 

3333 for param in [ 3333 ↛ 3336line 3333 didn't jump to line 3336 because the loop on line 3333 never started

3334 p for p in FILE_FORMATS_PARAMS if p.name in self.file_format_params 

3335 ]: 

3336 if self.file_format not in param.file_formats: 

3337 message = gettext( 

3338 "The parameter '%(param)s' is not applicable for the file format '%(format)s'." 

3339 ) % {"param": param.name, "format": self.file_format} 

3340 raise ValidationError({"file_format_params": message}) 

3341 

3342 def clean(self) -> None: 

3343 """ 

3344 Validate component parameters. 

3345 

3346 - validation fetches repository 

3347 - it tries to find translation files and checks that they are valid 

3348 """ 

3349 self.drop_file_format_cache() 

3350 if self.new_lang == "url" and not self.project.instructions: 

3351 msg = gettext( 

3352 "Please either fill in an instruction URL " 

3353 "or use a different option for adding a new language." 

3354 ) 

3355 raise ValidationError({"new_lang": msg}) 

3356 

3357 # Skip validation if we don't have valid project 

3358 if self.project_id is None or not self.file_format: 3358 ↛ 3359line 3358 didn't jump to line 3359 because the condition on line 3358 was never true

3359 return 

3360 

3361 # Check if we should rename 

3362 changed_git = True 

3363 if self.id: 

3364 old = Component.objects.get(pk=self.id) 

3365 self.check_rename(old, validate=True) 

3366 changed_git = ( 

3367 (old.vcs != self.vcs) 

3368 or (old.repo != self.repo) 

3369 or (old.push != self.push) 

3370 or (old.branch != self.branch) 

3371 or (old.filemask != self.filemask) 

3372 or (old.language_regex != self.language_regex) 

3373 ) 

3374 if old.source_language != self.source_language: 3374 ↛ 3380line 3374 didn't jump to line 3380 because the condition on line 3374 was never true

3375 # Might be implemented in future, but needs to handle: 

3376 # - properly toggle read-only flag for source translation 

3377 # - remap screenshots to different units 

3378 # - remap source string comments 

3379 # - possibly adjust other metadata 

3380 raise ValidationError( 

3381 { 

3382 "source_language": gettext( 

3383 "Source language can not be changed, " 

3384 "please recreate the component instead." 

3385 ) 

3386 } 

3387 ) 

3388 

3389 self.clean_unique_together() 

3390 

3391 # Check repo if config was changes 

3392 if changed_git: 

3393 self.drop_repository_cache() 

3394 self.clean_repo() 

3395 

3396 self.clean_category() 

3397 

3398 # Template validation 

3399 self.clean_template() 

3400 

3401 # New language options 

3402 self.clean_new_lang() 

3403 

3404 # File format parameters 

3405 self.clean_file_format_params() 

3406 

3407 try: 

3408 matches = self.get_mask_matches() 

3409 

3410 # Verify language codes 

3411 self.clean_lang_codes(matches) 

3412 

3413 # Try parsing files 

3414 self.clean_files(matches) 

3415 except re.error as error: 

3416 raise ValidationError( 

3417 gettext( 

3418 "Can not validate file matches due to invalid regular expression." 

3419 ) 

3420 ) from error 

3421 

3422 # Suggestions 

3423 if ( 3423 ↛ 3428line 3423 didn't jump to line 3428 because the condition on line 3423 was never true

3424 hasattr(self, "suggestion_autoaccept") 

3425 and self.suggestion_autoaccept 

3426 and not self.suggestion_voting 

3427 ): 

3428 msg = gettext( 

3429 "Accepting suggestions automatically only works with voting turned on." 

3430 ) 

3431 raise ValidationError( 

3432 {"suggestion_autoaccept": msg, "suggestion_voting": msg} 

3433 ) 

3434 

3435 if self.key_filter and not self.has_template(): 3435 ↛ 3436line 3435 didn't jump to line 3436 because the condition on line 3435 was never true

3436 raise ValidationError( 

3437 gettext("To use the key filter, the file format must be monolingual.") 

3438 ) 

3439 

3440 def get_template_filename(self): 

3441 """Create absolute filename for template.""" 

3442 return os.path.join(self.full_path, self.template) 

3443 

3444 def get_intermediate_filename(self): 

3445 """Create absolute filename for intermediate.""" 

3446 return os.path.join(self.full_path, self.intermediate) 

3447 

3448 def get_new_base_filename(self): 

3449 """Create absolute filename for base file for new translations.""" 

3450 if not self.new_base: 

3451 return None 

3452 return os.path.join(self.full_path, self.new_base) 

3453 

3454 def create_template_if_missing(self) -> None: 

3455 """Create blank template in case intermediate language is enabled.""" 

3456 fullname = self.get_template_filename() 

3457 if ( 3457 ↛ 3464line 3457 didn't jump to line 3464 because the condition on line 3457 was always true

3458 not self.intermediate 

3459 or not self.is_valid_base_for_new() 

3460 or os.path.exists(fullname) 

3461 or not os.path.exists(self.get_intermediate_filename()) 

3462 ): 

3463 return 

3464 self.file_format_cls.add_language( 

3465 fullname, 

3466 self.source_language, 

3467 self.get_new_base_filename(), 

3468 file_format_params=self.file_format_params, 

3469 ) 

3470 

3471 # Skip commit in case Component is not yet saved (called during validation) 

3472 if not self.pk: 

3473 return 

3474 

3475 with self.repository.lock: 

3476 self.commit_files( 

3477 template=self.add_message, 

3478 author="Weblate <noreply@weblate.org>", 

3479 extra_context={ 

3480 "translation": Translation( 

3481 filename=self.template, 

3482 language_code=self.source_language.code, 

3483 language=self.source_language, 

3484 component=self, 

3485 pk=-1, 

3486 ) 

3487 }, 

3488 files=[fullname], 

3489 ) 

3490 

3491 def after_save( 

3492 self, 

3493 *, 

3494 changed_git: bool, 

3495 changed_setup: bool, 

3496 changed_template: bool, 

3497 changed_variant: bool, 

3498 changed_enforced_checks: bool, 

3499 skip_push: bool, 

3500 create: bool, 

3501 ) -> None: 

3502 self.store_background_task() 

3503 self.translations_progress = 0 

3504 self.translations_count = 0 

3505 self.progress_step(0) 

3506 # Configure git repo if there were changes 

3507 if changed_git: 

3508 # Bring VCS repo in sync with current model 

3509 self.sync_git_repo(skip_push=skip_push, skip_commit=create) 

3510 

3511 # Create template in case intermediate file is present 

3512 self.create_template_if_missing() 

3513 

3514 # Rescan for possibly new translations if there were changes, needs to 

3515 # be done after actual creating the object above 

3516 was_change = False 

3517 if changed_setup: 

3518 was_change = self.create_translations( 

3519 force=True, changed_template=changed_template 

3520 ) 

3521 elif changed_git: 

3522 was_change = self.create_translations() 

3523 

3524 # Update variants (create_translation does this on change) 

3525 if changed_variant and not was_change: 3525 ↛ 3526line 3525 didn't jump to line 3526 because the condition on line 3525 was never true

3526 self.update_variants() 

3527 

3528 # Update changed enforced checks 

3529 if changed_enforced_checks: 3529 ↛ 3530line 3529 didn't jump to line 3530 because the condition on line 3529 was never true

3530 self.update_enforced_checks() 

3531 

3532 self.progress_step(100) 

3533 self.translations_count = None 

3534 

3535 # Invalidate stats on template change 

3536 if changed_template: 3536 ↛ 3537line 3536 didn't jump to line 3537 because the condition on line 3536 was never true

3537 self.invalidate_cache() 

3538 

3539 # Update alerts after stats update 

3540 self.update_alerts() 

3541 if self.linked_component: 3541 ↛ 3542line 3541 didn't jump to line 3542 because the condition on line 3541 was never true

3542 self.linked_component.update_alerts() 

3543 

3544 # Make sure we create glossary 

3545 if create and settings.CREATE_GLOSSARIES: 

3546 self.create_glossary() 

3547 

3548 # Make sure all languages are present 

3549 self.schedule_sync_terminology() 

3550 

3551 # Run automatically installed addons. They are run upon installation, 

3552 # but there are no translations created at that point. This complements 

3553 # installation in install_autoaddon. 

3554 for addon in self.addons_cache["__all__"]: 3554 ↛ 3556line 3554 didn't jump to line 3556 because the loop on line 3554 never started

3555 # Skip addons installed elsewhere (repo/project wide) 

3556 if addon.component_id != self.id: 

3557 continue 

3558 self.log_debug("triggering add-on: %s", addon.name) 

3559 addon.addon.post_configure_run() 

3560 

3561 def update_variants(self, updated_units=None) -> None: 

3562 from weblate.trans.models import Unit 

3563 

3564 component_units = Unit.objects.filter(translation__component=self, variant=None) 

3565 

3566 if updated_units is None: 3566 ↛ 3571line 3566 didn't jump to line 3571 because the condition on line 3566 was always true

3567 # Assume all units without a variant were updated 

3568 process_units = component_units 

3569 updated_unit_id_hashes = set() 

3570 else: 

3571 process_units = updated_units 

3572 updated_unit_id_hashes = {unit.id_hash for unit in updated_units} 

3573 

3574 # Delete stale regex variants 

3575 self.variant_set.exclude(variant_regex__in=("", self.variant_regex)).delete() 

3576 

3577 # Handle regex based variants 

3578 if self.variant_regex: 3578 ↛ 3579line 3578 didn't jump to line 3579 because the condition on line 3578 was never true

3579 variant_re = re.compile(self.variant_regex) 

3580 units = process_units.filter(context__regex=self.variant_regex) 

3581 variant_updates: dict[str, tuple[Variant, list[int]]] = {} 

3582 for unit in units.iterator(): 

3583 if variant_re.findall(unit.context): 

3584 key = variant_re.sub("", unit.context) 

3585 if key in variant_updates: 

3586 variant = variant_updates[key][0] 

3587 else: 

3588 variant = Variant.objects.get_or_create( 

3589 key=key, component=self, variant_regex=self.variant_regex 

3590 )[0] 

3591 variant_updates[key] = (variant, []) 

3592 variant_updates[key][1].append(unit.pk) 

3593 

3594 if variant_updates: 

3595 for variant, unit_ids in variant_updates.values(): 

3596 # Update matching units 

3597 Unit.objects.filter(pk__in=unit_ids).update(variant=variant) 

3598 # Update variant links for keys 

3599 component_units.filter(context=variant.key).update(variant=variant) 

3600 

3601 # Update variant links 

3602 for variant in self.variant_set.filter(variant_regex="").prefetch_related( 3602 ↛ 3605line 3602 didn't jump to line 3605 because the loop on line 3602 never started

3603 "defining_units" 

3604 ): 

3605 defining_units = {unit.id_hash for unit in variant.defining_units.all()} 

3606 if updated_unit_id_hashes and not updated_unit_id_hashes & defining_units: 

3607 continue 

3608 # Link based on source string or defining units 

3609 component_units.filter( 

3610 Q(source=variant.key) | Q(id_hash__in=defining_units) 

3611 ).update(variant=variant) 

3612 

3613 # Delete stale variant links 

3614 self.variant_set.annotate(unit_count=Count("defining_units")).filter( 

3615 variant_regex="", unit_count=0 

3616 ).delete() 

3617 

3618 def _update_alerts(self) -> None: 

3619 self._alerts_scheduled = False 

3620 # Flush alerts case, mostly needed for tests 

3621 self.__dict__.pop("all_alerts", None) 

3622 

3623 update_alerts(self) 

3624 

3625 # Update libre checklist upon save on all components in a project 

3626 if ( 3626 ↛ 3631line 3626 didn't jump to line 3631 because the condition on line 3626 was never true

3627 settings.OFFER_HOSTING 

3628 and self.project.billings 

3629 and self.project.billing.plan.price == 0 

3630 ): 

3631 for component in self.project.child_components: 

3632 update_alerts(component, {"NoLibreConditions"}) 

3633 

3634 def update_alerts(self) -> None: 

3635 if self._alerts_scheduled: 3635 ↛ 3636line 3635 didn't jump to line 3636 because the condition on line 3635 was never true

3636 return 

3637 

3638 self._alerts_scheduled = True 

3639 transaction.on_commit(self._update_alerts) 

3640 

3641 def get_ambiguous_translations(self): 

3642 return self.translation_set.filter(language__code__in=AMBIGUOUS.keys()) 

3643 

3644 @property 

3645 def count_pending_units(self): 

3646 """Return count of pending units.""" 

3647 from weblate.trans.models import Unit 

3648 

3649 return ( 

3650 Unit.objects.filter( 

3651 translation__component=self, pending_changes__isnull=False 

3652 ) 

3653 .distinct() 

3654 .count() 

3655 ) 

3656 

3657 @property 

3658 def count_repo_missing(self): 

3659 try: 

3660 return self.repository.count_missing() 

3661 except RepositoryError as error: 

3662 report_error( 

3663 "Could check merge needed", 

3664 project=self.project, 

3665 skip_sentry=not settings.DEBUG, 

3666 ) 

3667 self.add_alert("MergeFailure", error=self.error_text(error)) 

3668 return 0 

3669 

3670 def _get_count_repo_outgoing(self, retry: bool = True): 

3671 try: 

3672 return self.repository.count_outgoing() 

3673 except RepositoryError as error: 

3674 error_text = self.error_text(error) 

3675 if retry and "Host key verification failed" in error_text: 

3676 self.add_ssh_host_key() 

3677 return self._get_count_repo_outgoing(retry=False) 

3678 report_error( 

3679 "Could check push needed", 

3680 project=self.project, 

3681 skip_sentry=not settings.DEBUG, 

3682 ) 

3683 self.add_alert("PushFailure", error=error_text) 

3684 return 0 

3685 

3686 @property 

3687 def count_repo_outgoing(self): 

3688 return self._get_count_repo_outgoing() 

3689 

3690 @property 

3691 def count_push_branch_outgoing(self): 

3692 try: 

3693 return self.repository.count_outgoing(self.push_branch) 

3694 except RepositoryError: 

3695 # We silently ignore this error as push branch might not be existing if not needed 

3696 return self.count_repo_outgoing 

3697 

3698 def needs_commit(self): 

3699 """Check whether there are some not committed changes.""" 

3700 return self.count_pending_units > 0 

3701 

3702 def repo_needs_merge(self): 

3703 """Check for unmerged commits from remote repository.""" 

3704 return self.count_repo_missing > 0 

3705 

3706 def repo_needs_push(self, retry: bool = True): 

3707 """Check for something to push to remote repository.""" 

3708 return self.count_push_branch_outgoing > 0 

3709 

3710 @property 

3711 def file_format_name(self): 

3712 return self.file_format_cls.name 

3713 

3714 @property 

3715 def file_format_create_style(self): 

3716 return self.file_format_cls.create_style 

3717 

3718 @cached_property 

3719 def file_format_flags(self): 

3720 return Flags(self.file_format_cls.check_flags) 

3721 

3722 @cached_property 

3723 def file_format_cls(self) -> type[TranslationFormat]: 

3724 """Return file format object.""" 

3725 return FILE_FORMATS[self.file_format] 

3726 

3727 def has_template(self) -> bool: 

3728 """Return true if component is using template for translation.""" 

3729 monolingual = self.file_format_cls.monolingual 

3730 return (monolingual or monolingual is None) and bool(self.template) 

3731 

3732 def drop_template_store_cache(self) -> None: 

3733 if "template_store" in self.__dict__: 

3734 del self.__dict__["template_store"] 

3735 if "intermediate_store" in self.__dict__: 3735 ↛ 3736line 3735 didn't jump to line 3736 because the condition on line 3735 was never true

3736 del self.__dict__["intermediate_store"] 

3737 

3738 def drop_repository_cache(self) -> None: 

3739 if "repository" in self.__dict__: 3739 ↛ 3740line 3739 didn't jump to line 3740 because the condition on line 3739 was never true

3740 del self.__dict__["repository"] 

3741 

3742 def drop_addons_cache(self) -> None: 

3743 if "addons_cache" in self.__dict__: 

3744 del self.__dict__["addons_cache"] 

3745 

3746 def drop_key_filter_cache(self) -> None: 

3747 """Invalidate the cached value of key_filter.""" 

3748 if "key_filter_re" in self.__dict__: 

3749 del self.__dict__["key_filter_re"] 

3750 

3751 def drop_file_format_cache(self) -> None: 

3752 self.__dict__.pop("file_format_cls", None) 

3753 

3754 def load_intermediate_store(self): 

3755 """Load translate-toolkit store for intermediate.""" 

3756 return self.file_format_cls( # pylint: disable=too-many-function-args,unexpected-keyword-arg 

3757 self.get_intermediate_filename(), 

3758 language_code=self.source_language.code, 

3759 source_language=self.source_language.code, 

3760 file_format_params=self.file_format_params, 

3761 ) 

3762 

3763 @cached_property 

3764 def intermediate_store(self): 

3765 """Get translate-toolkit store for intermediate.""" 

3766 # Do we need template? 

3767 if not self.has_template() or not self.intermediate: 

3768 return None 

3769 

3770 try: 

3771 return self.load_intermediate_store() 

3772 except Exception as exc: 

3773 self.handle_parse_error(exc, filename=self.intermediate) 

3774 

3775 def load_template_store(self, fileobj=None): 

3776 """Load translate-toolkit store for template.""" 

3777 with self.start_sentry_span("load_template_store"): 

3778 return self.file_format_cls( # pylint: disable=too-many-function-args,unexpected-keyword-arg 

3779 fileobj or self.get_template_filename(), 

3780 language_code=self.source_language.code, 

3781 source_language=self.source_language.code, 

3782 is_template=True, 

3783 file_format_params=self.file_format_params, 

3784 ) 

3785 

3786 @cached_property 

3787 def template_store(self): 

3788 """Get translate-toolkit store for template.""" 

3789 # Do we need template? 

3790 if not self.has_template(): 3790 ↛ 3793line 3790 didn't jump to line 3793 because the condition on line 3790 was always true

3791 return None 

3792 

3793 try: 

3794 return self.load_template_store() 

3795 except Exception as error: 

3796 if not isinstance(error, FileNotFoundError): 

3797 report_error("Template parse error", project=self.project) 

3798 self.handle_parse_error(error, filename=self.template) 

3799 

3800 @cached_property 

3801 def all_flags(self): 

3802 """Return parsed list of flags.""" 

3803 return Flags(self.project.check_flags, self.file_format_flags, self.check_flags) 

3804 

3805 @property 

3806 def is_multivalue(self): 

3807 return self.file_format_cls.has_multiple_strings 

3808 

3809 def can_add_new_language(self, user: User | None, fast: bool = False): 

3810 """ 

3811 Check if a new language can be added. 

3812 

3813 Generic users can add only if configured, in other situations it works if there 

3814 is valid new base. 

3815 """ 

3816 # Consistency and possibly other add-ons 

3817 if user is not None and user.is_bot and user.username.startswith("addon:"): 3817 ↛ 3818line 3817 didn't jump to line 3818 because the condition on line 3817 was never true

3818 user = None 

3819 # The user is None in case of consistency or cli invocation 

3820 # The component.edit permission is intentional here as it allows overriding 

3821 # of new_lang configuration for admins and add languages even if adding 

3822 # for users is not configured. 

3823 self.new_lang_error_message = gettext("Could not add new translation file.") 

3824 if ( 3824 ↛ 3829line 3824 didn't jump to line 3829 because the condition on line 3824 was never true

3825 self.new_lang != "add" 

3826 and user is not None 

3827 and not user.has_perm("component.edit", self) 

3828 ): 

3829 self.new_lang_error_message = gettext( 

3830 "You do not have permissions to add new translation file." 

3831 ) 

3832 return False 

3833 

3834 # Check if template can be parsed 

3835 if self.has_template(): 3835 ↛ 3836line 3835 didn't jump to line 3836 because the condition on line 3835 was never true

3836 if not os.path.exists(self.get_template_filename()): 

3837 self.new_lang_error_message = gettext( 

3838 "The monolingual base language file is invalid." 

3839 ) 

3840 return False 

3841 if not fast: 

3842 try: 

3843 self.template_store.check_valid() 

3844 except (FileParseError, ValueError): 

3845 self.new_lang_error_message = gettext( 

3846 "The monolingual base language file is invalid." 

3847 ) 

3848 return False 

3849 

3850 self.new_lang_error_message = gettext( 

3851 "The template for new translations is invalid." 

3852 ) 

3853 if self.new_base and not os.path.exists(self.get_new_base_filename()): 3853 ↛ 3854line 3853 didn't jump to line 3854 because the condition on line 3853 was never true

3854 return False 

3855 return self.is_valid_base_for_new(fast=fast) 

3856 

3857 def format_new_language_code(self, language): 

3858 # Language code used for file 

3859 code = self.file_format_cls.get_language_code( 

3860 language.code, self.language_code_style 

3861 ) 

3862 

3863 # Apply language aliases 

3864 language_aliases = {v: k for k, v in self.project.language_aliases_dict.items()} 

3865 if code in language_aliases: 3865 ↛ 3866line 3865 didn't jump to line 3866 because the condition on line 3865 was never true

3866 code = language_aliases[code] 

3867 return code 

3868 

3869 @transaction.atomic 

3870 def add_new_language( # noqa: C901 

3871 self, 

3872 language: Language, 

3873 request: AuthenticatedHttpRequest | None, 

3874 send_signal: bool = True, 

3875 create_translations: bool = True, 

3876 show_messages: bool = True, 

3877 ) -> Translation | None: 

3878 """Create new language file.""" 

3879 if not self.can_add_new_language(request.user if request else None): 3879 ↛ 3880line 3879 didn't jump to line 3880 because the condition on line 3879 was never true

3880 if show_messages: 

3881 messages.error(request, cast("str", self.new_lang_error_message)) 

3882 return None 

3883 

3884 file_format = self.file_format_cls 

3885 

3886 # Language code used for file 

3887 code = self.format_new_language_code(language) 

3888 

3889 # Check if resulting language is not present 

3890 new_lang = Language.objects.fuzzy_get_strict(code=self.get_language_alias(code)) 

3891 if new_lang is not None: 3891 ↛ 3908line 3891 didn't jump to line 3908 because the condition on line 3891 was always true

3892 if new_lang == self.source_language: 3892 ↛ 3893line 3892 didn't jump to line 3893 because the condition on line 3892 was never true

3893 if show_messages: 

3894 messages.error( 

3895 request, 

3896 gettext("The given language is used as a source language."), 

3897 ) 

3898 return None 

3899 

3900 if self.translation_set.filter(language=new_lang).exists(): 3900 ↛ 3901line 3900 didn't jump to line 3901 because the condition on line 3900 was never true

3901 if show_messages: 

3902 messages.error( 

3903 request, gettext("The given language already exists.") 

3904 ) 

3905 return None 

3906 

3907 # Check if language code is valid 

3908 if re.match(self.language_regex, code) is None: 3908 ↛ 3909line 3908 didn't jump to line 3909 because the condition on line 3908 was never true

3909 if show_messages: 

3910 messages.error( 

3911 request, 

3912 gettext("The given language is filtered by the language filter."), 

3913 ) 

3914 return None 

3915 

3916 base_filename = self.get_new_base_filename() 

3917 

3918 filename = file_format.get_language_filename(self.filemask, code) 

3919 fullname = os.path.join(self.full_path, filename) 

3920 

3921 with self.repository.lock: 

3922 if create_translations: 3922 ↛ 3923line 3922 didn't jump to line 3923 because the condition on line 3922 was never true

3923 self.commit_pending("add language", None) 

3924 

3925 # Create or get translation object 

3926 translation, created = self.translation_set.get_or_create( 

3927 language=language, 

3928 defaults={ 

3929 "plural": language.plural, 

3930 "filename": filename, 

3931 "language_code": code, 

3932 }, 

3933 ) 

3934 # Make it clear that there is no change for the newly created translation 

3935 # to avoid expensive last change lookup in stats while committing changes. 

3936 if created: 3936 ↛ 3940line 3936 didn't jump to line 3940 because the condition on line 3936 was always true

3937 Change.store_last_change(translation, None) 

3938 

3939 # Create the file 

3940 if os.path.exists(fullname): 3940 ↛ 3944line 3940 didn't jump to line 3944 because the condition on line 3940 was never true

3941 # Ignore request if file exists (possibly race condition as 

3942 # the processing of new language can take some time and user 

3943 # can submit again) 

3944 if show_messages: 

3945 messages.error(request, gettext("Translation file already exists!")) 

3946 else: 

3947 file_format.add_language( 

3948 fullname, 

3949 language, 

3950 base_filename, 

3951 file_format_params=self.file_format_params, 

3952 ) 

3953 if send_signal: 3953 ↛ 3957line 3953 didn't jump to line 3957 because the condition on line 3953 was always true

3954 translation_post_add.send( 

3955 sender=self.__class__, translation=translation 

3956 ) 

3957 translation.git_commit( 

3958 request.user if request else None, 

3959 request.user.get_author_name() 

3960 if request 

3961 else "Weblate <noreply@weblate.org>", 

3962 template=self.add_message, 

3963 store_hash=False, 

3964 ) 

3965 

3966 # Trigger parsing of the newly added file 

3967 if create_translations: 3967 ↛ 3970line 3967 didn't jump to line 3970 because the condition on line 3967 was never true

3968 # Forced scanning is needed in case adding file does not trigger commit, 

3969 # for example when adding appstore metadata which only creates directory. 

3970 self.create_translations(request=request, force_scan=True) 

3971 if show_messages: 

3972 messages.info( 

3973 request, 

3974 gettext("The translation will be updated in the background."), 

3975 ) 

3976 

3977 # Delete no matches alert as we have just added the file 

3978 self.delete_alert("NoMaskMatches") 

3979 

3980 return translation 

3981 

3982 def do_lock(self, user: User | None, lock: bool = True, auto: bool = False) -> None: 

3983 """Lock or unlock component.""" 

3984 if self.locked == lock: 

3985 return 

3986 

3987 self.locked = lock 

3988 # We avoid save here because it has unwanted side effects 

3989 Component.objects.filter(pk=self.pk).update(locked=lock) 

3990 change = self.get_lock_change(user=user, lock=lock, auto=auto) 

3991 change.save() 

3992 

3993 def get_lock_change( 

3994 self, *, user: User | None, lock: bool = True, auto: bool = False 

3995 ) -> Change: 

3996 from weblate.trans.tasks import perform_commit 

3997 

3998 change = Change( 

3999 component=self, 

4000 user=user, 

4001 action=ActionEvents.LOCK if lock else ActionEvents.UNLOCK, 

4002 details={"auto": auto}, 

4003 ) 

4004 if lock and not auto: 

4005 perform_commit.delay_on_commit( 

4006 self.pk, "lock", user_id=user.id if user else None 

4007 ) 

4008 return change 

4009 

4010 @cached_property 

4011 def libre_license(self) -> bool: 

4012 return is_libre(self.license) 

4013 

4014 @cached_property 

4015 def license_url(self) -> str: 

4016 return get_license_url(self.license) 

4017 

4018 def get_license_display(self) -> str: # type: ignore[no-redef] 

4019 # Override Django implementation as that rebuilds the dict every time 

4020 return get_license_name(self.license) 

4021 

4022 def post_create(self, user: User, *, origin: str) -> None: 

4023 self.change_set.create( 

4024 action=ActionEvents.CREATE_COMPONENT, 

4025 user=user, 

4026 author=user, 

4027 details={"origin": origin}, 

4028 ) 

4029 

4030 @property 

4031 def context_label(self): 

4032 if self.file_format in {"po", "po-mono", "tbx"}: 

4033 # Translators: Translation context for Gettext 

4034 return gettext("Context") 

4035 # Translators: Translation key for monolingual translations 

4036 return pgettext("Translation key", "Key") 

4037 

4038 @cached_property 

4039 def guidelines(self): 

4040 from weblate.trans.guide import GUIDELINES 

4041 

4042 return [guide(self) for guide in GUIDELINES] 

4043 

4044 @cached_property 

4045 def addons_cache(self): 

4046 from weblate.addons.models import Addon 

4047 

4048 result = defaultdict(list) 

4049 result["__lookup__"] = {} 

4050 for addon in Addon.objects.filter_for_execution(self): 4050 ↛ 4051line 4050 didn't jump to line 4051 because the loop on line 4050 never started

4051 for installed in addon.event_set.all(): 

4052 result[installed.event].append(addon) 

4053 result["__all__"].append(addon) 

4054 result["__names__"].append(addon.name) 

4055 result["__lookup__"][addon.name] = addon 

4056 return result 

4057 

4058 def get_addon(self, name: str) -> Addon | None: 

4059 return self.addons_cache["__lookup__"].get(name) 

4060 

4061 def schedule_sync_terminology(self) -> None: 

4062 """Trigger terminology sync in the background.""" 

4063 from weblate.glossary.tasks import sync_glossary_languages, sync_terminology 

4064 

4065 if settings.CELERY_TASK_ALWAYS_EAGER: 4065 ↛ 4067line 4065 didn't jump to line 4067 because the condition on line 4065 was never true

4066 # Execute directly to avoid locking issues 

4067 if self.is_glossary: 

4068 sync_terminology(self.pk, component=self) 

4069 else: 

4070 for glossary in self.project.glossaries: 

4071 sync_glossary_languages(glossary.pk, component=glossary) 

4072 elif not self._glossary_sync_scheduled: 4072 ↛ exitline 4072 didn't return from function 'schedule_sync_terminology' because the condition on line 4072 was always true

4073 self._glossary_sync_scheduled = True 

4074 transaction.on_commit(self._schedule_sync_terminology) 

4075 

4076 def _schedule_sync_terminology(self) -> None: 

4077 from weblate.glossary.tasks import sync_glossary_languages, sync_terminology 

4078 

4079 if self.is_glossary: 

4080 sync_terminology.delay_on_commit(self.pk) 

4081 else: 

4082 for glossary in self.project.glossaries: 

4083 sync_glossary_languages.delay_on_commit(glossary.pk) 

4084 self._glossary_sync_scheduled = False 

4085 

4086 def get_unused_enforcements(self) -> Iterable[dict | BaseCheck]: 

4087 from weblate.trans.models import Unit 

4088 

4089 for current in self.enforced_checks: 4089 ↛ 4090line 4089 didn't jump to line 4090 because the loop on line 4089 never started

4090 try: 

4091 check = CHECKS[current] 

4092 except KeyError: 

4093 yield {"name": current, "notsupported": True} 

4094 continue 

4095 # Check is always enabled 

4096 if not check.default_disabled: 

4097 continue 

4098 flag = check.enable_string 

4099 # Enabled on component level 

4100 if flag in self.all_flags: 

4101 continue 

4102 # Enabled on translation level 

4103 if self.translation_set.filter(check_flags__contains=flag).exists(): 

4104 continue 

4105 # Enabled on unit level 

4106 if Unit.objects.filter( 

4107 Q(flags__contains=flag) | Q(extra_flags__contains=flag) 

4108 ).exists(): 

4109 continue 

4110 yield check 

4111 

4112 def get_language_alias(self, code): 

4113 if code in self.project.language_aliases_dict: 4113 ↛ 4114line 4113 didn't jump to line 4114 because the condition on line 4113 was never true

4114 return self.project.language_aliases_dict[code] 

4115 if code in {"source", "src", "default"}: 4115 ↛ 4116line 4115 didn't jump to line 4116 because the condition on line 4115 was never true

4116 return self.source_language.code 

4117 return code 

4118 

4119 @property 

4120 def get_add_label(self): 

4121 if self.is_glossary: 

4122 return gettext("Add term to glossary") 

4123 return gettext("Add new translation string") 

4124 

4125 def suggest_repo_link(self): 

4126 if self.is_repo_link or self.is_repo_local: 4126 ↛ 4129line 4126 didn't jump to line 4129 because the condition on line 4126 was always true

4127 return None 

4128 

4129 same_repo = self.project.component_set.filter( 

4130 repo=self.repo, vcs=self.vcs, branch=self.branch 

4131 ) 

4132 if self.push: 

4133 same_repo = same_repo.filter(push=self.push) 

4134 try: 

4135 return same_repo[0].get_repo_link_url() 

4136 except IndexError: 

4137 return None 

4138 

4139 @cached_property 

4140 def enable_review(self): 

4141 return self.project.enable_review 

4142 

4143 @property 

4144 def update_checks_key(self) -> str: 

4145 return f"component-update-checks-{self.pk}" 

4146 

4147 def schedule_update_checks(self, update_state: bool = False) -> None: 

4148 from weblate.trans.tasks import update_checks 

4149 

4150 update_token = get_random_identifier() 

4151 cache.set(self.update_checks_key, update_token) 

4152 update_checks.delay_on_commit(self.pk, update_token, update_state=update_state) 

4153 

4154 @property 

4155 def all_repo_components(self) -> list[Component]: 

4156 if self.linked_component: 

4157 return [self.linked_component] 

4158 return [self] 

4159 

4160 def start_sentry_span(self, op: str): 

4161 return sentry_sdk.start_span(op=op, name=self.full_slug) 

4162 

4163 @cached_property 

4164 def key_filter_re(self) -> re.Pattern: 

4165 """Provide the cached version of key_filter.""" 

4166 return re.compile(self.key_filter) 

4167 

4168 def repository_status(self) -> str: 

4169 try: 

4170 return self.repository.status() 

4171 except RepositoryError as error: 

4172 return "{}\n\n{}".format(gettext("Could not get repository status!"), error) 

4173 

4174 def update_enforced_checks(self) -> None: 

4175 from weblate.trans.models import Unit 

4176 

4177 units = Unit.objects.filter( 

4178 check__name__in=self.enforced_checks, 

4179 translation__component=self, 

4180 state__in=(STATE_TRANSLATED, STATE_APPROVED), 

4181 ) 

4182 

4183 for unit in units.select_for_update(): 

4184 unit.translate( 

4185 None, 

4186 unit.get_target_plurals(), 

4187 STATE_FUZZY, 

4188 change_action=ActionEvents.ENFORCED_CHECK, 

4189 propagate=False, 

4190 ) 

4191 

4192 @cached_property 

4193 def api_slug(self): 

4194 return "%252F".join(self.get_url_path()[1:]) 

4195 

4196 def get_all_available_languages(self) -> models.QuerySet[Language]: 

4197 return Language.objects.exclude( 

4198 Q(translation__component=self) | Q(component=self) 

4199 ) 

4200 

4201 

4202@receiver(m2m_changed, sender=Component.links.through) 

4203@disable_for_loaddata 

4204def change_component_link(sender, instance, action, pk_set, **kwargs) -> None: 

4205 from weblate.trans.models import Project 

4206 

4207 if action not in {"post_add", "post_remove", "post_clear"}: 

4208 return 

4209 for project in Project.objects.filter(pk__in=pk_set): 

4210 project.invalidate_glossary_cache()