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

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

2# 

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

4 

5from __future__ import annotations 

6 

7import fnmatch 

8import os 

9from typing import IO, TYPE_CHECKING, Any 

10 

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 

21 

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 

32 

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 

35 

36 

37class ScreenshotQuerySet(models.QuerySet): 

38 def order(self): 

39 return self.order_by("name") 

40 

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 

53 

54 

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 ) 

79 

80 objects = ScreenshotQuerySet.as_manager() 

81 

82 class Meta: 

83 verbose_name = "Screenshot" 

84 verbose_name_plural = "Screenshots" 

85 

86 def __str__(self) -> str: 

87 return self.name 

88 

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 

94 

95 def get_absolute_url(self) -> str: 

96 return reverse("screenshot", kwargs={"pk": self.pk}) 

97 

98 @property 

99 def filter_name(self) -> str: 

100 return f"screenshot:{Flags.format_value(self.name)}" 

101 

102 

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"}) 

111 

112 

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"}) 

120 

121 

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 

134 

135 

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) 

140 

141 screenshots = Screenshot.objects.filter( 

142 translation__component=component, repository_filename__in=changed_files 

143 ) 

144 

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) 

150 

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) 

160 

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)