Coverage for app/venv/lib/python3.14/site-packages/weblate/screenshots/forms.py: 37%

90 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 

5import io 

6from typing import Any 

7from urllib.parse import urlparse 

8 

9import requests 

10from django import forms 

11from django.conf import settings 

12from django.core.files.uploadedfile import InMemoryUploadedFile 

13from django.http.request import validate_host 

14from django.template.loader import render_to_string 

15from django.utils.html import format_html 

16from django.utils.translation import gettext, gettext_lazy 

17 

18from weblate.screenshots.models import Screenshot 

19from weblate.trans.forms import QueryField 

20from weblate.utils.forms import SortedSelect 

21from weblate.utils.requests import request 

22from weblate.utils.validators import ALLOWED_IMAGES, WeblateURLValidator 

23 

24 

25class ScreenshotImageValidationMixin: 

26 def clean_images( 

27 self, cleaned_data: dict[str, Any], edit: bool = False 

28 ) -> dict[str, Any]: 

29 image = cleaned_data.get("image") 

30 image_url = cleaned_data.get("image_url") 

31 if not image and not image_url: 

32 raise forms.ValidationError( 

33 gettext("You need to provide either image file or image URL.") 

34 ) 

35 

36 if (edit and ("image" not in self.changed_data) and image_url) or ( 

37 not edit and image_url and not image 

38 ): 

39 # download from image_url only if image is not provided and not updated 

40 cleaned_data["image"] = self.download_image(image_url) 

41 image_field = Screenshot._meta.get_field("image") # noqa: SLF001 

42 image_field.run_validators(cleaned_data["image"]) 

43 

44 cleaned_data.pop("image_url", None) 

45 return cleaned_data 

46 

47 def download_image(self, url: str) -> InMemoryUploadedFile: 

48 """Download image from the provided URL.""" 

49 if not validate_host( 

50 urlparse(url).hostname or "", settings.ALLOWED_ASSET_DOMAINS 

51 ): 

52 raise forms.ValidationError( 

53 {"image_url": gettext("Image URL domain is not allowed.")} 

54 ) 

55 try: 

56 with request("get", url, stream=True) as response: 

57 if response.status_code != 200: 

58 raise forms.ValidationError( 

59 { 

60 "image_url": gettext( 

61 "Unable to download image from the provided URL (HTTP status code: %(code)s)." 

62 ) 

63 % {"code": response.status_code} 

64 } 

65 ) 

66 content = b"" 

67 for chunk in response.iter_content( 

68 chunk_size=settings.ALLOWED_ASSET_SIZE + 1 

69 ): 

70 if not content: 

71 content = chunk 

72 else: 

73 # This can be slow, but it typically won't happen 

74 content += chunk 

75 if len(content) > settings.ALLOWED_ASSET_SIZE: 

76 break 

77 if len(content) > settings.ALLOWED_ASSET_SIZE: 

78 raise forms.ValidationError( 

79 {"image_url": gettext("Image is too big.")} 

80 ) 

81 content_type = response.headers.get("Content-Type") 

82 if not content_type or content_type not in ALLOWED_IMAGES: 

83 raise forms.ValidationError( 

84 { 

85 "image_url": gettext("Unsupported image type: %s") 

86 % content_type 

87 } 

88 ) 

89 except requests.RequestException as e: 

90 raise forms.ValidationError( 

91 { 

92 "image_url": gettext( 

93 "Unable to download image from the provided URL." 

94 ) 

95 } 

96 ) from e 

97 filename = url.rsplit("/", maxsplit=1)[-1] or "screenshot" 

98 return InMemoryUploadedFile( 

99 file=io.BytesIO(content), 

100 field_name="image", 

101 name=filename, 

102 content_type=content_type, 

103 size=len(content), 

104 charset=None, 

105 ) 

106 

107 

108class ScreenshotInput(forms.FileInput): 

109 def render(self, name, value, attrs=None, renderer=None, **kwargs): 

110 rendered_input = super().render(name, value, attrs, renderer, **kwargs) 

111 paste_button = render_to_string("screenshots/snippets/paste-button.html") 

112 return format_html("{}{}", rendered_input, paste_button) 

113 

114 

115class ScreenshotEditForm(forms.ModelForm, ScreenshotImageValidationMixin): 

116 """Screenshot editing.""" 

117 

118 image_url = forms.URLField( 

119 required=False, 

120 label=gettext_lazy("Image URL"), 

121 help_text=gettext_lazy("The image will be downloaded and stored."), 

122 validators=[WeblateURLValidator()], 

123 ) 

124 

125 class Meta: 

126 model = Screenshot 

127 fields = ("name", "image", "image_url", "repository_filename") 

128 widgets = { # noqa: RUF012 

129 "image": ScreenshotInput, 

130 } 

131 

132 def clean(self): 

133 cleaned_data = super().clean() 

134 return self.clean_images(cleaned_data or {}, edit=True) 

135 

136 

137class LanguageChoiceField(forms.ModelChoiceField): 

138 def label_from_instance(self, obj): 

139 return obj.language 

140 

141 

142class ScreenshotForm(forms.ModelForm, ScreenshotImageValidationMixin): 

143 """Screenshot upload.""" 

144 

145 image_url = forms.URLField( 

146 required=False, 

147 label=gettext_lazy("Image URL"), 

148 help_text=gettext_lazy("The image will be downloaded and stored."), 

149 validators=[WeblateURLValidator()], 

150 ) 

151 

152 class Meta: 

153 model = Screenshot 

154 fields = ("name", "repository_filename", "image", "image_url", "translation") 

155 widgets = { # noqa: RUF012 

156 "translation": SortedSelect, 

157 "image": ScreenshotInput, 

158 } 

159 field_classes = { # noqa: RUF012 

160 "translation": LanguageChoiceField, 

161 } 

162 

163 def __init__( 

164 self, component, data=None, files=None, instance=None, initial=None 

165 ) -> None: 

166 self.component = component 

167 super().__init__(data=data, files=files, instance=instance, initial=initial) 

168 

169 translations = component.translation_set.prefetch_related("language") 

170 if initial and "translation" in initial: 

171 translations = translations.filter( 

172 pk__in=(initial["translation"].pk, component.source_translation.pk) 

173 ) 

174 self.fields["translation"].queryset = translations 

175 # This is overridden from initial arg of the form 

176 self.fields["translation"].initial = component.source_translation 

177 self.fields["translation"].empty_label = None 

178 self.fields["image"].required = False 

179 

180 def clean(self): 

181 cleaned_data = super().clean() 

182 return self.clean_images(cleaned_data or {}) 

183 

184 

185class SearchForm(forms.Form): 

186 q = QueryField(required=False)