Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# (c) 2018-2020 

2# MPIB <https://www.mpib-berlin.mpg.de/>, 

3# MPI-CBS <https://www.cbs.mpg.de/>, 

4# MPIP <http://www.psych.mpg.de/> 

5# 

6# This file is part of Castellum. 

7# 

8# Castellum is free software; you can redistribute it and/or modify it 

9# under the terms of the GNU Affero General Public License as published 

10# by the Free Software Foundation; either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# Castellum is distributed in the hope that it will be useful, but 

14# WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

16# Affero General Public License for more details. 

17# 

18# You should have received a copy of the GNU Affero General Public 

19# License along with Castellum. If not, see 

20# <http://www.gnu.org/licenses/>. 

21 

22import codecs 

23import json 

24 

25from django import forms 

26from django.forms import ValidationError 

27from django.forms.widgets import TextInput 

28from django.utils.deconstruct import deconstructible 

29from django.utils.translation import gettext_lazy as _ 

30 

31import jsonschema 

32import magic 

33from phonenumber_field.formfields import PhoneNumberField as _PhoneNumberFormField 

34from phonenumber_field.widgets import PhoneNumberInternationalFallbackWidget 

35 

36 

37class ReadonlyFormMixin: 

38 def __init__(self, *args, **kwargs): 

39 self.readonly = kwargs.pop('readonly', False) 

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

41 

42 if callable(self.readonly): 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true

43 self.readonly = self.readonly(self) 

44 

45 if self.readonly: 

46 for field in self.fields.values(): 

47 if hasattr(field, 'choices'): 47 ↛ 48line 47 didn't jump to line 48, because the condition on line 47 was never true

48 field.widget.attrs['disabled'] = True 

49 elif getattr(field.widget, 'input_type', 'text') in ['checkbox', 'radio', 'file']: 

50 field.widget.attrs['disabled'] = True 

51 else: 

52 field.widget.attrs['readonly'] = True 

53 

54 

55class ReadonlyModelForm(ReadonlyFormMixin, forms.ModelForm): 

56 pass 

57 

58 

59class DateInput(forms.DateInput): 

60 def __init__(self, attrs=None): 

61 defaults = { 

62 'placeholder': _('yyyy-mm-dd, e.g. 2018-07-21'), 

63 'autocomplete': 'off', 

64 'type': 'date', 

65 } 

66 if attrs: 66 ↛ 67line 66 didn't jump to line 67, because the condition on line 66 was never true

67 defaults.update(attrs) 

68 super().__init__(format='%Y-%m-%d', attrs=defaults) 

69 

70 

71class DateTimeInput(forms.SplitDateTimeWidget): 

72 template_name = 'utils/widgets/splitdatetime.html' 

73 

74 def __init__(self, date_attrs=None, time_attrs=None): 

75 date_defaults = { 

76 'placeholder': _('yyyy-mm-dd, e.g. 2018-07-21'), 

77 'autocomplete': 'off', 

78 'type': 'date', 

79 } 

80 if date_attrs: 80 ↛ 81line 80 didn't jump to line 81, because the condition on line 80 was never true

81 date_defaults.update(date_attrs) 

82 

83 time_defaults = { 

84 'placeholder': _('HH:MM, e.g. 13:00'), 

85 } 

86 if time_attrs: 86 ↛ 87line 86 didn't jump to line 87, because the condition on line 86 was never true

87 time_defaults.update(time_attrs) 

88 

89 super().__init__(date_format='%Y-%m-%d', date_attrs=date_defaults, time_attrs=time_defaults) 

90 

91 

92class DateField(forms.DateField): 

93 widget = DateInput 

94 

95 

96class DateTimeField(forms.SplitDateTimeField): 

97 widget = DateTimeInput 

98 

99 

100class PhoneNumberWidget(PhoneNumberInternationalFallbackWidget): 

101 def __init__(self, attrs=None): 

102 defaults = { 

103 'placeholder': _('e.g. 030 123456'), 

104 } 

105 if attrs: 105 ↛ 106line 105 didn't jump to line 106, because the condition on line 105 was never true

106 defaults.update(defaults) 

107 super().__init__(attrs=defaults) 

108 

109 

110class PhoneNumberField(_PhoneNumberFormField): 

111 widget = PhoneNumberWidget 

112 

113 

114class DisabledSelect(forms.Select): 

115 def __init__(self, **kwargs): 

116 self.disabled_choices = kwargs.pop('disabled_choices', []) 

117 super().__init__(**kwargs) 

118 

119 def create_option(self, name, value, *args, **kwargs): 

120 option = super().create_option(name, value, *args, **kwargs) 

121 if value in self.disabled_choices: 121 ↛ 122line 121 didn't jump to line 122, because the condition on line 121 was never true

122 option['attrs']['disabled'] = True 

123 return option 

124 

125 

126class DisabledChoiceField(forms.TypedChoiceField): 

127 widget = DisabledSelect 

128 

