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

134 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 

4from __future__ import annotations 

5 

6from typing import Literal 

7 

8from crispy_forms.layout import Div, Field 

9from crispy_forms.utils import TEMPLATE_PACK 

10from django import forms 

11from django.core.exceptions import ValidationError 

12from django.db.models import Q 

13from django.forms.models import ModelChoiceIterator 

14from django.template.loader import render_to_string 

15from django.utils.text import normalize_newlines 

16from django.utils.translation import gettext, gettext_lazy 

17from pyparsing import ParseException 

18 

19from weblate.formats.helpers import CONTROLCHARS 

20from weblate.trans.defines import EMAIL_LENGTH, USERNAME_LENGTH 

21from weblate.trans.filter import FILTERS 

22from weblate.trans.util import sort_unicode 

23from weblate.utils.errors import report_error 

24 

25from .validators import WeblateServiceURLValidator, validate_email, validate_username 

26 

27 

28class QueryField(forms.CharField): 

29 def __init__( 

30 self, parser: Literal["unit", "user", "superuser"] = "unit", **kwargs 

31 ) -> None: 

32 if "label" not in kwargs: 32 ↛ 34line 32 didn't jump to line 34 because the condition on line 32 was always true

33 kwargs["label"] = gettext_lazy("Query") 

34 if "required" not in kwargs: 

35 kwargs["required"] = False 

36 if "widget" not in kwargs: 36 ↛ 38line 36 didn't jump to line 38 because the condition on line 36 was always true

37 kwargs["widget"] = forms.Textarea(attrs={"cols": None, "rows": 1}) 

38 self.parser = parser 

39 super().__init__(**kwargs) 

40 

41 def clean(self, value): 

42 from weblate.utils.search import parse_query 

43 

44 if not value: 44 ↛ 48line 44 didn't jump to line 48 because the condition on line 44 was always true

45 if self.required: 45 ↛ 47line 45 didn't jump to line 47 because the condition on line 45 was always true

46 raise ValidationError(gettext("Missing query string.")) 

47 return "" 

48 try: 

49 parse_query(value, parser=self.parser) 

50 except (ValueError, ParseException) as error: 

51 raise ValidationError( 

52 gettext("Could not parse query string: {}").format(error) 

53 ) from error 

54 except Exception as error: 

55 report_error("Error parsing search query") 

56 raise ValidationError( 

57 gettext("Could not parse query string: {}").format(error) 

58 ) from error 

59 return value 

60 

61 

62class UsernameField(forms.CharField): 

63 default_validators = [validate_username] # noqa: RUF012 

64 

65 def __init__( 

66 self, 

67 *, 

68 max_length: int | None = None, 

69 min_length: int | None = None, 

70 strip: bool = True, 

71 empty_value: str = "", 

72 **kwargs, 

73 ) -> None: 

74 params = { 

75 "max_length": USERNAME_LENGTH, 

76 "help_text": gettext_lazy( 

77 "Username may only contain letters, " 

78 "numbers or the following characters: @ . + - _" 

79 ), 

80 "label": gettext_lazy("Username"), 

81 "required": True, 

82 } 

83 params.update(kwargs) 

84 self.valid = None 

85 

86 super().__init__( 

87 max_length=max_length, 

88 min_length=min_length, 

89 strip=strip, 

90 empty_value=empty_value, 

91 **kwargs, 

92 ) 

93 

94 

95class UserField(forms.CharField): 

96 def __init__( 

97 self, 

98 queryset=None, 

99 empty_label="---------", 

100 to_field_name=None, 

101 limit_choices_to=None, 

102 blank=None, 

103 **kwargs, 

104 ) -> None: 

105 # This swallows some parameters to mimic ModelChoiceField API 

106 super().__init__(**kwargs) 

107 

108 def widget_attrs(self, widget): 

109 attrs = super().widget_attrs(widget) 

110 attrs["dir"] = "ltr" 

111 attrs["class"] = "user-autocomplete" 

112 attrs["spellcheck"] = "false" 

113 attrs["autocorrect"] = "off" 

114 attrs["autocomplete"] = "off" 

115 attrs["autocapitalize"] = "off" 

116 return attrs 

117 

118 def clean(self, value): 

119 from weblate.auth.models import User 

120 

121 if not value: 

122 if self.required: 

123 raise ValidationError(gettext("Missing username or e-mail.")) 

124 return None 

125 if any(char in value for char in CONTROLCHARS): 

126 raise ValidationError( 

127 gettext("String contains control character: %s") % repr(value) 

128 ) 

