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

404 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7from collections import UserDict 

8from typing import TYPE_CHECKING, ClassVar, Self, cast 

9 

10from django.conf import settings 

11from django.core.cache import cache 

12from django.core.exceptions import ObjectDoesNotExist 

13from django.db import models, transaction 

14from django.db.models import Count, F, Q, Value 

15from django.db.models.functions import Replace 

16from django.urls import reverse 

17from django.utils.functional import cached_property 

18from django.utils.translation import gettext_lazy, gettext_noop 

19 

20from weblate.configuration.models import Setting, SettingCategory 

21from weblate.formats.models import FILE_FORMATS 

22from weblate.lang.models import Language 

23from weblate.memory.tasks import import_memory 

24from weblate.trans.actions import ActionEvents 

25from weblate.trans.defines import PROJECT_NAME_LENGTH 

26from weblate.trans.mixins import CacheKeyMixin, LockMixin, PathMixin 

27from weblate.trans.validators import validate_check_flags 

28from weblate.utils.site import get_site_url 

29from weblate.utils.stats import ProjectLanguage, ProjectStats, prefetch_stats 

30from weblate.utils.validators import ( 

31 WeblateURLValidator, 

32 validate_language_aliases, 

33 validate_project_name, 

34 validate_project_web, 

35 validate_slug, 

36) 

37 

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

39 from collections.abc import Iterable 

40 

41 from weblate.auth.models import AuthenticatedHttpRequest, Group, User 

42 from weblate.machinery.types import SettingsDict 

43 from weblate.trans.models.component import Component, ComponentQuerySet 

44 from weblate.trans.models.label import Label 

45 from weblate.trans.models.translation import TranslationQuerySet 

46 

47 

48class CommitPolicyChoices(models.IntegerChoices): 

49 ALL = 0, gettext_lazy("Commit all translations regardless of quality") 

50 WITHOUT_NEEDS_EDITING = ( 

51 20, 

52 gettext_lazy("Skip translations marked as needing editing"), 

53 ) 

54 APPROVED_ONLY = 30, gettext_lazy("Only include approved translations") 

55 

56 

57class ProjectLanguageFactory(UserDict): 

58 def __init__(self, project: Project) -> None: 

59 super().__init__() 

60 self._project = project 

61 

62 def __getitem__(self, key: Language): 

63 try: 

64 return super().__getitem__(key.id) 

65 except KeyError: 

66 self[key.id] = result = ProjectLanguage(self._project, key) 

67 return result 

68 

69 def preload(self): 

70 return [self[language] for language in self._project.languages] 

71 

72 def preload_workflow_settings(self) -> None: 

73 from weblate.trans.models.workflow import WorkflowSetting 

74 

75 instances = self.preload() 

76 

77 pending = {instance.language.id: instance for instance in instances} 

78 

79 for setting in WorkflowSetting.objects.filter( 79 ↛ 83line 79 didn't jump to line 83 because the loop on line 79 never started

80 Q(project=None) | Q(project=self._project), 

81 language__in=[instance.language for instance in instances], 

82 ).order_by(F("project").desc(nulls_last=True)): 

83 if setting.language_id not in pending: 

84 continue 

85 pending[setting.language_id].__dict__["workflow_settings"] = setting 

86 del pending[setting.language_id] 

87 

88 # Indicate that there is no setting 

89 for instance in pending.values(): 

90 instance.__dict__["workflow_settings"] = None 

91 

92 

93class ProjectQuerySet(models.QuerySet["Project"]): 

94 def order(self): 

95 return self.order_by("name") 

96 

97 def only(self, *fields: str) -> Self: 

98 only_fields = set(fields) 

99 # These are used in Project.__init__ 

100 only_fields.update(("access_control", "translation_review", "source_review")) 

101 return super().only(*only_fields) 

102 

103 def search(self, query: str): 

104 return self.filter(Q(name__icontains=query) | Q(slug__icontains=query)) 

105 

106 def prefetch_languages(self): 

107 # Bitmap for languages 

108 language_map = set( 

109 self.values_list("id", "component__translation__language_id").distinct() 

110 ) 

111 # All used languages 

112 languages = ( 

113 Language.objects.filter(translation__component__project__in=self) 

114 .order() 

115 .distinct() 

116 ) 

117 

