##// END OF EJS Templates
Image deduplication (BB-53). When an image with the same hash is uploaded, it...
neko259 -
r944:6ed17cb6 default
parent child Browse files
Show More
@@ -1,246 +1,240 b''
1 import re
1 import re
2 import time
2 import time
3
3
4 from django import forms
4 from django import forms
5 from django.forms.util import ErrorList
5 from django.forms.util import ErrorList
6 from django.utils.translation import ugettext_lazy as _
6 from django.utils.translation import ugettext_lazy as _
7
7
8 from boards.mdx_neboard import formatters
8 from boards.mdx_neboard import formatters
9 from boards.models.post import TITLE_MAX_LENGTH
9 from boards.models.post import TITLE_MAX_LENGTH
10 from boards.models import PostImage, Tag
10 from boards.models import PostImage, Tag
11 from neboard import settings
11 from neboard import settings
12 import boards.settings as board_settings
12 import boards.settings as board_settings
13
13
14 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
14 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
15
15
16 VETERAN_POSTING_DELAY = 5
16 VETERAN_POSTING_DELAY = 5
17
17
18 ATTRIBUTE_PLACEHOLDER = 'placeholder'
18 ATTRIBUTE_PLACEHOLDER = 'placeholder'
19
19
20 LAST_POST_TIME = 'last_post_time'
20 LAST_POST_TIME = 'last_post_time'
21 LAST_LOGIN_TIME = 'last_login_time'
21 LAST_LOGIN_TIME = 'last_login_time'
22 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
22 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
23 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
23 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
24
24
25 ERROR_IMAGE_DUPLICATE = _('Such image was already posted')
26
27 LABEL_TITLE = _('Title')
25 LABEL_TITLE = _('Title')
28 LABEL_TEXT = _('Text')
26 LABEL_TEXT = _('Text')
29 LABEL_TAG = _('Tag')
27 LABEL_TAG = _('Tag')
30 LABEL_SEARCH = _('Search')
28 LABEL_SEARCH = _('Search')
31
29
32 TAG_MAX_LENGTH = 20
30 TAG_MAX_LENGTH = 20
33
31
34
32
35 class FormatPanel(forms.Textarea):
33 class FormatPanel(forms.Textarea):
36 """
34 """
37 Panel for text formatting. Consists of buttons to add different tags to the
35 Panel for text formatting. Consists of buttons to add different tags to the
38 form text area.
36 form text area.
39 """
37 """
40
38
41 def render(self, name, value, attrs=None):
39 def render(self, name, value, attrs=None):
42 output = '<div id="mark-panel">'
40 output = '<div id="mark-panel">'
43 for formatter in formatters:
41 for formatter in formatters:
44 output += '<span class="mark_btn"' + \
42 output += '<span class="mark_btn"' + \
45 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
43 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
46 '\', \'' + formatter.format_right + '\')">' + \
44 '\', \'' + formatter.format_right + '\')">' + \
47 formatter.preview_left + formatter.name + \
45 formatter.preview_left + formatter.name + \
48 formatter.preview_right + '</span>'
46 formatter.preview_right + '</span>'
49
47
50 output += '</div>'
48 output += '</div>'
51 output += super(FormatPanel, self).render(name, value, attrs=None)
49 output += super(FormatPanel, self).render(name, value, attrs=None)
52
50
53 return output
51 return output
54
52
55
53
56 class PlainErrorList(ErrorList):
54 class PlainErrorList(ErrorList):
57 def __unicode__(self):
55 def __unicode__(self):
58 return self.as_text()
56 return self.as_text()
59
57
60 def as_text(self):
58 def as_text(self):
61 return ''.join(['(!) %s ' % e for e in self])
59 return ''.join(['(!) %s ' % e for e in self])
62
60
63
61
64 class NeboardForm(forms.Form):
62 class NeboardForm(forms.Form):
65 """
63 """
66 Form with neboard-specific formatting.
64 Form with neboard-specific formatting.
67 """
65 """
68
66
69 def as_div(self):
67 def as_div(self):
70 """
68 """
71 Returns this form rendered as HTML <as_div>s.
69 Returns this form rendered as HTML <as_div>s.
72 """
70 """
73
71
74 return self._html_output(
72 return self._html_output(
75 # TODO Do not show hidden rows in the list here
73 # TODO Do not show hidden rows in the list here
76 normal_row='<div class="form-row"><div class="form-label">'
74 normal_row='<div class="form-row"><div class="form-label">'
77 '%(label)s'
75 '%(label)s'
78 '</div></div>'
76 '</div></div>'
79 '<div class="form-row"><div class="form-input">'
77 '<div class="form-row"><div class="form-input">'
80 '%(field)s'
78 '%(field)s'
81 '</div></div>'
79 '</div></div>'
82 '<div class="form-row">'
80 '<div class="form-row">'
83 '%(help_text)s'
81 '%(help_text)s'
84 '</div>',
82 '</div>',
85 error_row='<div class="form-row">'
83 error_row='<div class="form-row">'
86 '<div class="form-label"></div>'
84 '<div class="form-label"></div>'
87 '<div class="form-errors">%s</div>'
85 '<div class="form-errors">%s</div>'
88 '</div>',
86 '</div>',
89 row_ender='</div>',
87 row_ender='</div>',
90 help_text_html='%s',
88 help_text_html='%s',
91 errors_on_separate_row=True)
89 errors_on_separate_row=True)
92
90
93 def as_json_errors(self):
91 def as_json_errors(self):
94 errors = []
92 errors = []
95
93
96 for name, field in list(self.fields.items()):
94 for name, field in list(self.fields.items()):
97 if self[name].errors:
95 if self[name].errors:
98 errors.append({
96 errors.append({
99 'field': name,
97 'field': name,
100 'errors': self[name].errors.as_text(),
98 'errors': self[name].errors.as_text(),
101 })
99 })
102
100
103 return errors
101 return errors
104
102
105
103
106 class PostForm(NeboardForm):
104 class PostForm(NeboardForm):
107
105
108 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
106 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
109 label=LABEL_TITLE)
107 label=LABEL_TITLE)
110 text = forms.CharField(
108 text = forms.CharField(
111 widget=FormatPanel(attrs={ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER}),
109 widget=FormatPanel(attrs={ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER}),
112 required=False, label=LABEL_TEXT)
110 required=False, label=LABEL_TEXT)
113 image = forms.ImageField(required=False, label=_('Image'),
111 image = forms.ImageField(required=False, label=_('Image'),
114 widget=forms.ClearableFileInput(
112 widget=forms.ClearableFileInput(
115 attrs={'accept': 'image/*'}))
113 attrs={'accept': 'image/*'}))
116
114
117 # This field is for spam prevention only
115 # This field is for spam prevention only
118 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
116 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
119 widget=forms.TextInput(attrs={
117 widget=forms.TextInput(attrs={
120 'class': 'form-email'}))
118 'class': 'form-email'}))
121
119
122 session = None
120 session = None
123 need_to_ban = False
121 need_to_ban = False
124
122
125 def clean_title(self):
123 def clean_title(self):
126 title = self.cleaned_data['title']
124 title = self.cleaned_data['title']
127 if title:
125 if title:
128 if len(title) > TITLE_MAX_LENGTH:
126 if len(title) > TITLE_MAX_LENGTH:
129 raise forms.ValidationError(_('Title must have less than %s '
127 raise forms.ValidationError(_('Title must have less than %s '
130 'characters') %
128 'characters') %
131 str(TITLE_MAX_LENGTH))
129 str(TITLE_MAX_LENGTH))
132 return title
130 return title
133
131
134 def clean_text(self):
132 def clean_text(self):
135 text = self.cleaned_data['text'].strip()
133 text = self.cleaned_data['text'].strip()
136 if text:
134 if text:
137 if len(text) > board_settings.MAX_TEXT_LENGTH:
135 if len(text) > board_settings.MAX_TEXT_LENGTH:
138 raise forms.ValidationError(_('Text must have less than %s '
136 raise forms.ValidationError(_('Text must have less than %s '
139 'characters') %
137 'characters') %
140 str(board_settings
138 str(board_settings
141 .MAX_TEXT_LENGTH))
139 .MAX_TEXT_LENGTH))
142 return text
140 return text
143
141
144 def clean_image(self):
142 def clean_image(self):
145 image = self.cleaned_data['image']
143 image = self.cleaned_data['image']
146 if image:
144 if image:
147 if image.size > board_settings.MAX_IMAGE_SIZE:
145 if image.size > board_settings.MAX_IMAGE_SIZE:
148 raise forms.ValidationError(
146 raise forms.ValidationError(
149 _('Image must be less than %s bytes')
147 _('Image must be less than %s bytes')
150 % str(board_settings.MAX_IMAGE_SIZE))
148 % str(board_settings.MAX_IMAGE_SIZE))
151
149
152 image_hash = PostImage.get_hash(image)
153 if PostImage.objects.filter(hash=image_hash).exists():
154 raise forms.ValidationError(ERROR_IMAGE_DUPLICATE)
155
156 return image
150 return image
157
151
158 def clean(self):
152 def clean(self):
159 cleaned_data = super(PostForm, self).clean()
153 cleaned_data = super(PostForm, self).clean()
160
154
161 if not self.session:
155 if not self.session:
162 raise forms.ValidationError('Humans have sessions')
156 raise forms.ValidationError('Humans have sessions')
163
157
164 if cleaned_data['email']:
158 if cleaned_data['email']:
165 self.need_to_ban = True
159 self.need_to_ban = True
166 raise forms.ValidationError('A human cannot enter a hidden field')
160 raise forms.ValidationError('A human cannot enter a hidden field')
167
161
168 if not self.errors:
162 if not self.errors:
169 self._clean_text_image()
163 self._clean_text_image()
170
164
171 if not self.errors and self.session:
165 if not self.errors and self.session:
172 self._validate_posting_speed()
166 self._validate_posting_speed()
173
167
174 return cleaned_data
168 return cleaned_data
175
169
176 def _clean_text_image(self):
170 def _clean_text_image(self):
177 text = self.cleaned_data.get('text')
171 text = self.cleaned_data.get('text')
178 image = self.cleaned_data.get('image')
172 image = self.cleaned_data.get('image')
179
173
180 if (not text) and (not image):
174 if (not text) and (not image):
181 error_message = _('Either text or image must be entered.')
175 error_message = _('Either text or image must be entered.')
182 self._errors['text'] = self.error_class([error_message])
176 self._errors['text'] = self.error_class([error_message])
183
177
184 def _validate_posting_speed(self):
178 def _validate_posting_speed(self):
185 can_post = True
179 can_post = True
186
180
187 posting_delay = settings.POSTING_DELAY
181 posting_delay = settings.POSTING_DELAY
188
182
189 if board_settings.LIMIT_POSTING_SPEED and LAST_POST_TIME in \
183 if board_settings.LIMIT_POSTING_SPEED and LAST_POST_TIME in \
190 self.session:
184 self.session:
191 now = time.time()
185 now = time.time()
192 last_post_time = self.session[LAST_POST_TIME]
186 last_post_time = self.session[LAST_POST_TIME]
193
187
194 current_delay = int(now - last_post_time)
188 current_delay = int(now - last_post_time)
195
189
196 if current_delay < posting_delay:
190 if current_delay < posting_delay:
197 error_message = _('Wait %s seconds after last posting') % str(
191 error_message = _('Wait %s seconds after last posting') % str(
198 posting_delay - current_delay)
192 posting_delay - current_delay)
199 self._errors['text'] = self.error_class([error_message])
193 self._errors['text'] = self.error_class([error_message])
200
194
201 can_post = False
195 can_post = False
202
196
203 if can_post:
197 if can_post:
204 self.session[LAST_POST_TIME] = time.time()
198 self.session[LAST_POST_TIME] = time.time()
205
199
206
200
207 class ThreadForm(PostForm):
201 class ThreadForm(PostForm):
208
202
209 tags = forms.CharField(
203 tags = forms.CharField(
210 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
204 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
211 max_length=100, label=_('Tags'), required=True)
205 max_length=100, label=_('Tags'), required=True)
212
206
213 def clean_tags(self):
207 def clean_tags(self):
214 tags = self.cleaned_data['tags'].strip()
208 tags = self.cleaned_data['tags'].strip()
215
209
216 if not tags or not REGEX_TAGS.match(tags):
210 if not tags or not REGEX_TAGS.match(tags):
217 raise forms.ValidationError(
211 raise forms.ValidationError(
218 _('Inappropriate characters in tags.'))
212 _('Inappropriate characters in tags.'))
219
213
220 required_tag_exists = False
214 required_tag_exists = False
221 for tag in tags.split():
215 for tag in tags.split():
222 tag_model = Tag.objects.filter(name=tag.strip().lower(),
216 tag_model = Tag.objects.filter(name=tag.strip().lower(),
223 required=True)
217 required=True)
224 if tag_model.exists():
218 if tag_model.exists():
225 required_tag_exists = True
219 required_tag_exists = True
226 break
220 break
227
221
228 if not required_tag_exists:
222 if not required_tag_exists:
229 raise forms.ValidationError(_('Need at least 1 required tag.'))
223 raise forms.ValidationError(_('Need at least 1 required tag.'))
230
224
231 return tags
225 return tags
232
226
233 def clean(self):
227 def clean(self):
234 cleaned_data = super(ThreadForm, self).clean()
228 cleaned_data = super(ThreadForm, self).clean()
235
229
236 return cleaned_data
230 return cleaned_data
237
231
238
232
239 class SettingsForm(NeboardForm):
233 class SettingsForm(NeboardForm):
240
234
241 theme = forms.ChoiceField(choices=settings.THEMES,
235 theme = forms.ChoiceField(choices=settings.THEMES,
242 label=_('Theme'))
236 label=_('Theme'))
243
237
244
238
245 class SearchForm(NeboardForm):
239 class SearchForm(NeboardForm):
246 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
240 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,441 +1,451 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
5
6 from adjacent import Client
6 from adjacent import Client
7 from django.core.cache import cache
7 from django.core.cache import cache
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
10 from django.db.models import TextField
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 bbcode_extended
15 from boards.mdx_neboard import bbcode_extended
16 from boards.models import PostImage
16 from boards.models import PostImage
17 from boards.models.base import Viewable
17 from boards.models.base import Viewable
18 from boards.models.thread import Thread
18 from boards.models.thread import Thread
19 from boards.utils import datetime_to_epoch
19 from boards.utils import datetime_to_epoch
20
20
21
21
22 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
22 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
23 WS_NOTIFICATION_TYPE = 'notification_type'
23 WS_NOTIFICATION_TYPE = 'notification_type'
24
24
25 WS_CHANNEL_THREAD = "thread:"
25 WS_CHANNEL_THREAD = "thread:"
26
26
27 APP_LABEL_BOARDS = 'boards'
27 APP_LABEL_BOARDS = 'boards'
28
28
29 CACHE_KEY_PPD = 'ppd'
29 CACHE_KEY_PPD = 'ppd'
30 CACHE_KEY_POST_URL = 'post_url'
30 CACHE_KEY_POST_URL = 'post_url'
31
31
32 POSTS_PER_DAY_RANGE = 7
32 POSTS_PER_DAY_RANGE = 7
33
33
34 BAN_REASON_AUTO = 'Auto'
34 BAN_REASON_AUTO = 'Auto'
35
35
36 IMAGE_THUMB_SIZE = (200, 150)
36 IMAGE_THUMB_SIZE = (200, 150)
37
37
38 TITLE_MAX_LENGTH = 200
38 TITLE_MAX_LENGTH = 200
39
39
40 # TODO This should be removed
40 # TODO This should be removed
41 NO_IP = '0.0.0.0'
41 NO_IP = '0.0.0.0'
42
42
43 # TODO Real user agent should be saved instead of this
43 # TODO Real user agent should be saved instead of this
44 UNKNOWN_UA = ''
44 UNKNOWN_UA = ''
45
45
46 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
46 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
47
47
48 PARAMETER_TRUNCATED = 'truncated'
48 PARAMETER_TRUNCATED = 'truncated'
49 PARAMETER_TAG = 'tag'
49 PARAMETER_TAG = 'tag'
50 PARAMETER_OFFSET = 'offset'
50 PARAMETER_OFFSET = 'offset'
51 PARAMETER_DIFF_TYPE = 'type'
51 PARAMETER_DIFF_TYPE = 'type'
52 PARAMETER_BUMPABLE = 'bumpable'
52 PARAMETER_BUMPABLE = 'bumpable'
53 PARAMETER_THREAD = 'thread'
53 PARAMETER_THREAD = 'thread'
54 PARAMETER_IS_OPENING = 'is_opening'
54 PARAMETER_IS_OPENING = 'is_opening'
55 PARAMETER_MODERATOR = 'moderator'
55 PARAMETER_MODERATOR = 'moderator'
56 PARAMETER_POST = 'post'
56 PARAMETER_POST = 'post'
57 PARAMETER_OP_ID = 'opening_post_id'
57 PARAMETER_OP_ID = 'opening_post_id'
58 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
58 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
59
59
60 DIFF_TYPE_HTML = 'html'
60 DIFF_TYPE_HTML = 'html'
61 DIFF_TYPE_JSON = 'json'
61 DIFF_TYPE_JSON = 'json'
62
62
63 PREPARSE_PATTERNS = {
63 PREPARSE_PATTERNS = {
64 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
64 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
65 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
65 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
66 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
66 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
67 }
67 }
68
68
69
69
70 class PostManager(models.Manager):
70 class PostManager(models.Manager):
71 @transaction.atomic
71 @transaction.atomic
72 def create_post(self, title: str, text: str, image=None, thread=None,
72 def create_post(self, title: str, text: str, image=None, thread=None,
73 ip=NO_IP, tags: list=None):
73 ip=NO_IP, tags: list=None):
74 """
74 """
75 Creates new post
75 Creates new post
76 """
76 """
77
77
78 if not tags:
78 if not tags:
79 tags = []
79 tags = []
80
80
81 posting_time = timezone.now()
81 posting_time = timezone.now()
82 if not thread:
82 if not thread:
83 thread = Thread.objects.create(bump_time=posting_time,
83 thread = Thread.objects.create(bump_time=posting_time,
84 last_edit_time=posting_time)
84 last_edit_time=posting_time)
85 new_thread = True
85 new_thread = True
86 else:
86 else:
87 new_thread = False
87 new_thread = False
88
88
89 pre_text = self._preparse_text(text)
89 pre_text = self._preparse_text(text)
90
90
91 post = self.create(title=title,
91 post = self.create(title=title,
92 text=pre_text,
92 text=pre_text,
93 pub_time=posting_time,
93 pub_time=posting_time,
94 thread_new=thread,
94 thread_new=thread,
95 poster_ip=ip,
95 poster_ip=ip,
96 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
96 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
97 # last!
97 # last!
98 last_edit_time=posting_time)
98 last_edit_time=posting_time)
99
99
100 logger = logging.getLogger('boards.post.create')
100 logger = logging.getLogger('boards.post.create')
101
101
102 logger.info('Created post {} by {}'.format(
102 logger.info('Created post {} by {}'.format(
103 post, post.poster_ip))
103 post, post.poster_ip))
104
104
105 if image:
105 if image:
106 # Try to find existing image. If it exists, assign it to the post
107 # instead of createing the new one
108 image_hash = PostImage.get_hash(image)
109 existing = PostImage.objects.filter(hash=image_hash)
110 if len(existing) > 0:
111 post_image = existing[0]
112 else:
106 post_image = PostImage.objects.create(image=image)
113 post_image = PostImage.objects.create(image=image)
114 logger.info('Created new image #{} for post #{}'.format(
115 post_image.id, post.id))
107 post.images.add(post_image)
116 post.images.add(post_image)
108 logger.info('Created image #{} for post #{}'.format(
109 post_image.id, post.id))
110
117
111 thread.replies.add(post)
118 thread.replies.add(post)
112 list(map(thread.add_tag, tags))
119 list(map(thread.add_tag, tags))
113
120
114 if new_thread:
121 if new_thread:
115 Thread.objects.process_oldest_threads()
122 Thread.objects.process_oldest_threads()
116 else:
123 else:
117 thread.bump()
124 thread.bump()
118 thread.last_edit_time = posting_time
125 thread.last_edit_time = posting_time
119 thread.save()
126 thread.save()
120
127
121 self.connect_replies(post)
128 self.connect_replies(post)
122
129
123 return post
130 return post
124
131
125 def delete_posts_by_ip(self, ip):
132 def delete_posts_by_ip(self, ip):
126 """
133 """
127 Deletes all posts of the author with same IP
134 Deletes all posts of the author with same IP
128 """
135 """
129
136
130 posts = self.filter(poster_ip=ip)
137 posts = self.filter(poster_ip=ip)
131 for post in posts:
138 for post in posts:
132 post.delete()
139 post.delete()
133
140
134 def connect_replies(self, post):
141 def connect_replies(self, post):
135 """
142 """
136 Connects replies to a post to show them as a reflink map
143 Connects replies to a post to show them as a reflink map
137 """
144 """
138
145
139 for reply_number in re.finditer(REGEX_REPLY, post.get_raw_text()):
146 for reply_number in re.finditer(REGEX_REPLY, post.get_raw_text()):
140 post_id = reply_number.group(1)
147 post_id = reply_number.group(1)
141 ref_post = self.filter(id=post_id)
148 ref_post = self.filter(id=post_id)
142 if ref_post.count() > 0:
149 if ref_post.count() > 0:
143 referenced_post = ref_post[0]
150 referenced_post = ref_post[0]
144 referenced_post.referenced_posts.add(post)
151 referenced_post.referenced_posts.add(post)
145 referenced_post.last_edit_time = post.pub_time
152 referenced_post.last_edit_time = post.pub_time
146 referenced_post.build_refmap()
153 referenced_post.build_refmap()
147 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
154 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
148
155
149 referenced_thread = referenced_post.get_thread()
156 referenced_thread = referenced_post.get_thread()
150 referenced_thread.last_edit_time = post.pub_time
157 referenced_thread.last_edit_time = post.pub_time
151 referenced_thread.save(update_fields=['last_edit_time'])
158 referenced_thread.save(update_fields=['last_edit_time'])
152
159
153 def get_posts_per_day(self):
160 def get_posts_per_day(self):
154 """
161 """
155 Gets average count of posts per day for the last 7 days
162 Gets average count of posts per day for the last 7 days
156 """
163 """
157
164
158 day_end = date.today()
165 day_end = date.today()
159 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
166 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
160
167
161 cache_key = CACHE_KEY_PPD + str(day_end)
168 cache_key = CACHE_KEY_PPD + str(day_end)
162 ppd = cache.get(cache_key)
169 ppd = cache.get(cache_key)
163 if ppd:
170 if ppd:
164 return ppd
171 return ppd
165
172
166 day_time_start = timezone.make_aware(datetime.combine(
173 day_time_start = timezone.make_aware(datetime.combine(
167 day_start, dtime()), timezone.get_current_timezone())
174 day_start, dtime()), timezone.get_current_timezone())
168 day_time_end = timezone.make_aware(datetime.combine(
175 day_time_end = timezone.make_aware(datetime.combine(
169 day_end, dtime()), timezone.get_current_timezone())
176 day_end, dtime()), timezone.get_current_timezone())
170
177
171 posts_per_period = float(self.filter(
178 posts_per_period = float(self.filter(
172 pub_time__lte=day_time_end,
179 pub_time__lte=day_time_end,
173 pub_time__gte=day_time_start).count())
180 pub_time__gte=day_time_start).count())
174
181
175 ppd = posts_per_period / POSTS_PER_DAY_RANGE
182 ppd = posts_per_period / POSTS_PER_DAY_RANGE
176
183
177 cache.set(cache_key, ppd)
184 cache.set(cache_key, ppd)
178 return ppd
185 return ppd
179
186
180 def _preparse_text(self, text):
187 def _preparse_text(self, text):
181 """
188 """
182 Preparses text to change patterns like '>>' to a proper bbcode
189 Preparses text to change patterns like '>>' to a proper bbcode
183 tags.
190 tags.
184 """
191 """
185
192
186 for key, value in PREPARSE_PATTERNS.items():
193 for key, value in PREPARSE_PATTERNS.items():
187 text = re.sub(key, value, text, flags=re.MULTILINE)
194 text = re.sub(key, value, text, flags=re.MULTILINE)
188
195
189 return text
196 return text
190
197
191
198
192 class Post(models.Model, Viewable):
199 class Post(models.Model, Viewable):
193 """A post is a message."""
200 """A post is a message."""
194
201
195 objects = PostManager()
202 objects = PostManager()
196
203
197 class Meta:
204 class Meta:
198 app_label = APP_LABEL_BOARDS
205 app_label = APP_LABEL_BOARDS
199 ordering = ('id',)
206 ordering = ('id',)
200
207
201 title = models.CharField(max_length=TITLE_MAX_LENGTH)
208 title = models.CharField(max_length=TITLE_MAX_LENGTH)
202 pub_time = models.DateTimeField()
209 pub_time = models.DateTimeField()
203 text = TextField(blank=True, null=True)
210 text = TextField(blank=True, null=True)
204 _text_rendered = TextField(blank=True, null=True, editable=False)
211 _text_rendered = TextField(blank=True, null=True, editable=False)
205
212
206 images = models.ManyToManyField(PostImage, null=True, blank=True,
213 images = models.ManyToManyField(PostImage, null=True, blank=True,
207 related_name='ip+', db_index=True)
214 related_name='ip+', db_index=True)
208
215
209 poster_ip = models.GenericIPAddressField()
216 poster_ip = models.GenericIPAddressField()
210 poster_user_agent = models.TextField()
217 poster_user_agent = models.TextField()
211
218
212 thread_new = models.ForeignKey('Thread', null=True, default=None,
219 thread_new = models.ForeignKey('Thread', null=True, default=None,
213 db_index=True)
220 db_index=True)
214 last_edit_time = models.DateTimeField()
221 last_edit_time = models.DateTimeField()
215
222
216 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
223 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
217 null=True,
224 null=True,
218 blank=True, related_name='rfp+',
225 blank=True, related_name='rfp+',
219 db_index=True)
226 db_index=True)
220 refmap = models.TextField(null=True, blank=True)
227 refmap = models.TextField(null=True, blank=True)
221
228
222 def __str__(self):
229 def __str__(self):
223 return 'P#{}/{}'.format(self.id, self.title)
230 return 'P#{}/{}'.format(self.id, self.title)
224
231
225 def get_title(self) -> str:
232 def get_title(self) -> str:
226 """
233 """
227 Gets original post title or part of its text.
234 Gets original post title or part of its text.
228 """
235 """
229
236
230 title = self.title
237 title = self.title
231 if not title:
238 if not title:
232 title = self.get_text()
239 title = self.get_text()
233
240
234 return title
241 return title
235
242
236 def build_refmap(self) -> None:
243 def build_refmap(self) -> None:
237 """
244 """
238 Builds a replies map string from replies list. This is a cache to stop
245 Builds a replies map string from replies list. This is a cache to stop
239 the server from recalculating the map on every post show.
246 the server from recalculating the map on every post show.
240 """
247 """
241 map_string = ''
248 map_string = ''
242
249
243 first = True
250 first = True
244 for refpost in self.referenced_posts.all():
251 for refpost in self.referenced_posts.all():
245 if not first:
252 if not first:
246 map_string += ', '
253 map_string += ', '
247 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
254 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
248 refpost.id)
255 refpost.id)
249 first = False
256 first = False
250
257
251 self.refmap = map_string
258 self.refmap = map_string
252
259
253 def get_sorted_referenced_posts(self):
260 def get_sorted_referenced_posts(self):
254 return self.refmap
261 return self.refmap
255
262
256 def is_referenced(self) -> bool:
263 def is_referenced(self) -> bool:
257 if not self.refmap:
264 if not self.refmap:
258 return False
265 return False
259 else:
266 else:
260 return len(self.refmap) > 0
267 return len(self.refmap) > 0
261
268
262 def is_opening(self) -> bool:
269 def is_opening(self) -> bool:
263 """
270 """
264 Checks if this is an opening post or just a reply.
271 Checks if this is an opening post or just a reply.
265 """
272 """
266
273
267 return self.get_thread().get_opening_post_id() == self.id
274 return self.get_thread().get_opening_post_id() == self.id
268
275
269 @transaction.atomic
276 @transaction.atomic
270 def add_tag(self, tag):
277 def add_tag(self, tag):
271 edit_time = timezone.now()
278 edit_time = timezone.now()
272
279
273 thread = self.get_thread()
280 thread = self.get_thread()
274 thread.add_tag(tag)
281 thread.add_tag(tag)
275 self.last_edit_time = edit_time
282 self.last_edit_time = edit_time
276 self.save(update_fields=['last_edit_time'])
283 self.save(update_fields=['last_edit_time'])
277
284
278 thread.last_edit_time = edit_time
285 thread.last_edit_time = edit_time
279 thread.save(update_fields=['last_edit_time'])
286 thread.save(update_fields=['last_edit_time'])
280
287
281 def get_url(self, thread=None):
288 def get_url(self, thread=None):
282 """
289 """
283 Gets full url to the post.
290 Gets full url to the post.
284 """
291 """
285
292
286 cache_key = CACHE_KEY_POST_URL + str(self.id)
293 cache_key = CACHE_KEY_POST_URL + str(self.id)
287 link = cache.get(cache_key)
294 link = cache.get(cache_key)
288
295
289 if not link:
296 if not link:
290 if not thread:
297 if not thread:
291 thread = self.get_thread()
298 thread = self.get_thread()
292
299
293 opening_id = thread.get_opening_post_id()
300 opening_id = thread.get_opening_post_id()
294
301
295 if self.id != opening_id:
302 if self.id != opening_id:
296 link = reverse('thread', kwargs={
303 link = reverse('thread', kwargs={
297 'post_id': opening_id}) + '#' + str(self.id)
304 'post_id': opening_id}) + '#' + str(self.id)
298 else:
305 else:
299 link = reverse('thread', kwargs={'post_id': self.id})
306 link = reverse('thread', kwargs={'post_id': self.id})
300
307
301 cache.set(cache_key, link)
308 cache.set(cache_key, link)
302
309
303 return link
310 return link
304
311
305 def get_thread(self) -> Thread:
312 def get_thread(self) -> Thread:
306 """
313 """
307 Gets post's thread.
314 Gets post's thread.
308 """
315 """
309
316
310 return self.thread_new
317 return self.thread_new
311
318
312 def get_referenced_posts(self):
319 def get_referenced_posts(self):
313 return self.referenced_posts.only('id', 'thread_new')
320 return self.referenced_posts.only('id', 'thread_new')
314
321
315 def get_view(self, moderator=False, need_open_link=False,
322 def get_view(self, moderator=False, need_open_link=False,
316 truncated=False, *args, **kwargs):
323 truncated=False, *args, **kwargs):
317 """
324 """
318 Renders post's HTML view. Some of the post params can be passed over
325 Renders post's HTML view. Some of the post params can be passed over
319 kwargs for the means of caching (if we view the thread, some params
326 kwargs for the means of caching (if we view the thread, some params
320 are same for every post and don't need to be computed over and over.
327 are same for every post and don't need to be computed over and over.
321 """
328 """
322
329
323 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
330 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
324 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
331 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
325 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
332 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
326
333
327 if is_opening:
334 if is_opening:
328 opening_post_id = self.id
335 opening_post_id = self.id
329 else:
336 else:
330 opening_post_id = thread.get_opening_post_id()
337 opening_post_id = thread.get_opening_post_id()
331
338
332 return render_to_string('boards/post.html', {
339 return render_to_string('boards/post.html', {
333 PARAMETER_POST: self,
340 PARAMETER_POST: self,
334 PARAMETER_MODERATOR: moderator,
341 PARAMETER_MODERATOR: moderator,
335 PARAMETER_IS_OPENING: is_opening,
342 PARAMETER_IS_OPENING: is_opening,
336 PARAMETER_THREAD: thread,
343 PARAMETER_THREAD: thread,
337 PARAMETER_BUMPABLE: can_bump,
344 PARAMETER_BUMPABLE: can_bump,
338 PARAMETER_NEED_OPEN_LINK: need_open_link,
345 PARAMETER_NEED_OPEN_LINK: need_open_link,
339 PARAMETER_TRUNCATED: truncated,
346 PARAMETER_TRUNCATED: truncated,
340 PARAMETER_OP_ID: opening_post_id,
347 PARAMETER_OP_ID: opening_post_id,
341 })
348 })
342
349
343 def get_search_view(self, *args, **kwargs):
350 def get_search_view(self, *args, **kwargs):
344 return self.get_view(args, kwargs)
351 return self.get_view(args, kwargs)
345
352
346 def get_first_image(self) -> PostImage:
353 def get_first_image(self) -> PostImage:
347 return self.images.earliest('id')
354 return self.images.earliest('id')
348
355
349 def delete(self, using=None):
356 def delete(self, using=None):
350 """
357 """
351 Deletes all post images and the post itself. If the post is opening,
358 Deletes all post images and the post itself. If the post is opening,
352 thread with all posts is deleted.
359 thread with all posts is deleted.
353 """
360 """
354
361
355 self.images.all().delete()
362 for image in self.images.all():
363 image_refs_count = Post.objects.filter(images__in=[image]).count()
364 if image_refs_count == 1:
365 image.delete()
356
366
357 if self.is_opening():
367 if self.is_opening():
358 self.get_thread().delete()
368 self.get_thread().delete()
359 else:
369 else:
360 thread = self.get_thread()
370 thread = self.get_thread()
361 thread.last_edit_time = timezone.now()
371 thread.last_edit_time = timezone.now()
362 thread.save()
372 thread.save()
363
373
364 super(Post, self).delete(using)
374 super(Post, self).delete(using)
365
375
366 logging.getLogger('boards.post.delete').info(
376 logging.getLogger('boards.post.delete').info(
367 'Deleted post {}'.format(self))
377 'Deleted post {}'.format(self))
368
378
369 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
379 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
370 include_last_update=False):
380 include_last_update=False):
371 """
381 """
372 Gets post HTML or JSON data that can be rendered on a page or used by
382 Gets post HTML or JSON data that can be rendered on a page or used by
373 API.
383 API.
374 """
384 """
375
385
376 if format_type == DIFF_TYPE_HTML:
386 if format_type == DIFF_TYPE_HTML:
377 params = dict()
387 params = dict()
378 params['post'] = self
388 params['post'] = self
379 if PARAMETER_TRUNCATED in request.GET:
389 if PARAMETER_TRUNCATED in request.GET:
380 params[PARAMETER_TRUNCATED] = True
390 params[PARAMETER_TRUNCATED] = True
381
391
382 return render_to_string('boards/api_post.html', params)
392 return render_to_string('boards/api_post.html', params)
383 elif format_type == DIFF_TYPE_JSON:
393 elif format_type == DIFF_TYPE_JSON:
384 post_json = {
394 post_json = {
385 'id': self.id,
395 'id': self.id,
386 'title': self.title,
396 'title': self.title,
387 'text': self._text_rendered,
397 'text': self._text_rendered,
388 }
398 }
389 if self.images.exists():
399 if self.images.exists():
390 post_image = self.get_first_image()
400 post_image = self.get_first_image()
391 post_json['image'] = post_image.image.url
401 post_json['image'] = post_image.image.url
392 post_json['image_preview'] = post_image.image.url_200x150
402 post_json['image_preview'] = post_image.image.url_200x150
393 if include_last_update:
403 if include_last_update:
394 post_json['bump_time'] = datetime_to_epoch(
404 post_json['bump_time'] = datetime_to_epoch(
395 self.thread_new.bump_time)
405 self.thread_new.bump_time)
396 return post_json
406 return post_json
397
407
398 def send_to_websocket(self, request, recursive=True):
408 def send_to_websocket(self, request, recursive=True):
399 """
409 """
400 Sends post HTML data to the thread web socket.
410 Sends post HTML data to the thread web socket.
401 """
411 """
402
412
403 if not settings.WEBSOCKETS_ENABLED:
413 if not settings.WEBSOCKETS_ENABLED:
404 return
414 return
405
415
406 client = Client()
416 client = Client()
407
417
408 thread = self.get_thread()
418 thread = self.get_thread()
409 thread_id = thread.id
419 thread_id = thread.id
410 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
420 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
411 client.publish(channel_name, {
421 client.publish(channel_name, {
412 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
422 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
413 })
423 })
414 client.send()
424 client.send()
415
425
416 logger = logging.getLogger('boards.post.websocket')
426 logger = logging.getLogger('boards.post.websocket')
417
427
418 logger.info('Sent notification from post #{} to channel {}'.format(
428 logger.info('Sent notification from post #{} to channel {}'.format(
419 self.id, channel_name))
429 self.id, channel_name))
420
430
421 if recursive:
431 if recursive:
422 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
432 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
423 post_id = reply_number.group(1)
433 post_id = reply_number.group(1)
424 ref_post = Post.objects.filter(id=post_id)[0]
434 ref_post = Post.objects.filter(id=post_id)[0]
425
435
426 # If post is in this thread, its thread was already notified.
436 # If post is in this thread, its thread was already notified.
427 # Otherwise, notify its thread separately.
437 # Otherwise, notify its thread separately.
428 if ref_post.thread_new_id != thread_id:
438 if ref_post.thread_new_id != thread_id:
429 ref_post.send_to_websocket(request, recursive=False)
439 ref_post.send_to_websocket(request, recursive=False)
430
440
431 def save(self, force_insert=False, force_update=False, using=None,
441 def save(self, force_insert=False, force_update=False, using=None,
432 update_fields=None):
442 update_fields=None):
433 self._text_rendered = bbcode_extended(self.get_raw_text())
443 self._text_rendered = bbcode_extended(self.get_raw_text())
434
444
435 super().save(force_insert, force_update, using, update_fields)
445 super().save(force_insert, force_update, using, update_fields)
436
446
437 def get_text(self) -> str:
447 def get_text(self) -> str:
438 return self._text_rendered
448 return self._text_rendered
439
449
440 def get_raw_text(self) -> str:
450 def get_raw_text(self) -> str:
441 return self.text
451 return self.text
General Comments 0
You need to be logged in to leave comments. Login now