##// END OF EJS Templates
Don't include archived posts into the random image list
neko259 -
r1250:4c8a70f6 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', '0019_auto_20150519_1323'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='post',
16 name='images',
17 field=models.ManyToManyField(to='boards.PostImage', blank=True, null=True, db_index=True, related_name='post_images'),
18 ),
19 ]
@@ -1,120 +1,121 b''
1 1 import hashlib
2 2 import os
3 3 from random import random
4 4 import time
5 5
6 6 from django.db import models
7 7 from django.template.defaultfilters import filesizeformat
8 8
9 9 from boards import thumbs
10 10 import boards
11 11 from boards.models.base import Viewable
12 12
13 13 __author__ = 'neko259'
14 14
15 15
16 16 IMAGE_THUMB_SIZE = (200, 150)
17 17 IMAGES_DIRECTORY = 'images/'
18 18 FILE_EXTENSION_DELIMITER = '.'
19 19 HASH_LENGTH = 36
20 20
21 21 CSS_CLASS_IMAGE = 'image'
22 22 CSS_CLASS_THUMB = 'thumb'
23 23
24 24
25 25 class PostImageManager(models.Manager):
26 26 def create_with_hash(self, image):
27 27 image_hash = self.get_hash(image)
28 28 existing = self.filter(hash=image_hash)
29 29 if len(existing) > 0:
30 30 post_image = existing[0]
31 31 else:
32 32 post_image = PostImage.objects.create(image=image)
33 33
34 34 return post_image
35 35
36 36 def get_hash(self, image):
37 37 """
38 38 Gets hash of an image.
39 39 """
40 40 md5 = hashlib.md5()
41 41 for chunk in image.chunks():
42 42 md5.update(chunk)
43 43 return md5.hexdigest()
44 44
45 def get_random_images(self, count):
46 return self.order_by('?')[:count]
45 def get_random_images(self, count, include_archived=False):
46 return self.filter(post_images__thread__archived=include_archived)\
47 .order_by('?')[:count]
47 48
48 49
49 50 class PostImage(models.Model, Viewable):
50 51 objects = PostImageManager()
51 52
52 53 class Meta:
53 54 app_label = 'boards'
54 55 ordering = ('id',)
55 56
56 57 def _update_image_filename(self, filename):
57 58 """
58 59 Gets unique image filename
59 60 """
60 61
61 62 path = IMAGES_DIRECTORY
62 63
63 64 # TODO Use something other than random number in file name
64 65 new_name = '{}{}.{}'.format(
65 66 str(int(time.mktime(time.gmtime()))),
66 67 str(int(random() * 1000)),
67 68 filename.split(FILE_EXTENSION_DELIMITER)[-1:][0])
68 69
69 70 return os.path.join(path, new_name)
70 71
71 72 width = models.IntegerField(default=0)
72 73 height = models.IntegerField(default=0)
73 74
74 75 pre_width = models.IntegerField(default=0)
75 76 pre_height = models.IntegerField(default=0)
76 77
77 78 image = thumbs.ImageWithThumbsField(upload_to=_update_image_filename,
78 79 blank=True, sizes=(IMAGE_THUMB_SIZE,),
79 80 width_field='width',
80 81 height_field='height',
81 82 preview_width_field='pre_width',
82 83 preview_height_field='pre_height')
83 84 hash = models.CharField(max_length=HASH_LENGTH)
84 85
85 86 def save(self, *args, **kwargs):
86 87 """
87 88 Saves the model and computes the image hash for deduplication purposes.
88 89 """
89 90
90 91 if not self.pk and self.image:
91 92 self.hash = PostImage.objects.get_hash(self.image)
92 93 super(PostImage, self).save(*args, **kwargs)
93 94
94 95 def __str__(self):
95 96 return self.image.url
96 97
97 98 def get_view(self):
98 99 metadata = '{}, {}'.format(self.image.name.split('.')[-1],
99 100 filesizeformat(self.image.size))
100 101 return '<div class="{}">' \
101 102 '<a class="{}" href="{full}">' \
102 103 '<img class="post-image-preview"' \
103 104 ' src="{}"' \
104 105 ' alt="{}"' \
105 106 ' width="{}"' \
106 107 ' height="{}"' \
107 108 ' data-width="{}"' \
108 109 ' data-height="{}" />' \
109 110 '</a>' \
110 111 '<div class="image-metadata">{image_meta}</div>' \
111 112 '</div>'\
112 113 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB,
113 114 self.image.url_200x150,
114 115 str(self.hash), str(self.pre_width),
115 116 str(self.pre_height), str(self.width), str(self.height),
116 117 full=self.image.url, image_meta=metadata)
117 118
118 119 def get_random_associated_post(self):
119 120 return boards.models.Post.objects.filter(images__in=[self])\
120 121 .order_by('?').first()
@@ -1,418 +1,418 b''
1 1 from datetime import datetime, timedelta, date
2 2 from datetime import time as dtime
3 3 import logging
4 4 import re
5 5 import uuid
6 6
7 7 from django.core.exceptions import ObjectDoesNotExist
8 8 from django.core.urlresolvers import reverse
9 9 from django.db import models, transaction
10 10 from django.db.models import TextField, QuerySet
11 11 from django.template.loader import render_to_string
12 12 from django.utils import timezone
13 13
14 14 from boards import settings
15 15 from boards.mdx_neboard import Parser
16 16 from boards.models import PostImage
17 17 from boards.models.base import Viewable
18 18 from boards import utils
19 19 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
20 20 from boards.models.user import Notification, Ban
21 21 import boards.models.thread
22 22
23 23
24 24 APP_LABEL_BOARDS = 'boards'
25 25
26 26 POSTS_PER_DAY_RANGE = 7
27 27
28 28 BAN_REASON_AUTO = 'Auto'
29 29
30 30 IMAGE_THUMB_SIZE = (200, 150)
31 31
32 32 TITLE_MAX_LENGTH = 200
33 33
34 34 # TODO This should be removed
35 35 NO_IP = '0.0.0.0'
36 36
37 37 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 38 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
39 39
40 40 PARAMETER_TRUNCATED = 'truncated'
41 41 PARAMETER_TAG = 'tag'
42 42 PARAMETER_OFFSET = 'offset'
43 43 PARAMETER_DIFF_TYPE = 'type'
44 44 PARAMETER_CSS_CLASS = 'css_class'
45 45 PARAMETER_THREAD = 'thread'
46 46 PARAMETER_IS_OPENING = 'is_opening'
47 47 PARAMETER_MODERATOR = 'moderator'
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 'moderator',
58 58 'need_open_link',
59 59 'truncated',
60 60 'mode_tree',
61 61 )
62 62
63 63 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
64 64
65 65
66 66 class PostManager(models.Manager):
67 67 @transaction.atomic
68 68 def create_post(self, title: str, text: str, image=None, thread=None,
69 69 ip=NO_IP, tags: list=None, opening_posts: list=None):
70 70 """
71 71 Creates new post
72 72 """
73 73
74 74 is_banned = Ban.objects.filter(ip=ip).exists()
75 75
76 76 # TODO Raise specific exception and catch it in the views
77 77 if is_banned:
78 78 raise Exception("This user is banned")
79 79
80 80 if not tags:
81 81 tags = []
82 82 if not opening_posts:
83 83 opening_posts = []
84 84
85 85 posting_time = timezone.now()
86 86 new_thread = False
87 87 if not thread:
88 88 thread = boards.models.thread.Thread.objects.create(
89 89 bump_time=posting_time, last_edit_time=posting_time)
90 90 list(map(thread.tags.add, tags))
91 91 boards.models.thread.Thread.objects.process_oldest_threads()
92 92 new_thread = True
93 93
94 94 pre_text = Parser().preparse(text)
95 95
96 96 post = self.create(title=title,
97 97 text=pre_text,
98 98 pub_time=posting_time,
99 99 poster_ip=ip,
100 100 thread=thread,
101 101 last_edit_time=posting_time)
102 102 post.threads.add(thread)
103 103
104 104 logger = logging.getLogger('boards.post.create')
105 105
106 106 logger.info('Created post {} by {}'.format(post, post.poster_ip))
107 107
108 108 if image:
109 109 post.images.add(PostImage.objects.create_with_hash(image))
110 110
111 111 post.build_url()
112 112 post.connect_replies()
113 113 post.connect_threads(opening_posts)
114 114 post.connect_notifications()
115 115
116 116 # Thread needs to be bumped only when the post is already created
117 117 if not new_thread:
118 118 thread.last_edit_time = posting_time
119 119 thread.bump()
120 120 thread.save()
121 121
122 122 return post
123 123
124 124 def delete_posts_by_ip(self, ip):
125 125 """
126 126 Deletes all posts of the author with same IP
127 127 """
128 128
129 129 posts = self.filter(poster_ip=ip)
130 130 for post in posts:
131 131 post.delete()
132 132
133 133 @utils.cached_result()
134 134 def get_posts_per_day(self) -> float:
135 135 """
136 136 Gets average count of posts per day for the last 7 days
137 137 """
138 138
139 139 day_end = date.today()
140 140 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
141 141
142 142 day_time_start = timezone.make_aware(datetime.combine(
143 143 day_start, dtime()), timezone.get_current_timezone())
144 144 day_time_end = timezone.make_aware(datetime.combine(
145 145 day_end, dtime()), timezone.get_current_timezone())
146 146
147 147 posts_per_period = float(self.filter(
148 148 pub_time__lte=day_time_end,
149 149 pub_time__gte=day_time_start).count())
150 150
151 151 ppd = posts_per_period / POSTS_PER_DAY_RANGE
152 152
153 153 return ppd
154 154
155 155
156 156 class Post(models.Model, Viewable):
157 157 """A post is a message."""
158 158
159 159 objects = PostManager()
160 160
161 161 class Meta:
162 162 app_label = APP_LABEL_BOARDS
163 163 ordering = ('id',)
164 164
165 165 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
166 166 pub_time = models.DateTimeField()
167 167 text = TextField(blank=True, null=True)
168 168 _text_rendered = TextField(blank=True, null=True, editable=False)
169 169
170 170 images = models.ManyToManyField(PostImage, null=True, blank=True,
171 related_name='ip+', db_index=True)
171 related_name='post_images', db_index=True)
172 172
173 173 poster_ip = models.GenericIPAddressField()
174 174
175 175 # TODO This field can be removed cause UID is used for update now
176 176 last_edit_time = models.DateTimeField()
177 177
178 178 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
179 179 null=True,
180 180 blank=True, related_name='refposts',
181 181 db_index=True)
182 182 refmap = models.TextField(null=True, blank=True)
183 183 threads = models.ManyToManyField('Thread', db_index=True)
184 184 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
185 185
186 186 url = models.TextField()
187 187 uid = models.TextField(db_index=True)
188 188
189 189 def __str__(self):
190 190 return 'P#{}/{}'.format(self.id, self.title)
191 191
192 192 def get_referenced_posts(self):
193 193 threads = self.get_threads().all()
194 194 return self.referenced_posts.filter(threads__in=threads)\
195 195 .order_by('pub_time').distinct().all()
196 196
197 197 def get_title(self) -> str:
198 198 """
199 199 Gets original post title or part of its text.
200 200 """
201 201
202 202 title = self.title
203 203 if not title:
204 204 title = self.get_text()
205 205
206 206 return title
207 207
208 208 def build_refmap(self) -> None:
209 209 """
210 210 Builds a replies map string from replies list. This is a cache to stop
211 211 the server from recalculating the map on every post show.
212 212 """
213 213
214 214 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
215 215 for refpost in self.referenced_posts.all()]
216 216
217 217 self.refmap = ', '.join(post_urls)
218 218
219 219 def is_referenced(self) -> bool:
220 220 return self.refmap and len(self.refmap) > 0
221 221
222 222 def is_opening(self) -> bool:
223 223 """
224 224 Checks if this is an opening post or just a reply.
225 225 """
226 226
227 227 return self.get_thread().get_opening_post_id() == self.id
228 228
229 229 def get_absolute_url(self):
230 230 if self.url:
231 231 return self.url
232 232 else:
233 233 opening_id = self.get_thread().get_opening_post_id()
234 234 post_url = reverse('thread', kwargs={'post_id': opening_id})
235 235 if self.id != opening_id:
236 236 post_url += '#' + str(self.id)
237 237 return post_url
238 238
239 239
240 240 def get_thread(self):
241 241 return self.thread
242 242
243 243 def get_threads(self) -> QuerySet:
244 244 """
245 245 Gets post's thread.
246 246 """
247 247
248 248 return self.threads
249 249
250 250 def get_view(self, *args, **kwargs) -> str:
251 251 """
252 252 Renders post's HTML view. Some of the post params can be passed over
253 253 kwargs for the means of caching (if we view the thread, some params
254 254 are same for every post and don't need to be computed over and over.
255 255 """
256 256
257 257 thread = self.get_thread()
258 258 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
259 259
260 260 if is_opening:
261 261 opening_post_id = self.id
262 262 else:
263 263 opening_post_id = thread.get_opening_post_id()
264 264
265 265 css_class = 'post'
266 266 if thread.archived:
267 267 css_class += ' archive_post'
268 268 elif not thread.can_bump():
269 269 css_class += ' dead_post'
270 270
271 271 params = dict()
272 272 for param in POST_VIEW_PARAMS:
273 273 if param in kwargs:
274 274 params[param] = kwargs[param]
275 275
276 276 params.update({
277 277 PARAMETER_POST: self,
278 278 PARAMETER_IS_OPENING: is_opening,
279 279 PARAMETER_THREAD: thread,
280 280 PARAMETER_CSS_CLASS: css_class,
281 281 PARAMETER_OP_ID: opening_post_id,
282 282 })
283 283
284 284 return render_to_string('boards/post.html', params)
285 285
286 286 def get_search_view(self, *args, **kwargs):
287 287 return self.get_view(need_op_data=True, *args, **kwargs)
288 288
289 289 def get_first_image(self) -> PostImage:
290 290 return self.images.earliest('id')
291 291
292 292 def delete(self, using=None):
293 293 """
294 294 Deletes all post images and the post itself.
295 295 """
296 296
297 297 for image in self.images.all():
298 298 image_refs_count = Post.objects.filter(images__in=[image]).count()
299 299 if image_refs_count == 1:
300 300 image.delete()
301 301
302 302 thread = self.get_thread()
303 303 thread.last_edit_time = timezone.now()
304 304 thread.save()
305 305
306 306 super(Post, self).delete(using)
307 307
308 308 logging.getLogger('boards.post.delete').info(
309 309 'Deleted post {}'.format(self))
310 310
311 311 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
312 312 include_last_update=False) -> str:
313 313 """
314 314 Gets post HTML or JSON data that can be rendered on a page or used by
315 315 API.
316 316 """
317 317
318 318 return get_exporter(format_type).export(self, request,
319 319 include_last_update)
320 320
321 321 def notify_clients(self, recursive=True):
322 322 """
323 323 Sends post HTML data to the thread web socket.
324 324 """
325 325
326 326 if not settings.get_bool('External', 'WebsocketsEnabled'):
327 327 return
328 328
329 329 thread_ids = list()
330 330 for thread in self.get_threads().all():
331 331 thread_ids.append(thread.id)
332 332
333 333 thread.notify_clients()
334 334
335 335 if recursive:
336 336 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
337 337 post_id = reply_number.group(1)
338 338
339 339 try:
340 340 ref_post = Post.objects.get(id=post_id)
341 341
342 342 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
343 343 # If post is in this thread, its thread was already notified.
344 344 # Otherwise, notify its thread separately.
345 345 ref_post.notify_clients(recursive=False)
346 346 except ObjectDoesNotExist:
347 347 pass
348 348
349 349 def build_url(self):
350 350 self.url = self.get_absolute_url()
351 351 self.save(update_fields=['url'])
352 352
353 353 def save(self, force_insert=False, force_update=False, using=None,
354 354 update_fields=None):
355 355 self._text_rendered = Parser().parse(self.get_raw_text())
356 356
357 357 self.uid = str(uuid.uuid4())
358 358 if update_fields is not None and 'uid' not in update_fields:
359 359 update_fields += ['uid']
360 360
361 361 if self.id:
362 362 for thread in self.get_threads().all():
363 363 thread.last_edit_time = self.last_edit_time
364 364
365 365 thread.save(update_fields=['last_edit_time', 'bumpable'])
366 366
367 367 super().save(force_insert, force_update, using, update_fields)
368 368
369 369 def get_text(self) -> str:
370 370 return self._text_rendered
371 371
372 372 def get_raw_text(self) -> str:
373 373 return self.text
374 374
375 375 def get_absolute_id(self) -> str:
376 376 """
377 377 If the post has many threads, shows its main thread OP id in the post
378 378 ID.
379 379 """
380 380
381 381 if self.get_threads().count() > 1:
382 382 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
383 383 else:
384 384 return str(self.id)
385 385
386 386 def connect_notifications(self):
387 387 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
388 388 user_name = reply_number.group(1).lower()
389 389 Notification.objects.get_or_create(name=user_name, post=self)
390 390
391 391 def connect_replies(self):
392 392 """
393 393 Connects replies to a post to show them as a reflink map
394 394 """
395 395
396 396 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
397 397 post_id = reply_number.group(1)
398 398
399 399 try:
400 400 referenced_post = Post.objects.get(id=post_id)
401 401
402 402 referenced_post.referenced_posts.add(self)
403 403 referenced_post.last_edit_time = self.pub_time
404 404 referenced_post.build_refmap()
405 405 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
406 406 except ObjectDoesNotExist:
407 407 pass
408 408
409 409 def connect_threads(self, opening_posts):
410 410 for opening_post in opening_posts:
411 411 threads = opening_post.get_threads().all()
412 412 for thread in threads:
413 413 if thread.can_bump():
414 414 thread.update_bump_status()
415 415
416 416 thread.last_edit_time = self.last_edit_time
417 417 thread.save(update_fields=['last_edit_time', 'bumpable'])
418 418 self.threads.add(opening_post.get_thread())
General Comments 0
You need to be logged in to leave comments. Login now