129 def validate(self, value): 

130 if value in self.widget.disabled_choices: 130 ↛ 131line 130 didn't jump to line 131, because the condition on line 130 was never true

131 raise forms.ValidationError( 

132 self.error_messages['invalid_choice'], 

133 code='invalid_choice', 

134 params={'value': value}, 

135 ) 

136 return super().validate(value) 

137 

138 

139class DisabledModelChoiceField(forms.ModelChoiceField): 

140 widget = DisabledSelect 

141 

142 def validate(self, value): 

143 # value is the instance, so we have to check value.pk 

144 if value.pk in self.widget.disabled_choices: 

145 raise forms.ValidationError( 

146 self.error_messages['invalid_choice'], 

147 code='invalid_choice', 

148 params={'value': value}, 

149 ) 

150 return super().validate(value) 

151 

152 

153class IntegerChoiceField(forms.TypedChoiceField): 

154 def __init__(self, *args, **kwargs): 

155 kwargs.setdefault('coerce', int) 

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

157 

158 

159class IntegerMultipleChoiceField(forms.TypedMultipleChoiceField): 

160 def __init__(self, *args, **kwargs): 

161 kwargs.setdefault('coerce', int) 

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

163 

164 

165class DatalistWidget(TextInput): 

166 template_name = 'utils/widgets/datalist.html' 

167 

168 def __init__(self, *args, datalist=[], **kwargs): 

169 self.datalist = datalist 

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

171 

172 def get_context(self, name, value, attrs): 

173 context = super().get_context(name, value, attrs) 

174 context['widget']['attrs']['list'] = context['widget']['attrs']['id'] + '_list' 

175 context['datalist'] = self.datalist 

176 return context 

177 

178 

179class RestrictedFileField(forms.FileField): 

180 def __init__(self, *args, **kwargs): 

181 self.content_types = kwargs.pop('content_types', []) 

182 self.max_upload_size = kwargs.pop('max_upload_size', 0) 

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

184 

185 def to_python(self, data): 

186 f = super().to_python(data) 

187 if f is None: 

188 return None 

189 

190 if self.max_upload_size and f.size > self.max_upload_size: 190 ↛ 191line 190 didn't jump to line 191, because the condition on line 190 was never true

191 raise forms.ValidationError(_('File is too big.'), code='size') 

192 

193 content_type = magic.detect_from_content(f.read(1024)).mime_type 

194 if content_type not in self.content_types: 194 ↛ 195line 194 didn't jump to line 195, because the condition on line 194 was never true

195 raise forms.ValidationError(_('Filetype not supported.'), code='content_type') 

196 

197 f.seek(0) 

198 

199 return f 

200 

201 def widget_attrs(self, widget): 

202 attrs = super().widget_attrs(widget) 

203 if self.content_types: 203 ↛ 205line 203 didn't jump to line 205, because the condition on line 203 was never false

204 attrs.setdefault('accept', ','.join(self.content_types)) 

205 return attrs 

206 

207 

208@deconstructible 

209class JsonFileValidator: 

210 def __init__(self, schema, schema_ref): 

211 self.schema = {'$ref': schema_ref} 

212 self.schema.update(schema) 

213 

214 def format_error(self, error): 

215 path = '.'.join(str(x) for x in error.path) 

216 return '%s %s' % (path, error.message) 

217 

218 def __eq__(self, other): 

219 return self.schema == other.schema and self.schema_ref == other.schema_ref 

220 

221 def __call__(self, fh): 

222 try: 

223 reader = codecs.getreader('utf-8') 

224 data = json.load(reader(fh)) 

225 except json.JSONDecodeError: 225 ↛ 226line 225 didn't jump to line 226, because the exception caught by line 225 didn't happen

226 raise ValidationError(_('Uploaded file does not contain valid JSON.'), code='encoding') 

227 except UnicodeDecodeError: 

228 raise ValidationError(_('Uploaded file must be UTF-8 encoded.'), code='json') 

229 

230 validator = jsonschema.Draft7Validator(self.schema) 

231 errors = list(validator.iter_errors(data)) 

232 if errors: 232 ↛ 233line 232 didn't jump to line 233, because the condition on line 232 was never true

233 raise ValidationError([ 

234 ValidationError(self.format_error(error), code='schema') for error in errors 

235 ]) 

236 

237 return data 

238 

239 

240class BaseImportForm(forms.Form): 

241 # magic seems to detect 'text/plain' for json files 

242 file = RestrictedFileField( 

243 label=_('File'), content_types=['application/json', 'text/plain'], required=True 

244 ) 

245 

246 def clean(self): 

247 cleaned_data = super().clean() 

248 if 'file' in cleaned_data: 248 ↛ 251line 248 didn't jump to line 251, because the condition on line 248 was never false

249 validator = JsonFileValidator(self.schema, self.schema_ref) 

250 cleaned_data['json'] = validator(cleaned_data['file']) 

251 return cleaned_data