118 # Prefetch languages attribute 

119 for project in self: 

120 project.languages = [ 

121 language 

122 for language in languages 

123 if (project.id, language.id) in language_map 

124 ] 

125 

126 return self 

127 

128 

129def prefetch_project_flags(projects): 

130 lookup = {project.id: project for project in projects} 

131 if lookup: 

132 queryset = Project.objects.filter(id__in=lookup.keys()).values("id") 

133 # Fallback value for locking and alerts 

134 for project in projects: 

135 project.__dict__["locked"] = True 

136 project.__dict__["has_alerts"] = False 

137 # Indicate alerts 

138 for alert in queryset.filter(component__alert__dismissed=False).annotate( 

139 Count("component__alert") 

140 ): 

141 lookup[alert["id"]].__dict__["has_alerts"] = bool( 

142 alert["component__alert__count"] 

143 ) 

144 # Filter unlocked projects 

145 for locks in ( 

146 queryset.filter(component__locked=False) 

147 .distinct() 

148 .annotate(Count("component__id")) 

149 ): 

150 lookup[locks["id"]].__dict__["locked"] = locks["component__id__count"] == 0 

151 

152 # Prefetch source language ids 

153 lookup = {project.source_language_cache_key: project for project in projects} 

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

155 lookup[item].__dict__["source_language_ids"] = value 

156 return projects 

157 

158 

159class Project(models.Model, PathMixin, CacheKeyMixin, LockMixin): 

160 ACCESS_PUBLIC = 0 

161 ACCESS_PROTECTED = 1 

162 ACCESS_PRIVATE = 100 

163 ACCESS_CUSTOM = 200 

164 

165 ACCESS_CHOICES = ( 

166 (ACCESS_PUBLIC, gettext_lazy("Public")), 

167 (ACCESS_PROTECTED, gettext_lazy("Protected")), 

168 (ACCESS_PRIVATE, gettext_lazy("Private")), 

169 (ACCESS_CUSTOM, gettext_lazy("Custom")), 

170 ) 

171 

172 name = models.CharField( 

173 verbose_name=gettext_lazy("Project name"), 

174 max_length=PROJECT_NAME_LENGTH, 

175 unique=True, 

176 help_text=gettext_lazy("Display name"), 

177 validators=[validate_project_name], 

178 ) 

179 slug = models.SlugField( 

180 verbose_name=gettext_lazy("URL slug"), 

181 unique=True, 

182 max_length=PROJECT_NAME_LENGTH, 

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

184 validators=[validate_slug], 

185 ) 

186 web = models.URLField( 

187 verbose_name=gettext_lazy("Project website"), 

188 blank=not settings.WEBSITE_REQUIRED, 

189 help_text=gettext_lazy("Main website of translated project."), 

190 validators=[WeblateURLValidator(), validate_project_web], 

191 ) 

192 instructions = models.TextField( 

193 verbose_name=gettext_lazy("Translation instructions"), 

194 blank=True, 

195 help_text=gettext_lazy("You can use Markdown and mention users by @username."), 

196 ) 

197 

198 set_language_team = models.BooleanField( 

199 verbose_name=gettext_lazy('Set "Language-Team" header'), 

200 default=True, 

201 help_text=gettext_lazy( 

202 'Lets Weblate update the "Language-Team" file header of your project.' 

203 ), 

204 ) 

205 use_shared_tm = models.BooleanField( 

206 verbose_name=gettext_lazy("Use shared translation memory"), 

207 default=settings.DEFAULT_SHARED_TM, 

208 help_text=gettext_lazy( 

209 "Uses the pool of shared translations between projects." 

210 ), 

211 ) 

212 contribute_shared_tm = models.BooleanField( 

213 verbose_name=gettext_lazy("Contribute to shared translation memory"), 

214 default=settings.DEFAULT_SHARED_TM, 

215 help_text=gettext_lazy( 

216 "Contributes to the pool of shared translations between projects." 

217 ), 

218 ) 

219 autoclean_tm = models.BooleanField( 

220 verbose_name=gettext_lazy("Autoclean translation memory"), 

221 default=settings.DEFAULT_AUTOCLEAN_TM, 

222 help_text=gettext_lazy( 

223 "Automatically removes outdated and obsolete entries from translation memory." 

224 ), 

225 ) 

