Coverage for app/venv/lib/python3.14/site-packages/weblate/screenshots/models.py: 60%
101 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7import fnmatch
8import os
9from typing import IO, TYPE_CHECKING, Any
11from django.conf import settings
12from django.core.exceptions import ValidationError
13from django.core.files import File
14from django.core.files.storage import default_storage
15from django.db import models
16from django.db.models import Q
17from django.db.models.signals import m2m_changed, post_delete
18from django.dispatch import receiver
19from django.urls import reverse
20from django.utils.translation import gettext_lazy
22from weblate.auth.models import get_anonymous
23from weblate.checks.flags import Flags
24from weblate.screenshots.fields import ScreenshotField
25from weblate.trans.mixins import UserDisplayMixin
26from weblate.trans.models import Translation, Unit
27from weblate.trans.models.alert import update_alerts
28from weblate.trans.signals import vcs_post_update
29from weblate.utils.decorators import disable_for_loaddata
30from weblate.utils.errors import report_error
31from weblate.utils.validators import validate_bitmap
33if TYPE_CHECKING: 33 ↛ 34line 33 didn't jump to line 34 because the condition on line 33 was never true
34 from weblate.auth.models import User
37class ScreenshotQuerySet(models.QuerySet):
38 def order(self):
39 return self.order_by("name")
41 def filter_access(self, user: User):
42 result = self
43 if user.needs_project_filter: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 result = result.filter(
45 translation__component__project__in=user.allowed_projects
46 )
47 if user.needs_component_restrictions_filter: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true
48 result = result.filter(
49 Q(translation__component__restricted=False)
50 | Q(translation__component_id__in=user.component_permissions)
51 )
52 return result
55class Screenshot(models.Model, UserDisplayMixin):
56 name = models.CharField(
57 verbose_name=gettext_lazy("Screenshot name"), max_length=200
58 )
59 repository_filename = models.CharField(
60 verbose_name=gettext_lazy("Repository path to screenshot"),
61 help_text=gettext_lazy("Scan for screenshot file change on repository update."),
62 blank=True,
63 max_length=200,
64 )
65 image = ScreenshotField(
66 verbose_name=gettext_lazy("Image"),
67 help_text=gettext_lazy("Upload image up to 2000x2000 pixels."),
68 upload_to="screenshots/",
69 )
70 translation = models.ForeignKey(Translation, on_delete=models.deletion.CASCADE)
71 units = models.ManyToManyField(Unit, blank=True, related_name="screenshots")
72 timestamp = models.DateTimeField(auto_now_add=True)
73 user = models.ForeignKey(
74 settings.AUTH_USER_MODEL,
75 null=True,
76 blank=True,
77 on_delete=models.deletion.SET_NULL,
78 )
80 objects = ScreenshotQuerySet.as_manager()
82 class Meta:
83 verbose_name = "Screenshot"
84 verbose_name_plural = "Screenshots"
86 def __str__(self) -> str:
87 return self.name
89 def __init__(self, *args, **kwargs) -> None:
90 super().__init__(*args, **kwargs)
91 # Project backup integration
92 self.import_data: dict[str, Any] = {}
93 self.import_handle: IO[bytes] | None = None
95 def get_absolute_url(self) -> str:
96 return reverse("screenshot", kwargs={"pk": self.pk})
98 @property
99 def filter_name(self) -> str:
100 return f"screenshot:{Flags.format_value(self.name)}"
103@receiver(m2m_changed, sender=Screenshot.units.through)
104@disable_for_loaddata
105def change_screenshot_assignment(sender, instance, action, **kwargs) -> None:
106 # Update alerts in case there is change in string assignment
107 if instance.translation.component.alert_set.filter(
108 name="UnusedScreenshot"
109 ).exists():
110 update_alerts(instance.translation.component, alerts={"UnusedScreenshot"})
113@receiver(post_delete, sender=Screenshot)
114def update_alerts_on_screenshot_delete(sender, instance, **kwargs) -> None:
115 # Update the unused screenshot alert if screenshot is deleted
116 if instance.translation.component.alert_set.filter(
117 name="UnusedScreenshot"
118 ).exists():
119 update_alerts(instance.translation.component, alerts={"UnusedScreenshot"})
122def validate_screenshot_image(component, filename) -> bool:
123 """Validate a screenshot image."""
124 try:
125 full_name = os.path.join(component.full_path, filename)
126 with open(full_name, "rb") as f:
127 image_file = File(f, name=os.path.basename(filename))
128 validate_bitmap(image_file)
129 except ValidationError as error:
130 component.log_error("failed to validate screenshot %s: %s", filename, error)
131 report_error("Could not validate image from repository")
132 return False
133 return True
136@receiver(vcs_post_update)
137def sync_screenshots_from_repo(sender, component, previous_head: str, **kwargs) -> None:
138 repository = component.repository
139 changed_files = repository.get_changed_files(compare_to=previous_head)
141 screenshots = Screenshot.objects.filter(
142 translation__component=component, repository_filename__in=changed_files
143 )
145 # Update existing screenshots
146 for screenshot in screenshots: 146 ↛ 147line 146 didn't jump to line 147 because the loop on line 146 never started
147 filename = screenshot.repository_filename
148 component.log_debug("detected screenshot change in repository: %s", filename)
149 changed_files.remove(filename)
151 if validate_screenshot_image(component, filename):
152 full_name = os.path.join(component.full_path, filename)
153 with open(full_name, "rb") as f:
154 screenshot.image = File(
155 f,
156 name=default_storage.get_available_name(os.path.basename(filename)),
157 )
158 screenshot.save(update_fields=["image"])
159 component.log_info("updated screenshot from repository: %s", filename)
161 # Add new screenshots matching screenshot filemask
162 for filename in changed_files:
163 if fnmatch.fnmatch( 163 ↛ 166line 163 didn't jump to line 166 because the condition on line 163 was never true
164 filename, component.screenshot_filemask
165 ) and validate_screenshot_image(component, filename):
166 full_name = os.path.join(component.full_path, filename)
167 with open(full_name, "rb") as f:
168 screenshot = Screenshot.objects.create(
169 name=filename,
170 repository_filename=filename,
171 image=File(
172 f,
173 name=default_storage.get_available_name(
174 os.path.basename(filename)
175 ),
176 ),
177 translation=component.source_translation,
178 user=get_anonymous(),
179 )
180 screenshot.save()
181 component.log_info("create screenshot from repository: %s", filename)