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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7from collections import UserDict
8from typing import TYPE_CHECKING, ClassVar, Self, cast
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
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)
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
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
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")
57class ProjectLanguageFactory(UserDict):
58 def __init__(self, project: Project) -> None:
59 super().__init__()
60 self._project = project
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
69 def preload(self):
70 return [self[language] for language in self._project.languages]
72 def preload_workflow_settings(self) -> None:
73 from weblate.trans.models.workflow import WorkflowSetting
75 instances = self.preload()
77 pending = {instance.language.id: instance for instance in instances}
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]
88 # Indicate that there is no setting
89 for instance in pending.values():
90 instance.__dict__["workflow_settings"] = None
93class ProjectQuerySet(models.QuerySet["Project"]):
94 def order(self):
95 return self.order_by("name")
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)
103 def search(self, query: str):
104 return self.filter(Q(name__icontains=query) | Q(slug__icontains=query))
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 )
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 ]
126 return self
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
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
159class Project(models.Model, PathMixin, CacheKeyMixin, LockMixin):
160 ACCESS_PUBLIC = 0
161 ACCESS_PROTECTED = 1
162 ACCESS_PRIVATE = 100
163 ACCESS_CUSTOM = 200
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 )
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 )
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 )
303 machinery_settings = models.JSONField(default=dict, blank=True)
305 is_lockable: ClassVar[bool] = True
306 lockable_count: ClassVar[bool] = True
307 remove_permission = "project.edit"
308 settings_permission = "project.edit"
310 objects = ProjectQuerySet.as_manager()
312 # Used when updating for object removal
313 billings_to_update: list[int]
315 class Meta:
316 app_label = "trans"
317 verbose_name = "Project"
318 verbose_name_plural = "Projects"
320 def __str__(self) -> str:
321 return self.name
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] = {}
334 def save(self, *args, **kwargs) -> None:
335 from weblate.trans.tasks import component_alerts
337 update_tm = self.contribute_shared_tm
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
357 self.create_path()
359 super().save(*args, **kwargs)
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 )
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)))
374 # Update translation memory on enabled sharing
375 if update_tm:
376 import_memory.delay_on_commit(self.id)
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 )
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(","))
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)
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)
425 def get_url_path(self):
426 return (self.slug,)
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()}))
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()}))
436 @cached_property
437 def locked(self) -> bool:
438 return self.unlocked_components == 0 and self.locked_components > 0
440 def can_unlock(self) -> bool:
441 return self.locked_components > 0
443 def can_lock(self) -> bool:
444 return self.unlocked_components > 0
446 @cached_property
447 def unlocked_components(self) -> int:
448 return self.component_set.filter(locked=False).count()
450 @cached_property
451 def locked_components(self) -> int:
452 return self.component_set.filter(locked=True).count()
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 )
466 @property
467 def count_pending_units(self):
468 """Check whether there are any uncommitted changes."""
469 from weblate.trans.models import Unit
471 return Unit.objects.filter(
472 translation__component__project=self, pending_changes__isnull=False
473 ).count()
475 def needs_commit(self):
476 """Check whether there are some not committed changes."""
477 return self.count_pending_units > 0
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)
491 def commit_pending(self, reason, user: User):
492 """Commit any pending changes."""
493 return self.on_repo_components(True, "commit_pending", reason, user)
495 def repo_needs_merge(self):
496 return self.on_repo_components(False, "repo_needs_merge")
498 def repo_needs_push(self):
499 return self.on_repo_components(False, "repo_needs_push")
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)
507 def do_push(self, request: AuthenticatedHttpRequest | None = None):
508 """Push all Git repos."""
509 return self.on_repo_components(True, "do_push", request)
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 )
522 def do_cleanup(self, request: AuthenticatedHttpRequest | None = None):
523 """Push all Git repos."""
524 return self.on_repo_components(True, "do_cleanup", request)
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)
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)
534 def has_push_configuration(self):
535 """Check whether any suprojects can push."""
536 return self.on_repo_components(False, "has_push_configuration")
538 def can_push(self):
539 """Check whether any suprojects can push."""
540 return self.on_repo_components(False, "can_push")
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}
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)
555 return result
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()
563 @property
564 def billing(self):
565 return self.billings[0]
567 @cached_property
568 def paid(self):
569 return not self.billings or any(billing.paid for billing in self.billings)
571 @cached_property
572 def is_trial(self):
573 return any(billing.is_trial for billing in self.billings)
575 @cached_property
576 def is_libre_trial(self):
577 return any(billing.is_libre_trial for billing in self.billings)
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 )
593 @cached_property
594 def all_active_alerts(self):
595 from weblate.trans.models import Alert
597 result = Alert.objects.filter(component__project=self, dismissed=False)
598 list(result)
599 return result
601 @cached_property
602 def has_alerts(self):
603 return self.all_active_alerts.exists()
605 @cached_property
606 def all_admins(self):
607 from weblate.auth.models import User
609 return (
610 User.objects.all_admins(self).exclude(is_bot=True).select_related("profile")
611 )
613 @cached_property
614 def all_reviewers(self):
615 from weblate.auth.models import User
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 )
625 def get_child_components_access(self, user: User, filter_callback=None):
626 """
627 List child components.
629 This is slower than child_components, but allows additional
630 filtering on the result.
631 """
633 def filter_access(qs):
634 if filter_callback:
635 qs = filter_callback(qs)
636 return qs.filter_access(user).prefetch()
638 return self.get_child_components_filter(filter_access).order()
640 @cached_property
641 def has_shared_components(self) -> bool:
642 return self.shared_components.exists()
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
651 @cached_property
652 def child_components(self):
653 return self.get_child_components_filter(lambda qs: qs)
655 @property
656 def source_language_cache_key(self) -> str:
657 return f"project-source-language-ids-{self.pk}"
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}"
662 def invalidate_source_language_cache(self) -> None:
663 cache.delete(self.source_language_cache_key)
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
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)
714 @cached_property
715 def glossaries(self):
716 return list(
717 self.get_child_components_filter(lambda qs: qs.filter(is_glossary=True))
718 )
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)
732 @cached_property
733 def glossary_automaton(self):
734 from weblate.glossary.models import get_glossary_automaton
736 return get_glossary_automaton(self)
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
755 @cached_property
756 def enable_review(self):
757 return self.translation_review or self.source_review
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
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)
771 @property
772 def can_add_category(self) -> bool:
773 return True
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]
782 def collect_label_cleanup(self, label: Label) -> None:
783 from weblate.trans.models.translation import Translation
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)
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}")
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")
805 def filter_callback(qs):
806 return qs.exclude(filter_)
808 return self.get_child_components_access(user, filter_callback)
810 def needs_license(self, access_control: int | None = None) -> bool:
811 """
812 Whether the project components need a license.
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
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 )