226 access_control = models.IntegerField( 

227 default=settings.DEFAULT_ACCESS_CONTROL, 

228 db_index=True, 

229 choices=ACCESS_CHOICES, 

230 verbose_name=gettext_lazy("Access control"), 

231 help_text=gettext_lazy( 

232 "How to restrict access to this project is detailed in the documentation." 

233 ), 

234 ) 

235 enforced_2fa = models.BooleanField( 

236 verbose_name=gettext_lazy("Enforced two-factor authentication"), 

237 default=False, 

238 help_text=gettext_lazy( 

239 "Requires contributors to have two-factor authentication configured before being able to contribute." 

240 ), 

241 ) 

242 # This should match definition in WorkflowSetting 

243 translation_review = models.BooleanField( 

244 verbose_name=gettext_lazy("Enable reviews"), 

245 default=False, 

246 help_text=gettext_lazy("Requires dedicated reviewers to approve translations."), 

247 ) 

248 source_review = models.BooleanField( 

249 verbose_name=gettext_lazy("Enable source reviews"), 

250 default=False, 

251 help_text=gettext_lazy( 

252 "Requires dedicated reviewers to approve source strings." 

253 ), 

254 ) 

255 commit_policy = models.IntegerField( 

256 verbose_name=gettext_lazy("Translation quality filter"), 

257 default=CommitPolicyChoices.ALL, 

258 choices=CommitPolicyChoices, 

259 help_text=gettext_lazy( 

260 "Select which translations should be included when committing changes. " 

261 "More restrictive options will skip translations with potential quality issues." 

262 ), 

263 ) 

264 enable_hooks = models.BooleanField( 

265 verbose_name=gettext_lazy("Enable hooks"), 

266 default=True, 

267 help_text=gettext_lazy( 

268 "Whether to allow updating this repository by remote hooks." 

269 ), 

270 ) 

271 language_aliases = models.TextField( 

272 verbose_name=gettext_lazy("Language aliases"), 

273 default="", 

274 blank=True, 

275 help_text=gettext_lazy( 

276 "Comma-separated list of language code mappings, " 

277 "for example: en_GB:en,en_US:en" 

278 ), 

279 validators=[validate_language_aliases], 

280 ) 

281 secondary_language = models.ForeignKey( 

282 Language, 

283 verbose_name=gettext_lazy("Secondary language"), 

284 help_text=gettext_lazy( 

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

286 ), 

287 default=None, 

288 blank=True, 

289 null=True, 

290 related_name="project_secondary_languages", 

291 on_delete=models.deletion.CASCADE, 

292 ) 

293 check_flags = models.TextField( 

294 verbose_name=gettext_lazy("Translation flags"), 

295 default="", 

296 help_text=gettext_lazy( 

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

298 ), 

299 validators=[validate_check_flags], 

300 blank=True, 

301 ) 

302 

303 machinery_settings = models.JSONField(default=dict, blank=True) 

304 

305 is_lockable: ClassVar[bool] = True 

306 lockable_count: ClassVar[bool] = True 

307 remove_permission = "project.edit" 

308 settings_permission = "project.edit" 

309 

310 objects = ProjectQuerySet.as_manager() 

311 

312 # Used when updating for object removal 

313 billings_to_update: list[int] 

314 

315 class Meta: 

316 app_label = "trans" 

317 verbose_name = "Project" 

318 verbose_name_plural = "Projects" 

319 

320 def __str__(self) -> str: 

321 return self.name 

322 

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

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

325 self.old_access_control = self.access_control 

326 self.old_translation_review = self.translation_review 

327 self.old_source_review = self.source_review 

328 self.stats = ProjectStats(self) 

329 self.acting_user: User | None = None 

330 self.project_languages = ProjectLanguageFactory(self) 

331 self.label_cleanups: TranslationQuerySet | None = None 

332 self.languages_cache: dict[str, Language] = {} 

333 

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

335 from weblate.trans.tasks import component_alerts 

336 

337 update_tm = self.contribute_shared_tm 

338 

339 # Renaming detection 

340 old = None 

341 if self.id: 

342 old = Project.objects.get(pk=self.id) 

343 # Generate change entries for changes 

344 self.generate_changes(old) 

345 # Detect slug changes and rename directory 

346 self.check_rename(old) 

347 # Rename linked repos 

