##// END OF EJS Templates
Allow to edit tag and post in the admin site
neko259 -
r1367:2712a5bd default
parent child Browse files
Show More
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations, models
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0031_post_hidden'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='post',
16 name='tripcode',
17 field=models.CharField(default='', blank=True, max_length=50),
18 ),
19 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations, models
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0032_auto_20151014_2222'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='tag',
16 name='parent',
17 field=models.ForeignKey(blank=True, related_name='children', to='boards.Tag', null=True),
18 ),
19 ]
@@ -1,371 +1,376 b''
1 import hashlib
1 import hashlib
2 import re
2 import re
3 import time
3 import time
4
4
5 import pytz
5 import pytz
6 from django import forms
6 from django import forms
7 from django.core.files.uploadedfile import SimpleUploadedFile
7 from django.core.files.uploadedfile import SimpleUploadedFile
8 from django.core.exceptions import ObjectDoesNotExist
8 from django.core.exceptions import ObjectDoesNotExist
9 from django.forms.util import ErrorList
9 from django.forms.util import ErrorList
10 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
10 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
11
11
12 from boards.mdx_neboard import formatters
12 from boards.mdx_neboard import formatters
13 from boards.models.attachment.downloaders import Downloader
13 from boards.models.attachment.downloaders import Downloader
14 from boards.models.post import TITLE_MAX_LENGTH
14 from boards.models.post import TITLE_MAX_LENGTH
15 from boards.models import Tag, Post
15 from boards.models import Tag, Post
16 from boards.utils import validate_file_size
16 from boards.utils import validate_file_size
17 from neboard import settings
17 from neboard import settings
18 import boards.settings as board_settings
18 import boards.settings as board_settings
19 import neboard
19 import neboard
20
20
21 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
21 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
22
22
23 VETERAN_POSTING_DELAY = 5
23 VETERAN_POSTING_DELAY = 5
24
24
25 ATTRIBUTE_PLACEHOLDER = 'placeholder'
25 ATTRIBUTE_PLACEHOLDER = 'placeholder'
26 ATTRIBUTE_ROWS = 'rows'
26 ATTRIBUTE_ROWS = 'rows'
27
27
28 LAST_POST_TIME = 'last_post_time'
28 LAST_POST_TIME = 'last_post_time'
29 LAST_LOGIN_TIME = 'last_login_time'
29 LAST_LOGIN_TIME = 'last_login_time'
30 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
30 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
31 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
31 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
32
32
33 LABEL_TITLE = _('Title')
33 LABEL_TITLE = _('Title')
34 LABEL_TEXT = _('Text')
34 LABEL_TEXT = _('Text')
35 LABEL_TAG = _('Tag')
35 LABEL_TAG = _('Tag')
36 LABEL_SEARCH = _('Search')
36 LABEL_SEARCH = _('Search')
37
37
38 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
38 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
39 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
39 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
40
40
41 TAG_MAX_LENGTH = 20
41 TAG_MAX_LENGTH = 20
42
42
43 TEXTAREA_ROWS = 4
43 TEXTAREA_ROWS = 4
44
44
45 TRIPCODE_DELIM = '#'
46
45
47
46 def get_timezones():
48 def get_timezones():
47 timezones = []
49 timezones = []
48 for tz in pytz.common_timezones:
50 for tz in pytz.common_timezones:
49 timezones.append((tz, tz),)
51 timezones.append((tz, tz),)
50 return timezones
52 return timezones
51
53
52
54
53 class FormatPanel(forms.Textarea):
55 class FormatPanel(forms.Textarea):
54 """
56 """
55 Panel for text formatting. Consists of buttons to add different tags to the
57 Panel for text formatting. Consists of buttons to add different tags to the
56 form text area.
58 form text area.
57 """
59 """
58
60
59 def render(self, name, value, attrs=None):
61 def render(self, name, value, attrs=None):
60 output = '<div id="mark-panel">'
62 output = '<div id="mark-panel">'
61 for formatter in formatters:
63 for formatter in formatters:
62 output += '<span class="mark_btn"' + \
64 output += '<span class="mark_btn"' + \
63 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
65 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
64 '\', \'' + formatter.format_right + '\')">' + \
66 '\', \'' + formatter.format_right + '\')">' + \
65 formatter.preview_left + formatter.name + \
67 formatter.preview_left + formatter.name + \
66 formatter.preview_right + '</span>'
68 formatter.preview_right + '</span>'
67
69
68 output += '</div>'
70 output += '</div>'
69 output += super(FormatPanel, self).render(name, value, attrs=None)
71 output += super(FormatPanel, self).render(name, value, attrs=None)
70
72
71 return output
73 return output
72
74
73
75
74 class PlainErrorList(ErrorList):
76 class PlainErrorList(ErrorList):
75 def __unicode__(self):
77 def __unicode__(self):
76 return self.as_text()
78 return self.as_text()
77
79
78 def as_text(self):
80 def as_text(self):
79 return ''.join(['(!) %s ' % e for e in self])
81 return ''.join(['(!) %s ' % e for e in self])
80
82
81
83
82 class NeboardForm(forms.Form):
84 class NeboardForm(forms.Form):
83 """
85 """
84 Form with neboard-specific formatting.
86 Form with neboard-specific formatting.
85 """
87 """
86
88
87 def as_div(self):
89 def as_div(self):
88 """
90 """
89 Returns this form rendered as HTML <as_div>s.
91 Returns this form rendered as HTML <as_div>s.
90 """
92 """
91
93
92 return self._html_output(
94 return self._html_output(
93 # TODO Do not show hidden rows in the list here
95 # TODO Do not show hidden rows in the list here
94 normal_row='<div class="form-row">'
96 normal_row='<div class="form-row">'
95 '<div class="form-label">'
97 '<div class="form-label">'
96 '%(label)s'
98 '%(label)s'
97 '</div>'
99 '</div>'
98 '<div class="form-input">'
100 '<div class="form-input">'
99 '%(field)s'
101 '%(field)s'
100 '</div>'
102 '</div>'
101 '</div>'
103 '</div>'
102 '<div class="form-row">'
104 '<div class="form-row">'
103 '%(help_text)s'
105 '%(help_text)s'
104 '</div>',
106 '</div>',
105 error_row='<div class="form-row">'
107 error_row='<div class="form-row">'
106 '<div class="form-label"></div>'
108 '<div class="form-label"></div>'
107 '<div class="form-errors">%s</div>'
109 '<div class="form-errors">%s</div>'
108 '</div>',
110 '</div>',
109 row_ender='</div>',
111 row_ender='</div>',
110 help_text_html='%s',
112 help_text_html='%s',
111 errors_on_separate_row=True)
113 errors_on_separate_row=True)
112
114
113 def as_json_errors(self):
115 def as_json_errors(self):
114 errors = []
116 errors = []
115
117
116 for name, field in list(self.fields.items()):
118 for name, field in list(self.fields.items()):
117 if self[name].errors:
119 if self[name].errors:
118 errors.append({
120 errors.append({
119 'field': name,
121 'field': name,
120 'errors': self[name].errors.as_text(),
122 'errors': self[name].errors.as_text(),
121 })
123 })
122
124
123 return errors
125 return errors
124
126
125
127
126 class PostForm(NeboardForm):
128 class PostForm(NeboardForm):
127
129
128 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
130 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
129 label=LABEL_TITLE,
131 label=LABEL_TITLE,
130 widget=forms.TextInput(
132 widget=forms.TextInput(
131 attrs={ATTRIBUTE_PLACEHOLDER:
133 attrs={ATTRIBUTE_PLACEHOLDER:
132 'test#tripcode'}))
134 'test#tripcode'}))
133 text = forms.CharField(
135 text = forms.CharField(
134 widget=FormatPanel(attrs={
136 widget=FormatPanel(attrs={
135 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
137 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
136 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
138 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
137 }),
139 }),
138 required=False, label=LABEL_TEXT)
140 required=False, label=LABEL_TEXT)
139 file = forms.FileField(required=False, label=_('File'),
141 file = forms.FileField(required=False, label=_('File'),
140 widget=forms.ClearableFileInput(
142 widget=forms.ClearableFileInput(
141 attrs={'accept': 'file/*'}))
143 attrs={'accept': 'file/*'}))
142 file_url = forms.CharField(required=False, label=_('File URL'),
144 file_url = forms.CharField(required=False, label=_('File URL'),
143 widget=forms.TextInput(
145 widget=forms.TextInput(
144 attrs={ATTRIBUTE_PLACEHOLDER:
146 attrs={ATTRIBUTE_PLACEHOLDER:
145 'http://example.com/image.png'}))
147 'http://example.com/image.png'}))
146
148
147 # This field is for spam prevention only
149 # This field is for spam prevention only
148 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
150 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
149 widget=forms.TextInput(attrs={
151 widget=forms.TextInput(attrs={
150 'class': 'form-email'}))
152 'class': 'form-email'}))
151 threads = forms.CharField(required=False, label=_('Additional threads'),
153 threads = forms.CharField(required=False, label=_('Additional threads'),
152 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
154 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
153 '123 456 789'}))
155 '123 456 789'}))
154
156
155 session = None
157 session = None
156 need_to_ban = False
158 need_to_ban = False
157
159
158 def clean_title(self):
160 def clean_title(self):
159 title = self.cleaned_data['title']
161 title = self.cleaned_data['title']
160 if title:
162 if title:
161 if len(title) > TITLE_MAX_LENGTH:
163 if len(title) > TITLE_MAX_LENGTH:
162 raise forms.ValidationError(_('Title must have less than %s '
164 raise forms.ValidationError(_('Title must have less than %s '
163 'characters') %
165 'characters') %
164 str(TITLE_MAX_LENGTH))
166 str(TITLE_MAX_LENGTH))
165 return title
167 return title
166
168
167 def clean_text(self):
169 def clean_text(self):
168 text = self.cleaned_data['text'].strip()
170 text = self.cleaned_data['text'].strip()
169 if text:
171 if text:
170 max_length = board_settings.get_int('Forms', 'MaxTextLength')
172 max_length = board_settings.get_int('Forms', 'MaxTextLength')
171 if len(text) > max_length:
173 if len(text) > max_length:
172 raise forms.ValidationError(_('Text must have less than %s '
174 raise forms.ValidationError(_('Text must have less than %s '
173 'characters') % str(max_length))
175 'characters') % str(max_length))
174 return text
176 return text
175
177
176 def clean_file(self):
178 def clean_file(self):
177 file = self.cleaned_data['file']
179 file = self.cleaned_data['file']
178
180
179 if file:
181 if file:
180 validate_file_size(file.size)
182 validate_file_size(file.size)
181
183
182 return file
184 return file
183
185
184 def clean_file_url(self):
186 def clean_file_url(self):
185 url = self.cleaned_data['file_url']
187 url = self.cleaned_data['file_url']
186
188
187 file = None
189 file = None
188 if url:
190 if url:
189 file = self._get_file_from_url(url)
191 file = self._get_file_from_url(url)
190
192
191 if not file:
193 if not file:
192 raise forms.ValidationError(_('Invalid URL'))
194 raise forms.ValidationError(_('Invalid URL'))
193 else:
195 else:
194 validate_file_size(file.size)
196 validate_file_size(file.size)
195
197
196 return file
198 return file
197
199
198 def clean_threads(self):
200 def clean_threads(self):
199 threads_str = self.cleaned_data['threads']
201 threads_str = self.cleaned_data['threads']
200
202
201 if len(threads_str) > 0:
203 if len(threads_str) > 0:
202 threads_id_list = threads_str.split(' ')
204 threads_id_list = threads_str.split(' ')
203
205
204 threads = list()
206 threads = list()
205
207
206 for thread_id in threads_id_list:
208 for thread_id in threads_id_list:
207 try:
209 try:
208 thread = Post.objects.get(id=int(thread_id))
210 thread = Post.objects.get(id=int(thread_id))
209 if not thread.is_opening() or thread.get_thread().archived:
211 if not thread.is_opening() or thread.get_thread().archived:
210 raise ObjectDoesNotExist()
212 raise ObjectDoesNotExist()
211 threads.append(thread)
213 threads.append(thread)
212 except (ObjectDoesNotExist, ValueError):
214 except (ObjectDoesNotExist, ValueError):
213 raise forms.ValidationError(_('Invalid additional thread list'))
215 raise forms.ValidationError(_('Invalid additional thread list'))
214
216
215 return threads
217 return threads
216
218
217 def clean(self):
219 def clean(self):
218 cleaned_data = super(PostForm, self).clean()
220 cleaned_data = super(PostForm, self).clean()
219
221
220 if cleaned_data['email']:
222 if cleaned_data['email']:
221 self.need_to_ban = True
223 self.need_to_ban = True
222 raise forms.ValidationError('A human cannot enter a hidden field')
224 raise forms.ValidationError('A human cannot enter a hidden field')
223
225
224 if not self.errors:
226 if not self.errors:
225 self._clean_text_file()
227 self._clean_text_file()
226
228
227 if not self.errors and self.session:
229 if not self.errors and self.session:
228 self._validate_posting_speed()
230 self._validate_posting_speed()
229
231
230 return cleaned_data
232 return cleaned_data
231
233
232 def get_file(self):
234 def get_file(self):
233 """
235 """
234 Gets file from form or URL.
236 Gets file from form or URL.
235 """
237 """
236
238
237 file = self.cleaned_data['file']
239 file = self.cleaned_data['file']
238 return file or self.cleaned_data['file_url']
240 return file or self.cleaned_data['file_url']
239
241
240 def get_tripcode(self):
242 def get_tripcode(self):
241 title = self.cleaned_data['title']
243 title = self.cleaned_data['title']
242 if title is not None and '#' in title:
244 if title is not None and TRIPCODE_DELIM in title:
243 code = title.split('#', maxsplit=1)[1] + neboard.settings.SECRET_KEY
245 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
244 return hashlib.md5(code.encode()).hexdigest()
246 tripcode = hashlib.md5(code.encode()).hexdigest()
247 else:
248 tripcode = ''
249 return tripcode
245
250
246 def get_title(self):
251 def get_title(self):
247 title = self.cleaned_data['title']
252 title = self.cleaned_data['title']
248 if title is not None and '#' in title:
253 if title is not None and TRIPCODE_DELIM in title:
249 return title.split('#', maxsplit=1)[0]
254 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
250 else:
255 else:
251 return title
256 return title
252
257
253 def _clean_text_file(self):
258 def _clean_text_file(self):
254 text = self.cleaned_data.get('text')
259 text = self.cleaned_data.get('text')
255 file = self.get_file()
260 file = self.get_file()
256
261
257 if (not text) and (not file):
262 if (not text) and (not file):
258 error_message = _('Either text or file must be entered.')
263 error_message = _('Either text or file must be entered.')
259 self._errors['text'] = self.error_class([error_message])
264 self._errors['text'] = self.error_class([error_message])
260
265
261 def _validate_posting_speed(self):
266 def _validate_posting_speed(self):
262 can_post = True
267 can_post = True
263
268
264 posting_delay = settings.POSTING_DELAY
269 posting_delay = settings.POSTING_DELAY
265
270
266 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
271 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
267 now = time.time()
272 now = time.time()
268
273
269 current_delay = 0
274 current_delay = 0
270
275
271 if LAST_POST_TIME not in self.session:
276 if LAST_POST_TIME not in self.session:
272 self.session[LAST_POST_TIME] = now
277 self.session[LAST_POST_TIME] = now
273
278
274 need_delay = True
279 need_delay = True
275 else:
280 else:
276 last_post_time = self.session.get(LAST_POST_TIME)
281 last_post_time = self.session.get(LAST_POST_TIME)
277 current_delay = int(now - last_post_time)
282 current_delay = int(now - last_post_time)
278
283
279 need_delay = current_delay < posting_delay
284 need_delay = current_delay < posting_delay
280
285
281 if need_delay:
286 if need_delay:
282 delay = posting_delay - current_delay
287 delay = posting_delay - current_delay
283 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
288 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
284 delay) % {'delay': delay}
289 delay) % {'delay': delay}
285 self._errors['text'] = self.error_class([error_message])
290 self._errors['text'] = self.error_class([error_message])
286
291
287 can_post = False
292 can_post = False
288
293
289 if can_post:
294 if can_post:
290 self.session[LAST_POST_TIME] = now
295 self.session[LAST_POST_TIME] = now
291
296
292 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
297 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
293 """
298 """
294 Gets an file file from URL.
299 Gets an file file from URL.
295 """
300 """
296
301
297 img_temp = None
302 img_temp = None
298
303
299 try:
304 try:
300 for downloader in Downloader.__subclasses__():
305 for downloader in Downloader.__subclasses__():
301 if downloader.handles(url):
306 if downloader.handles(url):
302 return downloader.download(url)
307 return downloader.download(url)
303 # If nobody of the specific downloaders handles this, use generic
308 # If nobody of the specific downloaders handles this, use generic
304 # one
309 # one
305 return Downloader.download(url)
310 return Downloader.download(url)
306 except forms.ValidationError as e:
311 except forms.ValidationError as e:
307 raise e
312 raise e
308 except Exception as e:
313 except Exception as e:
309 # Just return no file
314 # Just return no file
310 pass
315 pass
311
316
312
317
313 class ThreadForm(PostForm):
318 class ThreadForm(PostForm):
314
319
315 tags = forms.CharField(
320 tags = forms.CharField(
316 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
321 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
317 max_length=100, label=_('Tags'), required=True)
322 max_length=100, label=_('Tags'), required=True)
318
323
319 def clean_tags(self):
324 def clean_tags(self):
320 tags = self.cleaned_data['tags'].strip()
325 tags = self.cleaned_data['tags'].strip()
321
326
322 if not tags or not REGEX_TAGS.match(tags):
327 if not tags or not REGEX_TAGS.match(tags):
323 raise forms.ValidationError(
328 raise forms.ValidationError(
324 _('Inappropriate characters in tags.'))
329 _('Inappropriate characters in tags.'))
325
330
326 required_tag_exists = False
331 required_tag_exists = False
327 tag_set = set()
332 tag_set = set()
328 for tag_string in tags.split():
333 for tag_string in tags.split():
329 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
334 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
330 tag_set.add(tag)
335 tag_set.add(tag)
331
336
332 # If this is a new tag, don't check for its parents because nobody
337 # If this is a new tag, don't check for its parents because nobody
333 # added them yet
338 # added them yet
334 if not created:
339 if not created:
335 tag_set |= set(tag.get_all_parents())
340 tag_set |= set(tag.get_all_parents())
336
341
337 for tag in tag_set:
342 for tag in tag_set:
338 if tag.required:
343 if tag.required:
339 required_tag_exists = True
344 required_tag_exists = True
340 break
345 break
341
346
342 if not required_tag_exists:
347 if not required_tag_exists:
343 raise forms.ValidationError(
348 raise forms.ValidationError(
344 _('Need at least one section.'))
349 _('Need at least one section.'))
345
350
346 return tag_set
351 return tag_set
347
352
348 def clean(self):
353 def clean(self):
349 cleaned_data = super(ThreadForm, self).clean()
354 cleaned_data = super(ThreadForm, self).clean()
350
355
351 return cleaned_data
356 return cleaned_data
352
357
353
358
354 class SettingsForm(NeboardForm):
359 class SettingsForm(NeboardForm):
355
360
356 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
361 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
357 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
362 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
358 username = forms.CharField(label=_('User name'), required=False)
363 username = forms.CharField(label=_('User name'), required=False)
359 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
364 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
360
365
361 def clean_username(self):
366 def clean_username(self):
362 username = self.cleaned_data['username']
367 username = self.cleaned_data['username']
363
368
364 if username and not REGEX_TAGS.match(username):
369 if username and not REGEX_TAGS.match(username):
365 raise forms.ValidationError(_('Inappropriate characters.'))
370 raise forms.ValidationError(_('Inappropriate characters.'))
366
371
367 return username
372 return username
368
373
369
374
370 class SearchForm(NeboardForm):
375 class SearchForm(NeboardForm):
371 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
376 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,361 +1,361 b''
1 import logging
1 import logging
2 import re
2 import re
3 import uuid
3 import uuid
4
4
5 from django.core.exceptions import ObjectDoesNotExist
5 from django.core.exceptions import ObjectDoesNotExist
6 from django.core.urlresolvers import reverse
6 from django.core.urlresolvers import reverse
7 from django.db import models
7 from django.db import models
8 from django.db.models import TextField, QuerySet
8 from django.db.models import TextField, QuerySet
9 from django.template.defaultfilters import striptags, truncatewords
9 from django.template.defaultfilters import striptags, truncatewords
10 from django.template.loader import render_to_string
10 from django.template.loader import render_to_string
11 from django.utils import timezone
11 from django.utils import timezone
12
12
13 from boards import settings
13 from boards import settings
14 from boards.abstracts.tripcode import Tripcode
14 from boards.abstracts.tripcode import Tripcode
15 from boards.mdx_neboard import Parser
15 from boards.mdx_neboard import Parser
16 from boards.models import PostImage, Attachment
16 from boards.models import PostImage, Attachment
17 from boards.models.base import Viewable
17 from boards.models.base import Viewable
18 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
18 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
19 from boards.models.post.manager import PostManager
19 from boards.models.post.manager import PostManager
20 from boards.models.user import Notification
20 from boards.models.user import Notification
21
21
22 CSS_CLS_HIDDEN_POST = 'hidden_post'
22 CSS_CLS_HIDDEN_POST = 'hidden_post'
23 CSS_CLS_DEAD_POST = 'dead_post'
23 CSS_CLS_DEAD_POST = 'dead_post'
24 CSS_CLS_ARCHIVE_POST = 'archive_post'
24 CSS_CLS_ARCHIVE_POST = 'archive_post'
25 CSS_CLS_POST = 'post'
25 CSS_CLS_POST = 'post'
26
26
27 TITLE_MAX_WORDS = 10
27 TITLE_MAX_WORDS = 10
28
28
29 APP_LABEL_BOARDS = 'boards'
29 APP_LABEL_BOARDS = 'boards'
30
30
31 BAN_REASON_AUTO = 'Auto'
31 BAN_REASON_AUTO = 'Auto'
32
32
33 IMAGE_THUMB_SIZE = (200, 150)
33 IMAGE_THUMB_SIZE = (200, 150)
34
34
35 TITLE_MAX_LENGTH = 200
35 TITLE_MAX_LENGTH = 200
36
36
37 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
37 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
38 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
39
39
40 PARAMETER_TRUNCATED = 'truncated'
40 PARAMETER_TRUNCATED = 'truncated'
41 PARAMETER_TAG = 'tag'
41 PARAMETER_TAG = 'tag'
42 PARAMETER_OFFSET = 'offset'
42 PARAMETER_OFFSET = 'offset'
43 PARAMETER_DIFF_TYPE = 'type'
43 PARAMETER_DIFF_TYPE = 'type'
44 PARAMETER_CSS_CLASS = 'css_class'
44 PARAMETER_CSS_CLASS = 'css_class'
45 PARAMETER_THREAD = 'thread'
45 PARAMETER_THREAD = 'thread'
46 PARAMETER_IS_OPENING = 'is_opening'
46 PARAMETER_IS_OPENING = 'is_opening'
47 PARAMETER_MODERATOR = 'moderator'
47 PARAMETER_MODERATOR = 'moderator'
48 PARAMETER_POST = 'post'
48 PARAMETER_POST = 'post'
49 PARAMETER_OP_ID = 'opening_post_id'
49 PARAMETER_OP_ID = 'opening_post_id'
50 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
50 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
51 PARAMETER_REPLY_LINK = 'reply_link'
51 PARAMETER_REPLY_LINK = 'reply_link'
52 PARAMETER_NEED_OP_DATA = 'need_op_data'
52 PARAMETER_NEED_OP_DATA = 'need_op_data'
53
53
54 POST_VIEW_PARAMS = (
54 POST_VIEW_PARAMS = (
55 'need_op_data',
55 'need_op_data',
56 'reply_link',
56 'reply_link',
57 'moderator',
57 'moderator',
58 'need_open_link',
58 'need_open_link',
59 'truncated',
59 'truncated',
60 'mode_tree',
60 'mode_tree',
61 )
61 )
62
62
63
63
64 class Post(models.Model, Viewable):
64 class Post(models.Model, Viewable):
65 """A post is a message."""
65 """A post is a message."""
66
66
67 objects = PostManager()
67 objects = PostManager()
68
68
69 class Meta:
69 class Meta:
70 app_label = APP_LABEL_BOARDS
70 app_label = APP_LABEL_BOARDS
71 ordering = ('id',)
71 ordering = ('id',)
72
72
73 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
73 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
74 pub_time = models.DateTimeField()
74 pub_time = models.DateTimeField()
75 text = TextField(blank=True, null=True)
75 text = TextField(blank=True, null=True)
76 _text_rendered = TextField(blank=True, null=True, editable=False)
76 _text_rendered = TextField(blank=True, null=True, editable=False)
77
77
78 images = models.ManyToManyField(PostImage, null=True, blank=True,
78 images = models.ManyToManyField(PostImage, null=True, blank=True,
79 related_name='post_images', db_index=True)
79 related_name='post_images', db_index=True)
80 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
80 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
81 related_name='attachment_posts')
81 related_name='attachment_posts')
82
82
83 poster_ip = models.GenericIPAddressField()
83 poster_ip = models.GenericIPAddressField()
84
84
85 # TODO This field can be removed cause UID is used for update now
85 # TODO This field can be removed cause UID is used for update now
86 last_edit_time = models.DateTimeField()
86 last_edit_time = models.DateTimeField()
87
87
88 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
88 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
89 null=True,
89 null=True,
90 blank=True, related_name='refposts',
90 blank=True, related_name='refposts',
91 db_index=True)
91 db_index=True)
92 refmap = models.TextField(null=True, blank=True)
92 refmap = models.TextField(null=True, blank=True)
93 threads = models.ManyToManyField('Thread', db_index=True,
93 threads = models.ManyToManyField('Thread', db_index=True,
94 related_name='multi_replies')
94 related_name='multi_replies')
95 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
95 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
96
96
97 url = models.TextField()
97 url = models.TextField()
98 uid = models.TextField(db_index=True)
98 uid = models.TextField(db_index=True)
99
99
100 tripcode = models.CharField(max_length=50, null=True)
100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 opening = models.BooleanField()
101 opening = models.BooleanField()
102 hidden = models.BooleanField(default=False)
102 hidden = models.BooleanField(default=False)
103
103
104 def __str__(self):
104 def __str__(self):
105 return 'P#{}/{}'.format(self.id, self.get_title())
105 return 'P#{}/{}'.format(self.id, self.get_title())
106
106
107 def get_referenced_posts(self):
107 def get_referenced_posts(self):
108 threads = self.get_threads().all()
108 threads = self.get_threads().all()
109 return self.referenced_posts.filter(threads__in=threads)\
109 return self.referenced_posts.filter(threads__in=threads)\
110 .order_by('pub_time').distinct().all()
110 .order_by('pub_time').distinct().all()
111
111
112 def get_title(self) -> str:
112 def get_title(self) -> str:
113 return self.title
113 return self.title
114
114
115 def get_title_or_text(self):
115 def get_title_or_text(self):
116 title = self.get_title()
116 title = self.get_title()
117 if not title:
117 if not title:
118 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
118 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
119
119
120 return title
120 return title
121
121
122 def build_refmap(self) -> None:
122 def build_refmap(self) -> None:
123 """
123 """
124 Builds a replies map string from replies list. This is a cache to stop
124 Builds a replies map string from replies list. This is a cache to stop
125 the server from recalculating the map on every post show.
125 the server from recalculating the map on every post show.
126 """
126 """
127
127
128 post_urls = [refpost.get_link_view()
128 post_urls = [refpost.get_link_view()
129 for refpost in self.referenced_posts.all()]
129 for refpost in self.referenced_posts.all()]
130
130
131 self.refmap = ', '.join(post_urls)
131 self.refmap = ', '.join(post_urls)
132
132
133 def is_referenced(self) -> bool:
133 def is_referenced(self) -> bool:
134 return self.refmap and len(self.refmap) > 0
134 return self.refmap and len(self.refmap) > 0
135
135
136 def is_opening(self) -> bool:
136 def is_opening(self) -> bool:
137 """
137 """
138 Checks if this is an opening post or just a reply.
138 Checks if this is an opening post or just a reply.
139 """
139 """
140
140
141 return self.opening
141 return self.opening
142
142
143 def get_absolute_url(self, thread=None):
143 def get_absolute_url(self, thread=None):
144 url = None
144 url = None
145
145
146 if thread is None:
146 if thread is None:
147 thread = self.get_thread()
147 thread = self.get_thread()
148
148
149 # Url is cached only for the "main" thread. When getting url
149 # Url is cached only for the "main" thread. When getting url
150 # for other threads, do it manually.
150 # for other threads, do it manually.
151 if self.url:
151 if self.url:
152 url = self.url
152 url = self.url
153
153
154 if url is None:
154 if url is None:
155 opening_id = thread.get_opening_post_id()
155 opening_id = thread.get_opening_post_id()
156 url = reverse('thread', kwargs={'post_id': opening_id})
156 url = reverse('thread', kwargs={'post_id': opening_id})
157 if self.id != opening_id:
157 if self.id != opening_id:
158 url += '#' + str(self.id)
158 url += '#' + str(self.id)
159
159
160 return url
160 return url
161
161
162 def get_thread(self):
162 def get_thread(self):
163 return self.thread
163 return self.thread
164
164
165 def get_threads(self) -> QuerySet:
165 def get_threads(self) -> QuerySet:
166 """
166 """
167 Gets post's thread.
167 Gets post's thread.
168 """
168 """
169
169
170 return self.threads
170 return self.threads
171
171
172 def get_view(self, *args, **kwargs) -> str:
172 def get_view(self, *args, **kwargs) -> str:
173 """
173 """
174 Renders post's HTML view. Some of the post params can be passed over
174 Renders post's HTML view. Some of the post params can be passed over
175 kwargs for the means of caching (if we view the thread, some params
175 kwargs for the means of caching (if we view the thread, some params
176 are same for every post and don't need to be computed over and over.
176 are same for every post and don't need to be computed over and over.
177 """
177 """
178
178
179 thread = self.get_thread()
179 thread = self.get_thread()
180
180
181 css_classes = [CSS_CLS_POST]
181 css_classes = [CSS_CLS_POST]
182 if thread.archived:
182 if thread.archived:
183 css_classes.append(CSS_CLS_ARCHIVE_POST)
183 css_classes.append(CSS_CLS_ARCHIVE_POST)
184 elif not thread.can_bump():
184 elif not thread.can_bump():
185 css_classes.append(CSS_CLS_DEAD_POST)
185 css_classes.append(CSS_CLS_DEAD_POST)
186 if self.is_hidden():
186 if self.is_hidden():
187 css_classes.append(CSS_CLS_HIDDEN_POST)
187 css_classes.append(CSS_CLS_HIDDEN_POST)
188
188
189 params = dict()
189 params = dict()
190 for param in POST_VIEW_PARAMS:
190 for param in POST_VIEW_PARAMS:
191 if param in kwargs:
191 if param in kwargs:
192 params[param] = kwargs[param]
192 params[param] = kwargs[param]
193
193
194 params.update({
194 params.update({
195 PARAMETER_POST: self,
195 PARAMETER_POST: self,
196 PARAMETER_IS_OPENING: self.is_opening(),
196 PARAMETER_IS_OPENING: self.is_opening(),
197 PARAMETER_THREAD: thread,
197 PARAMETER_THREAD: thread,
198 PARAMETER_CSS_CLASS: ' '.join(css_classes),
198 PARAMETER_CSS_CLASS: ' '.join(css_classes),
199 })
199 })
200
200
201 return render_to_string('boards/post.html', params)
201 return render_to_string('boards/post.html', params)
202
202
203 def get_search_view(self, *args, **kwargs):
203 def get_search_view(self, *args, **kwargs):
204 return self.get_view(need_op_data=True, *args, **kwargs)
204 return self.get_view(need_op_data=True, *args, **kwargs)
205
205
206 def get_first_image(self) -> PostImage:
206 def get_first_image(self) -> PostImage:
207 return self.images.earliest('id')
207 return self.images.earliest('id')
208
208
209 def delete(self, using=None):
209 def delete(self, using=None):
210 """
210 """
211 Deletes all post images and the post itself.
211 Deletes all post images and the post itself.
212 """
212 """
213
213
214 for image in self.images.all():
214 for image in self.images.all():
215 image_refs_count = image.post_images.count()
215 image_refs_count = image.post_images.count()
216 if image_refs_count == 1:
216 if image_refs_count == 1:
217 image.delete()
217 image.delete()
218
218
219 for attachment in self.attachments.all():
219 for attachment in self.attachments.all():
220 attachment_refs_count = attachment.attachment_posts.count()
220 attachment_refs_count = attachment.attachment_posts.count()
221 if attachment_refs_count == 1:
221 if attachment_refs_count == 1:
222 attachment.delete()
222 attachment.delete()
223
223
224 thread = self.get_thread()
224 thread = self.get_thread()
225 thread.last_edit_time = timezone.now()
225 thread.last_edit_time = timezone.now()
226 thread.save()
226 thread.save()
227
227
228 super(Post, self).delete(using)
228 super(Post, self).delete(using)
229
229
230 logging.getLogger('boards.post.delete').info(
230 logging.getLogger('boards.post.delete').info(
231 'Deleted post {}'.format(self))
231 'Deleted post {}'.format(self))
232
232
233 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
233 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
234 include_last_update=False) -> str:
234 include_last_update=False) -> str:
235 """
235 """
236 Gets post HTML or JSON data that can be rendered on a page or used by
236 Gets post HTML or JSON data that can be rendered on a page or used by
237 API.
237 API.
238 """
238 """
239
239
240 return get_exporter(format_type).export(self, request,
240 return get_exporter(format_type).export(self, request,
241 include_last_update)
241 include_last_update)
242
242
243 def notify_clients(self, recursive=True):
243 def notify_clients(self, recursive=True):
244 """
244 """
245 Sends post HTML data to the thread web socket.
245 Sends post HTML data to the thread web socket.
246 """
246 """
247
247
248 if not settings.get_bool('External', 'WebsocketsEnabled'):
248 if not settings.get_bool('External', 'WebsocketsEnabled'):
249 return
249 return
250
250
251 thread_ids = list()
251 thread_ids = list()
252 for thread in self.get_threads().all():
252 for thread in self.get_threads().all():
253 thread_ids.append(thread.id)
253 thread_ids.append(thread.id)
254
254
255 thread.notify_clients()
255 thread.notify_clients()
256
256
257 if recursive:
257 if recursive:
258 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
258 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
259 post_id = reply_number.group(1)
259 post_id = reply_number.group(1)
260
260
261 try:
261 try:
262 ref_post = Post.objects.get(id=post_id)
262 ref_post = Post.objects.get(id=post_id)
263
263
264 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
264 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
265 # If post is in this thread, its thread was already notified.
265 # If post is in this thread, its thread was already notified.
266 # Otherwise, notify its thread separately.
266 # Otherwise, notify its thread separately.
267 ref_post.notify_clients(recursive=False)
267 ref_post.notify_clients(recursive=False)
268 except ObjectDoesNotExist:
268 except ObjectDoesNotExist:
269 pass
269 pass
270
270
271 def build_url(self):
271 def build_url(self):
272 self.url = self.get_absolute_url()
272 self.url = self.get_absolute_url()
273 self.save(update_fields=['url'])
273 self.save(update_fields=['url'])
274
274
275 def save(self, force_insert=False, force_update=False, using=None,
275 def save(self, force_insert=False, force_update=False, using=None,
276 update_fields=None):
276 update_fields=None):
277 self._text_rendered = Parser().parse(self.get_raw_text())
277 self._text_rendered = Parser().parse(self.get_raw_text())
278
278
279 self.uid = str(uuid.uuid4())
279 self.uid = str(uuid.uuid4())
280 if update_fields is not None and 'uid' not in update_fields:
280 if update_fields is not None and 'uid' not in update_fields:
281 update_fields += ['uid']
281 update_fields += ['uid']
282
282
283 if self.id:
283 if self.id:
284 for thread in self.get_threads().all():
284 for thread in self.get_threads().all():
285 thread.last_edit_time = self.last_edit_time
285 thread.last_edit_time = self.last_edit_time
286
286
287 thread.save(update_fields=['last_edit_time', 'bumpable'])
287 thread.save(update_fields=['last_edit_time', 'bumpable'])
288
288
289 super().save(force_insert, force_update, using, update_fields)
289 super().save(force_insert, force_update, using, update_fields)
290
290
291 def get_text(self) -> str:
291 def get_text(self) -> str:
292 return self._text_rendered
292 return self._text_rendered
293
293
294 def get_raw_text(self) -> str:
294 def get_raw_text(self) -> str:
295 return self.text
295 return self.text
296
296
297 def get_absolute_id(self) -> str:
297 def get_absolute_id(self) -> str:
298 """
298 """
299 If the post has many threads, shows its main thread OP id in the post
299 If the post has many threads, shows its main thread OP id in the post
300 ID.
300 ID.
301 """
301 """
302
302
303 if self.get_threads().count() > 1:
303 if self.get_threads().count() > 1:
304 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
304 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
305 else:
305 else:
306 return str(self.id)
306 return str(self.id)
307
307
308 def connect_notifications(self):
308 def connect_notifications(self):
309 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
309 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
310 user_name = reply_number.group(1).lower()
310 user_name = reply_number.group(1).lower()
311 Notification.objects.get_or_create(name=user_name, post=self)
311 Notification.objects.get_or_create(name=user_name, post=self)
312
312
313 def connect_replies(self):
313 def connect_replies(self):
314 """
314 """
315 Connects replies to a post to show them as a reflink map
315 Connects replies to a post to show them as a reflink map
316 """
316 """
317
317
318 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
318 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
319 post_id = reply_number.group(1)
319 post_id = reply_number.group(1)
320
320
321 try:
321 try:
322 referenced_post = Post.objects.get(id=post_id)
322 referenced_post = Post.objects.get(id=post_id)
323
323
324 referenced_post.referenced_posts.add(self)
324 referenced_post.referenced_posts.add(self)
325 referenced_post.last_edit_time = self.pub_time
325 referenced_post.last_edit_time = self.pub_time
326 referenced_post.build_refmap()
326 referenced_post.build_refmap()
327 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
327 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
328 except ObjectDoesNotExist:
328 except ObjectDoesNotExist:
329 pass
329 pass
330
330
331 def connect_threads(self, opening_posts):
331 def connect_threads(self, opening_posts):
332 for opening_post in opening_posts:
332 for opening_post in opening_posts:
333 threads = opening_post.get_threads().all()
333 threads = opening_post.get_threads().all()
334 for thread in threads:
334 for thread in threads:
335 if thread.can_bump():
335 if thread.can_bump():
336 thread.update_bump_status()
336 thread.update_bump_status()
337
337
338 thread.last_edit_time = self.last_edit_time
338 thread.last_edit_time = self.last_edit_time
339 thread.save(update_fields=['last_edit_time', 'bumpable'])
339 thread.save(update_fields=['last_edit_time', 'bumpable'])
340 self.threads.add(opening_post.get_thread())
340 self.threads.add(opening_post.get_thread())
341
341
342 def get_tripcode(self):
342 def get_tripcode(self):
343 if self.tripcode:
343 if self.tripcode:
344 return Tripcode(self.tripcode)
344 return Tripcode(self.tripcode)
345
345
346 def get_link_view(self):
346 def get_link_view(self):
347 """
347 """
348 Gets view of a reflink to the post.
348 Gets view of a reflink to the post.
349 """
349 """
350 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
350 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
351 self.id)
351 self.id)
352 if self.is_opening():
352 if self.is_opening():
353 result = '<b>{}</b>'.format(result)
353 result = '<b>{}</b>'.format(result)
354
354
355 return result
355 return result
356
356
357 def is_hidden(self) -> bool:
357 def is_hidden(self) -> bool:
358 return self.hidden
358 return self.hidden
359
359
360 def set_hidden(self, hidden):
360 def set_hidden(self, hidden):
361 self.hidden = hidden
361 self.hidden = hidden
@@ -1,126 +1,127 b''
1 import logging
1 import logging
2
2
3 from datetime import datetime, timedelta, date
3 from datetime import datetime, timedelta, date
4 from datetime import time as dtime
4 from datetime import time as dtime
5
5
6 from django.db import models, transaction
6 from django.db import models, transaction
7 from django.utils import timezone
7 from django.utils import timezone
8
8
9 import boards
9 import boards
10
10
11 from boards.models.user import Ban
11 from boards.models.user import Ban
12 from boards.mdx_neboard import Parser
12 from boards.mdx_neboard import Parser
13 from boards.models import PostImage, Attachment
13 from boards.models import PostImage, Attachment
14 from boards import utils
14 from boards import utils
15
15
16 __author__ = 'neko259'
16 __author__ = 'neko259'
17
17
18 IMAGE_TYPES = (
18 IMAGE_TYPES = (
19 'jpeg',
19 'jpeg',
20 'jpg',
20 'jpg',
21 'png',
21 'png',
22 'bmp',
22 'bmp',
23 'gif',
23 'gif',
24 )
24 )
25
25
26 POSTS_PER_DAY_RANGE = 7
26 POSTS_PER_DAY_RANGE = 7
27 NO_IP = '0.0.0.0'
27 NO_IP = '0.0.0.0'
28
28
29
29
30 class PostManager(models.Manager):
30 class PostManager(models.Manager):
31 @transaction.atomic
31 @transaction.atomic
32 def create_post(self, title: str, text: str, file=None, thread=None,
32 def create_post(self, title: str, text: str, file=None, thread=None,
33 ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None):
33 ip=NO_IP, tags: list=None, opening_posts: list=None,
34 tripcode=''):
34 """
35 """
35 Creates new post
36 Creates new post
36 """
37 """
37
38
38 if not utils.is_anonymous_mode():
39 if not utils.is_anonymous_mode():
39 is_banned = Ban.objects.filter(ip=ip).exists()
40 is_banned = Ban.objects.filter(ip=ip).exists()
40
41
41 # TODO Raise specific exception and catch it in the views
42 # TODO Raise specific exception and catch it in the views
42 if is_banned:
43 if is_banned:
43 raise Exception("This user is banned")
44 raise Exception("This user is banned")
44
45
45 if not tags:
46 if not tags:
46 tags = []
47 tags = []
47 if not opening_posts:
48 if not opening_posts:
48 opening_posts = []
49 opening_posts = []
49
50
50 posting_time = timezone.now()
51 posting_time = timezone.now()
51 new_thread = False
52 new_thread = False
52 if not thread:
53 if not thread:
53 thread = boards.models.thread.Thread.objects.create(
54 thread = boards.models.thread.Thread.objects.create(
54 bump_time=posting_time, last_edit_time=posting_time)
55 bump_time=posting_time, last_edit_time=posting_time)
55 list(map(thread.tags.add, tags))
56 list(map(thread.tags.add, tags))
56 boards.models.thread.Thread.objects.process_oldest_threads()
57 boards.models.thread.Thread.objects.process_oldest_threads()
57 new_thread = True
58 new_thread = True
58
59
59 pre_text = Parser().preparse(text)
60 pre_text = Parser().preparse(text)
60
61
61 post = self.create(title=title,
62 post = self.create(title=title,
62 text=pre_text,
63 text=pre_text,
63 pub_time=posting_time,
64 pub_time=posting_time,
64 poster_ip=ip,
65 poster_ip=ip,
65 thread=thread,
66 thread=thread,
66 last_edit_time=posting_time,
67 last_edit_time=posting_time,
67 tripcode=tripcode,
68 tripcode=tripcode,
68 opening=new_thread)
69 opening=new_thread)
69 post.threads.add(thread)
70 post.threads.add(thread)
70
71
71 logger = logging.getLogger('boards.post.create')
72 logger = logging.getLogger('boards.post.create')
72
73
73 logger.info('Created post {} by {}'.format(post, post.poster_ip))
74 logger.info('Created post {} by {}'.format(post, post.poster_ip))
74
75
75 # TODO Move this to other place
76 # TODO Move this to other place
76 if file:
77 if file:
77 file_type = file.name.split('.')[-1].lower()
78 file_type = file.name.split('.')[-1].lower()
78 if file_type in IMAGE_TYPES:
79 if file_type in IMAGE_TYPES:
79 post.images.add(PostImage.objects.create_with_hash(file))
80 post.images.add(PostImage.objects.create_with_hash(file))
80 else:
81 else:
81 post.attachments.add(Attachment.objects.create_with_hash(file))
82 post.attachments.add(Attachment.objects.create_with_hash(file))
82
83
83 post.build_url()
84 post.build_url()
84 post.connect_replies()
85 post.connect_replies()
85 post.connect_threads(opening_posts)
86 post.connect_threads(opening_posts)
86 post.connect_notifications()
87 post.connect_notifications()
87
88
88 # Thread needs to be bumped only when the post is already created
89 # Thread needs to be bumped only when the post is already created
89 if not new_thread:
90 if not new_thread:
90 thread.last_edit_time = posting_time
91 thread.last_edit_time = posting_time
91 thread.bump()
92 thread.bump()
92 thread.save()
93 thread.save()
93
94
94 return post
95 return post
95
96
96 def delete_posts_by_ip(self, ip):
97 def delete_posts_by_ip(self, ip):
97 """
98 """
98 Deletes all posts of the author with same IP
99 Deletes all posts of the author with same IP
99 """
100 """
100
101
101 posts = self.filter(poster_ip=ip)
102 posts = self.filter(poster_ip=ip)
102 for post in posts:
103 for post in posts:
103 post.delete()
104 post.delete()
104
105
105 @utils.cached_result()
106 @utils.cached_result()
106 def get_posts_per_day(self) -> float:
107 def get_posts_per_day(self) -> float:
107 """
108 """
108 Gets average count of posts per day for the last 7 days
109 Gets average count of posts per day for the last 7 days
109 """
110 """
110
111
111 day_end = date.today()
112 day_end = date.today()
112 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
113 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
113
114
114 day_time_start = timezone.make_aware(datetime.combine(
115 day_time_start = timezone.make_aware(datetime.combine(
115 day_start, dtime()), timezone.get_current_timezone())
116 day_start, dtime()), timezone.get_current_timezone())
116 day_time_end = timezone.make_aware(datetime.combine(
117 day_time_end = timezone.make_aware(datetime.combine(
117 day_end, dtime()), timezone.get_current_timezone())
118 day_end, dtime()), timezone.get_current_timezone())
118
119
119 posts_per_period = float(self.filter(
120 posts_per_period = float(self.filter(
120 pub_time__lte=day_time_end,
121 pub_time__lte=day_time_end,
121 pub_time__gte=day_time_start).count())
122 pub_time__gte=day_time_start).count())
122
123
123 ppd = posts_per_period / POSTS_PER_DAY_RANGE
124 ppd = posts_per_period / POSTS_PER_DAY_RANGE
124
125
125 return ppd
126 return ppd
126
127
@@ -1,137 +1,138 b''
1 import hashlib
1 import hashlib
2 from django.template.loader import render_to_string
2 from django.template.loader import render_to_string
3 from django.db import models
3 from django.db import models
4 from django.db.models import Count
4 from django.db.models import Count
5 from django.core.urlresolvers import reverse
5 from django.core.urlresolvers import reverse
6
6
7 from boards.models.base import Viewable
7 from boards.models.base import Viewable
8 from boards.utils import cached_result
8 from boards.utils import cached_result
9 import boards
9 import boards
10
10
11 __author__ = 'neko259'
11 __author__ = 'neko259'
12
12
13
13
14 RELATED_TAGS_COUNT = 5
14 RELATED_TAGS_COUNT = 5
15
15
16
16
17 class TagManager(models.Manager):
17 class TagManager(models.Manager):
18
18
19 def get_not_empty_tags(self):
19 def get_not_empty_tags(self):
20 """
20 """
21 Gets tags that have non-archived threads.
21 Gets tags that have non-archived threads.
22 """
22 """
23
23
24 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
24 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
25 .order_by('-required', 'name')
25 .order_by('-required', 'name')
26
26
27 def get_tag_url_list(self, tags: list) -> str:
27 def get_tag_url_list(self, tags: list) -> str:
28 """
28 """
29 Gets a comma-separated list of tag links.
29 Gets a comma-separated list of tag links.
30 """
30 """
31
31
32 return ', '.join([tag.get_view() for tag in tags])
32 return ', '.join([tag.get_view() for tag in tags])
33
33
34
34
35 class Tag(models.Model, Viewable):
35 class Tag(models.Model, Viewable):
36 """
36 """
37 A tag is a text node assigned to the thread. The tag serves as a board
37 A tag is a text node assigned to the thread. The tag serves as a board
38 section. There can be multiple tags for each thread
38 section. There can be multiple tags for each thread
39 """
39 """
40
40
41 objects = TagManager()
41 objects = TagManager()
42
42
43 class Meta:
43 class Meta:
44 app_label = 'boards'
44 app_label = 'boards'
45 ordering = ('name',)
45 ordering = ('name',)
46
46
47 name = models.CharField(max_length=100, db_index=True, unique=True)
47 name = models.CharField(max_length=100, db_index=True, unique=True)
48 required = models.BooleanField(default=False, db_index=True)
48 required = models.BooleanField(default=False, db_index=True)
49 description = models.TextField(blank=True)
49 description = models.TextField(blank=True)
50
50
51 parent = models.ForeignKey('Tag', null=True, related_name='children')
51 parent = models.ForeignKey('Tag', null=True, blank=True,
52 related_name='children')
52
53
53 def __str__(self):
54 def __str__(self):
54 return self.name
55 return self.name
55
56
56 def is_empty(self) -> bool:
57 def is_empty(self) -> bool:
57 """
58 """
58 Checks if the tag has some threads.
59 Checks if the tag has some threads.
59 """
60 """
60
61
61 return self.get_thread_count() == 0
62 return self.get_thread_count() == 0
62
63
63 def get_thread_count(self, archived=None) -> int:
64 def get_thread_count(self, archived=None) -> int:
64 threads = self.get_threads()
65 threads = self.get_threads()
65 if archived is not None:
66 if archived is not None:
66 threads = threads.filter(archived=archived)
67 threads = threads.filter(archived=archived)
67 return threads.count()
68 return threads.count()
68
69
69 def get_active_thread_count(self) -> int:
70 def get_active_thread_count(self) -> int:
70 return self.get_thread_count(archived=False)
71 return self.get_thread_count(archived=False)
71
72
72 def get_archived_thread_count(self) -> int:
73 def get_archived_thread_count(self) -> int:
73 return self.get_thread_count(archived=True)
74 return self.get_thread_count(archived=True)
74
75
75 def get_absolute_url(self):
76 def get_absolute_url(self):
76 return reverse('tag', kwargs={'tag_name': self.name})
77 return reverse('tag', kwargs={'tag_name': self.name})
77
78
78 def get_threads(self):
79 def get_threads(self):
79 return self.thread_tags.order_by('-bump_time')
80 return self.thread_tags.order_by('-bump_time')
80
81
81 def is_required(self):
82 def is_required(self):
82 return self.required
83 return self.required
83
84
84 def get_view(self):
85 def get_view(self):
85 link = '<a class="tag" href="{}">{}</a>'.format(
86 link = '<a class="tag" href="{}">{}</a>'.format(
86 self.get_absolute_url(), self.name)
87 self.get_absolute_url(), self.name)
87 if self.is_required():
88 if self.is_required():
88 link = '<b>{}</b>'.format(link)
89 link = '<b>{}</b>'.format(link)
89 return link
90 return link
90
91
91 def get_search_view(self, *args, **kwargs):
92 def get_search_view(self, *args, **kwargs):
92 return render_to_string('boards/tag.html', {
93 return render_to_string('boards/tag.html', {
93 'tag': self,
94 'tag': self,
94 })
95 })
95
96
96 @cached_result()
97 @cached_result()
97 def get_post_count(self):
98 def get_post_count(self):
98 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
99 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
99
100
100 def get_description(self):
101 def get_description(self):
101 return self.description
102 return self.description
102
103
103 def get_random_image_post(self, archived=False):
104 def get_random_image_post(self, archived=False):
104 posts = boards.models.Post.objects.annotate(images_count=Count(
105 posts = boards.models.Post.objects.annotate(images_count=Count(
105 'images')).filter(images_count__gt=0, threads__tags__in=[self])
106 'images')).filter(images_count__gt=0, threads__tags__in=[self])
106 if archived is not None:
107 if archived is not None:
107 posts = posts.filter(thread__archived=archived)
108 posts = posts.filter(thread__archived=archived)
108 return posts.order_by('?').first()
109 return posts.order_by('?').first()
109
110
110 def get_first_letter(self):
111 def get_first_letter(self):
111 return self.name and self.name[0] or ''
112 return self.name and self.name[0] or ''
112
113
113 def get_related_tags(self):
114 def get_related_tags(self):
114 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
115 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
115 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
116 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
116
117
117 @cached_result()
118 @cached_result()
118 def get_color(self):
119 def get_color(self):
119 """
120 """
120 Gets color hashed from the tag name.
121 Gets color hashed from the tag name.
121 """
122 """
122 return hashlib.md5(self.name.encode()).hexdigest()[:6]
123 return hashlib.md5(self.name.encode()).hexdigest()[:6]
123
124
124 def get_parent(self):
125 def get_parent(self):
125 return self.parent
126 return self.parent
126
127
127 def get_all_parents(self):
128 def get_all_parents(self):
128 parents = list()
129 parents = list()
129 parent = self.get_parent()
130 parent = self.get_parent()
130 if parent and parent not in parents:
131 if parent and parent not in parents:
131 parents.insert(0, parent)
132 parents.insert(0, parent)
132 parents = parent.get_all_parents() + parents
133 parents = parent.get_all_parents() + parents
133
134
134 return parents
135 return parents
135
136
136 def get_children(self):
137 def get_children(self):
137 return self.children
138 return self.children
General Comments 0
You need to be logged in to leave comments. Login now