129 try: 

130 return User.objects.get(Q(username=value) | Q(email=value)) 

131 except User.DoesNotExist as error: 

132 raise ValidationError(gettext("Could not find any such user.")) from error 

133 except User.MultipleObjectsReturned as error: 

134 raise ValidationError(gettext("More possible users were found.")) from error 

135 

136 

137class EmailField(forms.EmailField): 

138 """ 

139 Slightly restricted EmailField. 

140 

141 We block some additional local parts and customize error messages. 

142 """ 

143 

144 default_validators = [validate_email] # noqa: RUF012 

145 

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

147 kwargs.setdefault("max_length", EMAIL_LENGTH) 

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

149 

150 

151class SortedChoiceWidget(forms.widgets.ChoiceWidget): 

152 """Wrapper class to sort choices alphabetically.""" 

153 

154 def optgroups(self, name, value, attrs=None): 

155 groups = super().optgroups(name, value, attrs) 

156 return sort_unicode(groups, lambda val: str(val[1][0]["label"])) 

157 

158 

159class SortedSelect(SortedChoiceWidget, forms.Select): 

160 """Wrapper class to sort choices alphabetically.""" 

161 

162 

163class ColorWidget(forms.RadioSelect): 

164 def __init__(self, attrs=None, choices=()) -> None: 

165 attrs = {**(attrs or {}), "class": "color_edit"} 

166 super().__init__(attrs, choices) 

167 

168 

169class SortedSelectMultiple(SortedSelect, forms.SelectMultiple): 

170 """Wrapper class to sort choices alphabetically.""" 

171 

172 

173class ContextDiv(Div): 

174 def __init__(self, *fields, **kwargs) -> None: 

175 self.context = kwargs.pop("context", {}) 

176 super().__init__(*fields, **kwargs) 

177 

178 def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs): 

179 template = self.get_template_name(template_pack) 

180 return render_to_string(template, self.context) 

181 

182 

183class SearchField(Field): 

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

185 kwargs["template"] = "snippets/query-field.html" 

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

187 

188 def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs): 

189 extra_context = {"custom_filter_list": self.get_search_query_choices()} 

190 return super().render(form, context, template_pack, extra_context, **kwargs) 

191 

192 def get_search_query_choices(self): 

193 """Return all filtering choices for query field.""" 

194 filter_keys = [ 

195 "nottranslated", 

196 "todo", 

197 "translated", 

198 "fuzzy", 

199 "suggestions", 

200 "variants", 

201 "screenshots", 

202 "labels", 

203 "context", 

204 "nosuggestions", 

205 "comments", 

206 "allchecks", 

207 "approved", 

208 "unapproved", 

209 ] 

210 return [ 

211 (key, FILTERS.get_filter_name(key), FILTERS.get_filter_query(key)) 

212 for key in filter_keys 

213 ] 

214 

215 

216class CachedQueryIterator(ModelChoiceIterator): 

217 """ 

218 Choice iterator for cached querysets. 

219 

220 It assumes the queryset is reused and avoids using an iterator or counting queries. 

221 """ 

222 

223 def __iter__(self): 

224 if self.field.empty_label is not None: 

225 yield ("", self.field.empty_label) 

226 for obj in self.queryset: 

227 yield self.choice(obj) 

228 

229 def __len__(self) -> int: 

230 return len(self.queryset) + (1 if self.field.empty_label is not None else 0) 

231 

232 def __bool__(self) -> bool: 

233 return self.field.empty_label is not None or bool(self.queryset) 

234 

235 

236class CachedModelChoiceField(forms.ModelChoiceField): 

237 iterator = CachedQueryIterator 

238 

239 def _get_queryset(self): 

240 return self._queryset 

241 

242 def _set_queryset(self, queryset) -> None: 

243 self._queryset = queryset 

244 self.widget.choices = self.choices 

245 

246 queryset = property(_get_queryset, _set_queryset) 

247 

248 

249class CachedModelMultipleChoiceField( 

250 CachedModelChoiceField, forms.ModelMultipleChoiceField 

251): 

252 pass 

253 

254 

255class WeblateServiceURLField(forms.URLField): 

256 default_validators = [WeblateServiceURLValidator()] # noqa: RUF012 

257 

258 

259class NormalizedNewlineCharField(forms.CharField): 

260 def to_python(self, value): 

261 return normalize_newlines(str(super().to_python(value))) 

262 

263 

264class WeblateDateInput(forms.DateInput): 

265 input_type = "date"