348 if old.slug != self.slug: 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true

349 for component in old.component_set.iterator(): 

350 new_component = self.component_set.get(pk=component.pk) 

351 new_component.project = self 

352 component.linked_children.update( 

353 repo=new_component.get_repo_link_url() 

354 ) 

355 update_tm = self.contribute_shared_tm and not old.contribute_shared_tm 

356 

357 self.create_path() 

358 

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

360 

361 if old is not None: 

362 # Update alerts if needed 

363 if old.web != self.web: 

364 component_alerts.delay_on_commit( 

365 list(self.component_set.values_list("id", flat=True)) 

366 ) 

367 

368 # Update glossaries if needed 

369 if old.name != self.name: 

370 self.component_set.filter( 

371 is_glossary=True, name__contains=old.name 

372 ).update(name=Replace("name", Value(old.name), Value(self.name))) 

373 

374 # Update translation memory on enabled sharing 

375 if update_tm: 

376 import_memory.delay_on_commit(self.id) 

377 

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

379 tracked = (("slug", ActionEvents.RENAME_PROJECT),) 

380 for attribute, action in tracked: 

381 old_value = getattr(old, attribute) 

382 current_value = getattr(self, attribute) 

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

384 self.change_set.create( 

385 action=action, 

386 old=old_value, 

387 target=current_value, 

388 user=self.acting_user, 

389 ) 

390 

391 @cached_property 

392 def language_aliases_dict(self): 

393 if not self.language_aliases: 393 ↛ 395line 393 didn't jump to line 395 because the condition on line 393 was always true

394 return {} 

395 return dict(part.split(":") for part in self.language_aliases.split(",")) 

396 

397 def add_user(self, user: User, group: str | None = None) -> None: 

398 """Add user based on username or email address.""" 

399 implicit_group = False 

400 if group is None: 

401 implicit_group = True 

402 if self.access_control != self.ACCESS_PUBLIC: 

403 group = "Translate" 

404 elif self.source_review or self.translation_review: 

405 group = "Review" 

406 else: 

407 group = "Administration" 

408 group_objs: Iterable[Group] 

409 try: 

410 group_objs = [self.defined_groups.get(name=group)] 

411 except ObjectDoesNotExist: 

412 if group == "Administration" or implicit_group: 

413 group_objs = self.defined_groups.all() 

414 else: 

415 raise 

416 for team in group_objs: 

417 user.add_team(None, team) 

418 user.profile.watched.add(self) 

419 

420 def remove_user(self, user: User) -> None: 

421 """Add user based on username or email address.""" 

422 for group in self.defined_groups.iterator(): 

423 user.remove_team(None, group) 

424 

425 def get_url_path(self): 

426 return (self.slug,) 

427 

428 def get_widgets_url(self): 

429 """Return absolute URL for widgets.""" 

430 return get_site_url(reverse("widgets", kwargs={"path": self.get_url_path()})) 

431 

432 def get_share_url(self): 

433 """Return absolute URL usable for sharing.""" 

434 return get_site_url(reverse("engage", kwargs={"path": self.get_url_path()})) 

435 

436 @cached_property 

437 def locked(self) -> bool: 

438 return self.unlocked_components == 0 and self.locked_components > 0 

439 

440 def can_unlock(self) -> bool: 

441 return self.locked_components > 0 

442 

443 def can_lock(self) -> bool: 

444 return self.unlocked_components > 0 

445 

446 @cached_property 

447 def unlocked_components(self) -> int: 

448 return self.component_set.filter(locked=False).count() 

449 

450 @cached_property 

451 def locked_components(self) -> int: 

452 return self.component_set.filter(locked=True).count() 

453 

454 @cached_property 

455 def languages(self): 

456 """Return list of all languages used in project.""" 

457 return ( 

458 Language.objects.filter( 

459 Q(translation__component__project=self) 

460 | Q(translation__component__links=self) 

461 ) 

462 .distinct() 

463 .order() 

464 ) 

465 

466 @property 

467 def count_pending_units(self): 

468 """Check whether there are any uncommitted changes.""" 

469 from weblate.trans.models import Unit 

470 

471 return Unit.objects.filter( 

472 translation__component__project=self, pending_changes__isnull=False 

473 ).count() 

474 

475 def needs_commit(self): 

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

