##// END OF EJS Templates
Speed up getting post URL and made it work when OP is just created
neko259 -
r1443:e96568cb default
parent child Browse files
Show More
@@ -1,372 +1,372 b''
1 1 import logging
2 2 import re
3 3 import uuid
4 4
5 5 from django.core.exceptions import ObjectDoesNotExist
6 6 from django.core.urlresolvers import reverse
7 7 from django.db import models
8 8 from django.db.models import TextField, QuerySet
9 9 from django.template.defaultfilters import striptags, truncatewords
10 10 from django.template.loader import render_to_string
11 11 from django.utils import timezone
12 12
13 13 from boards import settings
14 14 from boards.abstracts.tripcode import Tripcode
15 15 from boards.mdx_neboard import Parser
16 16 from boards.models import PostImage, Attachment
17 17 from boards.models.base import Viewable
18 18 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
19 19 from boards.models.post.manager import PostManager
20 20 from boards.models.user import Notification
21 21
22 22 CSS_CLS_HIDDEN_POST = 'hidden_post'
23 23 CSS_CLS_DEAD_POST = 'dead_post'
24 24 CSS_CLS_ARCHIVE_POST = 'archive_post'
25 25 CSS_CLS_POST = 'post'
26 26 CSS_CLS_MONOCHROME = 'monochrome'
27 27
28 28 TITLE_MAX_WORDS = 10
29 29
30 30 APP_LABEL_BOARDS = 'boards'
31 31
32 32 BAN_REASON_AUTO = 'Auto'
33 33
34 34 IMAGE_THUMB_SIZE = (200, 150)
35 35
36 36 TITLE_MAX_LENGTH = 200
37 37
38 38 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
39 39 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
40 40
41 41 PARAMETER_TRUNCATED = 'truncated'
42 42 PARAMETER_TAG = 'tag'
43 43 PARAMETER_OFFSET = 'offset'
44 44 PARAMETER_DIFF_TYPE = 'type'
45 45 PARAMETER_CSS_CLASS = 'css_class'
46 46 PARAMETER_THREAD = 'thread'
47 47 PARAMETER_IS_OPENING = 'is_opening'
48 48 PARAMETER_POST = 'post'
49 49 PARAMETER_OP_ID = 'opening_post_id'
50 50 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
51 51 PARAMETER_REPLY_LINK = 'reply_link'
52 52 PARAMETER_NEED_OP_DATA = 'need_op_data'
53 53
54 54 POST_VIEW_PARAMS = (
55 55 'need_op_data',
56 56 'reply_link',
57 57 'need_open_link',
58 58 'truncated',
59 59 'mode_tree',
60 60 'perms',
61 61 )
62 62
63 63
64 64 class Post(models.Model, Viewable):
65 65 """A post is a message."""
66 66
67 67 objects = PostManager()
68 68
69 69 class Meta:
70 70 app_label = APP_LABEL_BOARDS
71 71 ordering = ('id',)
72 72
73 73 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
74 74 pub_time = models.DateTimeField()
75 75 text = TextField(blank=True, null=True)
76 76 _text_rendered = TextField(blank=True, null=True, editable=False)
77 77
78 78 images = models.ManyToManyField(PostImage, null=True, blank=True,
79 79 related_name='post_images', db_index=True)
80 80 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
81 81 related_name='attachment_posts')
82 82
83 83 poster_ip = models.GenericIPAddressField()
84 84
85 85 # TODO This field can be removed cause UID is used for update now
86 86 last_edit_time = models.DateTimeField()
87 87
88 88 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
89 89 null=True,
90 90 blank=True, related_name='refposts',
91 91 db_index=True)
92 92 refmap = models.TextField(null=True, blank=True)
93 93 threads = models.ManyToManyField('Thread', db_index=True,
94 94 related_name='multi_replies')
95 95 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
96 96
97 97 url = models.TextField()
98 98 uid = models.TextField(db_index=True)
99 99
100 100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 101 opening = models.BooleanField(db_index=True)
102 102 hidden = models.BooleanField(default=False)
103 103
104 104 def __str__(self):
105 105 return 'P#{}/{}'.format(self.id, self.get_title())
106 106
107 107 def get_referenced_posts(self):
108 108 threads = self.get_threads().all()
109 109 return self.referenced_posts.filter(threads__in=threads)\
110 110 .order_by('pub_time').distinct().all()
111 111
112 112 def get_title(self) -> str:
113 113 return self.title
114 114
115 115 def get_title_or_text(self):
116 116 title = self.get_title()
117 117 if not title:
118 118 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
119 119
120 120 return title
121 121
122 122 def build_refmap(self) -> None:
123 123 """
124 124 Builds a replies map string from replies list. This is a cache to stop
125 125 the server from recalculating the map on every post show.
126 126 """
127 127
128 128 post_urls = [refpost.get_link_view()
129 129 for refpost in self.referenced_posts.all()]
130 130
131 131 self.refmap = ', '.join(post_urls)
132 132
133 133 def is_referenced(self) -> bool:
134 134 return self.refmap and len(self.refmap) > 0
135 135
136 136 def is_opening(self) -> bool:
137 137 """
138 138 Checks if this is an opening post or just a reply.
139 139 """
140 140
141 141 return self.opening
142 142
143 143 def get_absolute_url(self, thread=None):
144 144 url = None
145 145
146 146 if thread is None:
147 147 thread = self.get_thread()
148 148
149 149 # Url is cached only for the "main" thread. When getting url
150 150 # for other threads, do it manually.
151 151 if self.url:
152 152 url = self.url
153 153
154 154 if url is None:
155 opening_id = thread.get_opening_post_id()
155 opening = self.is_opening()
156 opening_id = self.id if opening else thread.get_opening_post_id()
156 157 url = reverse('thread', kwargs={'post_id': opening_id})
157 if self.id != opening_id:
158 if not opening:
158 159 url += '#' + str(self.id)
159 160
160 161 return url
161 162
162 163 def get_thread(self):
163 164 return self.thread
164 165
165 166 def get_threads(self) -> QuerySet:
166 167 """
167 168 Gets post's thread.
168 169 """
169 170
170 171 return self.threads
171 172
172 173 def get_view(self, *args, **kwargs) -> str:
173 174 """
174 175 Renders post's HTML view. Some of the post params can be passed over
175 176 kwargs for the means of caching (if we view the thread, some params
176 177 are same for every post and don't need to be computed over and over.
177 178 """
178 179
179 180 thread = self.get_thread()
180 181
181 182 css_classes = [CSS_CLS_POST]
182 183 if thread.is_archived():
183 184 css_classes.append(CSS_CLS_ARCHIVE_POST)
184 185 elif not thread.can_bump():
185 186 css_classes.append(CSS_CLS_DEAD_POST)
186 187 if self.is_hidden():
187 188 css_classes.append(CSS_CLS_HIDDEN_POST)
188 189 if thread.is_monochrome():
189 190 css_classes.append(CSS_CLS_MONOCHROME)
190 191
191 192 params = dict()
192 193 for param in POST_VIEW_PARAMS:
193 194 if param in kwargs:
194 195 params[param] = kwargs[param]
195 196
196 197 params.update({
197 198 PARAMETER_POST: self,
198 199 PARAMETER_IS_OPENING: self.is_opening(),
199 200 PARAMETER_THREAD: thread,
200 201 PARAMETER_CSS_CLASS: ' '.join(css_classes),
201 202 })
202 203
203 204 return render_to_string('boards/post.html', params)
204 205
205 206 def get_search_view(self, *args, **kwargs):
206 207 return self.get_view(need_op_data=True, *args, **kwargs)
207 208
208 209 def get_first_image(self) -> PostImage:
209 210 return self.images.earliest('id')
210 211
211 212 def delete(self, using=None):
212 213 """
213 214 Deletes all post images and the post itself.
214 215 """
215 216
216 217 for image in self.images.all():
217 218 image_refs_count = image.post_images.count()
218 219 if image_refs_count == 1:
219 220 image.delete()
220 221
221 222 for attachment in self.attachments.all():
222 223 attachment_refs_count = attachment.attachment_posts.count()
223 224 if attachment_refs_count == 1:
224 225 attachment.delete()
225 226
226 227 thread = self.get_thread()
227 228 thread.last_edit_time = timezone.now()
228 229 thread.save()
229 230
230 231 super(Post, self).delete(using)
231 232
232 233 logging.getLogger('boards.post.delete').info(
233 234 'Deleted post {}'.format(self))
234 235
235 236 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
236 237 include_last_update=False) -> str:
237 238 """
238 239 Gets post HTML or JSON data that can be rendered on a page or used by
239 240 API.
240 241 """
241 242
242 243 return get_exporter(format_type).export(self, request,
243 244 include_last_update)
244 245
245 246 def notify_clients(self, recursive=True):
246 247 """
247 248 Sends post HTML data to the thread web socket.
248 249 """
249 250
250 251 if not settings.get_bool('External', 'WebsocketsEnabled'):
251 252 return
252 253
253 254 thread_ids = list()
254 255 for thread in self.get_threads().all():
255 256 thread_ids.append(thread.id)
256 257
257 258 thread.notify_clients()
258 259
259 260 if recursive:
260 261 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
261 262 post_id = reply_number.group(1)
262 263
263 264 try:
264 265 ref_post = Post.objects.get(id=post_id)
265 266
266 267 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
267 268 # If post is in this thread, its thread was already notified.
268 269 # Otherwise, notify its thread separately.
269 270 ref_post.notify_clients(recursive=False)
270 271 except ObjectDoesNotExist:
271 272 pass
272 273
273 274 def build_url(self):
274 275 self.url = self.get_absolute_url()
275 276 self.save(update_fields=['url'])
276 277
277 278 def save(self, force_insert=False, force_update=False, using=None,
278 279 update_fields=None):
279 280 new_post = self.id is None
280 281
281 282 self._text_rendered = Parser().parse(self.get_raw_text())
282 283
283 284 self.uid = str(uuid.uuid4())
284 285 if update_fields is not None and 'uid' not in update_fields:
285 286 update_fields += ['uid']
286 287
287 288 if not new_post:
288 289 for thread in self.get_threads().all():
289 290 thread.last_edit_time = self.last_edit_time
290 291
291 292 thread.save(update_fields=['last_edit_time', 'status'])
292 293
293 294 super().save(force_insert, force_update, using, update_fields)
294 295
295 # Post save triggers
296 if new_post:
296 if self.url is None:
297 297 self.build_url()
298 298
299 299 self._connect_replies()
300 300 self._connect_notifications()
301 301
302 302 def get_text(self) -> str:
303 303 return self._text_rendered
304 304
305 305 def get_raw_text(self) -> str:
306 306 return self.text
307 307
308 308 def get_absolute_id(self) -> str:
309 309 """
310 310 If the post has many threads, shows its main thread OP id in the post
311 311 ID.
312 312 """
313 313
314 314 if self.get_threads().count() > 1:
315 315 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
316 316 else:
317 317 return str(self.id)
318 318
319 319 def _connect_notifications(self):
320 320 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
321 321 user_name = reply_number.group(1).lower()
322 322 Notification.objects.get_or_create(name=user_name, post=self)
323 323
324 324 def _connect_replies(self):
325 325 """
326 326 Connects replies to a post to show them as a reflink map
327 327 """
328 328
329 329 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
330 330 post_id = reply_number.group(1)
331 331
332 332 try:
333 333 referenced_post = Post.objects.get(id=post_id)
334 334
335 335 referenced_post.referenced_posts.add(self)
336 336 referenced_post.last_edit_time = self.pub_time
337 337 referenced_post.build_refmap()
338 338 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
339 339 except ObjectDoesNotExist:
340 340 pass
341 341
342 342 def connect_threads(self, opening_posts):
343 343 for opening_post in opening_posts:
344 344 threads = opening_post.get_threads().all()
345 345 for thread in threads:
346 346 if thread.can_bump():
347 347 thread.update_bump_status()
348 348
349 349 thread.last_edit_time = self.last_edit_time
350 350 thread.save(update_fields=['last_edit_time', 'status'])
351 351 self.threads.add(opening_post.get_thread())
352 352
353 353 def get_tripcode(self):
354 354 if self.tripcode:
355 355 return Tripcode(self.tripcode)
356 356
357 357 def get_link_view(self):
358 358 """
359 359 Gets view of a reflink to the post.
360 360 """
361 361 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
362 362 self.id)
363 363 if self.is_opening():
364 364 result = '<b>{}</b>'.format(result)
365 365
366 366 return result
367 367
368 368 def is_hidden(self) -> bool:
369 369 return self.hidden
370 370
371 371 def set_hidden(self, hidden):
372 372 self.hidden = hidden
@@ -1,144 +1,145 b''
1 1 """
2 2 This module contains helper functions and helper classes.
3 3 """
4 4 import hashlib
5 5 from random import random
6 6 import time
7 7 import hmac
8 8
9 9 from django.core.cache import cache
10 10 from django.db.models import Model
11 11 from django import forms
12 12 from django.template.defaultfilters import filesizeformat
13 13 from django.utils import timezone
14 14 from django.utils.translation import ugettext_lazy as _
15 15 import magic
16 16 from portage import os
17 17
18 18 import boards
19 19 from boards.settings import get_bool
20 20 from neboard import settings
21 21
22 22 CACHE_KEY_DELIMITER = '_'
23 23
24 24 HTTP_FORWARDED = 'HTTP_X_FORWARDED_FOR'
25 25 META_REMOTE_ADDR = 'REMOTE_ADDR'
26 26
27 27 SETTING_MESSAGES = 'Messages'
28 28 SETTING_ANON_MODE = 'AnonymousMode'
29 29
30 30 ANON_IP = '127.0.0.1'
31 31
32 32 UPLOAD_DIRS ={
33 33 'PostImage': 'images/',
34 34 'Attachment': 'files/',
35 35 }
36 36 FILE_EXTENSION_DELIMITER = '.'
37 37
38 38
39 39 def is_anonymous_mode():
40 40 return get_bool(SETTING_MESSAGES, SETTING_ANON_MODE)
41 41
42 42
43 43 def get_client_ip(request):
44 44 if is_anonymous_mode():
45 45 ip = ANON_IP
46 46 else:
47 47 x_forwarded_for = request.META.get(HTTP_FORWARDED)
48 48 if x_forwarded_for:
49 49 ip = x_forwarded_for.split(',')[-1].strip()
50 50 else:
51 51 ip = request.META.get(META_REMOTE_ADDR)
52 52 return ip
53 53
54 54
55 55 # TODO The output format is not epoch because it includes microseconds
56 56 def datetime_to_epoch(datetime):
57 57 return int(time.mktime(timezone.localtime(
58 58 datetime,timezone.get_current_timezone()).timetuple())
59 59 * 1000000 + datetime.microsecond)
60 60
61 61
62 62 def get_websocket_token(user_id='', timestamp=''):
63 63 """
64 64 Create token to validate information provided by new connection.
65 65 """
66 66
67 67 sign = hmac.new(settings.CENTRIFUGE_PROJECT_SECRET.encode())
68 68 sign.update(settings.CENTRIFUGE_PROJECT_ID.encode())
69 69 sign.update(user_id.encode())
70 70 sign.update(timestamp.encode())
71 71 token = sign.hexdigest()
72 72
73 73 return token
74 74
75 75
76 76 # TODO Test this carefully
77 77 def cached_result(key_method=None):
78 78 """
79 79 Caches method result in the Django's cache system, persisted by object name,
80 80 object name, model id if object is a Django model, args and kwargs if any.
81 81 """
82 82 def _cached_result(function):
83 83 def inner_func(obj, *args, **kwargs):
84 84 cache_key_params = [obj.__class__.__name__, function.__name__]
85 85
86 86 cache_key_params += args
87 87 for key, value in kwargs:
88 88 cache_key_params.append(key + ':' + value)
89 89
90 90 if isinstance(obj, Model):
91 91 cache_key_params.append(str(obj.id))
92 92
93 93 if key_method is not None:
94 94 cache_key_params += [str(arg) for arg in key_method(obj)]
95 95
96 96 cache_key = CACHE_KEY_DELIMITER.join(cache_key_params)
97 97
98 98 persisted_result = cache.get(cache_key)
99 99 if persisted_result is not None:
100 100 result = persisted_result
101 101 else:
102 102 result = function(obj, *args, **kwargs)
103 if result is not None:
103 104 cache.set(cache_key, result)
104 105
105 106 return result
106 107
107 108 return inner_func
108 109 return _cached_result
109 110
110 111
111 112 def get_file_hash(file) -> str:
112 113 md5 = hashlib.md5()
113 114 for chunk in file.chunks():
114 115 md5.update(chunk)
115 116 return md5.hexdigest()
116 117
117 118
118 119 def validate_file_size(size: int):
119 120 max_size = boards.settings.get_int('Forms', 'MaxFileSize')
120 121 if size > max_size:
121 122 raise forms.ValidationError(
122 123 _('File must be less than %s but is %s.')
123 124 % (filesizeformat(max_size), filesizeformat(size)))
124 125
125 126
126 127 def get_extension(filename):
127 128 return filename.split(FILE_EXTENSION_DELIMITER)[-1:][0]
128 129
129 130
130 131 def get_upload_filename(model_instance, old_filename):
131 132 # TODO Use something other than random number in file name
132 133 extension = get_extension(old_filename)
133 134 new_name = '{}{}.{}'.format(
134 135 str(int(time.mktime(time.gmtime()))),
135 136 str(int(random() * 1000)),
136 137 extension)
137 138
138 139 directory = UPLOAD_DIRS[type(model_instance).__name__]
139 140
140 141 return os.path.join(directory, new_name)
141 142
142 143
143 144 def get_file_mimetype(file) -> str:
144 145 return magic.from_buffer(file.chunks().__next__(), mime=True).decode()
General Comments 0
You need to be logged in to leave comments. Login now