##// END OF EJS Templates
Added support for different attachment types
neko259 -
r1273:2c3f55c9 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 models, migrations
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0021_tag_description'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='thread',
16 name='tags',
17 field=models.ManyToManyField(related_name='thread_tags', to='boards.Tag'),
18 ),
19 ]
@@ -0,0 +1,29 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import models, migrations
5 import boards.models.attachment
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('boards', '0022_auto_20150812_1819'),
12 ]
13
14 operations = [
15 migrations.CreateModel(
16 name='Attachment',
17 fields=[
18 ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
19 ('file', models.FileField(upload_to=boards.models.attachment.Attachment._update_filename)),
20 ('mimetype', models.CharField(max_length=50)),
21 ('hash', models.CharField(max_length=36)),
22 ],
23 ),
24 migrations.AddField(
25 model_name='post',
26 name='attachments',
27 field=models.ManyToManyField(blank=True, null=True, related_name='attachment_posts', to='boards.Attachment'),
28 ),
29 ]
@@ -0,0 +1,75 b''
1 import hashlib
2 import os
3 import time
4
5 from random import random
6
7 from django.db import models
8
9 from boards.models.attachment.viewers import AbstractViewer, WebmViewer
10
11
12 FILES_DIRECTORY = 'files/'
13 FILE_EXTENSION_DELIMITER = '.'
14
15 VIEWERS = (
16 WebmViewer,
17 )
18
19
20 class AttachmentManager(models.Manager):
21 def create_with_hash(self, file):
22 file_hash = self.get_hash(file)
23 existing = self.filter(hash=file_hash)
24 if len(existing) > 0:
25 attachment = existing[0]
26 else:
27 file_type = file.name.split(FILE_EXTENSION_DELIMITER)[-1].lower()
28 attachment = Attachment.objects.create(file=file,
29 mimetype=file_type, hash=file_hash)
30
31 return attachment
32
33 def get_hash(self, file):
34 """
35 Gets hash of an file.
36 """
37 md5 = hashlib.md5()
38 for chunk in file.chunks():
39 md5.update(chunk)
40 return md5.hexdigest()
41
42
43 class Attachment(models.Model):
44 objects = AttachmentManager()
45
46 # TODO Dedup the method
47 def _update_filename(self, filename):
48 """
49 Gets unique filename
50 """
51
52 # TODO Use something other than random number in file name
53 new_name = '{}{}.{}'.format(
54 str(int(time.mktime(time.gmtime()))),
55 str(int(random() * 1000)),
56 filename.split(FILE_EXTENSION_DELIMITER)[-1:][0])
57
58 return os.path.join(FILES_DIRECTORY, new_name)
59
60 file = models.FileField(upload_to=_update_filename)
61 mimetype = models.CharField(max_length=50)
62 hash = models.CharField(max_length=36)
63
64 def get_view(self):
65 file_viewer = None
66 for viewer in VIEWERS:
67 if viewer.supports(self.mimetype):
68 file_viewer = viewer(self.file, self.mimetype)
69 break
70 if file_viewer is None:
71 file_viewer = AbstractViewer(self.file, self.mimetype)
72
73 return file_viewer.get_view()
74
75
@@ -0,0 +1,39 b''
1 from django.template.defaultfilters import filesizeformat
2
3
4 class AbstractViewer:
5 def __init__(self, file, file_type):
6 self.file = file
7 self.file_type = file_type
8
9 @staticmethod
10 def get_viewer(file_type, file):
11 for viewer in VIEWERS:
12 if viewer.supports(file_type):
13 return viewer(file)
14 return AbstractViewer(file)
15
16 @staticmethod
17 def supports(file_type):
18 return true
19
20 def get_view(self):
21 return '<div class="image"><a href="{}">'\
22 '<img src="/static/images/file.png" width="200" height="150"/>'\
23 '</a>'\
24 '<div class="image-metadata">{}, {}</div>'\
25 '</div>'.format(self.file.url, self.file_type,
26 filesizeformat(self.file.size))
27
28
29 class WebmViewer(AbstractViewer):
30 @staticmethod
31 def supports(file_type):
32 return file_type == 'webm'
33
34 def get_view(self):
35 return '<div class="image">'\
36 '<video width="200" height="150" controls/>'\
37 '<source src="{}">'\
38 '</div>'.format(self.file.url)
39
@@ -1,33 +1,33 b''
1 [Version]
1 [Version]
2 Version = 2.8.3 Charlie
2 Version = 2.8.3 Charlie
3 SiteName = Neboard DEV
3 SiteName = Neboard DEV
4
4
5 [Cache]
5 [Cache]
6 # Timeout for caching, if cache is used
6 # Timeout for caching, if cache is used
7 CacheTimeout = 600
7 CacheTimeout = 600
8
8
9 [Forms]
9 [Forms]
10 # Max post length in characters
10 # Max post length in characters
11 MaxTextLength = 30000
11 MaxTextLength = 30000
12 MaxImageSize = 8000000
12 MaxFileSize = 8000000
13 LimitPostingSpeed = false
13 LimitPostingSpeed = false
14
14
15 [Messages]
15 [Messages]
16 # Thread bumplimit
16 # Thread bumplimit
17 MaxPostsPerThread = 10
17 MaxPostsPerThread = 10
18 # Old posts will be archived or deleted if this value is reached
18 # Old posts will be archived or deleted if this value is reached
19 MaxThreadCount = 5
19 MaxThreadCount = 5
20
20
21 [View]
21 [View]
22 DefaultTheme = md
22 DefaultTheme = md
23 DefaultImageViewer = simple
23 DefaultImageViewer = simple
24 LastRepliesCount = 3
24 LastRepliesCount = 3
25 ThreadsPerPage = 3
25 ThreadsPerPage = 3
26
26
27 [Storage]
27 [Storage]
28 # Enable archiving threads instead of deletion when the thread limit is reached
28 # Enable archiving threads instead of deletion when the thread limit is reached
29 ArchiveThreads = true
29 ArchiveThreads = true
30
30
31 [External]
31 [External]
32 # Thread update
32 # Thread update
33 WebsocketsEnabled = false
33 WebsocketsEnabled = false
@@ -1,386 +1,377 b''
1 import re
1 import re
2 import time
2 import time
3 import pytz
3 import pytz
4
4
5 from django import forms
5 from django import forms
6 from django.core.files.uploadedfile import SimpleUploadedFile
6 from django.core.files.uploadedfile import SimpleUploadedFile
7 from django.core.exceptions import ObjectDoesNotExist
7 from django.core.exceptions import ObjectDoesNotExist
8 from django.forms.util import ErrorList
8 from django.forms.util import ErrorList
9 from django.utils.translation import ugettext_lazy as _
9 from django.utils.translation import ugettext_lazy as _
10 import requests
10 import requests
11
11
12 from boards.mdx_neboard import formatters
12 from boards.mdx_neboard import formatters
13 from boards.models.post import TITLE_MAX_LENGTH
13 from boards.models.post import TITLE_MAX_LENGTH
14 from boards.models import Tag, Post
14 from boards.models import Tag, Post
15 from neboard import settings
15 from neboard import settings
16 import boards.settings as board_settings
16 import boards.settings as board_settings
17
17
18 HEADER_CONTENT_LENGTH = 'content-length'
18 HEADER_CONTENT_LENGTH = 'content-length'
19 HEADER_CONTENT_TYPE = 'content-type'
19 HEADER_CONTENT_TYPE = 'content-type'
20
20
21 CONTENT_TYPE_IMAGE = (
22 'image/jpeg',
23 'image/jpg',
24 'image/png',
25 'image/gif',
26 'image/bmp',
27 )
28
29 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
21 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
30
22
31 VETERAN_POSTING_DELAY = 5
23 VETERAN_POSTING_DELAY = 5
32
24
33 ATTRIBUTE_PLACEHOLDER = 'placeholder'
25 ATTRIBUTE_PLACEHOLDER = 'placeholder'
34 ATTRIBUTE_ROWS = 'rows'
26 ATTRIBUTE_ROWS = 'rows'
35
27
36 LAST_POST_TIME = 'last_post_time'
28 LAST_POST_TIME = 'last_post_time'
37 LAST_LOGIN_TIME = 'last_login_time'
29 LAST_LOGIN_TIME = 'last_login_time'
38 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.')
39 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
31 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
40
32
41 LABEL_TITLE = _('Title')
33 LABEL_TITLE = _('Title')
42 LABEL_TEXT = _('Text')
34 LABEL_TEXT = _('Text')
43 LABEL_TAG = _('Tag')
35 LABEL_TAG = _('Tag')
44 LABEL_SEARCH = _('Search')
36 LABEL_SEARCH = _('Search')
45
37
46 ERROR_SPEED = _('Please wait %s seconds before sending message')
38 ERROR_SPEED = _('Please wait %s seconds before sending message')
47
39
48 TAG_MAX_LENGTH = 20
40 TAG_MAX_LENGTH = 20
49
41
50 IMAGE_DOWNLOAD_CHUNK_BYTES = 100000
42 FILE_DOWNLOAD_CHUNK_BYTES = 100000
51
43
52 HTTP_RESULT_OK = 200
44 HTTP_RESULT_OK = 200
53
45
54 TEXTAREA_ROWS = 4
46 TEXTAREA_ROWS = 4
55
47
56
48
57 def get_timezones():
49 def get_timezones():
58 timezones = []
50 timezones = []
59 for tz in pytz.common_timezones:
51 for tz in pytz.common_timezones:
60 timezones.append((tz, tz),)
52 timezones.append((tz, tz),)
61 return timezones
53 return timezones
62
54
63
55
64 class FormatPanel(forms.Textarea):
56 class FormatPanel(forms.Textarea):
65 """
57 """
66 Panel for text formatting. Consists of buttons to add different tags to the
58 Panel for text formatting. Consists of buttons to add different tags to the
67 form text area.
59 form text area.
68 """
60 """
69
61
70 def render(self, name, value, attrs=None):
62 def render(self, name, value, attrs=None):
71 output = '<div id="mark-panel">'
63 output = '<div id="mark-panel">'
72 for formatter in formatters:
64 for formatter in formatters:
73 output += '<span class="mark_btn"' + \
65 output += '<span class="mark_btn"' + \
74 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
66 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
75 '\', \'' + formatter.format_right + '\')">' + \
67 '\', \'' + formatter.format_right + '\')">' + \
76 formatter.preview_left + formatter.name + \
68 formatter.preview_left + formatter.name + \
77 formatter.preview_right + '</span>'
69 formatter.preview_right + '</span>'
78
70
79 output += '</div>'
71 output += '</div>'
80 output += super(FormatPanel, self).render(name, value, attrs=None)
72 output += super(FormatPanel, self).render(name, value, attrs=None)
81
73
82 return output
74 return output
83
75
84
76
85 class PlainErrorList(ErrorList):
77 class PlainErrorList(ErrorList):
86 def __unicode__(self):
78 def __unicode__(self):
87 return self.as_text()
79 return self.as_text()
88
80
89 def as_text(self):
81 def as_text(self):
90 return ''.join(['(!) %s ' % e for e in self])
82 return ''.join(['(!) %s ' % e for e in self])
91
83
92
84
93 class NeboardForm(forms.Form):
85 class NeboardForm(forms.Form):
94 """
86 """
95 Form with neboard-specific formatting.
87 Form with neboard-specific formatting.
96 """
88 """
97
89
98 def as_div(self):
90 def as_div(self):
99 """
91 """
100 Returns this form rendered as HTML <as_div>s.
92 Returns this form rendered as HTML <as_div>s.
101 """
93 """
102
94
103 return self._html_output(
95 return self._html_output(
104 # TODO Do not show hidden rows in the list here
96 # TODO Do not show hidden rows in the list here
105 normal_row='<div class="form-row">'
97 normal_row='<div class="form-row">'
106 '<div class="form-label">'
98 '<div class="form-label">'
107 '%(label)s'
99 '%(label)s'
108 '</div>'
100 '</div>'
109 '<div class="form-input">'
101 '<div class="form-input">'
110 '%(field)s'
102 '%(field)s'
111 '</div>'
103 '</div>'
112 '</div>'
104 '</div>'
113 '<div class="form-row">'
105 '<div class="form-row">'
114 '%(help_text)s'
106 '%(help_text)s'
115 '</div>',
107 '</div>',
116 error_row='<div class="form-row">'
108 error_row='<div class="form-row">'
117 '<div class="form-label"></div>'
109 '<div class="form-label"></div>'
118 '<div class="form-errors">%s</div>'
110 '<div class="form-errors">%s</div>'
119 '</div>',
111 '</div>',
120 row_ender='</div>',
112 row_ender='</div>',
121 help_text_html='%s',
113 help_text_html='%s',
122 errors_on_separate_row=True)
114 errors_on_separate_row=True)
123
115
124 def as_json_errors(self):
116 def as_json_errors(self):
125 errors = []
117 errors = []
126
118
127 for name, field in list(self.fields.items()):
119 for name, field in list(self.fields.items()):
128 if self[name].errors:
120 if self[name].errors:
129 errors.append({
121 errors.append({
130 'field': name,
122 'field': name,
131 'errors': self[name].errors.as_text(),
123 'errors': self[name].errors.as_text(),
132 })
124 })
133
125
134 return errors
126 return errors
135
127
136
128
137 class PostForm(NeboardForm):
129 class PostForm(NeboardForm):
138
130
139 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
131 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
140 label=LABEL_TITLE)
132 label=LABEL_TITLE)
141 text = forms.CharField(
133 text = forms.CharField(
142 widget=FormatPanel(attrs={
134 widget=FormatPanel(attrs={
143 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
135 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
144 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
136 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
145 }),
137 }),
146 required=False, label=LABEL_TEXT)
138 required=False, label=LABEL_TEXT)
147 image = forms.ImageField(required=False, label=_('Image'),
139 file = forms.FileField(required=False, label=_('File'),
148 widget=forms.ClearableFileInput(
140 widget=forms.ClearableFileInput(
149 attrs={'accept': 'image/*'}))
141 attrs={'accept': 'file/*'}))
150 image_url = forms.CharField(required=False, label=_('Image URL'),
142 file_url = forms.CharField(required=False, label=_('File URL'),
151 widget=forms.TextInput(
143 widget=forms.TextInput(
152 attrs={ATTRIBUTE_PLACEHOLDER:
144 attrs={ATTRIBUTE_PLACEHOLDER:
153 'http://example.com/image.png'}))
145 'http://example.com/image.png'}))
154
146
155 # This field is for spam prevention only
147 # This field is for spam prevention only
156 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
148 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
157 widget=forms.TextInput(attrs={
149 widget=forms.TextInput(attrs={
158 'class': 'form-email'}))
150 'class': 'form-email'}))
159 threads = forms.CharField(required=False, label=_('Additional threads'),
151 threads = forms.CharField(required=False, label=_('Additional threads'),
160 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
152 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
161 '123 456 789'}))
153 '123 456 789'}))
162
154
163 session = None
155 session = None
164 need_to_ban = False
156 need_to_ban = False
165
157
166 def clean_title(self):
158 def clean_title(self):
167 title = self.cleaned_data['title']
159 title = self.cleaned_data['title']
168 if title:
160 if title:
169 if len(title) > TITLE_MAX_LENGTH:
161 if len(title) > TITLE_MAX_LENGTH:
170 raise forms.ValidationError(_('Title must have less than %s '
162 raise forms.ValidationError(_('Title must have less than %s '
171 'characters') %
163 'characters') %
172 str(TITLE_MAX_LENGTH))
164 str(TITLE_MAX_LENGTH))
173 return title
165 return title
174
166
175 def clean_text(self):
167 def clean_text(self):
176 text = self.cleaned_data['text'].strip()
168 text = self.cleaned_data['text'].strip()
177 if text:
169 if text:
178 max_length = board_settings.get_int('Forms', 'MaxTextLength')
170 max_length = board_settings.get_int('Forms', 'MaxTextLength')
179 if len(text) > max_length:
171 if len(text) > max_length:
180 raise forms.ValidationError(_('Text must have less than %s '
172 raise forms.ValidationError(_('Text must have less than %s '
181 'characters') % str(max_length))
173 'characters') % str(max_length))
182 return text
174 return text
183
175
184 def clean_image(self):
176 def clean_file(self):
185 image = self.cleaned_data['image']
177 file = self.cleaned_data['file']
186
178
187 if image:
179 if file:
188 self.validate_image_size(image.size)
180 self.validate_file_size(file.size)
189
181
190 return image
182 return file
191
183
192 def clean_image_url(self):
184 def clean_file_url(self):
193 url = self.cleaned_data['image_url']
185 url = self.cleaned_data['file_url']
194
186
195 image = None
187 file = None
196 if url:
188 if url:
197 image = self._get_image_from_url(url)
189 file = self._get_file_from_url(url)
198
190
199 if not image:
191 if not file:
200 raise forms.ValidationError(_('Invalid URL'))
192 raise forms.ValidationError(_('Invalid URL'))
201 else:
193 else:
202 self.validate_image_size(image.size)
194 self.validate_file_size(file.size)
203
195
204 return image
196 return file
205
197
206 def clean_threads(self):
198 def clean_threads(self):
207 threads_str = self.cleaned_data['threads']
199 threads_str = self.cleaned_data['threads']
208
200
209 if len(threads_str) > 0:
201 if len(threads_str) > 0:
210 threads_id_list = threads_str.split(' ')
202 threads_id_list = threads_str.split(' ')
211
203
212 threads = list()
204 threads = list()
213
205
214 for thread_id in threads_id_list:
206 for thread_id in threads_id_list:
215 try:
207 try:
216 thread = Post.objects.get(id=int(thread_id))
208 thread = Post.objects.get(id=int(thread_id))
217 if not thread.is_opening() or thread.get_thread().archived:
209 if not thread.is_opening() or thread.get_thread().archived:
218 raise ObjectDoesNotExist()
210 raise ObjectDoesNotExist()
219 threads.append(thread)
211 threads.append(thread)
220 except (ObjectDoesNotExist, ValueError):
212 except (ObjectDoesNotExist, ValueError):
221 raise forms.ValidationError(_('Invalid additional thread list'))
213 raise forms.ValidationError(_('Invalid additional thread list'))
222
214
223 return threads
215 return threads
224
216
225 def clean(self):
217 def clean(self):
226 cleaned_data = super(PostForm, self).clean()
218 cleaned_data = super(PostForm, self).clean()
227
219
228 if cleaned_data['email']:
220 if cleaned_data['email']:
229 self.need_to_ban = True
221 self.need_to_ban = True
230 raise forms.ValidationError('A human cannot enter a hidden field')
222 raise forms.ValidationError('A human cannot enter a hidden field')
231
223
232 if not self.errors:
224 if not self.errors:
233 self._clean_text_image()
225 self._clean_text_file()
234
226
235 if not self.errors and self.session:
227 if not self.errors and self.session:
236 self._validate_posting_speed()
228 self._validate_posting_speed()
237
229
238 return cleaned_data
230 return cleaned_data
239
231
240 def get_image(self):
232 def get_file(self):
241 """
233 """
242 Gets image from file or URL.
234 Gets file from form or URL.
243 """
235 """
244
236
245 image = self.cleaned_data['image']
237 file = self.cleaned_data['file']
246 return image if image else self.cleaned_data['image_url']
238 return file or self.cleaned_data['file_url']
247
239
248 def _clean_text_image(self):
240 def _clean_text_file(self):
249 text = self.cleaned_data.get('text')
241 text = self.cleaned_data.get('text')
250 image = self.get_image()
242 file = self.get_file()
251
243
252 if (not text) and (not image):
244 if (not text) and (not file):
253 error_message = _('Either text or image must be entered.')
245 error_message = _('Either text or file must be entered.')
254 self._errors['text'] = self.error_class([error_message])
246 self._errors['text'] = self.error_class([error_message])
255
247
256 def _validate_posting_speed(self):
248 def _validate_posting_speed(self):
257 can_post = True
249 can_post = True
258
250
259 posting_delay = settings.POSTING_DELAY
251 posting_delay = settings.POSTING_DELAY
260
252
261 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
253 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
262 now = time.time()
254 now = time.time()
263
255
264 current_delay = 0
256 current_delay = 0
265 need_delay = False
257 need_delay = False
266
258
267 if not LAST_POST_TIME in self.session:
259 if not LAST_POST_TIME in self.session:
268 self.session[LAST_POST_TIME] = now
260 self.session[LAST_POST_TIME] = now
269
261
270 need_delay = True
262 need_delay = True
271 else:
263 else:
272 last_post_time = self.session.get(LAST_POST_TIME)
264 last_post_time = self.session.get(LAST_POST_TIME)
273 current_delay = int(now - last_post_time)
265 current_delay = int(now - last_post_time)
274
266
275 need_delay = current_delay < posting_delay
267 need_delay = current_delay < posting_delay
276
268
277 if need_delay:
269 if need_delay:
278 error_message = ERROR_SPEED % str(posting_delay
270 error_message = ERROR_SPEED % str(posting_delay
279 - current_delay)
271 - current_delay)
280 self._errors['text'] = self.error_class([error_message])
272 self._errors['text'] = self.error_class([error_message])
281
273
282 can_post = False
274 can_post = False
283
275
284 if can_post:
276 if can_post:
285 self.session[LAST_POST_TIME] = now
277 self.session[LAST_POST_TIME] = now
286
278
287 def validate_image_size(self, size: int):
279 def validate_file_size(self, size: int):
288 max_size = board_settings.get_int('Forms', 'MaxImageSize')
280 max_size = board_settings.get_int('Forms', 'MaxFileSize')
289 if size > max_size:
281 if size > max_size:
290 raise forms.ValidationError(
282 raise forms.ValidationError(
291 _('Image must be less than %s bytes')
283 _('File must be less than %s bytes')
292 % str(max_size))
284 % str(max_size))
293
285
294 def _get_image_from_url(self, url: str) -> SimpleUploadedFile:
286 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
295 """
287 """
296 Gets an image file from URL.
288 Gets an file file from URL.
297 """
289 """
298
290
299 img_temp = None
291 img_temp = None
300
292
301 try:
293 try:
302 # Verify content headers
294 # Verify content headers
303 response_head = requests.head(url, verify=False)
295 response_head = requests.head(url, verify=False)
304 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
296 content_type = response_head.headers[HEADER_CONTENT_TYPE].split(';')[0]
305 if content_type in CONTENT_TYPE_IMAGE:
297 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
306 length_header = response_head.headers.get(HEADER_CONTENT_LENGTH)
298 if length_header:
307 if length_header:
299 length = int(length_header)
308 length = int(length_header)
300 self.validate_file_size(length)
309 self.validate_image_size(length)
301 # Get the actual content into memory
310 # Get the actual content into memory
302 response = requests.get(url, verify=False, stream=True)
311 response = requests.get(url, verify=False, stream=True)
312
303
313 # Download image, stop if the size exceeds limit
304 # Download file, stop if the size exceeds limit
314 size = 0
305 size = 0
315 content = b''
306 content = b''
316 for chunk in response.iter_content(IMAGE_DOWNLOAD_CHUNK_BYTES):
307 for chunk in response.iter_content(file_DOWNLOAD_CHUNK_BYTES):
317 size += len(chunk)
308 size += len(chunk)
318 self.validate_image_size(size)
309 self.validate_file_size(size)
319 content += chunk
310 content += chunk
320
311
321 if response.status_code == HTTP_RESULT_OK and content:
312 if response.status_code == HTTP_RESULT_OK and content:
322 # Set a dummy file name that will be replaced
313 # Set a dummy file name that will be replaced
323 # anyway, just keep the valid extension
314 # anyway, just keep the valid extension
324 filename = 'image.' + content_type.split('/')[1]
315 filename = 'file.' + content_type.split('/')[1]
325 img_temp = SimpleUploadedFile(filename, content,
316 img_temp = SimpleUploadedFile(filename, content,
326 content_type)
317 content_type)
327 except Exception:
318 except Exception:
328 # Just return no image
319 # Just return no file
329 pass
320 pass
330
321
331 return img_temp
322 return img_temp
332
323
333
324
334 class ThreadForm(PostForm):
325 class ThreadForm(PostForm):
335
326
336 tags = forms.CharField(
327 tags = forms.CharField(
337 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
328 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
338 max_length=100, label=_('Tags'), required=True)
329 max_length=100, label=_('Tags'), required=True)
339
330
340 def clean_tags(self):
331 def clean_tags(self):
341 tags = self.cleaned_data['tags'].strip()
332 tags = self.cleaned_data['tags'].strip()
342
333
343 if not tags or not REGEX_TAGS.match(tags):
334 if not tags or not REGEX_TAGS.match(tags):
344 raise forms.ValidationError(
335 raise forms.ValidationError(
345 _('Inappropriate characters in tags.'))
336 _('Inappropriate characters in tags.'))
346
337
347 required_tag_exists = False
338 required_tag_exists = False
348 for tag in tags.split():
339 for tag in tags.split():
349 try:
340 try:
350 Tag.objects.get(name=tag.strip().lower(), required=True)
341 Tag.objects.get(name=tag.strip().lower(), required=True)
351 required_tag_exists = True
342 required_tag_exists = True
352 break
343 break
353 except ObjectDoesNotExist:
344 except ObjectDoesNotExist:
354 pass
345 pass
355
346
356 if not required_tag_exists:
347 if not required_tag_exists:
357 all_tags = Tag.objects.filter(required=True)
348 all_tags = Tag.objects.filter(required=True)
358 raise forms.ValidationError(
349 raise forms.ValidationError(
359 _('Need at least one section.'))
350 _('Need at least one section.'))
360
351
361 return tags
352 return tags
362
353
363 def clean(self):
354 def clean(self):
364 cleaned_data = super(ThreadForm, self).clean()
355 cleaned_data = super(ThreadForm, self).clean()
365
356
366 return cleaned_data
357 return cleaned_data
367
358
368
359
369 class SettingsForm(NeboardForm):
360 class SettingsForm(NeboardForm):
370
361
371 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
362 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
372 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
363 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('image view mode'))
373 username = forms.CharField(label=_('User name'), required=False)
364 username = forms.CharField(label=_('User name'), required=False)
374 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
365 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
375
366
376 def clean_username(self):
367 def clean_username(self):
377 username = self.cleaned_data['username']
368 username = self.cleaned_data['username']
378
369
379 if username and not REGEX_TAGS.match(username):
370 if username and not REGEX_TAGS.match(username):
380 raise forms.ValidationError(_('Inappropriate characters.'))
371 raise forms.ValidationError(_('Inappropriate characters.'))
381
372
382 return username
373 return username
383
374
384
375
385 class SearchForm(NeboardForm):
376 class SearchForm(NeboardForm):
386 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
377 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,8 +1,9 b''
1 __author__ = 'neko259'
1 __author__ = 'neko259'
2
2
3 from boards.models.image import PostImage
3 from boards.models.image import PostImage
4 from boards.models.attachment import Attachment
4 from boards.models.thread import Thread
5 from boards.models.thread import Thread
5 from boards.models.post import Post
6 from boards.models.post import Post
6 from boards.models.tag import Tag
7 from boards.models.tag import Tag
7 from boards.models.user import Ban
8 from boards.models.user import Ban
8 from boards.models.banner import Banner
9 from boards.models.banner import Banner
@@ -1,123 +1,121 b''
1 import hashlib
1 import hashlib
2 import os
2 import os
3 from random import random
3 from random import random
4 import time
4 import time
5
5
6 from django.db import models
6 from django.db import models
7 from django.template.defaultfilters import filesizeformat
7 from django.template.defaultfilters import filesizeformat
8
8
9 from boards import thumbs
9 from boards import thumbs
10 import boards
10 import boards
11 from boards.models.base import Viewable
11 from boards.models.base import Viewable
12
12
13 __author__ = 'neko259'
13 __author__ = 'neko259'
14
14
15
15
16 IMAGE_THUMB_SIZE = (200, 150)
16 IMAGE_THUMB_SIZE = (200, 150)
17 IMAGES_DIRECTORY = 'images/'
17 IMAGES_DIRECTORY = 'images/'
18 FILE_EXTENSION_DELIMITER = '.'
18 FILE_EXTENSION_DELIMITER = '.'
19 HASH_LENGTH = 36
19 HASH_LENGTH = 36
20
20
21 CSS_CLASS_IMAGE = 'image'
21 CSS_CLASS_IMAGE = 'image'
22 CSS_CLASS_THUMB = 'thumb'
22 CSS_CLASS_THUMB = 'thumb'
23
23
24
24
25 class PostImageManager(models.Manager):
25 class PostImageManager(models.Manager):
26 def create_with_hash(self, image):
26 def create_with_hash(self, image):
27 image_hash = self.get_hash(image)
27 image_hash = self.get_hash(image)
28 existing = self.filter(hash=image_hash)
28 existing = self.filter(hash=image_hash)
29 if len(existing) > 0:
29 if len(existing) > 0:
30 post_image = existing[0]
30 post_image = existing[0]
31 else:
31 else:
32 post_image = PostImage.objects.create(image=image)
32 post_image = PostImage.objects.create(image=image)
33
33
34 return post_image
34 return post_image
35
35
36 def get_hash(self, image):
36 def get_hash(self, image):
37 """
37 """
38 Gets hash of an image.
38 Gets hash of an image.
39 """
39 """
40 md5 = hashlib.md5()
40 md5 = hashlib.md5()
41 for chunk in image.chunks():
41 for chunk in image.chunks():
42 md5.update(chunk)
42 md5.update(chunk)
43 return md5.hexdigest()
43 return md5.hexdigest()
44
44
45 def get_random_images(self, count, include_archived=False, tags=None):
45 def get_random_images(self, count, include_archived=False, tags=None):
46 images = self.filter(post_images__thread__archived=include_archived)
46 images = self.filter(post_images__thread__archived=include_archived)
47 if tags is not None:
47 if tags is not None:
48 images = images.filter(post_images__threads__tags__in=tags)
48 images = images.filter(post_images__threads__tags__in=tags)
49 return images.order_by('?')[:count]
49 return images.order_by('?')[:count]
50
50
51
51
52 class PostImage(models.Model, Viewable):
52 class PostImage(models.Model, Viewable):
53 objects = PostImageManager()
53 objects = PostImageManager()
54
54
55 class Meta:
55 class Meta:
56 app_label = 'boards'
56 app_label = 'boards'
57 ordering = ('id',)
57 ordering = ('id',)
58
58
59 def _update_image_filename(self, filename):
59 def _update_image_filename(self, filename):
60 """
60 """
61 Gets unique image filename
61 Gets unique image filename
62 """
62 """
63
63
64 path = IMAGES_DIRECTORY
65
66 # TODO Use something other than random number in file name
64 # TODO Use something other than random number in file name
67 new_name = '{}{}.{}'.format(
65 new_name = '{}{}.{}'.format(
68 str(int(time.mktime(time.gmtime()))),
66 str(int(time.mktime(time.gmtime()))),
69 str(int(random() * 1000)),
67 str(int(random() * 1000)),
70 filename.split(FILE_EXTENSION_DELIMITER)[-1:][0])
68 filename.split(FILE_EXTENSION_DELIMITER)[-1:][0])
71
69
72 return os.path.join(path, new_name)
70 return os.path.join(IMAGES_DIRECTORY, new_name)
73
71
74 width = models.IntegerField(default=0)
72 width = models.IntegerField(default=0)
75 height = models.IntegerField(default=0)
73 height = models.IntegerField(default=0)
76
74
77 pre_width = models.IntegerField(default=0)
75 pre_width = models.IntegerField(default=0)
78 pre_height = models.IntegerField(default=0)
76 pre_height = models.IntegerField(default=0)
79
77
80 image = thumbs.ImageWithThumbsField(upload_to=_update_image_filename,
78 image = thumbs.ImageWithThumbsField(upload_to=_update_image_filename,
81 blank=True, sizes=(IMAGE_THUMB_SIZE,),
79 blank=True, sizes=(IMAGE_THUMB_SIZE,),
82 width_field='width',
80 width_field='width',
83 height_field='height',
81 height_field='height',
84 preview_width_field='pre_width',
82 preview_width_field='pre_width',
85 preview_height_field='pre_height')
83 preview_height_field='pre_height')
86 hash = models.CharField(max_length=HASH_LENGTH)
84 hash = models.CharField(max_length=HASH_LENGTH)
87
85
88 def save(self, *args, **kwargs):
86 def save(self, *args, **kwargs):
89 """
87 """
90 Saves the model and computes the image hash for deduplication purposes.
88 Saves the model and computes the image hash for deduplication purposes.
91 """
89 """
92
90
93 if not self.pk and self.image:
91 if not self.pk and self.image:
94 self.hash = PostImage.objects.get_hash(self.image)
92 self.hash = PostImage.objects.get_hash(self.image)
95 super(PostImage, self).save(*args, **kwargs)
93 super(PostImage, self).save(*args, **kwargs)
96
94
97 def __str__(self):
95 def __str__(self):
98 return self.image.url
96 return self.image.url
99
97
100 def get_view(self):
98 def get_view(self):
101 metadata = '{}, {}'.format(self.image.name.split('.')[-1],
99 metadata = '{}, {}'.format(self.image.name.split('.')[-1],
102 filesizeformat(self.image.size))
100 filesizeformat(self.image.size))
103 return '<div class="{}">' \
101 return '<div class="{}">' \
104 '<a class="{}" href="{full}">' \
102 '<a class="{}" href="{full}">' \
105 '<img class="post-image-preview"' \
103 '<img class="post-image-preview"' \
106 ' src="{}"' \
104 ' src="{}"' \
107 ' alt="{}"' \
105 ' alt="{}"' \
108 ' width="{}"' \
106 ' width="{}"' \
109 ' height="{}"' \
107 ' height="{}"' \
110 ' data-width="{}"' \
108 ' data-width="{}"' \
111 ' data-height="{}" />' \
109 ' data-height="{}" />' \
112 '</a>' \
110 '</a>' \
113 '<div class="image-metadata">{image_meta}</div>' \
111 '<div class="image-metadata">{image_meta}</div>' \
114 '</div>'\
112 '</div>'\
115 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB,
113 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB,
116 self.image.url_200x150,
114 self.image.url_200x150,
117 str(self.hash), str(self.pre_width),
115 str(self.hash), str(self.pre_width),
118 str(self.pre_height), str(self.width), str(self.height),
116 str(self.pre_height), str(self.width), str(self.height),
119 full=self.image.url, image_meta=metadata)
117 full=self.image.url, image_meta=metadata)
120
118
121 def get_random_associated_post(self):
119 def get_random_associated_post(self):
122 posts = boards.models.Post.objects.filter(images__in=[self])
120 posts = boards.models.Post.objects.filter(images__in=[self])
123 return posts.order_by('?').first()
121 return posts.order_by('?').first()
@@ -1,418 +1,432 b''
1 from datetime import datetime, timedelta, date
1 from datetime import datetime, timedelta, date
2 from datetime import time as dtime
2 from datetime import time as dtime
3 import logging
3 import logging
4 import re
4 import re
5 import uuid
5 import uuid
6
6
7 from django.core.exceptions import ObjectDoesNotExist
7 from django.core.exceptions import ObjectDoesNotExist
8 from django.core.urlresolvers import reverse
8 from django.core.urlresolvers import reverse
9 from django.db import models, transaction
9 from django.db import models, transaction
10 from django.db.models import TextField, QuerySet
10 from django.db.models import TextField, QuerySet
11 from django.template.loader import render_to_string
11 from django.template.loader import render_to_string
12 from django.utils import timezone
12 from django.utils import timezone
13
13
14 from boards import settings
14 from boards import settings
15 from boards.mdx_neboard import Parser
15 from boards.mdx_neboard import Parser
16 from boards.models import PostImage
16 from boards.models import PostImage, Attachment
17 from boards.models.base import Viewable
17 from boards.models.base import Viewable
18 from boards import utils
18 from boards import utils
19 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
19 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
20 from boards.models.user import Notification, Ban
20 from boards.models.user import Notification, Ban
21 import boards.models.thread
21 import boards.models.thread
22
22
23
23
24 APP_LABEL_BOARDS = 'boards'
24 APP_LABEL_BOARDS = 'boards'
25
25
26 POSTS_PER_DAY_RANGE = 7
26 POSTS_PER_DAY_RANGE = 7
27
27
28 BAN_REASON_AUTO = 'Auto'
28 BAN_REASON_AUTO = 'Auto'
29
29
30 IMAGE_THUMB_SIZE = (200, 150)
30 IMAGE_THUMB_SIZE = (200, 150)
31
31
32 TITLE_MAX_LENGTH = 200
32 TITLE_MAX_LENGTH = 200
33
33
34 # TODO This should be removed
34 # TODO This should be removed
35 NO_IP = '0.0.0.0'
35 NO_IP = '0.0.0.0'
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 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
63 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
64
64
65 IMAGE_TYPES = (
66 'jpeg',
67 'jpg',
68 'png',
69 'bmp',
70 )
71
65
72
66 class PostManager(models.Manager):
73 class PostManager(models.Manager):
67 @transaction.atomic
74 @transaction.atomic
68 def create_post(self, title: str, text: str, image=None, thread=None,
75 def create_post(self, title: str, text: str, file=None, thread=None,
69 ip=NO_IP, tags: list=None, opening_posts: list=None):
76 ip=NO_IP, tags: list=None, opening_posts: list=None):
70 """
77 """
71 Creates new post
78 Creates new post
72 """
79 """
73
80
74 is_banned = Ban.objects.filter(ip=ip).exists()
81 is_banned = Ban.objects.filter(ip=ip).exists()
75
82
76 # TODO Raise specific exception and catch it in the views
83 # TODO Raise specific exception and catch it in the views
77 if is_banned:
84 if is_banned:
78 raise Exception("This user is banned")
85 raise Exception("This user is banned")
79
86
80 if not tags:
87 if not tags:
81 tags = []
88 tags = []
82 if not opening_posts:
89 if not opening_posts:
83 opening_posts = []
90 opening_posts = []
84
91
85 posting_time = timezone.now()
92 posting_time = timezone.now()
86 new_thread = False
93 new_thread = False
87 if not thread:
94 if not thread:
88 thread = boards.models.thread.Thread.objects.create(
95 thread = boards.models.thread.Thread.objects.create(
89 bump_time=posting_time, last_edit_time=posting_time)
96 bump_time=posting_time, last_edit_time=posting_time)
90 list(map(thread.tags.add, tags))
97 list(map(thread.tags.add, tags))
91 boards.models.thread.Thread.objects.process_oldest_threads()
98 boards.models.thread.Thread.objects.process_oldest_threads()
92 new_thread = True
99 new_thread = True
93
100
94 pre_text = Parser().preparse(text)
101 pre_text = Parser().preparse(text)
95
102
96 post = self.create(title=title,
103 post = self.create(title=title,
97 text=pre_text,
104 text=pre_text,
98 pub_time=posting_time,
105 pub_time=posting_time,
99 poster_ip=ip,
106 poster_ip=ip,
100 thread=thread,
107 thread=thread,
101 last_edit_time=posting_time)
108 last_edit_time=posting_time)
102 post.threads.add(thread)
109 post.threads.add(thread)
103
110
104 logger = logging.getLogger('boards.post.create')
111 logger = logging.getLogger('boards.post.create')
105
112
106 logger.info('Created post {} by {}'.format(post, post.poster_ip))
113 logger.info('Created post {} by {}'.format(post, post.poster_ip))
107
114
108 if image:
115 # TODO Move this to other place
109 post.images.add(PostImage.objects.create_with_hash(image))
116 if file:
117 file_type = file.name.split('.')[-1].lower()
118 if file_type in IMAGE_TYPES:
119 post.images.add(PostImage.objects.create_with_hash(file))
120 else:
121 post.attachments.add(Attachment.objects.create_with_hash(file))
110
122
111 post.build_url()
123 post.build_url()
112 post.connect_replies()
124 post.connect_replies()
113 post.connect_threads(opening_posts)
125 post.connect_threads(opening_posts)
114 post.connect_notifications()
126 post.connect_notifications()
115
127
116 # Thread needs to be bumped only when the post is already created
128 # Thread needs to be bumped only when the post is already created
117 if not new_thread:
129 if not new_thread:
118 thread.last_edit_time = posting_time
130 thread.last_edit_time = posting_time
119 thread.bump()
131 thread.bump()
120 thread.save()
132 thread.save()
121
133
122 return post
134 return post
123
135
124 def delete_posts_by_ip(self, ip):
136 def delete_posts_by_ip(self, ip):
125 """
137 """
126 Deletes all posts of the author with same IP
138 Deletes all posts of the author with same IP
127 """
139 """
128
140
129 posts = self.filter(poster_ip=ip)
141 posts = self.filter(poster_ip=ip)
130 for post in posts:
142 for post in posts:
131 post.delete()
143 post.delete()
132
144
133 @utils.cached_result()
145 @utils.cached_result()
134 def get_posts_per_day(self) -> float:
146 def get_posts_per_day(self) -> float:
135 """
147 """
136 Gets average count of posts per day for the last 7 days
148 Gets average count of posts per day for the last 7 days
137 """
149 """
138
150
139 day_end = date.today()
151 day_end = date.today()
140 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
152 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
141
153
142 day_time_start = timezone.make_aware(datetime.combine(
154 day_time_start = timezone.make_aware(datetime.combine(
143 day_start, dtime()), timezone.get_current_timezone())
155 day_start, dtime()), timezone.get_current_timezone())
144 day_time_end = timezone.make_aware(datetime.combine(
156 day_time_end = timezone.make_aware(datetime.combine(
145 day_end, dtime()), timezone.get_current_timezone())
157 day_end, dtime()), timezone.get_current_timezone())
146
158
147 posts_per_period = float(self.filter(
159 posts_per_period = float(self.filter(
148 pub_time__lte=day_time_end,
160 pub_time__lte=day_time_end,
149 pub_time__gte=day_time_start).count())
161 pub_time__gte=day_time_start).count())
150
162
151 ppd = posts_per_period / POSTS_PER_DAY_RANGE
163 ppd = posts_per_period / POSTS_PER_DAY_RANGE
152
164
153 return ppd
165 return ppd
154
166
155
167
156 class Post(models.Model, Viewable):
168 class Post(models.Model, Viewable):
157 """A post is a message."""
169 """A post is a message."""
158
170
159 objects = PostManager()
171 objects = PostManager()
160
172
161 class Meta:
173 class Meta:
162 app_label = APP_LABEL_BOARDS
174 app_label = APP_LABEL_BOARDS
163 ordering = ('id',)
175 ordering = ('id',)
164
176
165 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
177 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
166 pub_time = models.DateTimeField()
178 pub_time = models.DateTimeField()
167 text = TextField(blank=True, null=True)
179 text = TextField(blank=True, null=True)
168 _text_rendered = TextField(blank=True, null=True, editable=False)
180 _text_rendered = TextField(blank=True, null=True, editable=False)
169
181
170 images = models.ManyToManyField(PostImage, null=True, blank=True,
182 images = models.ManyToManyField(PostImage, null=True, blank=True,
171 related_name='post_images', db_index=True)
183 related_name='post_images', db_index=True)
184 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
185 related_name='attachment_posts')
172
186
173 poster_ip = models.GenericIPAddressField()
187 poster_ip = models.GenericIPAddressField()
174
188
175 # TODO This field can be removed cause UID is used for update now
189 # TODO This field can be removed cause UID is used for update now
176 last_edit_time = models.DateTimeField()
190 last_edit_time = models.DateTimeField()
177
191
178 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
192 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
179 null=True,
193 null=True,
180 blank=True, related_name='refposts',
194 blank=True, related_name='refposts',
181 db_index=True)
195 db_index=True)
182 refmap = models.TextField(null=True, blank=True)
196 refmap = models.TextField(null=True, blank=True)
183 threads = models.ManyToManyField('Thread', db_index=True)
197 threads = models.ManyToManyField('Thread', db_index=True)
184 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
198 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
185
199
186 url = models.TextField()
200 url = models.TextField()
187 uid = models.TextField(db_index=True)
201 uid = models.TextField(db_index=True)
188
202
189 def __str__(self):
203 def __str__(self):
190 return 'P#{}/{}'.format(self.id, self.title)
204 return 'P#{}/{}'.format(self.id, self.title)
191
205
192 def get_referenced_posts(self):
206 def get_referenced_posts(self):
193 threads = self.get_threads().all()
207 threads = self.get_threads().all()
194 return self.referenced_posts.filter(threads__in=threads)\
208 return self.referenced_posts.filter(threads__in=threads)\
195 .order_by('pub_time').distinct().all()
209 .order_by('pub_time').distinct().all()
196
210
197 def get_title(self) -> str:
211 def get_title(self) -> str:
198 """
212 """
199 Gets original post title or part of its text.
213 Gets original post title or part of its text.
200 """
214 """
201
215
202 title = self.title
216 title = self.title
203 if not title:
217 if not title:
204 title = self.get_text()
218 title = self.get_text()
205
219
206 return title
220 return title
207
221
208 def build_refmap(self) -> None:
222 def build_refmap(self) -> None:
209 """
223 """
210 Builds a replies map string from replies list. This is a cache to stop
224 Builds a replies map string from replies list. This is a cache to stop
211 the server from recalculating the map on every post show.
225 the server from recalculating the map on every post show.
212 """
226 """
213
227
214 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
228 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
215 for refpost in self.referenced_posts.all()]
229 for refpost in self.referenced_posts.all()]
216
230
217 self.refmap = ', '.join(post_urls)
231 self.refmap = ', '.join(post_urls)
218
232
219 def is_referenced(self) -> bool:
233 def is_referenced(self) -> bool:
220 return self.refmap and len(self.refmap) > 0
234 return self.refmap and len(self.refmap) > 0
221
235
222 def is_opening(self) -> bool:
236 def is_opening(self) -> bool:
223 """
237 """
224 Checks if this is an opening post or just a reply.
238 Checks if this is an opening post or just a reply.
225 """
239 """
226
240
227 return self.get_thread().get_opening_post_id() == self.id
241 return self.get_thread().get_opening_post_id() == self.id
228
242
229 def get_absolute_url(self):
243 def get_absolute_url(self):
230 if self.url:
244 if self.url:
231 return self.url
245 return self.url
232 else:
246 else:
233 opening_id = self.get_thread().get_opening_post_id()
247 opening_id = self.get_thread().get_opening_post_id()
234 post_url = reverse('thread', kwargs={'post_id': opening_id})
248 post_url = reverse('thread', kwargs={'post_id': opening_id})
235 if self.id != opening_id:
249 if self.id != opening_id:
236 post_url += '#' + str(self.id)
250 post_url += '#' + str(self.id)
237 return post_url
251 return post_url
238
252
239
253
240 def get_thread(self):
254 def get_thread(self):
241 return self.thread
255 return self.thread
242
256
243 def get_threads(self) -> QuerySet:
257 def get_threads(self) -> QuerySet:
244 """
258 """
245 Gets post's thread.
259 Gets post's thread.
246 """
260 """
247
261
248 return self.threads
262 return self.threads
249
263
250 def get_view(self, *args, **kwargs) -> str:
264 def get_view(self, *args, **kwargs) -> str:
251 """
265 """
252 Renders post's HTML view. Some of the post params can be passed over
266 Renders post's HTML view. Some of the post params can be passed over
253 kwargs for the means of caching (if we view the thread, some params
267 kwargs for the means of caching (if we view the thread, some params
254 are same for every post and don't need to be computed over and over.
268 are same for every post and don't need to be computed over and over.
255 """
269 """
256
270
257 thread = self.get_thread()
271 thread = self.get_thread()
258 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
272 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
259
273
260 if is_opening:
274 if is_opening:
261 opening_post_id = self.id
275 opening_post_id = self.id
262 else:
276 else:
263 opening_post_id = thread.get_opening_post_id()
277 opening_post_id = thread.get_opening_post_id()
264
278
265 css_class = 'post'
279 css_class = 'post'
266 if thread.archived:
280 if thread.archived:
267 css_class += ' archive_post'
281 css_class += ' archive_post'
268 elif not thread.can_bump():
282 elif not thread.can_bump():
269 css_class += ' dead_post'
283 css_class += ' dead_post'
270
284
271 params = dict()
285 params = dict()
272 for param in POST_VIEW_PARAMS:
286 for param in POST_VIEW_PARAMS:
273 if param in kwargs:
287 if param in kwargs:
274 params[param] = kwargs[param]
288 params[param] = kwargs[param]
275
289
276 params.update({
290 params.update({
277 PARAMETER_POST: self,
291 PARAMETER_POST: self,
278 PARAMETER_IS_OPENING: is_opening,
292 PARAMETER_IS_OPENING: is_opening,
279 PARAMETER_THREAD: thread,
293 PARAMETER_THREAD: thread,
280 PARAMETER_CSS_CLASS: css_class,
294 PARAMETER_CSS_CLASS: css_class,
281 PARAMETER_OP_ID: opening_post_id,
295 PARAMETER_OP_ID: opening_post_id,
282 })
296 })
283
297
284 return render_to_string('boards/post.html', params)
298 return render_to_string('boards/post.html', params)
285
299
286 def get_search_view(self, *args, **kwargs):
300 def get_search_view(self, *args, **kwargs):
287 return self.get_view(need_op_data=True, *args, **kwargs)
301 return self.get_view(need_op_data=True, *args, **kwargs)
288
302
289 def get_first_image(self) -> PostImage:
303 def get_first_image(self) -> PostImage:
290 return self.images.earliest('id')
304 return self.images.earliest('id')
291
305
292 def delete(self, using=None):
306 def delete(self, using=None):
293 """
307 """
294 Deletes all post images and the post itself.
308 Deletes all post images and the post itself.
295 """
309 """
296
310
297 for image in self.images.all():
311 for image in self.images.all():
298 image_refs_count = Post.objects.filter(images__in=[image]).count()
312 image_refs_count = Post.objects.filter(images__in=[image]).count()
299 if image_refs_count == 1:
313 if image_refs_count == 1:
300 image.delete()
314 image.delete()
301
315
302 thread = self.get_thread()
316 thread = self.get_thread()
303 thread.last_edit_time = timezone.now()
317 thread.last_edit_time = timezone.now()
304 thread.save()
318 thread.save()
305
319
306 super(Post, self).delete(using)
320 super(Post, self).delete(using)
307
321
308 logging.getLogger('boards.post.delete').info(
322 logging.getLogger('boards.post.delete').info(
309 'Deleted post {}'.format(self))
323 'Deleted post {}'.format(self))
310
324
311 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
325 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
312 include_last_update=False) -> str:
326 include_last_update=False) -> str:
313 """
327 """
314 Gets post HTML or JSON data that can be rendered on a page or used by
328 Gets post HTML or JSON data that can be rendered on a page or used by
315 API.
329 API.
316 """
330 """
317
331
318 return get_exporter(format_type).export(self, request,
332 return get_exporter(format_type).export(self, request,
319 include_last_update)
333 include_last_update)
320
334
321 def notify_clients(self, recursive=True):
335 def notify_clients(self, recursive=True):
322 """
336 """
323 Sends post HTML data to the thread web socket.
337 Sends post HTML data to the thread web socket.
324 """
338 """
325
339
326 if not settings.get_bool('External', 'WebsocketsEnabled'):
340 if not settings.get_bool('External', 'WebsocketsEnabled'):
327 return
341 return
328
342
329 thread_ids = list()
343 thread_ids = list()
330 for thread in self.get_threads().all():
344 for thread in self.get_threads().all():
331 thread_ids.append(thread.id)
345 thread_ids.append(thread.id)
332
346
333 thread.notify_clients()
347 thread.notify_clients()
334
348
335 if recursive:
349 if recursive:
336 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
350 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
337 post_id = reply_number.group(1)
351 post_id = reply_number.group(1)
338
352
339 try:
353 try:
340 ref_post = Post.objects.get(id=post_id)
354 ref_post = Post.objects.get(id=post_id)
341
355
342 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
356 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
343 # If post is in this thread, its thread was already notified.
357 # If post is in this thread, its thread was already notified.
344 # Otherwise, notify its thread separately.
358 # Otherwise, notify its thread separately.
345 ref_post.notify_clients(recursive=False)
359 ref_post.notify_clients(recursive=False)
346 except ObjectDoesNotExist:
360 except ObjectDoesNotExist:
347 pass
361 pass
348
362
349 def build_url(self):
363 def build_url(self):
350 self.url = self.get_absolute_url()
364 self.url = self.get_absolute_url()
351 self.save(update_fields=['url'])
365 self.save(update_fields=['url'])
352
366
353 def save(self, force_insert=False, force_update=False, using=None,
367 def save(self, force_insert=False, force_update=False, using=None,
354 update_fields=None):
368 update_fields=None):
355 self._text_rendered = Parser().parse(self.get_raw_text())
369 self._text_rendered = Parser().parse(self.get_raw_text())
356
370
357 self.uid = str(uuid.uuid4())
371 self.uid = str(uuid.uuid4())
358 if update_fields is not None and 'uid' not in update_fields:
372 if update_fields is not None and 'uid' not in update_fields:
359 update_fields += ['uid']
373 update_fields += ['uid']
360
374
361 if self.id:
375 if self.id:
362 for thread in self.get_threads().all():
376 for thread in self.get_threads().all():
363 thread.last_edit_time = self.last_edit_time
377 thread.last_edit_time = self.last_edit_time
364
378
365 thread.save(update_fields=['last_edit_time', 'bumpable'])
379 thread.save(update_fields=['last_edit_time', 'bumpable'])
366
380
367 super().save(force_insert, force_update, using, update_fields)
381 super().save(force_insert, force_update, using, update_fields)
368
382
369 def get_text(self) -> str:
383 def get_text(self) -> str:
370 return self._text_rendered
384 return self._text_rendered
371
385
372 def get_raw_text(self) -> str:
386 def get_raw_text(self) -> str:
373 return self.text
387 return self.text
374
388
375 def get_absolute_id(self) -> str:
389 def get_absolute_id(self) -> str:
376 """
390 """
377 If the post has many threads, shows its main thread OP id in the post
391 If the post has many threads, shows its main thread OP id in the post
378 ID.
392 ID.
379 """
393 """
380
394
381 if self.get_threads().count() > 1:
395 if self.get_threads().count() > 1:
382 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
396 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
383 else:
397 else:
384 return str(self.id)
398 return str(self.id)
385
399
386 def connect_notifications(self):
400 def connect_notifications(self):
387 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
401 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
388 user_name = reply_number.group(1).lower()
402 user_name = reply_number.group(1).lower()
389 Notification.objects.get_or_create(name=user_name, post=self)
403 Notification.objects.get_or_create(name=user_name, post=self)
390
404
391 def connect_replies(self):
405 def connect_replies(self):
392 """
406 """
393 Connects replies to a post to show them as a reflink map
407 Connects replies to a post to show them as a reflink map
394 """
408 """
395
409
396 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
410 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
397 post_id = reply_number.group(1)
411 post_id = reply_number.group(1)
398
412
399 try:
413 try:
400 referenced_post = Post.objects.get(id=post_id)
414 referenced_post = Post.objects.get(id=post_id)
401
415
402 referenced_post.referenced_posts.add(self)
416 referenced_post.referenced_posts.add(self)
403 referenced_post.last_edit_time = self.pub_time
417 referenced_post.last_edit_time = self.pub_time
404 referenced_post.build_refmap()
418 referenced_post.build_refmap()
405 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
419 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
406 except ObjectDoesNotExist:
420 except ObjectDoesNotExist:
407 pass
421 pass
408
422
409 def connect_threads(self, opening_posts):
423 def connect_threads(self, opening_posts):
410 for opening_post in opening_posts:
424 for opening_post in opening_posts:
411 threads = opening_post.get_threads().all()
425 threads = opening_post.get_threads().all()
412 for thread in threads:
426 for thread in threads:
413 if thread.can_bump():
427 if thread.can_bump():
414 thread.update_bump_status()
428 thread.update_bump_status()
415
429
416 thread.last_edit_time = self.last_edit_time
430 thread.last_edit_time = self.last_edit_time
417 thread.save(update_fields=['last_edit_time', 'bumpable'])
431 thread.save(update_fields=['last_edit_time', 'bumpable'])
418 self.threads.add(opening_post.get_thread())
432 self.threads.add(opening_post.get_thread())
@@ -1,103 +1,110 b''
1 {% load i18n %}
1 {% load i18n %}
2 {% load board %}
2 {% load board %}
3
3
4 {% get_current_language as LANGUAGE_CODE %}
4 {% get_current_language as LANGUAGE_CODE %}
5
5
6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
7 <div class="post-info">
7 <div class="post-info">
8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
9 <span class="title">{{ post.title }}</span>
9 <span class="title">{{ post.title }}</span>
10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
11 {% comment %}
11 {% comment %}
12 Thread death time needs to be shown only if the thread is alredy archived
12 Thread death time needs to be shown only if the thread is alredy archived
13 and this is an opening post (thread death time) or a post for popup
13 and this is an opening post (thread death time) or a post for popup
14 (we don't see OP here so we show the death time in the post itself).
14 (we don't see OP here so we show the death time in the post itself).
15 {% endcomment %}
15 {% endcomment %}
16 {% if thread.archived %}
16 {% if thread.archived %}
17 {% if is_opening %}
17 {% if is_opening %}
18 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
18 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
19 {% endif %}
19 {% endif %}
20 {% endif %}
20 {% endif %}
21 {% if is_opening %}
21 {% if is_opening %}
22 {% if need_open_link %}
22 {% if need_open_link %}
23 {% if thread.archived %}
23 {% if thread.archived %}
24 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
24 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
25 {% else %}
25 {% else %}
26 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
26 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
27 {% endif %}
27 {% endif %}
28 {% endif %}
28 {% endif %}
29 {% else %}
29 {% else %}
30 {% if need_op_data %}
30 {% if need_op_data %}
31 {% with thread.get_opening_post as op %}
31 {% with thread.get_opening_post as op %}
32 {% trans " in " %}<a href="{{ op.get_absolute_url }}">&gt;&gt;{{ op.id }}</a> <span class="title">{{ op.get_title|striptags|truncatewords:5 }}</span>
32 {% trans " in " %}<a href="{{ op.get_absolute_url }}">&gt;&gt;{{ op.id }}</a> <span class="title">{{ op.get_title|striptags|truncatewords:5 }}</span>
33 {% endwith %}
33 {% endwith %}
34 {% endif %}
34 {% endif %}
35 {% endif %}
35 {% endif %}
36 {% if reply_link and not thread.archived %}
36 {% if reply_link and not thread.archived %}
37 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
37 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
38 {% endif %}
38 {% endif %}
39
39
40 {% if moderator %}
40 {% if moderator %}
41 <span class="moderator_info">
41 <span class="moderator_info">
42 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
42 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
43 {% if is_opening %}
43 {% if is_opening %}
44 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
44 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
45 {% endif %}
45 {% endif %}
46 </span>
46 </span>
47 {% endif %}
47 {% endif %}
48 </div>
48 </div>
49 {% comment %}
49 {% comment %}
50 Post images. Currently only 1 image can be posted and shown, but post model
50 Post images. Currently only 1 image can be posted and shown, but post model
51 supports multiple.
51 supports multiple.
52 {% endcomment %}
52 {% endcomment %}
53 {% if post.images.exists %}
53 {% if post.images.exists %}
54 {% with post.images.all.0 as image %}
54 {% with post.images.first as image %}
55 {% autoescape off %}
55 {% autoescape off %}
56 {{ image.get_view }}
56 {{ image.get_view }}
57 {% endautoescape %}
57 {% endautoescape %}
58 {% endwith %}
58 {% endwith %}
59 {% endif %}
59 {% endif %}
60 {% if post.attachments.exists %}
61 {% with post.attachments.first as file %}
62 {% autoescape off %}
63 {{ file.get_view }}
64 {% endautoescape %}
65 {% endwith %}
66 {% endif %}
60 {% comment %}
67 {% comment %}
61 Post message (text)
68 Post message (text)
62 {% endcomment %}
69 {% endcomment %}
63 <div class="message">
70 <div class="message">
64 {% autoescape off %}
71 {% autoescape off %}
65 {% if truncated %}
72 {% if truncated %}
66 {{ post.get_text|truncatewords_html:50 }}
73 {{ post.get_text|truncatewords_html:50 }}
67 {% else %}
74 {% else %}
68 {{ post.get_text }}
75 {{ post.get_text }}
69 {% endif %}
76 {% endif %}
70 {% endautoescape %}
77 {% endautoescape %}
71 {% if post.is_referenced %}
78 {% if post.is_referenced %}
72 {% if mode_tree %}
79 {% if mode_tree %}
73 <div class="tree_reply">
80 <div class="tree_reply">
74 {% for refpost in post.get_referenced_posts %}
81 {% for refpost in post.get_referenced_posts %}
75 {% post_view refpost mode_tree=True %}
82 {% post_view refpost mode_tree=True %}
76 {% endfor %}
83 {% endfor %}
77 </div>
84 </div>
78 {% else %}
85 {% else %}
79 <div class="refmap">
86 <div class="refmap">
80 {% autoescape off %}
87 {% autoescape off %}
81 {% trans "Replies" %}: {{ post.refmap }}
88 {% trans "Replies" %}: {{ post.refmap }}
82 {% endautoescape %}
89 {% endautoescape %}
83 </div>
90 </div>
84 {% endif %}
91 {% endif %}
85 {% endif %}
92 {% endif %}
86 </div>
93 </div>
87 {% comment %}
94 {% comment %}
88 Thread metadata: counters, tags etc
95 Thread metadata: counters, tags etc
89 {% endcomment %}
96 {% endcomment %}
90 {% if is_opening %}
97 {% if is_opening %}
91 <div class="metadata">
98 <div class="metadata">
92 {% if is_opening and need_open_link %}
99 {% if is_opening and need_open_link %}
93 {{ thread.get_reply_count }} {% trans 'messages' %},
100 {{ thread.get_reply_count }} {% trans 'messages' %},
94 {{ thread.get_images_count }} {% trans 'images' %}.
101 {{ thread.get_images_count }} {% trans 'images' %}.
95 {% endif %}
102 {% endif %}
96 <span class="tags">
103 <span class="tags">
97 {% autoescape off %}
104 {% autoescape off %}
98 {{ thread.get_tag_url_list }}
105 {{ thread.get_tag_url_list }}
99 {% endautoescape %}
106 {% endautoescape %}
100 </span>
107 </span>
101 </div>
108 </div>
102 {% endif %}
109 {% endif %}
103 </div>
110 </div>
@@ -1,169 +1,169 b''
1 from django.core.urlresolvers import reverse
1 from django.core.urlresolvers import reverse
2 from django.core.files import File
2 from django.core.files import File
3 from django.core.files.temp import NamedTemporaryFile
3 from django.core.files.temp import NamedTemporaryFile
4 from django.core.paginator import EmptyPage
4 from django.core.paginator import EmptyPage
5 from django.db import transaction
5 from django.db import transaction
6 from django.http import Http404
6 from django.http import Http404
7 from django.shortcuts import render, redirect
7 from django.shortcuts import render, redirect
8 import requests
8 import requests
9
9
10 from boards import utils, settings
10 from boards import utils, settings
11 from boards.abstracts.paginator import get_paginator
11 from boards.abstracts.paginator import get_paginator
12 from boards.abstracts.settingsmanager import get_settings_manager
12 from boards.abstracts.settingsmanager import get_settings_manager
13 from boards.forms import ThreadForm, PlainErrorList
13 from boards.forms import ThreadForm, PlainErrorList
14 from boards.models import Post, Thread, Ban, Tag, PostImage, Banner
14 from boards.models import Post, Thread, Ban, Tag, PostImage, Banner
15 from boards.views.banned import BannedView
15 from boards.views.banned import BannedView
16 from boards.views.base import BaseBoardView, CONTEXT_FORM
16 from boards.views.base import BaseBoardView, CONTEXT_FORM
17 from boards.views.posting_mixin import PostMixin
17 from boards.views.posting_mixin import PostMixin
18
18
19
19
20 FORM_TAGS = 'tags'
20 FORM_TAGS = 'tags'
21 FORM_TEXT = 'text'
21 FORM_TEXT = 'text'
22 FORM_TITLE = 'title'
22 FORM_TITLE = 'title'
23 FORM_IMAGE = 'image'
23 FORM_IMAGE = 'image'
24 FORM_THREADS = 'threads'
24 FORM_THREADS = 'threads'
25
25
26 TAG_DELIMITER = ' '
26 TAG_DELIMITER = ' '
27
27
28 PARAMETER_CURRENT_PAGE = 'current_page'
28 PARAMETER_CURRENT_PAGE = 'current_page'
29 PARAMETER_PAGINATOR = 'paginator'
29 PARAMETER_PAGINATOR = 'paginator'
30 PARAMETER_THREADS = 'threads'
30 PARAMETER_THREADS = 'threads'
31 PARAMETER_BANNERS = 'banners'
31 PARAMETER_BANNERS = 'banners'
32
32
33 PARAMETER_PREV_LINK = 'prev_page_link'
33 PARAMETER_PREV_LINK = 'prev_page_link'
34 PARAMETER_NEXT_LINK = 'next_page_link'
34 PARAMETER_NEXT_LINK = 'next_page_link'
35
35
36 TEMPLATE = 'boards/all_threads.html'
36 TEMPLATE = 'boards/all_threads.html'
37 DEFAULT_PAGE = 1
37 DEFAULT_PAGE = 1
38
38
39
39
40 class AllThreadsView(PostMixin, BaseBoardView):
40 class AllThreadsView(PostMixin, BaseBoardView):
41
41
42 def __init__(self):
42 def __init__(self):
43 self.settings_manager = None
43 self.settings_manager = None
44 super(AllThreadsView, self).__init__()
44 super(AllThreadsView, self).__init__()
45
45
46 def get(self, request, form: ThreadForm=None):
46 def get(self, request, form: ThreadForm=None):
47 page = request.GET.get('page', DEFAULT_PAGE)
47 page = request.GET.get('page', DEFAULT_PAGE)
48
48
49 params = self.get_context_data(request=request)
49 params = self.get_context_data(request=request)
50
50
51 if not form:
51 if not form:
52 form = ThreadForm(error_class=PlainErrorList)
52 form = ThreadForm(error_class=PlainErrorList)
53
53
54 self.settings_manager = get_settings_manager(request)
54 self.settings_manager = get_settings_manager(request)
55 paginator = get_paginator(self.get_threads(),
55 paginator = get_paginator(self.get_threads(),
56 settings.get_int('View', 'ThreadsPerPage'))
56 settings.get_int('View', 'ThreadsPerPage'))
57 paginator.current_page = int(page)
57 paginator.current_page = int(page)
58
58
59 try:
59 try:
60 threads = paginator.page(page).object_list
60 threads = paginator.page(page).object_list
61 except EmptyPage:
61 except EmptyPage:
62 raise Http404()
62 raise Http404()
63
63
64 params[PARAMETER_THREADS] = threads
64 params[PARAMETER_THREADS] = threads
65 params[CONTEXT_FORM] = form
65 params[CONTEXT_FORM] = form
66 params[PARAMETER_BANNERS] = Banner.objects.order_by('-id').all()
66 params[PARAMETER_BANNERS] = Banner.objects.order_by('-id').all()
67
67
68 self.get_page_context(paginator, params, page)
68 self.get_page_context(paginator, params, page)
69
69
70 return render(request, TEMPLATE, params)
70 return render(request, TEMPLATE, params)
71
71
72 def post(self, request):
72 def post(self, request):
73 form = ThreadForm(request.POST, request.FILES,
73 form = ThreadForm(request.POST, request.FILES,
74 error_class=PlainErrorList)
74 error_class=PlainErrorList)
75 form.session = request.session
75 form.session = request.session
76
76
77 if form.is_valid():
77 if form.is_valid():
78 return self.create_thread(request, form)
78 return self.create_thread(request, form)
79 if form.need_to_ban:
79 if form.need_to_ban:
80 # Ban user because he is suspected to be a bot
80 # Ban user because he is suspected to be a bot
81 self._ban_current_user(request)
81 self._ban_current_user(request)
82
82
83 return self.get(request, form)
83 return self.get(request, form)
84
84
85 def get_page_context(self, paginator, params, page):
85 def get_page_context(self, paginator, params, page):
86 """
86 """
87 Get pagination context variables
87 Get pagination context variables
88 """
88 """
89
89
90 params[PARAMETER_PAGINATOR] = paginator
90 params[PARAMETER_PAGINATOR] = paginator
91 current_page = paginator.page(int(page))
91 current_page = paginator.page(int(page))
92 params[PARAMETER_CURRENT_PAGE] = current_page
92 params[PARAMETER_CURRENT_PAGE] = current_page
93 if current_page.has_previous():
93 if current_page.has_previous():
94 params[PARAMETER_PREV_LINK] = self.get_previous_page_link(
94 params[PARAMETER_PREV_LINK] = self.get_previous_page_link(
95 current_page)
95 current_page)
96 if current_page.has_next():
96 if current_page.has_next():
97 params[PARAMETER_NEXT_LINK] = self.get_next_page_link(current_page)
97 params[PARAMETER_NEXT_LINK] = self.get_next_page_link(current_page)
98
98
99 def get_previous_page_link(self, current_page):
99 def get_previous_page_link(self, current_page):
100 return reverse('index') + '?page=' \
100 return reverse('index') + '?page=' \
101 + str(current_page.previous_page_number())
101 + str(current_page.previous_page_number())
102
102
103 def get_next_page_link(self, current_page):
103 def get_next_page_link(self, current_page):
104 return reverse('index') + '?page=' \
104 return reverse('index') + '?page=' \
105 + str(current_page.next_page_number())
105 + str(current_page.next_page_number())
106
106
107 @staticmethod
107 @staticmethod
108 def parse_tags_string(tag_strings):
108 def parse_tags_string(tag_strings):
109 """
109 """
110 Parses tag list string and returns tag object list.
110 Parses tag list string and returns tag object list.
111 """
111 """
112
112
113 tags = []
113 tags = []
114
114
115 if tag_strings:
115 if tag_strings:
116 tag_strings = tag_strings.split(TAG_DELIMITER)
116 tag_strings = tag_strings.split(TAG_DELIMITER)
117 for tag_name in tag_strings:
117 for tag_name in tag_strings:
118 tag_name = tag_name.strip().lower()
118 tag_name = tag_name.strip().lower()
119 if len(tag_name) > 0:
119 if len(tag_name) > 0:
120 tag, created = Tag.objects.get_or_create(name=tag_name)
120 tag, created = Tag.objects.get_or_create(name=tag_name)
121 tags.append(tag)
121 tags.append(tag)
122
122
123 return tags
123 return tags
124
124
125 @transaction.atomic
125 @transaction.atomic
126 def create_thread(self, request, form: ThreadForm, html_response=True):
126 def create_thread(self, request, form: ThreadForm, html_response=True):
127 """
127 """
128 Creates a new thread with an opening post.
128 Creates a new thread with an opening post.
129 """
129 """
130
130
131 ip = utils.get_client_ip(request)
131 ip = utils.get_client_ip(request)
132 is_banned = Ban.objects.filter(ip=ip).exists()
132 is_banned = Ban.objects.filter(ip=ip).exists()
133
133
134 if is_banned:
134 if is_banned:
135 if html_response:
135 if html_response:
136 return redirect(BannedView().as_view())
136 return redirect(BannedView().as_view())
137 else:
137 else:
138 return
138 return
139
139
140 data = form.cleaned_data
140 data = form.cleaned_data
141
141
142 title = data[FORM_TITLE]
142 title = data[FORM_TITLE]
143 text = data[FORM_TEXT]
143 text = data[FORM_TEXT]
144 image = form.get_image()
144 file = form.get_file()
145 threads = data[FORM_THREADS]
145 threads = data[FORM_THREADS]
146
146
147 text = self._remove_invalid_links(text)
147 text = self._remove_invalid_links(text)
148
148
149 tag_strings = data[FORM_TAGS]
149 tag_strings = data[FORM_TAGS]
150
150
151 tags = self.parse_tags_string(tag_strings)
151 tags = self.parse_tags_string(tag_strings)
152
152
153 post = Post.objects.create_post(title=title, text=text, image=image,
153 post = Post.objects.create_post(title=title, text=text, file=file,
154 ip=ip, tags=tags, opening_posts=threads)
154 ip=ip, tags=tags, opening_posts=threads)
155
155
156 # This is required to update the threads to which posts we have replied
156 # This is required to update the threads to which posts we have replied
157 # when creating this one
157 # when creating this one
158 post.notify_clients()
158 post.notify_clients()
159
159
160 if html_response:
160 if html_response:
161 return redirect(post.get_absolute_url())
161 return redirect(post.get_absolute_url())
162
162
163 def get_threads(self):
163 def get_threads(self):
164 """
164 """
165 Gets list of threads that will be shown on a page.
165 Gets list of threads that will be shown on a page.
166 """
166 """
167
167
168 return Thread.objects.order_by('-bump_time')\
168 return Thread.objects.order_by('-bump_time')\
169 .exclude(tags__in=self.settings_manager.get_hidden_tags())
169 .exclude(tags__in=self.settings_manager.get_hidden_tags())
@@ -1,138 +1,138 b''
1 from django.core.exceptions import ObjectDoesNotExist
1 from django.core.exceptions import ObjectDoesNotExist
2 from django.http import Http404
2 from django.http import Http404
3 from django.shortcuts import get_object_or_404, render, redirect
3 from django.shortcuts import get_object_or_404, render, redirect
4 from django.views.generic.edit import FormMixin
4 from django.views.generic.edit import FormMixin
5 from django.utils import timezone
5 from django.utils import timezone
6 from django.utils.dateformat import format
6 from django.utils.dateformat import format
7
7
8 from boards import utils, settings
8 from boards import utils, settings
9 from boards.forms import PostForm, PlainErrorList
9 from boards.forms import PostForm, PlainErrorList
10 from boards.models import Post
10 from boards.models import Post
11 from boards.views.base import BaseBoardView, CONTEXT_FORM
11 from boards.views.base import BaseBoardView, CONTEXT_FORM
12 from boards.views.posting_mixin import PostMixin
12 from boards.views.posting_mixin import PostMixin
13
13
14 import neboard
14 import neboard
15
15
16
16
17 CONTEXT_LASTUPDATE = "last_update"
17 CONTEXT_LASTUPDATE = "last_update"
18 CONTEXT_THREAD = 'thread'
18 CONTEXT_THREAD = 'thread'
19 CONTEXT_WS_TOKEN = 'ws_token'
19 CONTEXT_WS_TOKEN = 'ws_token'
20 CONTEXT_WS_PROJECT = 'ws_project'
20 CONTEXT_WS_PROJECT = 'ws_project'
21 CONTEXT_WS_HOST = 'ws_host'
21 CONTEXT_WS_HOST = 'ws_host'
22 CONTEXT_WS_PORT = 'ws_port'
22 CONTEXT_WS_PORT = 'ws_port'
23 CONTEXT_WS_TIME = 'ws_token_time'
23 CONTEXT_WS_TIME = 'ws_token_time'
24 CONTEXT_MODE = 'mode'
24 CONTEXT_MODE = 'mode'
25 CONTEXT_OP = 'opening_post'
25 CONTEXT_OP = 'opening_post'
26
26
27 FORM_TITLE = 'title'
27 FORM_TITLE = 'title'
28 FORM_TEXT = 'text'
28 FORM_TEXT = 'text'
29 FORM_IMAGE = 'image'
29 FORM_IMAGE = 'image'
30 FORM_THREADS = 'threads'
30 FORM_THREADS = 'threads'
31
31
32
32
33 class ThreadView(BaseBoardView, PostMixin, FormMixin):
33 class ThreadView(BaseBoardView, PostMixin, FormMixin):
34
34
35 def get(self, request, post_id, form: PostForm=None):
35 def get(self, request, post_id, form: PostForm=None):
36 try:
36 try:
37 opening_post = Post.objects.get(id=post_id)
37 opening_post = Post.objects.get(id=post_id)
38 except ObjectDoesNotExist:
38 except ObjectDoesNotExist:
39 raise Http404
39 raise Http404
40
40
41 # If this is not OP, don't show it as it is
41 # If this is not OP, don't show it as it is
42 if not opening_post.is_opening():
42 if not opening_post.is_opening():
43 return redirect(opening_post.get_thread().get_opening_post()
43 return redirect(opening_post.get_thread().get_opening_post()
44 .get_absolute_url())
44 .get_absolute_url())
45
45
46 if not form:
46 if not form:
47 form = PostForm(error_class=PlainErrorList)
47 form = PostForm(error_class=PlainErrorList)
48
48
49 thread_to_show = opening_post.get_thread()
49 thread_to_show = opening_post.get_thread()
50
50
51 params = dict()
51 params = dict()
52
52
53 params[CONTEXT_FORM] = form
53 params[CONTEXT_FORM] = form
54 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
54 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
55 params[CONTEXT_THREAD] = thread_to_show
55 params[CONTEXT_THREAD] = thread_to_show
56 params[CONTEXT_MODE] = self.get_mode()
56 params[CONTEXT_MODE] = self.get_mode()
57 params[CONTEXT_OP] = opening_post
57 params[CONTEXT_OP] = opening_post
58
58
59 if settings.get_bool('External', 'WebsocketsEnabled'):
59 if settings.get_bool('External', 'WebsocketsEnabled'):
60 token_time = format(timezone.now(), u'U')
60 token_time = format(timezone.now(), u'U')
61
61
62 params[CONTEXT_WS_TIME] = token_time
62 params[CONTEXT_WS_TIME] = token_time
63 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
63 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
64 timestamp=token_time)
64 timestamp=token_time)
65 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
65 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
66 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
66 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
67 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
67 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
68
68
69 params.update(self.get_data(thread_to_show))
69 params.update(self.get_data(thread_to_show))
70
70
71 return render(request, self.get_template(), params)
71 return render(request, self.get_template(), params)
72
72
73 def post(self, request, post_id):
73 def post(self, request, post_id):
74 opening_post = get_object_or_404(Post, id=post_id)
74 opening_post = get_object_or_404(Post, id=post_id)
75
75
76 # If this is not OP, don't show it as it is
76 # If this is not OP, don't show it as it is
77 if not opening_post.is_opening():
77 if not opening_post.is_opening():
78 raise Http404
78 raise Http404
79
79
80 if not opening_post.get_thread().archived:
80 if not opening_post.get_thread().archived:
81 form = PostForm(request.POST, request.FILES,
81 form = PostForm(request.POST, request.FILES,
82 error_class=PlainErrorList)
82 error_class=PlainErrorList)
83 form.session = request.session
83 form.session = request.session
84
84
85 if form.is_valid():
85 if form.is_valid():
86 return self.new_post(request, form, opening_post)
86 return self.new_post(request, form, opening_post)
87 if form.need_to_ban:
87 if form.need_to_ban:
88 # Ban user because he is suspected to be a bot
88 # Ban user because he is suspected to be a bot
89 self._ban_current_user(request)
89 self._ban_current_user(request)
90
90
91 return self.get(request, post_id, form)
91 return self.get(request, post_id, form)
92
92
93 def new_post(self, request, form: PostForm, opening_post: Post=None,
93 def new_post(self, request, form: PostForm, opening_post: Post=None,
94 html_response=True):
94 html_response=True):
95 """
95 """
96 Adds a new post (in thread or as a reply).
96 Adds a new post (in thread or as a reply).
97 """
97 """
98
98
99 ip = utils.get_client_ip(request)
99 ip = utils.get_client_ip(request)
100
100
101 data = form.cleaned_data
101 data = form.cleaned_data
102
102
103 title = data[FORM_TITLE]
103 title = data[FORM_TITLE]
104 text = data[FORM_TEXT]
104 text = data[FORM_TEXT]
105 image = form.get_image()
105 file = form.get_file()
106 threads = data[FORM_THREADS]
106 threads = data[FORM_THREADS]
107
107
108 text = self._remove_invalid_links(text)
108 text = self._remove_invalid_links(text)
109
109
110 post_thread = opening_post.get_thread()
110 post_thread = opening_post.get_thread()
111
111
112 post = Post.objects.create_post(title=title, text=text, image=image,
112 post = Post.objects.create_post(title=title, text=text, file=file,
113 thread=post_thread, ip=ip,
113 thread=post_thread, ip=ip,
114 opening_posts=threads)
114 opening_posts=threads)
115 post.notify_clients()
115 post.notify_clients()
116
116
117 if html_response:
117 if html_response:
118 if opening_post:
118 if opening_post:
119 return redirect(post.get_absolute_url())
119 return redirect(post.get_absolute_url())
120 else:
120 else:
121 return post
121 return post
122
122
123 def get_data(self, thread) -> dict:
123 def get_data(self, thread) -> dict:
124 """
124 """
125 Returns context params for the view.
125 Returns context params for the view.
126 """
126 """
127
127
128 return dict()
128 return dict()
129
129
130 def get_template(self) -> str:
130 def get_template(self) -> str:
131 """
131 """
132 Gets template to show the thread mode on.
132 Gets template to show the thread mode on.
133 """
133 """
134
134
135 pass
135 pass
136
136
137 def get_mode(self) -> str:
137 def get_mode(self) -> str:
138 pass
138 pass
General Comments 0
You need to be logged in to leave comments. Login now