477 return self.count_pending_units > 0 

478 

479 def on_repo_components(self, use_all: bool, func: str, *args, **kwargs): 

480 """Perform operation on all repository components.""" 

481 generator = ( 

482 getattr(component, func)(*args, **kwargs) 

483 for component in self.all_repo_components 

484 ) 

485 if use_all: 

486 # Call methods on all components as this performs an operation 

487 return all(list(generator)) 

488 # This is status checking, call only needed methods 

489 return any(generator) 

490 

491 def commit_pending(self, reason, user: User): 

492 """Commit any pending changes.""" 

493 return self.on_repo_components(True, "commit_pending", reason, user) 

494 

495 def repo_needs_merge(self): 

496 return self.on_repo_components(False, "repo_needs_merge") 

497 

498 def repo_needs_push(self): 

499 return self.on_repo_components(False, "repo_needs_push") 

500 

501 def do_update( 

502 self, request: AuthenticatedHttpRequest | None = None, method: str | None = None 

503 ): 

504 """Update all Git repos.""" 

505 return self.on_repo_components(True, "do_update", request, method=method) 

506 

507 def do_push(self, request: AuthenticatedHttpRequest | None = None): 

508 """Push all Git repos.""" 

509 return self.on_repo_components(True, "do_push", request) 

510 

511 def do_reset( 

512 self, 

513 request: AuthenticatedHttpRequest | None = None, 

514 *, 

515 keep_changes: bool = False, 

516 ) -> bool: 

517 """Push all Git repos.""" 

518 return self.on_repo_components( 

519 True, "do_reset", request, keep_changes=keep_changes 

520 ) 

521 

522 def do_cleanup(self, request: AuthenticatedHttpRequest | None = None): 

523 """Push all Git repos.""" 

524 return self.on_repo_components(True, "do_cleanup", request) 

525 

526 def do_file_sync(self, request: AuthenticatedHttpRequest | None = None): 

527 """Force updating of all files.""" 

528 return self.on_repo_components(True, "do_file_sync", request) 

529 

530 def do_file_scan(self, request: AuthenticatedHttpRequest | None = None): 

531 """Rescanls all VCS repos.""" 

532 return self.on_repo_components(True, "do_file_scan", request) 

533 

534 def has_push_configuration(self): 

535 """Check whether any suprojects can push.""" 

536 return self.on_repo_components(False, "has_push_configuration") 

537 

538 def can_push(self): 

539 """Check whether any suprojects can push.""" 

540 return self.on_repo_components(False, "can_push") 

541 

542 @cached_property 

543 def all_repo_components(self) -> Iterable[Component]: 

544 """Return list of all unique VCS components.""" 

545 result = list(self.component_set.with_repo()) 

546 included = {component.id for component in result} 

547 

548 linked = self.component_set.filter(repo__startswith="weblate:") 

549 for other in linked: 549 ↛ 550line 549 didn't jump to line 550 because the loop on line 549 never started

550 if other.linked_component_id in included: 

551 continue 

552 included.add(other.linked_component_id) 

553 result.append(other) 

554 

555 return result 

556 

557 @cached_property 

558 def billings(self): 

559 if "weblate.billing" not in settings.INSTALLED_APPS: 559 ↛ 561line 559 didn't jump to line 561 because the condition on line 559 was always true

560 return [] 

561 return self.billing_set.all() 

562 

563 @property 

564 def billing(self): 

565 return self.billings[0] 

566 

567 @cached_property 

568 def paid(self): 

569 return not self.billings or any(billing.paid for billing in self.billings) 

570 

571 @cached_property 

572 def is_trial(self): 

573 return any(billing.is_trial for billing in self.billings) 

574 

575 @cached_property 

576 def is_libre_trial(self): 

577 return any(billing.is_libre_trial for billing in self.billings) 

578 

579 def post_create(self, user: User, billing=None) -> None: 

580 if billing: 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true

581 billing.projects.add(self) 

582 if billing.plan.change_access_control: 

583 self.access_control = Project.ACCESS_PRIVATE 

584 else: 

585 self.access_control = Project.ACCESS_PUBLIC 

586 self.save() 

587 if not user.is_superuser: 587 ↛ 588line 587 didn't jump to line 588 because the condition on line 587 was never true

588 self.add_user(user, "Administration") 

