##// END OF EJS Templates
Added asterisk to required field in forms
neko259 -
r1654:30740dfa default
parent child Browse files
Show More
@@ -1,468 +1,469 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4 import logging
5 5
6 6 import pytz
7 7
8 8 from django import forms
9 9 from django.core.files.uploadedfile import SimpleUploadedFile
10 10 from django.core.exceptions import ObjectDoesNotExist
11 11 from django.forms.utils import ErrorList
12 12 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
13 13 from django.utils import timezone
14 14
15 15 from boards.abstracts.settingsmanager import get_settings_manager
16 16 from boards.abstracts.attachment_alias import get_image_by_alias
17 17 from boards.mdx_neboard import formatters
18 18 from boards.models.attachment.downloaders import download
19 19 from boards.models.post import TITLE_MAX_LENGTH
20 20 from boards.models import Tag, Post
21 21 from boards.utils import validate_file_size, get_file_mimetype, \
22 22 FILE_EXTENSION_DELIMITER
23 23 from neboard import settings
24 24 import boards.settings as board_settings
25 25 import neboard
26 26
27 27 POW_HASH_LENGTH = 16
28 28 POW_LIFE_MINUTES = 5
29 29
30 30 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
31 31 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
32 32
33 33 VETERAN_POSTING_DELAY = 5
34 34
35 35 ATTRIBUTE_PLACEHOLDER = 'placeholder'
36 36 ATTRIBUTE_ROWS = 'rows'
37 37
38 38 LAST_POST_TIME = 'last_post_time'
39 39 LAST_LOGIN_TIME = 'last_login_time'
40 40 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
41 41 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
42 42
43 43 LABEL_TITLE = _('Title')
44 44 LABEL_TEXT = _('Text')
45 45 LABEL_TAG = _('Tag')
46 46 LABEL_SEARCH = _('Search')
47 47
48 48 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
49 49 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
50 50
51 51 TAG_MAX_LENGTH = 20
52 52
53 53 TEXTAREA_ROWS = 4
54 54
55 55 TRIPCODE_DELIM = '#'
56 56
57 57 # TODO Maybe this may be converted into the database table?
58 58 MIMETYPE_EXTENSIONS = {
59 59 'image/jpeg': 'jpeg',
60 60 'image/png': 'png',
61 61 'image/gif': 'gif',
62 62 'video/webm': 'webm',
63 63 'application/pdf': 'pdf',
64 64 'x-diff': 'diff',
65 65 'image/svg+xml': 'svg',
66 66 'application/x-shockwave-flash': 'swf',
67 67 'image/x-ms-bmp': 'bmp',
68 68 'image/bmp': 'bmp',
69 69 }
70 70
71 71
72 72 def get_timezones():
73 73 timezones = []
74 74 for tz in pytz.common_timezones:
75 75 timezones.append((tz, tz),)
76 76 return timezones
77 77
78 78
79 79 class FormatPanel(forms.Textarea):
80 80 """
81 81 Panel for text formatting. Consists of buttons to add different tags to the
82 82 form text area.
83 83 """
84 84
85 85 def render(self, name, value, attrs=None):
86 86 output = '<div id="mark-panel">'
87 87 for formatter in formatters:
88 88 output += '<span class="mark_btn"' + \
89 89 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
90 90 '\', \'' + formatter.format_right + '\')">' + \
91 91 formatter.preview_left + formatter.name + \
92 92 formatter.preview_right + '</span>'
93 93
94 94 output += '</div>'
95 95 output += super(FormatPanel, self).render(name, value, attrs=attrs)
96 96
97 97 return output
98 98
99 99
100 100 class PlainErrorList(ErrorList):
101 101 def __unicode__(self):
102 102 return self.as_text()
103 103
104 104 def as_text(self):
105 105 return ''.join(['(!) %s ' % e for e in self])
106 106
107 107
108 108 class NeboardForm(forms.Form):
109 109 """
110 110 Form with neboard-specific formatting.
111 111 """
112 required_css_class = 'required-field'
112 113
113 114 def as_div(self):
114 115 """
115 116 Returns this form rendered as HTML <as_div>s.
116 117 """
117 118
118 119 return self._html_output(
119 120 # TODO Do not show hidden rows in the list here
120 121 normal_row='<div class="form-row">'
121 122 '<div class="form-label">'
122 123 '%(label)s'
123 124 '</div>'
124 125 '<div class="form-input">'
125 126 '%(field)s'
126 127 '</div>'
127 128 '</div>'
128 129 '<div class="form-row">'
129 130 '%(help_text)s'
130 131 '</div>',
131 132 error_row='<div class="form-row">'
132 133 '<div class="form-label"></div>'
133 134 '<div class="form-errors">%s</div>'
134 135 '</div>',
135 136 row_ender='</div>',
136 137 help_text_html='%s',
137 138 errors_on_separate_row=True)
138 139
139 140 def as_json_errors(self):
140 141 errors = []
141 142
142 143 for name, field in list(self.fields.items()):
143 144 if self[name].errors:
144 145 errors.append({
145 146 'field': name,
146 147 'errors': self[name].errors.as_text(),
147 148 })
148 149
149 150 return errors
150 151
151 152
152 153 class PostForm(NeboardForm):
153 154
154 155 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
155 156 label=LABEL_TITLE,
156 157 widget=forms.TextInput(
157 158 attrs={ATTRIBUTE_PLACEHOLDER:
158 159 'test#tripcode'}))
159 160 text = forms.CharField(
160 161 widget=FormatPanel(attrs={
161 162 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
162 163 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
163 164 }),
164 165 required=False, label=LABEL_TEXT)
165 166 file = forms.FileField(required=False, label=_('File'),
166 167 widget=forms.ClearableFileInput(
167 168 attrs={'accept': 'file/*'}))
168 169 file_url = forms.CharField(required=False, label=_('File URL'),
169 170 widget=forms.TextInput(
170 171 attrs={ATTRIBUTE_PLACEHOLDER:
171 172 'http://example.com/image.png'}))
172 173
173 174 # This field is for spam prevention only
174 175 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
175 176 widget=forms.TextInput(attrs={
176 177 'class': 'form-email'}))
177 178 threads = forms.CharField(required=False, label=_('Additional threads'),
178 179 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
179 180 '123 456 789'}))
180 181 subscribe = forms.BooleanField(required=False, label=_('Subscribe to thread'))
181 182
182 183 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
183 184 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
184 185 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
185 186
186 187 session = None
187 188 need_to_ban = False
188 189 image = None
189 190
190 191 def _update_file_extension(self, file):
191 192 if file:
192 193 mimetype = get_file_mimetype(file)
193 194 extension = MIMETYPE_EXTENSIONS.get(mimetype)
194 195 if extension:
195 196 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
196 197 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
197 198
198 199 file.name = new_filename
199 200 else:
200 201 logger = logging.getLogger('boards.forms.extension')
201 202
202 203 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
203 204
204 205 def clean_title(self):
205 206 title = self.cleaned_data['title']
206 207 if title:
207 208 if len(title) > TITLE_MAX_LENGTH:
208 209 raise forms.ValidationError(_('Title must have less than %s '
209 210 'characters') %
210 211 str(TITLE_MAX_LENGTH))
211 212 return title
212 213
213 214 def clean_text(self):
214 215 text = self.cleaned_data['text'].strip()
215 216 if text:
216 217 max_length = board_settings.get_int('Forms', 'MaxTextLength')
217 218 if len(text) > max_length:
218 219 raise forms.ValidationError(_('Text must have less than %s '
219 220 'characters') % str(max_length))
220 221 return text
221 222
222 223 def clean_file(self):
223 224 file = self.cleaned_data['file']
224 225
225 226 if file:
226 227 validate_file_size(file.size)
227 228 self._update_file_extension(file)
228 229
229 230 return file
230 231
231 232 def clean_file_url(self):
232 233 url = self.cleaned_data['file_url']
233 234
234 235 file = None
235 236
236 237 if url:
237 238 file = get_image_by_alias(url, self.session)
238 239 self.image = file
239 240
240 241 if file is not None:
241 242 return
242 243
243 244 if file is None:
244 245 file = self._get_file_from_url(url)
245 246 if not file:
246 247 raise forms.ValidationError(_('Invalid URL'))
247 248 else:
248 249 validate_file_size(file.size)
249 250 self._update_file_extension(file)
250 251
251 252 return file
252 253
253 254 def clean_threads(self):
254 255 threads_str = self.cleaned_data['threads']
255 256
256 257 if len(threads_str) > 0:
257 258 threads_id_list = threads_str.split(' ')
258 259
259 260 threads = list()
260 261
261 262 for thread_id in threads_id_list:
262 263 try:
263 264 thread = Post.objects.get(id=int(thread_id))
264 265 if not thread.is_opening() or thread.get_thread().is_archived():
265 266 raise ObjectDoesNotExist()
266 267 threads.append(thread)
267 268 except (ObjectDoesNotExist, ValueError):
268 269 raise forms.ValidationError(_('Invalid additional thread list'))
269 270
270 271 return threads
271 272
272 273 def clean(self):
273 274 cleaned_data = super(PostForm, self).clean()
274 275
275 276 if cleaned_data['email']:
276 277 if board_settings.get_bool('Forms', 'Autoban'):
277 278 self.need_to_ban = True
278 279 raise forms.ValidationError('A human cannot enter a hidden field')
279 280
280 281 if not self.errors:
281 282 self._clean_text_file()
282 283
283 284 limit_speed = board_settings.get_bool('Forms', 'LimitPostingSpeed')
284 285 limit_first = board_settings.get_bool('Forms', 'LimitFirstPosting')
285 286
286 287 settings_manager = get_settings_manager(self)
287 288 if not self.errors and limit_speed or (limit_first and not settings_manager.get_setting('confirmed_user')):
288 289 pow_difficulty = board_settings.get_int('Forms', 'PowDifficulty')
289 290 if pow_difficulty > 0:
290 291 # PoW-based
291 292 if cleaned_data['timestamp'] \
292 293 and cleaned_data['iteration'] and cleaned_data['guess'] \
293 294 and not settings_manager.get_setting('confirmed_user'):
294 295 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
295 296 else:
296 297 # Time-based
297 298 self._validate_posting_speed()
298 299 settings_manager.set_setting('confirmed_user', True)
299 300
300 301
301 302 return cleaned_data
302 303
303 304 def get_file(self):
304 305 """
305 306 Gets file from form or URL.
306 307 """
307 308
308 309 file = self.cleaned_data['file']
309 310 return file or self.cleaned_data['file_url']
310 311
311 312 def get_tripcode(self):
312 313 title = self.cleaned_data['title']
313 314 if title is not None and TRIPCODE_DELIM in title:
314 315 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
315 316 tripcode = hashlib.md5(code.encode()).hexdigest()
316 317 else:
317 318 tripcode = ''
318 319 return tripcode
319 320
320 321 def get_title(self):
321 322 title = self.cleaned_data['title']
322 323 if title is not None and TRIPCODE_DELIM in title:
323 324 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
324 325 else:
325 326 return title
326 327
327 328 def get_images(self):
328 329 if self.image:
329 330 return [self.image]
330 331 else:
331 332 return []
332 333
333 334 def is_subscribe(self):
334 335 return self.cleaned_data['subscribe']
335 336
336 337 def _clean_text_file(self):
337 338 text = self.cleaned_data.get('text')
338 339 file = self.get_file()
339 340 images = self.get_images()
340 341
341 342 if (not text) and (not file) and len(images) == 0:
342 343 error_message = _('Either text or file must be entered.')
343 344 self._errors['text'] = self.error_class([error_message])
344 345
345 346 def _validate_posting_speed(self):
346 347 can_post = True
347 348
348 349 posting_delay = board_settings.get_int('Forms', 'PostingDelay')
349 350
350 351 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
351 352 now = time.time()
352 353
353 354 current_delay = 0
354 355
355 356 if LAST_POST_TIME not in self.session:
356 357 self.session[LAST_POST_TIME] = now
357 358
358 359 need_delay = True
359 360 else:
360 361 last_post_time = self.session.get(LAST_POST_TIME)
361 362 current_delay = int(now - last_post_time)
362 363
363 364 need_delay = current_delay < posting_delay
364 365
365 366 if need_delay:
366 367 delay = posting_delay - current_delay
367 368 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
368 369 delay) % {'delay': delay}
369 370 self._errors['text'] = self.error_class([error_message])
370 371
371 372 can_post = False
372 373
373 374 if can_post:
374 375 self.session[LAST_POST_TIME] = now
375 376
376 377 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
377 378 """
378 379 Gets an file file from URL.
379 380 """
380 381
381 382 try:
382 383 return download(url)
383 384 except forms.ValidationError as e:
384 385 raise e
385 386 except Exception as e:
386 387 raise forms.ValidationError(e)
387 388
388 389 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
389 390 post_time = timezone.datetime.fromtimestamp(
390 391 int(timestamp[:-3]), tz=timezone.get_current_timezone())
391 392
392 393 payload = timestamp + message.replace('\r\n', '\n')
393 394 difficulty = board_settings.get_int('Forms', 'PowDifficulty')
394 395 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
395 396 if len(target) < POW_HASH_LENGTH:
396 397 target = '0' * (POW_HASH_LENGTH - len(target)) + target
397 398
398 399 computed_guess = hashlib.sha256((payload + iteration).encode())\
399 400 .hexdigest()[0:POW_HASH_LENGTH]
400 401 if guess != computed_guess or guess > target:
401 402 self._errors['text'] = self.error_class(
402 403 [_('Invalid PoW.')])
403 404
404 405
405 406
406 407 class ThreadForm(PostForm):
407 408
408 409 tags = forms.CharField(
409 410 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
410 411 max_length=100, label=_('Tags'), required=True)
411 412 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
412 413
413 414 def clean_tags(self):
414 415 tags = self.cleaned_data['tags'].strip()
415 416
416 417 if not tags or not REGEX_TAGS.match(tags):
417 418 raise forms.ValidationError(
418 419 _('Inappropriate characters in tags.'))
419 420
420 421 required_tag_exists = False
421 422 tag_set = set()
422 423 for tag_string in tags.split():
423 424 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
424 425 tag_set.add(tag)
425 426
426 427 # If this is a new tag, don't check for its parents because nobody
427 428 # added them yet
428 429 if not created:
429 430 tag_set |= set(tag.get_all_parents())
430 431
431 432 for tag in tag_set:
432 433 if tag.required:
433 434 required_tag_exists = True
434 435 break
435 436
436 437 if not required_tag_exists:
437 438 raise forms.ValidationError(
438 439 _('Need at least one section.'))
439 440
440 441 return tag_set
441 442
442 443 def clean(self):
443 444 cleaned_data = super(ThreadForm, self).clean()
444 445
445 446 return cleaned_data
446 447
447 448 def is_monochrome(self):
448 449 return self.cleaned_data['monochrome']
449 450
450 451
451 452 class SettingsForm(NeboardForm):
452 453
453 454 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
454 455 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
455 456 username = forms.CharField(label=_('User name'), required=False)
456 457 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
457 458
458 459 def clean_username(self):
459 460 username = self.cleaned_data['username']
460 461
461 462 if username and not REGEX_USERNAMES.match(username):
462 463 raise forms.ValidationError(_('Inappropriate characters.'))
463 464
464 465 return username
465 466
466 467
467 468 class SearchForm(NeboardForm):
468 469 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,162 +1,166 b''
1 1 .ui-button {
2 2 display: none;
3 3 }
4 4
5 5 .ui-dialog-content {
6 6 padding: 0;
7 7 min-height: 0;
8 8 }
9 9
10 10 .mark_btn {
11 11 cursor: pointer;
12 12 }
13 13
14 14 .img-full {
15 15 position: fixed;
16 16 background-color: #CCC;
17 17 border: 1px solid #000;
18 18 cursor: pointer;
19 19 }
20 20
21 21 .strikethrough {
22 22 text-decoration: line-through;
23 23 }
24 24
25 25 .post_preview {
26 26 z-index: 300;
27 27 position:absolute;
28 28 }
29 29
30 30 .gallery_image {
31 31 display: inline-block;
32 32 }
33 33
34 34 @media print {
35 35 .post-form-w {
36 36 display: none;
37 37 }
38 38 }
39 39
40 40 input[name="image"] {
41 41 display: block;
42 42 width: 100px;
43 43 height: 100px;
44 44 cursor: pointer;
45 45 position: absolute;
46 46 opacity: 0;
47 47 z-index: 1;
48 48 }
49 49
50 50 .file_wrap {
51 51 width: 100px;
52 52 height: 100px;
53 53 border: solid 1px white;
54 54 display: inline-block;
55 55 }
56 56
57 57 form > .file_wrap {
58 58 float: left;
59 59 }
60 60
61 61 .file-thumb {
62 62 width: 100px;
63 63 height: 100px;
64 64 background-size: cover;
65 65 background-position: center;
66 66 }
67 67
68 68 .compact-form-text {
69 69 margin-left:110px;
70 70 }
71 71
72 72 textarea, input {
73 73 -moz-box-sizing: border-box;
74 74 -webkit-box-sizing: border-box;
75 75 box-sizing: border-box;
76 76 }
77 77
78 78 .compact-form-text > textarea {
79 79 height: 100px;
80 80 width: 100%;
81 81 }
82 82
83 83 .post-button-form {
84 84 display: inline;
85 85 }
86 86
87 87 .post-button-form > button, #autoupdate {
88 88 border: none;
89 89 margin: inherit;
90 90 padding: inherit;
91 91 background: none;
92 92 font-size: inherit;
93 93 cursor: pointer;
94 94 }
95 95
96 96 #form-close-button {
97 97 display: none;
98 98 }
99 99
100 100 .post-image-full {
101 101 width: 100%;
102 102 height: auto;
103 103 }
104 104
105 105 #preview-text {
106 106 display: none;
107 107 }
108 108
109 109 .random-images-table {
110 110 text-align: center;
111 111 width: 100%;
112 112 }
113 113
114 114 .random-images-table > div {
115 115 margin-left: auto;
116 116 margin-right: auto;
117 117 }
118 118
119 119 .tag-image, .tag-text-data {
120 120 display: inline-block;
121 121 }
122 122
123 123 .tag-text-data > h2 {
124 124 margin: 0;
125 125 }
126 126
127 127 .tag-image {
128 128 margin-right: 5px;
129 129 }
130 130
131 131 .reply-to-message {
132 132 display: none;
133 133 }
134 134
135 135 .tripcode {
136 136 padding: 2px;
137 137 }
138 138
139 139 #fav-panel {
140 140 display: none;
141 141 margin: 1ex;
142 142 }
143 143
144 144 .hidden_post {
145 145 opacity: 0.2;
146 146 }
147 147
148 148 .hidden_post:hover {
149 149 opacity: 1;
150 150 }
151 151
152 152 .monochrome > .image > .thumb > img {
153 153 filter: grayscale(100%);
154 154 -webkit-filter: grayscale(100%);
155 155 }
156 156
157 157 #quote-button {
158 158 position: absolute;
159 159 display: none;
160 160 cursor: pointer;
161 161 z-index: 400;
162 162 }
163
164 .required-field:before {
165 content: '* ';
166 }
General Comments 0
You need to be logged in to leave comments. Login now