589 self.change_set.create( 

590 action=ActionEvents.CREATE_PROJECT, user=user, author=user 

591 ) 

592 

593 @cached_property 

594 def all_active_alerts(self): 

595 from weblate.trans.models import Alert 

596 

597 result = Alert.objects.filter(component__project=self, dismissed=False) 

598 list(result) 

599 return result 

600 

601 @cached_property 

602 def has_alerts(self): 

603 return self.all_active_alerts.exists() 

604 

605 @cached_property 

606 def all_admins(self): 

607 from weblate.auth.models import User 

608 

609 return ( 

610 User.objects.all_admins(self).exclude(is_bot=True).select_related("profile") 

611 ) 

612 

613 @cached_property 

614 def all_reviewers(self): 

615 from weblate.auth.models import User 

616 

617 if not self.enable_review: 

618 return User.objects.none() 

619 return ( 

620 User.objects.all_reviewers(self) 

621 .exclude(is_bot=True) 

622 .select_related("profile") 

623 ) 

624 

625 def get_child_components_access(self, user: User, filter_callback=None): 

626 """ 

627 List child components. 

628 

629 This is slower than child_components, but allows additional 

630 filtering on the result. 

631 """ 

632 

633 def filter_access(qs): 

634 if filter_callback: 

635 qs = filter_callback(qs) 

636 return qs.filter_access(user).prefetch() 

637 

638 return self.get_child_components_filter(filter_access).order() 

639 

640 @cached_property 

641 def has_shared_components(self) -> bool: 

642 return self.shared_components.exists() 

643 

644 def get_child_components_filter(self, filter_callback): 

645 own = filter_callback(self.component_set.defer_huge()) 

646 if self.has_shared_components: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 shared = filter_callback(self.shared_components.defer_huge()) 

648 return (own | shared).distinct() 

649 return own 

650 

651 @cached_property 

652 def child_components(self): 

653 return self.get_child_components_filter(lambda qs: qs) 

654 

655 @property 

656 def source_language_cache_key(self) -> str: 

657 return f"project-source-language-ids-{self.pk}" 

658 

659 def get_glossary_tsv_cache_key(self, source_language, language) -> str: 

660 return f"project-glossary-tsv-{self.pk}-{source_language.code}-{language.code}" 

661 

662 def invalidate_source_language_cache(self) -> None: 

663 cache.delete(self.source_language_cache_key) 

664 

665 @cached_property 

666 def source_language_ids(self): 

667 cached = cache.get(self.source_language_cache_key) 

668 if cached is not None: 

669 return cached 

670 result = set( 

671 self.get_child_components_filter( 

672 lambda qs: qs.values_list("source_language_id", flat=True).distinct() 

673 ) 

674 ) 

675 cache.set(self.source_language_cache_key, result, 7 * 24 * 3600) 

676 return result 

677 

678 def scratch_create_component( 

679 self, 

680 name: str, 

681 slug: str, 

682 source_language, 

683 file_format: str, 

684 has_template: bool | None = None, 

685 is_glossary: bool = False, 

686 **kwargs, 

687 ): 

688 format_cls = FILE_FORMATS[file_format] 

689 if has_template is None: 689 ↛ 690line 689 didn't jump to line 690 because the condition on line 689 was never true

690 has_template = format_cls.monolingual is None or format_cls.monolingual 

691 if has_template: 691 ↛ 692line 691 didn't jump to line 692 because the condition on line 691 was never true

692 template = f"{source_language.code}.{format_cls.extension()}" 

693 else: 

694 template = "" 

695 kwargs.update( 

696 { 

697 "file_format": file_format, 

698 "filemask": f"*.{format_cls.extension()}", 

699 "template": template, 

700 "vcs": "local", 

701 "repo": "local:", 

702 "source_language": source_language, 

703 "manage_units": True, 

704 "is_glossary": is_glossary, 

705 } 

706 ) 

707 # Create component 

708 if is_glossary: 708 ↛ 712line 708 didn't jump to line 712 because the condition on line 708 was always true

709 return self.component_set.get_or_create( 

710 name=name, slug=slug, defaults=kwargs 

711 )[0] 

712 return self.component_set.create(name=name, slug=slug, **kwargs) 

713 

714 @cached_property 

715 def glossaries(self): 

716 return list( 

717 self.get_child_components_filter(lambda qs: qs.filter(is_glossary=True)) 

718 ) 

719 

720 def invalidate_glossary_cache(self) -> None: 

721 if "glossary_automaton" in self.__dict__: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true

722 del self.__dict__["glossary_automaton"] 

723 tsv_cache_keys = [ 

724 self.get_glossary_tsv_cache_key(source_language, language) 

725 for source_language in Language.objects.filter( 

726 component__project=self 

727 ).distinct() 

728 for language in self.languages 

729 ] 

730 cache.delete_many(tsv_cache_keys) 

731 

732 @cached_property 

733 def glossary_automaton(self): 

734 from weblate.glossary.models import get_glossary_automaton 

735 

736 return get_glossary_automaton(self) 

737 

738 def get_machinery_settings(self) -> dict[str, SettingsDict]: 

739 settings = cast( 

740 "dict[str, SettingsDict]", 

741 Setting.objects.get_settings_dict(SettingCategory.MT), 

742 ) 

743 for item, value in self.machinery_settings.items(): 743 ↛ 744line 743 didn't jump to line 744 because the loop on line 743 never started

744 if value is None: 

745 if item in settings: 

746 del settings[item] 

747 else: 

748 settings[item] = value 

749 # Include project field so that different projects do not share 

750 # cache keys via MachineTranslation.get_cache_key when service 

751 # is installed at project level. 

752 settings[item]["_project"] = self 

753 return settings 

754 

755 @cached_property 

756 def enable_review(self): 

757 return self.translation_review or self.source_review 

758 

759 @transaction.atomic 

760 def do_lock(self, user: User, lock: bool = True, auto: bool = False) -> None: 

761 from weblate.trans.models.change import Change 

762 

763 actionable = self.component_set.exclude(locked=lock) 

764 changes = [ 

765 component.get_lock_change(user=user, lock=lock, auto=auto) 

766 for component in actionable 

767 ] 

768 actionable.update(locked=lock) 

769 Change.objects.bulk_create(changes, batch_size=500) 

770 

771 @property 

772 def can_add_category(self) -> bool: 

773 return True 

774 

775 @cached_property 

776 def automatically_translated_label(self) -> Label: 

777 return self.label_set.get_or_create( 

778 name=gettext_noop("Automatically translated"), 

779 defaults={"color": "yellow"}, 

780 )[0] 

781 

782 def collect_label_cleanup(self, label: Label) -> None: 

783 from weblate.trans.models.translation import Translation 

784 

785 translations = Translation.objects.filter( 

786 Q(unit__labels=label) | Q(unit__source_unit__labels=label) 

787 ) 

788 if self.label_cleanups is None: 

789 self.label_cleanups = translations 

790 else: 

791 self.label_cleanups |= translations 

792 prefetch_stats(self.label_cleanups) 

793 

794 def cleanup_label_stats(self, name: str) -> None: 

795 if self.label_cleanups is not None: 

796 for translation in self.label_cleanups: 

797 translation.stats.remove_stats(f"label:{name}") 

798 

799 def components_user_can_add_new_language(self, user: User) -> ComponentQuerySet: 

800 """Return a queryset of components within the project that the given user is allowed to add new languages to.""" 

801 filter_ = Q(is_glossary=True) 

802 if not user.has_perm("project.edit", self): 

803 filter_ |= Q(new_lang="none") | Q(new_lang="url") 

804 

805 def filter_callback(qs): 

806 return qs.exclude(filter_) 

807 

808 return self.get_child_components_access(user, filter_callback) 

809 

810 def needs_license(self, access_control: int | None = None) -> bool: 

811 """ 

812 Whether the project components need a license. 

813 

814 License is needed on publicly accessible projects when 

815 enforced by configuration and any licenses are available. 

816 """ 

817 if access_control is None: 817 ↛ 820line 817 didn't jump to line 820 because the condition on line 817 was always true

818 access_control = self.access_control 

819 

820 return ( 

821 access_control in {Project.ACCESS_PUBLIC, Project.ACCESS_PROTECTED} 

822 and settings.LICENSE_REQUIRED 

823 and not settings.LOGIN_REQUIRED_URLS 

824 and (settings.LICENSE_FILTER is None or settings.LICENSE_FILTER) 

825 )