##// END OF EJS Templates
Fixed thread bumping
neko259 -
r1221:a2cae5ee default
parent child Browse files
Show More
@@ -1,421 +1,419 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
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
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
65
66 class PostManager(models.Manager):
66 class PostManager(models.Manager):
67 @transaction.atomic
67 @transaction.atomic
68 def create_post(self, title: str, text: str, image=None, thread=None,
68 def create_post(self, title: str, text: str, image=None, thread=None,
69 ip=NO_IP, tags: list=None, threads: list=None):
69 ip=NO_IP, tags: list=None, opening_posts: list=None):
70 """
70 """
71 Creates new post
71 Creates new post
72 """
72 """
73
73
74 is_banned = Ban.objects.filter(ip=ip).exists()
74 is_banned = Ban.objects.filter(ip=ip).exists()
75
75
76 # TODO Raise specific exception and catch it in the views
76 # TODO Raise specific exception and catch it in the views
77 if is_banned:
77 if is_banned:
78 raise Exception("This user is banned")
78 raise Exception("This user is banned")
79
79
80 if not tags:
80 if not tags:
81 tags = []
81 tags = []
82 if not threads:
82 if not opening_posts:
83 threads = []
83 opening_posts = []
84
84
85 posting_time = timezone.now()
85 posting_time = timezone.now()
86 if not thread:
86 if not thread:
87 thread = boards.models.thread.Thread.objects.create(
87 thread = boards.models.thread.Thread.objects.create(
88 bump_time=posting_time, last_edit_time=posting_time)
88 bump_time=posting_time, last_edit_time=posting_time)
89 list(map(thread.tags.add, tags))
89 list(map(thread.tags.add, tags))
90 new_thread = True
90 new_thread = True
91 else:
91 else:
92 new_thread = False
92 new_thread = False
93
93
94 pre_text = Parser().preparse(text)
94 pre_text = Parser().preparse(text)
95
95
96 post = self.create(title=title,
96 post = self.create(title=title,
97 text=pre_text,
97 text=pre_text,
98 pub_time=posting_time,
98 pub_time=posting_time,
99 poster_ip=ip,
99 poster_ip=ip,
100 thread=thread,
100 thread=thread,
101 last_edit_time=posting_time)
101 last_edit_time=posting_time)
102 post.threads.add(thread)
102 post.threads.add(thread)
103
103
104 logger = logging.getLogger('boards.post.create')
104 logger = logging.getLogger('boards.post.create')
105
105
106 logger.info('Created post {} by {}'.format(post, post.poster_ip))
106 logger.info('Created post {} by {}'.format(post, post.poster_ip))
107
107
108 if image:
108 if image:
109 post.images.add(PostImage.objects.create_with_hash(image))
109 post.images.add(PostImage.objects.create_with_hash(image))
110
110
111 if new_thread:
111 if new_thread:
112 boards.models.thread.Thread.objects.process_oldest_threads()
112 boards.models.thread.Thread.objects.process_oldest_threads()
113 else:
113 else:
114 thread.last_edit_time = posting_time
114 thread.last_edit_time = posting_time
115 thread.bump()
115 thread.bump()
116 thread.save()
116 thread.save()
117
117
118 post.build_url()
118 post.build_url()
119 post.connect_replies()
119 post.connect_replies()
120 post.connect_threads(threads)
120 post.connect_threads(opening_posts)
121 post.connect_notifications()
121 post.connect_notifications()
122
122
123 return post
123 return post
124
124
125 def delete_posts_by_ip(self, ip):
125 def delete_posts_by_ip(self, ip):
126 """
126 """
127 Deletes all posts of the author with same IP
127 Deletes all posts of the author with same IP
128 """
128 """
129
129
130 posts = self.filter(poster_ip=ip)
130 posts = self.filter(poster_ip=ip)
131 for post in posts:
131 for post in posts:
132 post.delete()
132 post.delete()
133
133
134 @utils.cached_result()
134 @utils.cached_result()
135 def get_posts_per_day(self) -> float:
135 def get_posts_per_day(self) -> float:
136 """
136 """
137 Gets average count of posts per day for the last 7 days
137 Gets average count of posts per day for the last 7 days
138 """
138 """
139
139
140 day_end = date.today()
140 day_end = date.today()
141 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
141 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
142
142
143 day_time_start = timezone.make_aware(datetime.combine(
143 day_time_start = timezone.make_aware(datetime.combine(
144 day_start, dtime()), timezone.get_current_timezone())
144 day_start, dtime()), timezone.get_current_timezone())
145 day_time_end = timezone.make_aware(datetime.combine(
145 day_time_end = timezone.make_aware(datetime.combine(
146 day_end, dtime()), timezone.get_current_timezone())
146 day_end, dtime()), timezone.get_current_timezone())
147
147
148 posts_per_period = float(self.filter(
148 posts_per_period = float(self.filter(
149 pub_time__lte=day_time_end,
149 pub_time__lte=day_time_end,
150 pub_time__gte=day_time_start).count())
150 pub_time__gte=day_time_start).count())
151
151
152 ppd = posts_per_period / POSTS_PER_DAY_RANGE
152 ppd = posts_per_period / POSTS_PER_DAY_RANGE
153
153
154 return ppd
154 return ppd
155
155
156
156
157 class Post(models.Model, Viewable):
157 class Post(models.Model, Viewable):
158 """A post is a message."""
158 """A post is a message."""
159
159
160 objects = PostManager()
160 objects = PostManager()
161
161
162 class Meta:
162 class Meta:
163 app_label = APP_LABEL_BOARDS
163 app_label = APP_LABEL_BOARDS
164 ordering = ('id',)
164 ordering = ('id',)
165
165
166 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
166 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
167 pub_time = models.DateTimeField()
167 pub_time = models.DateTimeField()
168 text = TextField(blank=True, null=True)
168 text = TextField(blank=True, null=True)
169 _text_rendered = TextField(blank=True, null=True, editable=False)
169 _text_rendered = TextField(blank=True, null=True, editable=False)
170
170
171 images = models.ManyToManyField(PostImage, null=True, blank=True,
171 images = models.ManyToManyField(PostImage, null=True, blank=True,
172 related_name='ip+', db_index=True)
172 related_name='ip+', db_index=True)
173
173
174 poster_ip = models.GenericIPAddressField()
174 poster_ip = models.GenericIPAddressField()
175
175
176 # TODO This field can be removed cause UID is used for update now
176 # TODO This field can be removed cause UID is used for update now
177 last_edit_time = models.DateTimeField()
177 last_edit_time = models.DateTimeField()
178
178
179 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
179 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
180 null=True,
180 null=True,
181 blank=True, related_name='refposts',
181 blank=True, related_name='refposts',
182 db_index=True)
182 db_index=True)
183 refmap = models.TextField(null=True, blank=True)
183 refmap = models.TextField(null=True, blank=True)
184 threads = models.ManyToManyField('Thread', db_index=True)
184 threads = models.ManyToManyField('Thread', db_index=True)
185 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
185 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
186
186
187 url = models.TextField()
187 url = models.TextField()
188 uid = models.TextField(db_index=True)
188 uid = models.TextField(db_index=True)
189
189
190 def __str__(self):
190 def __str__(self):
191 return 'P#{}/{}'.format(self.id, self.title)
191 return 'P#{}/{}'.format(self.id, self.title)
192
192
193 def get_referenced_posts(self):
193 def get_referenced_posts(self):
194 threads = self.get_threads().all()
194 threads = self.get_threads().all()
195 return self.referenced_posts.filter(threads__in=threads)\
195 return self.referenced_posts.filter(threads__in=threads)\
196 .order_by('pub_time').distinct().all()
196 .order_by('pub_time').distinct().all()
197
197
198 def get_title(self) -> str:
198 def get_title(self) -> str:
199 """
199 """
200 Gets original post title or part of its text.
200 Gets original post title or part of its text.
201 """
201 """
202
202
203 title = self.title
203 title = self.title
204 if not title:
204 if not title:
205 title = self.get_text()
205 title = self.get_text()
206
206
207 return title
207 return title
208
208
209 def build_refmap(self) -> None:
209 def build_refmap(self) -> None:
210 """
210 """
211 Builds a replies map string from replies list. This is a cache to stop
211 Builds a replies map string from replies list. This is a cache to stop
212 the server from recalculating the map on every post show.
212 the server from recalculating the map on every post show.
213 """
213 """
214
214
215 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
215 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
216 for refpost in self.referenced_posts.all()]
216 for refpost in self.referenced_posts.all()]
217
217
218 self.refmap = ', '.join(post_urls)
218 self.refmap = ', '.join(post_urls)
219
219
220 def is_referenced(self) -> bool:
220 def is_referenced(self) -> bool:
221 return self.refmap and len(self.refmap) > 0
221 return self.refmap and len(self.refmap) > 0
222
222
223 def is_opening(self) -> bool:
223 def is_opening(self) -> bool:
224 """
224 """
225 Checks if this is an opening post or just a reply.
225 Checks if this is an opening post or just a reply.
226 """
226 """
227
227
228 return self.get_thread().get_opening_post_id() == self.id
228 return self.get_thread().get_opening_post_id() == self.id
229
229
230 def get_absolute_url(self):
230 def get_absolute_url(self):
231 if self.url:
231 if self.url:
232 return self.url
232 return self.url
233 else:
233 else:
234 opening_id = self.get_thread().get_opening_post_id()
234 opening_id = self.get_thread().get_opening_post_id()
235 post_url = reverse('thread', kwargs={'post_id': opening_id})
235 post_url = reverse('thread', kwargs={'post_id': opening_id})
236 if self.id != opening_id:
236 if self.id != opening_id:
237 post_url += '#' + str(self.id)
237 post_url += '#' + str(self.id)
238 return post_url
238 return post_url
239
239
240
240
241 def get_thread(self):
241 def get_thread(self):
242 return self.thread
242 return self.thread
243
243
244 def get_threads(self) -> list:
244 def get_threads(self) -> QuerySet:
245 """
245 """
246 Gets post's thread.
246 Gets post's thread.
247 """
247 """
248
248
249 return self.threads
249 return self.threads
250
250
251 def get_view(self, *args, **kwargs) -> str:
251 def get_view(self, *args, **kwargs) -> str:
252 """
252 """
253 Renders post's HTML view. Some of the post params can be passed over
253 Renders post's HTML view. Some of the post params can be passed over
254 kwargs for the means of caching (if we view the thread, some params
254 kwargs for the means of caching (if we view the thread, some params
255 are same for every post and don't need to be computed over and over.
255 are same for every post and don't need to be computed over and over.
256 """
256 """
257
257
258 thread = self.get_thread()
258 thread = self.get_thread()
259 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
259 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
260
260
261 if is_opening:
261 if is_opening:
262 opening_post_id = self.id
262 opening_post_id = self.id
263 else:
263 else:
264 opening_post_id = thread.get_opening_post_id()
264 opening_post_id = thread.get_opening_post_id()
265
265
266 css_class = 'post'
266 css_class = 'post'
267 if thread.archived:
267 if thread.archived:
268 css_class += ' archive_post'
268 css_class += ' archive_post'
269 elif not thread.can_bump():
269 elif not thread.can_bump():
270 css_class += ' dead_post'
270 css_class += ' dead_post'
271
271
272 params = dict()
272 params = dict()
273 for param in POST_VIEW_PARAMS:
273 for param in POST_VIEW_PARAMS:
274 if param in kwargs:
274 if param in kwargs:
275 params[param] = kwargs[param]
275 params[param] = kwargs[param]
276
276
277 params.update({
277 params.update({
278 PARAMETER_POST: self,
278 PARAMETER_POST: self,
279 PARAMETER_IS_OPENING: is_opening,
279 PARAMETER_IS_OPENING: is_opening,
280 PARAMETER_THREAD: thread,
280 PARAMETER_THREAD: thread,
281 PARAMETER_CSS_CLASS: css_class,
281 PARAMETER_CSS_CLASS: css_class,
282 PARAMETER_OP_ID: opening_post_id,
282 PARAMETER_OP_ID: opening_post_id,
283 })
283 })
284
284
285 return render_to_string('boards/post.html', params)
285 return render_to_string('boards/post.html', params)
286
286
287 def get_search_view(self, *args, **kwargs):
287 def get_search_view(self, *args, **kwargs):
288 return self.get_view(need_op_data=True, *args, **kwargs)
288 return self.get_view(need_op_data=True, *args, **kwargs)
289
289
290 def get_first_image(self) -> PostImage:
290 def get_first_image(self) -> PostImage:
291 return self.images.earliest('id')
291 return self.images.earliest('id')
292
292
293 def delete(self, using=None):
293 def delete(self, using=None):
294 """
294 """
295 Deletes all post images and the post itself.
295 Deletes all post images and the post itself.
296 """
296 """
297
297
298 for image in self.images.all():
298 for image in self.images.all():
299 image_refs_count = Post.objects.filter(images__in=[image]).count()
299 image_refs_count = Post.objects.filter(images__in=[image]).count()
300 if image_refs_count == 1:
300 if image_refs_count == 1:
301 image.delete()
301 image.delete()
302
302
303 thread = self.get_thread()
303 thread = self.get_thread()
304 thread.last_edit_time = timezone.now()
304 thread.last_edit_time = timezone.now()
305 thread.save()
305 thread.save()
306
306
307 super(Post, self).delete(using)
307 super(Post, self).delete(using)
308
308
309 logging.getLogger('boards.post.delete').info(
309 logging.getLogger('boards.post.delete').info(
310 'Deleted post {}'.format(self))
310 'Deleted post {}'.format(self))
311
311
312 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
312 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
313 include_last_update=False) -> str:
313 include_last_update=False) -> str:
314 """
314 """
315 Gets post HTML or JSON data that can be rendered on a page or used by
315 Gets post HTML or JSON data that can be rendered on a page or used by
316 API.
316 API.
317 """
317 """
318
318
319 return get_exporter(format_type).export(self, request,
319 return get_exporter(format_type).export(self, request,
320 include_last_update)
320 include_last_update)
321
321
322 def notify_clients(self, recursive=True):
322 def notify_clients(self, recursive=True):
323 """
323 """
324 Sends post HTML data to the thread web socket.
324 Sends post HTML data to the thread web socket.
325 """
325 """
326
326
327 if not settings.get_bool('External', 'WebsocketsEnabled'):
327 if not settings.get_bool('External', 'WebsocketsEnabled'):
328 return
328 return
329
329
330 thread_ids = list()
330 thread_ids = list()
331 for thread in self.get_threads().all():
331 for thread in self.get_threads().all():
332 thread_ids.append(thread.id)
332 thread_ids.append(thread.id)
333
333
334 thread.notify_clients()
334 thread.notify_clients()
335
335
336 if recursive:
336 if recursive:
337 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
337 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
338 post_id = reply_number.group(1)
338 post_id = reply_number.group(1)
339
339
340 try:
340 try:
341 ref_post = Post.objects.get(id=post_id)
341 ref_post = Post.objects.get(id=post_id)
342
342
343 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
343 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
344 # If post is in this thread, its thread was already notified.
344 # If post is in this thread, its thread was already notified.
345 # Otherwise, notify its thread separately.
345 # Otherwise, notify its thread separately.
346 ref_post.notify_clients(recursive=False)
346 ref_post.notify_clients(recursive=False)
347 except ObjectDoesNotExist:
347 except ObjectDoesNotExist:
348 pass
348 pass
349
349
350 def build_url(self):
350 def build_url(self):
351 self.url = self.get_absolute_url()
351 self.url = self.get_absolute_url()
352 self.save(update_fields=['url'])
352 self.save(update_fields=['url'])
353
353
354 def save(self, force_insert=False, force_update=False, using=None,
354 def save(self, force_insert=False, force_update=False, using=None,
355 update_fields=None):
355 update_fields=None):
356 self._text_rendered = Parser().parse(self.get_raw_text())
356 self._text_rendered = Parser().parse(self.get_raw_text())
357
357
358 self.uid = str(uuid.uuid4())
358 self.uid = str(uuid.uuid4())
359 if update_fields is not None and 'uid' not in update_fields:
359 if update_fields is not None and 'uid' not in update_fields:
360 update_fields += ['uid']
360 update_fields += ['uid']
361
361
362 if self.id:
362 if self.id:
363 for thread in self.get_threads().all():
363 for thread in self.get_threads().all():
364 if thread.can_bump():
365 thread.update_bump_status(exclude_posts=[self])
366 thread.last_edit_time = self.last_edit_time
364 thread.last_edit_time = self.last_edit_time
367
365
368 thread.save(update_fields=['last_edit_time', 'bumpable'])
366 thread.save(update_fields=['last_edit_time', 'bumpable'])
369
367
370 super().save(force_insert, force_update, using, update_fields)
368 super().save(force_insert, force_update, using, update_fields)
371
369
372 def get_text(self) -> str:
370 def get_text(self) -> str:
373 return self._text_rendered
371 return self._text_rendered
374
372
375 def get_raw_text(self) -> str:
373 def get_raw_text(self) -> str:
376 return self.text
374 return self.text
377
375
378 def get_absolute_id(self) -> str:
376 def get_absolute_id(self) -> str:
379 """
377 """
380 If the post has many threads, shows its main thread OP id in the post
378 If the post has many threads, shows its main thread OP id in the post
381 ID.
379 ID.
382 """
380 """
383
381
384 if self.get_threads().count() > 1:
382 if self.get_threads().count() > 1:
385 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
383 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
386 else:
384 else:
387 return str(self.id)
385 return str(self.id)
388
386
389 def connect_notifications(self):
387 def connect_notifications(self):
390 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
388 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
391 user_name = reply_number.group(1).lower()
389 user_name = reply_number.group(1).lower()
392 Notification.objects.get_or_create(name=user_name, post=self)
390 Notification.objects.get_or_create(name=user_name, post=self)
393
391
394 def connect_replies(self):
392 def connect_replies(self):
395 """
393 """
396 Connects replies to a post to show them as a reflink map
394 Connects replies to a post to show them as a reflink map
397 """
395 """
398
396
399 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
397 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
400 post_id = reply_number.group(1)
398 post_id = reply_number.group(1)
401
399
402 try:
400 try:
403 referenced_post = Post.objects.get(id=post_id)
401 referenced_post = Post.objects.get(id=post_id)
404
402
405 referenced_post.referenced_posts.add(self)
403 referenced_post.referenced_posts.add(self)
406 referenced_post.last_edit_time = self.pub_time
404 referenced_post.last_edit_time = self.pub_time
407 referenced_post.build_refmap()
405 referenced_post.build_refmap()
408 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
406 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
409 except ObjectDoesNotExist:
407 except ObjectDoesNotExist:
410 pass
408 pass
411
409
412 def connect_threads(self, opening_posts):
410 def connect_threads(self, opening_posts):
413 for opening_post in opening_posts:
411 for opening_post in opening_posts:
414 threads = opening_post.get_threads().all()
412 threads = opening_post.get_threads().all()
415 for thread in threads:
413 for thread in threads:
416 if thread.can_bump():
414 if thread.can_bump():
417 thread.update_bump_status()
415 thread.update_bump_status()
418
416
419 thread.last_edit_time = self.last_edit_time
417 thread.last_edit_time = self.last_edit_time
420 thread.save(update_fields=['last_edit_time', 'bumpable'])
418 thread.save(update_fields=['last_edit_time', 'bumpable'])
421 self.threads.add(opening_post.get_thread())
419 self.threads.add(opening_post.get_thread())
@@ -1,235 +1,237 b''
1 import logging
1 import logging
2 from adjacent import Client
2 from adjacent import Client
3
3
4 from django.db.models import Count, Sum, QuerySet
4 from django.db.models import Count, Sum, QuerySet
5 from django.utils import timezone
5 from django.utils import timezone
6 from django.db import models
6 from django.db import models
7
7
8 from boards import settings
8 from boards import settings
9 import boards
9 import boards
10 from boards.utils import cached_result, datetime_to_epoch
10 from boards.utils import cached_result, datetime_to_epoch
11 from boards.models.post import Post
11 from boards.models.post import Post
12 from boards.models.tag import Tag
12 from boards.models.tag import Tag
13
13
14
14
15 __author__ = 'neko259'
15 __author__ = 'neko259'
16
16
17
17
18 logger = logging.getLogger(__name__)
18 logger = logging.getLogger(__name__)
19
19
20
20
21 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
21 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
22 WS_NOTIFICATION_TYPE = 'notification_type'
22 WS_NOTIFICATION_TYPE = 'notification_type'
23
23
24 WS_CHANNEL_THREAD = "thread:"
24 WS_CHANNEL_THREAD = "thread:"
25
25
26
26
27 class ThreadManager(models.Manager):
27 class ThreadManager(models.Manager):
28 def process_oldest_threads(self):
28 def process_oldest_threads(self):
29 """
29 """
30 Preserves maximum thread count. If there are too many threads,
30 Preserves maximum thread count. If there are too many threads,
31 archive or delete the old ones.
31 archive or delete the old ones.
32 """
32 """
33
33
34 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
34 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
35 thread_count = threads.count()
35 thread_count = threads.count()
36
36
37 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
37 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
38 if thread_count > max_thread_count:
38 if thread_count > max_thread_count:
39 num_threads_to_delete = thread_count - max_thread_count
39 num_threads_to_delete = thread_count - max_thread_count
40 old_threads = threads[thread_count - num_threads_to_delete:]
40 old_threads = threads[thread_count - num_threads_to_delete:]
41
41
42 for thread in old_threads:
42 for thread in old_threads:
43 if settings.get_bool('Storage', 'ArchiveThreads'):
43 if settings.get_bool('Storage', 'ArchiveThreads'):
44 self._archive_thread(thread)
44 self._archive_thread(thread)
45 else:
45 else:
46 thread.delete()
46 thread.delete()
47
47
48 logger.info('Processed %d old threads' % num_threads_to_delete)
48 logger.info('Processed %d old threads' % num_threads_to_delete)
49
49
50 def _archive_thread(self, thread):
50 def _archive_thread(self, thread):
51 thread.archived = True
51 thread.archived = True
52 thread.bumpable = False
52 thread.bumpable = False
53 thread.last_edit_time = timezone.now()
53 thread.last_edit_time = timezone.now()
54 thread.update_posts_time()
54 thread.update_posts_time()
55 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
55 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
56
56
57
57
58 def get_thread_max_posts():
58 def get_thread_max_posts():
59 return settings.get_int('Messages', 'MaxPostsPerThread')
59 return settings.get_int('Messages', 'MaxPostsPerThread')
60
60
61
61
62 class Thread(models.Model):
62 class Thread(models.Model):
63 objects = ThreadManager()
63 objects = ThreadManager()
64
64
65 class Meta:
65 class Meta:
66 app_label = 'boards'
66 app_label = 'boards'
67
67
68 tags = models.ManyToManyField('Tag')
68 tags = models.ManyToManyField('Tag')
69 bump_time = models.DateTimeField(db_index=True)
69 bump_time = models.DateTimeField(db_index=True)
70 last_edit_time = models.DateTimeField()
70 last_edit_time = models.DateTimeField()
71 archived = models.BooleanField(default=False)
71 archived = models.BooleanField(default=False)
72 bumpable = models.BooleanField(default=True)
72 bumpable = models.BooleanField(default=True)
73 max_posts = models.IntegerField(default=get_thread_max_posts)
73 max_posts = models.IntegerField(default=get_thread_max_posts)
74
74
75 def get_tags(self) -> QuerySet:
75 def get_tags(self) -> QuerySet:
76 """
76 """
77 Gets a sorted tag list.
77 Gets a sorted tag list.
78 """
78 """
79
79
80 return self.tags.order_by('name')
80 return self.tags.order_by('name')
81
81
82 def bump(self):
82 def bump(self):
83 """
83 """
84 Bumps (moves to up) thread if possible.
84 Bumps (moves to up) thread if possible.
85 """
85 """
86
86
87 if self.can_bump():
87 if self.can_bump():
88 self.bump_time = self.last_edit_time
88 self.bump_time = self.last_edit_time
89
89
90 self.update_bump_status()
90 self.update_bump_status()
91
91
92 logger.info('Bumped thread %d' % self.id)
92 logger.info('Bumped thread %d' % self.id)
93
93
94 def has_post_limit(self) -> bool:
94 def has_post_limit(self) -> bool:
95 return self.max_posts > 0
95 return self.max_posts > 0
96
96
97 def update_bump_status(self, exclude_posts=None):
97 def update_bump_status(self, exclude_posts=None):
98 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
98 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
99 self.bumpable = False
99 self.bumpable = False
100 self.update_posts_time(exclude_posts=exclude_posts)
100 self.update_posts_time(exclude_posts=exclude_posts)
101
101
102 def _get_cache_key(self):
102 def _get_cache_key(self):
103 return [datetime_to_epoch(self.last_edit_time)]
103 return [datetime_to_epoch(self.last_edit_time)]
104
104
105 @cached_result(key_method=_get_cache_key)
105 @cached_result(key_method=_get_cache_key)
106 def get_reply_count(self) -> int:
106 def get_reply_count(self) -> int:
107 return self.get_replies().count()
107 return self.get_replies().count()
108
108
109 @cached_result(key_method=_get_cache_key)
109 @cached_result(key_method=_get_cache_key)
110 def get_images_count(self) -> int:
110 def get_images_count(self) -> int:
111 return self.get_replies().annotate(images_count=Count(
111 return self.get_replies().annotate(images_count=Count(
112 'images')).aggregate(Sum('images_count'))['images_count__sum']
112 'images')).aggregate(Sum('images_count'))['images_count__sum']
113
113
114 def can_bump(self) -> bool:
114 def can_bump(self) -> bool:
115 """
115 """
116 Checks if the thread can be bumped by replying to it.
116 Checks if the thread can be bumped by replying to it.
117 """
117 """
118
118
119 return self.bumpable and not self.archived
119 return self.bumpable and not self.archived
120
120
121 def get_last_replies(self) -> QuerySet:
121 def get_last_replies(self) -> QuerySet:
122 """
122 """
123 Gets several last replies, not including opening post
123 Gets several last replies, not including opening post
124 """
124 """
125
125
126 last_replies_count = settings.get_int('View', 'LastRepliesCount')
126 last_replies_count = settings.get_int('View', 'LastRepliesCount')
127
127
128 if last_replies_count > 0:
128 if last_replies_count > 0:
129 reply_count = self.get_reply_count()
129 reply_count = self.get_reply_count()
130
130
131 if reply_count > 0:
131 if reply_count > 0:
132 reply_count_to_show = min(last_replies_count,
132 reply_count_to_show = min(last_replies_count,
133 reply_count - 1)
133 reply_count - 1)
134 replies = self.get_replies()
134 replies = self.get_replies()
135 last_replies = replies[reply_count - reply_count_to_show:]
135 last_replies = replies[reply_count - reply_count_to_show:]
136
136
137 return last_replies
137 return last_replies
138
138
139 def get_skipped_replies_count(self) -> int:
139 def get_skipped_replies_count(self) -> int:
140 """
140 """
141 Gets number of posts between opening post and last replies.
141 Gets number of posts between opening post and last replies.
142 """
142 """
143 reply_count = self.get_reply_count()
143 reply_count = self.get_reply_count()
144 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
144 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
145 reply_count - 1)
145 reply_count - 1)
146 return reply_count - last_replies_count - 1
146 return reply_count - last_replies_count - 1
147
147
148 def get_replies(self, view_fields_only=False) -> QuerySet:
148 def get_replies(self, view_fields_only=False) -> QuerySet:
149 """
149 """
150 Gets sorted thread posts
150 Gets sorted thread posts
151 """
151 """
152
152
153 query = Post.objects.filter(threads__in=[self])
153 query = Post.objects.filter(threads__in=[self])
154 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
154 query = query.order_by('pub_time').prefetch_related('images', 'thread', 'threads')
155 if view_fields_only:
155 if view_fields_only:
156 query = query.defer('poster_ip')
156 query = query.defer('poster_ip')
157 return query.all()
157 return query.all()
158
158
159 def get_top_level_replies(self) -> QuerySet:
159 def get_top_level_replies(self) -> QuerySet:
160 return self.get_replies().exclude(refposts__threads__in=[self])
160 return self.get_replies().exclude(refposts__threads__in=[self])
161
161
162 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
162 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
163 """
163 """
164 Gets replies that have at least one image attached
164 Gets replies that have at least one image attached
165 """
165 """
166
166
167 return self.get_replies(view_fields_only).annotate(images_count=Count(
167 return self.get_replies(view_fields_only).annotate(images_count=Count(
168 'images')).filter(images_count__gt=0)
168 'images')).filter(images_count__gt=0)
169
169
170 def get_opening_post(self, only_id=False) -> Post:
170 def get_opening_post(self, only_id=False) -> Post:
171 """
171 """
172 Gets the first post of the thread
172 Gets the first post of the thread
173 """
173 """
174
174
175 query = self.get_replies().order_by('pub_time')
175 query = self.get_replies().order_by('pub_time')
176 if only_id:
176 if only_id:
177 query = query.only('id')
177 query = query.only('id')
178 opening_post = query.first()
178 opening_post = query.first()
179
179
180 return opening_post
180 return opening_post
181
181
182 @cached_result()
182 @cached_result()
183 def get_opening_post_id(self) -> int:
183 def get_opening_post_id(self) -> int:
184 """
184 """
185 Gets ID of the first thread post.
185 Gets ID of the first thread post.
186 """
186 """
187
187
188 return self.get_opening_post(only_id=True).id
188 return self.get_opening_post(only_id=True).id
189
189
190 def get_pub_time(self):
190 def get_pub_time(self):
191 """
191 """
192 Gets opening post's pub time because thread does not have its own one.
192 Gets opening post's pub time because thread does not have its own one.
193 """
193 """
194
194
195 return self.get_opening_post().pub_time
195 return self.get_opening_post().pub_time
196
196
197 def delete(self, using=None):
197 def delete(self, using=None):
198 """
198 """
199 Deletes thread with all replies.
199 Deletes thread with all replies.
200 """
200 """
201
201
202 for reply in self.get_replies().all():
202 for reply in self.get_replies().all():
203 reply.delete()
203 reply.delete()
204
204
205 super(Thread, self).delete(using)
205 super(Thread, self).delete(using)
206
206
207 def __str__(self):
207 def __str__(self):
208 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
208 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
209
209
210 def get_tag_url_list(self) -> list:
210 def get_tag_url_list(self) -> list:
211 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
211 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
212
212
213 def update_posts_time(self, exclude_posts=None):
213 def update_posts_time(self, exclude_posts=None):
214 last_edit_time = self.last_edit_time
215
214 for post in self.post_set.all():
216 for post in self.post_set.all():
215 if exclude_posts is None or post not in exclude_posts:
217 if exclude_posts is None or post not in exclude_posts:
216 # Manual update is required because uids are generated on save
218 # Manual update is required because uids are generated on save
217 post.last_edit_time = self.last_edit_time
219 post.last_edit_time = last_edit_time
218 post.save(update_fields=['last_edit_time'])
220 post.save(update_fields=['last_edit_time'])
219
221
220 post.threads.update(last_edit_time=self.last_edit_time)
222 post.get_threads().update(last_edit_time=last_edit_time)
221
223
222 def notify_clients(self):
224 def notify_clients(self):
223 if not settings.get_bool('External', 'WebsocketsEnabled'):
225 if not settings.get_bool('External', 'WebsocketsEnabled'):
224 return
226 return
225
227
226 client = Client()
228 client = Client()
227
229
228 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
230 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
229 client.publish(channel_name, {
231 client.publish(channel_name, {
230 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
232 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
231 })
233 })
232 client.send()
234 client.send()
233
235
234 def get_absolute_url(self):
236 def get_absolute_url(self):
235 return self.get_opening_post().get_absolute_url()
237 return self.get_opening_post().get_absolute_url()
@@ -1,388 +1,391 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013-2014 neko259
6 Copyright (C) 2013-2014 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var CLASS_POST = '.post'
26 var CLASS_POST = '.post'
27
27
28 var POST_ADDED = 0;
28 var POST_ADDED = 0;
29 var POST_UPDATED = 1;
29 var POST_UPDATED = 1;
30
30
31 var JS_AUTOUPDATE_PERIOD = 20000;
31 var JS_AUTOUPDATE_PERIOD = 20000;
32
32
33 var wsUser = '';
33 var wsUser = '';
34
34
35 var unreadPosts = 0;
35 var unreadPosts = 0;
36 var documentOriginalTitle = '';
36 var documentOriginalTitle = '';
37
37
38 // Thread ID does not change, can be stored one time
38 // Thread ID does not change, can be stored one time
39 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
39 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
40
40
41 /**
41 /**
42 * Connect to websocket server and subscribe to thread updates. On any update we
42 * Connect to websocket server and subscribe to thread updates. On any update we
43 * request a thread diff.
43 * request a thread diff.
44 *
44 *
45 * @returns {boolean} true if connected, false otherwise
45 * @returns {boolean} true if connected, false otherwise
46 */
46 */
47 function connectWebsocket() {
47 function connectWebsocket() {
48 var metapanel = $('.metapanel')[0];
48 var metapanel = $('.metapanel')[0];
49
49
50 var wsHost = metapanel.getAttribute('data-ws-host');
50 var wsHost = metapanel.getAttribute('data-ws-host');
51 var wsPort = metapanel.getAttribute('data-ws-port');
51 var wsPort = metapanel.getAttribute('data-ws-port');
52
52
53 if (wsHost.length > 0 && wsPort.length > 0) {
53 if (wsHost.length > 0 && wsPort.length > 0) {
54 var centrifuge = new Centrifuge({
54 var centrifuge = new Centrifuge({
55 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
55 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
56 "project": metapanel.getAttribute('data-ws-project'),
56 "project": metapanel.getAttribute('data-ws-project'),
57 "user": wsUser,
57 "user": wsUser,
58 "timestamp": metapanel.getAttribute('data-ws-token-time'),
58 "timestamp": metapanel.getAttribute('data-ws-token-time'),
59 "token": metapanel.getAttribute('data-ws-token'),
59 "token": metapanel.getAttribute('data-ws-token'),
60 "debug": false
60 "debug": false
61 });
61 });
62
62
63 centrifuge.on('error', function(error_message) {
63 centrifuge.on('error', function(error_message) {
64 console.log("Error connecting to websocket server.");
64 console.log("Error connecting to websocket server.");
65 console.log(error_message);
65 console.log(error_message);
66 console.log("Using javascript update instead.");
66 console.log("Using javascript update instead.");
67
67
68 // If websockets don't work, enable JS update instead
68 // If websockets don't work, enable JS update instead
69 enableJsUpdate()
69 enableJsUpdate()
70 });
70 });
71
71
72 centrifuge.on('connect', function() {
72 centrifuge.on('connect', function() {
73 var channelName = 'thread:' + threadId;
73 var channelName = 'thread:' + threadId;
74 centrifuge.subscribe(channelName, function(message) {
74 centrifuge.subscribe(channelName, function(message) {
75 getThreadDiff();
75 getThreadDiff();
76 });
76 });
77
77
78 // For the case we closed the browser and missed some updates
78 // For the case we closed the browser and missed some updates
79 getThreadDiff();
79 getThreadDiff();
80 $('#autoupdate').hide();
80 $('#autoupdate').hide();
81 });
81 });
82
82
83 centrifuge.connect();
83 centrifuge.connect();
84
84
85 return true;
85 return true;
86 } else {
86 } else {
87 return false;
87 return false;
88 }
88 }
89 }
89 }
90
90
91 /**
91 /**
92 * Get diff of the posts from the current thread timestamp.
92 * Get diff of the posts from the current thread timestamp.
93 * This is required if the browser was closed and some post updates were
93 * This is required if the browser was closed and some post updates were
94 * missed.
94 * missed.
95 */
95 */
96 function getThreadDiff() {
96 function getThreadDiff() {
97 var lastUpdateTime = $('.metapanel').attr('data-last-update');
97 var lastUpdateTime = $('.metapanel').attr('data-last-update');
98 var lastPostId = $('.post').last().attr('id');
98 var lastPostId = $('.post').last().attr('id');
99
99
100 var uids = '';
100 var uids = '';
101 var posts = $('.post');
101 var posts = $('.post');
102 for (var i = 0; i < posts.length; i++) {
102 for (var i = 0; i < posts.length; i++) {
103 uids += posts[i].getAttribute('data-uid') + ' ';
103 uids += posts[i].getAttribute('data-uid') + ' ';
104 }
104 }
105
105
106 var data = {
106 var data = {
107 uids: uids,
107 uids: uids,
108 thread: threadId
108 thread: threadId
109 }
109 }
110
110
111 var diffUrl = '/api/diff_thread/';
111 var diffUrl = '/api/diff_thread/';
112
112
113 $.post(diffUrl,
113 $.post(diffUrl,
114 data,
114 data,
115 function(data) {
115 function(data) {
116 var updatedPosts = data.updated;
116 var updatedPosts = data.updated;
117 var addedPostCount = 0;
117 var addedPostCount = 0;
118
118
119 for (var i = 0; i < updatedPosts.length; i++) {
119 for (var i = 0; i < updatedPosts.length; i++) {
120 var postText = updatedPosts[i];
120 var postText = updatedPosts[i];
121 var post = $(postText);
121 var post = $(postText);
122
122
123 if (updatePost(post) == POST_ADDED) {
123 if (updatePost(post) == POST_ADDED) {
124 addedPostCount++;
124 addedPostCount++;
125 }
125 }
126 }
126 }
127
127
128 var hasMetaUpdates = updatedPosts.length > 0;
128 var hasMetaUpdates = updatedPosts.length > 0;
129 if (hasMetaUpdates) {
129 if (hasMetaUpdates) {
130 updateMetadataPanel();
130 updateMetadataPanel();
131 }
131 }
132
132
133 if (addedPostCount > 0) {
133 if (addedPostCount > 0) {
134 updateBumplimitProgress(addedPostCount);
134 updateBumplimitProgress(addedPostCount);
135 }
135 }
136
136
137 if (updatedPosts.length > 0) {
137 if (updatedPosts.length > 0) {
138 showNewPostsTitle(addedPostCount);
138 showNewPostsTitle(addedPostCount);
139 }
139 }
140
140
141 // TODO Process removed posts if any
141 // TODO Process removed posts if any
142 $('.metapanel').attr('data-last-update', data.last_update);
142 $('.metapanel').attr('data-last-update', data.last_update);
143 },
143 },
144 'json'
144 'json'
145 )
145 )
146 }
146 }
147
147
148 /**
148 /**
149 * Add or update the post on html page.
149 * Add or update the post on html page.
150 */
150 */
151 function updatePost(postHtml) {
151 function updatePost(postHtml) {
152 // This needs to be set on start because the page is scrolled after posts
152 // This needs to be set on start because the page is scrolled after posts
153 // are added or updated
153 // are added or updated
154 var bottom = isPageBottom();
154 var bottom = isPageBottom();
155
155
156 var post = $(postHtml);
156 var post = $(postHtml);
157
157
158 var threadBlock = $('div.thread');
158 var threadBlock = $('div.thread');
159
159
160 var postId = post.attr('id');
160 var postId = post.attr('id');
161
161
162 // If the post already exists, replace it. Otherwise add as a new one.
162 // If the post already exists, replace it. Otherwise add as a new one.
163 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
163 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
164
164
165 var type;
165 var type;
166
166
167 if (existingPosts.size() > 0) {
167 if (existingPosts.size() > 0) {
168 existingPosts.replaceWith(post);
168 existingPosts.replaceWith(post);
169
169
170 type = POST_UPDATED;
170 type = POST_UPDATED;
171 } else {
171 } else {
172 post.appendTo(threadBlock);
172 post.appendTo(threadBlock);
173
173
174 if (bottom) {
174 if (bottom) {
175 scrollToBottom();
175 scrollToBottom();
176 }
176 }
177
177
178 type = POST_ADDED;
178 type = POST_ADDED;
179 }
179 }
180
180
181 processNewPost(post);
181 processNewPost(post);
182
182
183 return type;
183 return type;
184 }
184 }
185
185
186 /**
186 /**
187 * Initiate a blinking animation on a node to show it was updated.
187 * Initiate a blinking animation on a node to show it was updated.
188 */
188 */
189 function blink(node) {
189 function blink(node) {
190 var blinkCount = 2;
190 var blinkCount = 2;
191
191
192 var nodeToAnimate = node;
192 var nodeToAnimate = node;
193 for (var i = 0; i < blinkCount; i++) {
193 for (var i = 0; i < blinkCount; i++) {
194 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
194 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
195 }
195 }
196 }
196 }
197
197
198 function isPageBottom() {
198 function isPageBottom() {
199 var scroll = $(window).scrollTop() / ($(document).height()
199 var scroll = $(window).scrollTop() / ($(document).height()
200 - $(window).height());
200 - $(window).height());
201
201
202 return scroll == 1
202 return scroll == 1
203 }
203 }
204
204
205 function enableJsUpdate() {
205 function enableJsUpdate() {
206 setInterval(getThreadDiff, JS_AUTOUPDATE_PERIOD);
206 setInterval(getThreadDiff, JS_AUTOUPDATE_PERIOD);
207 return true;
207 return true;
208 }
208 }
209
209
210 function initAutoupdate() {
210 function initAutoupdate() {
211 if (location.protocol === 'https:') {
211 if (location.protocol === 'https:') {
212 return enableJsUpdate();
212 return enableJsUpdate();
213 } else {
213 } else {
214 if (connectWebsocket()) {
214 if (connectWebsocket()) {
215 return true;
215 return true;
216 } else {
216 } else {
217 return enableJsUpdate();
217 return enableJsUpdate();
218 }
218 }
219 }
219 }
220 }
220 }
221
221
222 function getReplyCount() {
222 function getReplyCount() {
223 return $('.thread').children(CLASS_POST).length
223 return $('.thread').children(CLASS_POST).length
224 }
224 }
225
225
226 function getImageCount() {
226 function getImageCount() {
227 return $('.thread').find('img').length
227 return $('.thread').find('img').length
228 }
228 }
229
229
230 /**
230 /**
231 * Update post count, images count and last update time in the metadata
231 * Update post count, images count and last update time in the metadata
232 * panel.
232 * panel.
233 */
233 */
234 function updateMetadataPanel() {
234 function updateMetadataPanel() {
235 var replyCountField = $('#reply-count');
235 var replyCountField = $('#reply-count');
236 var imageCountField = $('#image-count');
236 var imageCountField = $('#image-count');
237
237
238 replyCountField.text(getReplyCount());
238 replyCountField.text(getReplyCount());
239 imageCountField.text(getImageCount());
239 imageCountField.text(getImageCount());
240
240
241 var lastUpdate = $('.post:last').children('.post-info').first()
241 var lastUpdate = $('.post:last').children('.post-info').first()
242 .children('.pub_time').first().html();
242 .children('.pub_time').first().html();
243 if (lastUpdate !== '') {
243 if (lastUpdate !== '') {
244 var lastUpdateField = $('#last-update');
244 var lastUpdateField = $('#last-update');
245 lastUpdateField.html(lastUpdate);
245 lastUpdateField.html(lastUpdate);
246 blink(lastUpdateField);
246 blink(lastUpdateField);
247 }
247 }
248
248
249 blink(replyCountField);
249 blink(replyCountField);
250 blink(imageCountField);
250 blink(imageCountField);
251 }
251 }
252
252
253 /**
253 /**
254 * Update bumplimit progress bar
254 * Update bumplimit progress bar
255 */
255 */
256 function updateBumplimitProgress(postDelta) {
256 function updateBumplimitProgress(postDelta) {
257 var progressBar = $('#bumplimit_progress');
257 var progressBar = $('#bumplimit_progress');
258 if (progressBar) {
258 if (progressBar) {
259 var postsToLimitElement = $('#left_to_limit');
259 var postsToLimitElement = $('#left_to_limit');
260
260
261 var oldPostsToLimit = parseInt(postsToLimitElement.text());
261 var oldPostsToLimit = parseInt(postsToLimitElement.text());
262 var postCount = getReplyCount();
262 var postCount = getReplyCount();
263 var bumplimit = postCount - postDelta + oldPostsToLimit;
263 var bumplimit = postCount - postDelta + oldPostsToLimit;
264
264
265 var newPostsToLimit = bumplimit - postCount;
265 var newPostsToLimit = bumplimit - postCount;
266 if (newPostsToLimit <= 0) {
266 if (newPostsToLimit <= 0) {
267 $('.bar-bg').remove();
267 $('.bar-bg').remove();
268 } else {
268 } else {
269 postsToLimitElement.text(newPostsToLimit);
269 postsToLimitElement.text(newPostsToLimit);
270 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
270 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
271 }
271 }
272 }
272 }
273 }
273 }
274
274
275 /**
275 /**
276 * Show 'new posts' text in the title if the document is not visible to a user
276 * Show 'new posts' text in the title if the document is not visible to a user
277 */
277 */
278 function showNewPostsTitle(newPostCount) {
278 function showNewPostsTitle(newPostCount) {
279 if (document.hidden) {
279 if (document.hidden) {
280 if (documentOriginalTitle === '') {
280 if (documentOriginalTitle === '') {
281 documentOriginalTitle = document.title;
281 documentOriginalTitle = document.title;
282 }
282 }
283 unreadPosts = unreadPosts + newPostCount;
283 unreadPosts = unreadPosts + newPostCount;
284
284
285 var newTitle = '* ';
285 var newTitle = '* ';
286 if (unreadPosts > 0) {
286 if (unreadPosts > 0) {
287 newTitle += '[' + unreadPosts + '] ';
287 newTitle += '[' + unreadPosts + '] ';
288 }
288 }
289 newTitle += documentOriginalTitle;
289 newTitle += documentOriginalTitle;
290
290
291 document.title = newTitle;
291 document.title = newTitle;
292
292
293 document.addEventListener('visibilitychange', function() {
293 document.addEventListener('visibilitychange', function() {
294 if (documentOriginalTitle !== '') {
294 if (documentOriginalTitle !== '') {
295 document.title = documentOriginalTitle;
295 document.title = documentOriginalTitle;
296 documentOriginalTitle = '';
296 documentOriginalTitle = '';
297 unreadPosts = 0;
297 unreadPosts = 0;
298 }
298 }
299
299
300 document.removeEventListener('visibilitychange', null);
300 document.removeEventListener('visibilitychange', null);
301 });
301 });
302 }
302 }
303 }
303 }
304
304
305 /**
305 /**
306 * Clear all entered values in the form fields
306 * Clear all entered values in the form fields
307 */
307 */
308 function resetForm(form) {
308 function resetForm(form) {
309 form.find('input:text, input:password, input:file, select, textarea').val('');
309 form.find('input:text, input:password, input:file, select, textarea').val('');
310 form.find('input:radio, input:checkbox')
310 form.find('input:radio, input:checkbox')
311 .removeAttr('checked').removeAttr('selected');
311 .removeAttr('checked').removeAttr('selected');
312 $('.file_wrap').find('.file-thumb').remove();
312 $('.file_wrap').find('.file-thumb').remove();
313 $('#preview-text').hide();
313 $('#preview-text').hide();
314 }
314 }
315
315
316 /**
316 /**
317 * When the form is posted, this method will be run as a callback
317 * When the form is posted, this method will be run as a callback
318 */
318 */
319 function updateOnPost(response, statusText, xhr, form) {
319 function updateOnPost(response, statusText, xhr, form) {
320 var json = $.parseJSON(response);
320 var json = $.parseJSON(response);
321 var status = json.status;
321 var status = json.status;
322
322
323 showAsErrors(form, '');
323 showAsErrors(form, '');
324
324
325 if (status === 'ok') {
325 if (status === 'ok') {
326 resetFormPosition();
326 resetFormPosition();
327 resetForm(form);
327 resetForm(form);
328 getThreadDiff();
328 getThreadDiff();
329 scrollToBottom();
329 scrollToBottom();
330 } else {
330 } else {
331 var errors = json.errors;
331 var errors = json.errors;
332 for (var i = 0; i < errors.length; i++) {
332 for (var i = 0; i < errors.length; i++) {
333 var fieldErrors = errors[i];
333 var fieldErrors = errors[i];
334
334
335 var error = fieldErrors.errors;
335 var error = fieldErrors.errors;
336
336
337 showAsErrors(form, error);
337 showAsErrors(form, error);
338 }
338 }
339 }
339 }
340 }
340 }
341
341
342 /**
342 /**
343 * Show text in the errors row of the form.
343 * Show text in the errors row of the form.
344 * @param form
344 * @param form
345 * @param text
345 * @param text
346 */
346 */
347 function showAsErrors(form, text) {
347 function showAsErrors(form, text) {
348 form.children('.form-errors').remove();
348 form.children('.form-errors').remove();
349
349
350 if (text.length > 0) {
350 if (text.length > 0) {
351 var errorList = $('<div class="form-errors">' + text + '<div>');
351 var errorList = $('<div class="form-errors">' + text + '<div>');
352 errorList.appendTo(form);
352 errorList.appendTo(form);
353 }
353 }
354 }
354 }
355
355
356 /**
356 /**
357 * Run js methods that are usually run on the document, on the new post
357 * Run js methods that are usually run on the document, on the new post
358 */
358 */
359 function processNewPost(post) {
359 function processNewPost(post) {
360 addRefLinkPreview(post[0]);
360 addRefLinkPreview(post[0]);
361 highlightCode(post);
361 highlightCode(post);
362 blink(post);
362 blink(post);
363 }
363 }
364
364
365 $(document).ready(function(){
365 $(document).ready(function(){
366 if (initAutoupdate()) {
366 if (initAutoupdate()) {
367 // Post form data over AJAX
367 // Post form data over AJAX
368 var threadId = $('div.thread').children('.post').first().attr('id');
368 var threadId = $('div.thread').children('.post').first().attr('id');
369
369
370 var form = $('#form');
370 var form = $('#form');
371
371
372 if (form.length > 0) {
372 if (form.length > 0) {
373 var options = {
373 var options = {
374 beforeSubmit: function(arr, $form, options) {
374 beforeSubmit: function(arr, $form, options) {
375 showAsErrors($('form'), gettext('Sending message...'));
375 showAsErrors($('form'), gettext('Sending message...'));
376 },
376 },
377 success: updateOnPost,
377 success: updateOnPost,
378 error: function() {
379 showAsErrors($('form'), gettext('Server error!'));
380 },
378 url: '/api/add_post/' + threadId + '/'
381 url: '/api/add_post/' + threadId + '/'
379 };
382 };
380
383
381 form.ajaxForm(options);
384 form.ajaxForm(options);
382
385
383 resetForm(form);
386 resetForm(form);
384 }
387 }
385 }
388 }
386
389
387 $('#autoupdate').click(getThreadDiff);
390 $('#autoupdate').click(getThreadDiff);
388 });
391 });
@@ -1,135 +1,178 b''
1 from django.core.paginator import Paginator
1 from django.core.paginator import Paginator
2 from django.test import TestCase
2 from django.test import TestCase
3
3 from boards import settings
4 from boards import settings
4 from boards.models import Tag, Post, Thread
5 from boards.models import Tag, Post, Thread
5
6
6
7
7 class PostTests(TestCase):
8 class PostTests(TestCase):
8
9
9 def _create_post(self):
10 def _create_post(self):
10 tag, created = Tag.objects.get_or_create(name='test_tag')
11 tag, created = Tag.objects.get_or_create(name='test_tag')
11 return Post.objects.create_post(title='title', text='text',
12 return Post.objects.create_post(title='title', text='text',
12 tags=[tag])
13 tags=[tag])
13
14
14 def test_post_add(self):
15 def test_post_add(self):
15 """Test adding post"""
16 """Test adding post"""
16
17
17 post = self._create_post()
18 post = self._create_post()
18
19
19 self.assertIsNotNone(post, 'No post was created.')
20 self.assertIsNotNone(post, 'No post was created.')
20 self.assertEqual('test_tag', post.get_thread().tags.all()[0].name,
21 self.assertEqual('test_tag', post.get_thread().tags.all()[0].name,
21 'No tags were added to the post.')
22 'No tags were added to the post.')
22
23
23 def test_delete_post(self):
24 def test_delete_post(self):
24 """Test post deletion"""
25 """Test post deletion"""
25
26
26 post = self._create_post()
27 post = self._create_post()
27 post_id = post.id
28 post_id = post.id
28
29
29 post.delete()
30 post.delete()
30
31
31 self.assertFalse(Post.objects.filter(id=post_id).exists())
32 self.assertFalse(Post.objects.filter(id=post_id).exists())
32
33
33 def test_delete_thread(self):
34 def test_delete_thread(self):
34 """Test thread deletion"""
35 """Test thread deletion"""
35
36
36 opening_post = self._create_post()
37 opening_post = self._create_post()
37 thread = opening_post.get_thread()
38 thread = opening_post.get_thread()
38 reply = Post.objects.create_post("", "", thread=thread)
39 reply = Post.objects.create_post("", "", thread=thread)
39
40
40 thread.delete()
41 thread.delete()
41
42
42 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
43 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
43 'Reply was not deleted with the thread.')
44 'Reply was not deleted with the thread.')
44 self.assertFalse(Post.objects.filter(id=opening_post.id).exists(),
45 self.assertFalse(Post.objects.filter(id=opening_post.id).exists(),
45 'Opening post was not deleted with the thread.')
46 'Opening post was not deleted with the thread.')
46
47
47 def test_post_to_thread(self):
48 def test_post_to_thread(self):
48 """Test adding post to a thread"""
49 """Test adding post to a thread"""
49
50
50 op = self._create_post()
51 op = self._create_post()
51 post = Post.objects.create_post("", "", thread=op.get_thread())
52 post = Post.objects.create_post("", "", thread=op.get_thread())
52
53
53 self.assertIsNotNone(post, 'Reply to thread wasn\'t created')
54 self.assertIsNotNone(post, 'Reply to thread wasn\'t created')
54 self.assertEqual(op.get_thread().last_edit_time, post.pub_time,
55 self.assertEqual(op.get_thread().last_edit_time, post.pub_time,
55 'Post\'s create time doesn\'t match thread last edit'
56 'Post\'s create time doesn\'t match thread last edit'
56 ' time')
57 ' time')
57
58
58 def test_delete_posts_by_ip(self):
59 def test_delete_posts_by_ip(self):
59 """Test deleting posts with the given ip"""
60 """Test deleting posts with the given ip"""
60
61
61 post = self._create_post()
62 post = self._create_post()
62 post_id = post.id
63 post_id = post.id
63
64
64 Post.objects.delete_posts_by_ip('0.0.0.0')
65 Post.objects.delete_posts_by_ip('0.0.0.0')
65
66
66 self.assertFalse(Post.objects.filter(id=post_id).exists())
67 self.assertFalse(Post.objects.filter(id=post_id).exists())
67
68
68 def test_get_thread(self):
69 def test_get_thread(self):
69 """Test getting all posts of a thread"""
70 """Test getting all posts of a thread"""
70
71
71 opening_post = self._create_post()
72 opening_post = self._create_post()
72
73
73 for i in range(2):
74 for i in range(2):
74 Post.objects.create_post('title', 'text',
75 Post.objects.create_post('title', 'text',
75 thread=opening_post.get_thread())
76 thread=opening_post.get_thread())
76
77
77 thread = opening_post.get_thread()
78 thread = opening_post.get_thread()
78
79
79 self.assertEqual(3, thread.get_replies().count())
80 self.assertEqual(3, thread.get_replies().count())
80
81
81 def test_create_post_with_tag(self):
82 def test_create_post_with_tag(self):
82 """Test adding tag to post"""
83 """Test adding tag to post"""
83
84
84 tag = Tag.objects.create(name='test_tag')
85 tag = Tag.objects.create(name='test_tag')
85 post = Post.objects.create_post(title='title', text='text', tags=[tag])
86 post = Post.objects.create_post(title='title', text='text', tags=[tag])
86
87
87 thread = post.get_thread()
88 thread = post.get_thread()
88 self.assertIsNotNone(post, 'Post not created')
89 self.assertIsNotNone(post, 'Post not created')
89 self.assertTrue(tag in thread.tags.all(), 'Tag not added to thread')
90 self.assertTrue(tag in thread.tags.all(), 'Tag not added to thread')
90
91
91 def test_thread_max_count(self):
92 def test_thread_max_count(self):
92 """Test deletion of old posts when the max thread count is reached"""
93 """Test deletion of old posts when the max thread count is reached"""
93
94
94 for i in range(settings.get_int('Messages', 'MaxThreadCount') + 1):
95 for i in range(settings.get_int('Messages', 'MaxThreadCount') + 1):
95 self._create_post()
96 self._create_post()
96
97
97 self.assertEqual(settings.get_int('Messages', 'MaxThreadCount'),
98 self.assertEqual(settings.get_int('Messages', 'MaxThreadCount'),
98 len(Thread.objects.filter(archived=False)))
99 len(Thread.objects.filter(archived=False)))
99
100
100 def test_pages(self):
101 def test_pages(self):
101 """Test that the thread list is properly split into pages"""
102 """Test that the thread list is properly split into pages"""
102
103
103 for i in range(settings.get_int('Messages', 'MaxThreadCount')):
104 for i in range(settings.get_int('Messages', 'MaxThreadCount')):
104 self._create_post()
105 self._create_post()
105
106
106 all_threads = Thread.objects.filter(archived=False)
107 all_threads = Thread.objects.filter(archived=False)
107
108
108 paginator = Paginator(Thread.objects.filter(archived=False),
109 paginator = Paginator(Thread.objects.filter(archived=False),
109 settings.get_int('View', 'ThreadsPerPage'))
110 settings.get_int('View', 'ThreadsPerPage'))
110 posts_in_second_page = paginator.page(2).object_list
111 posts_in_second_page = paginator.page(2).object_list
111 first_post = posts_in_second_page[0]
112 first_post = posts_in_second_page[0]
112
113
113 self.assertEqual(all_threads[settings.get_int('View', 'ThreadsPerPage')].id,
114 self.assertEqual(all_threads[settings.get_int('View', 'ThreadsPerPage')].id,
114 first_post.id)
115 first_post.id)
115
116
116 def test_thread_replies(self):
117 def test_thread_replies(self):
117 """
118 """
118 Tests that the replies can be queried from a thread in all possible
119 Tests that the replies can be queried from a thread in all possible
119 ways.
120 ways.
120 """
121 """
121
122
122 tag = Tag.objects.create(name='test_tag')
123 tag = Tag.objects.create(name='test_tag')
123 opening_post = Post.objects.create_post(title='title', text='text',
124 opening_post = Post.objects.create_post(title='title', text='text',
124 tags=[tag])
125 tags=[tag])
125 thread = opening_post.get_thread()
126 thread = opening_post.get_thread()
126
127
127 reply1 = Post.objects.create_post(title='title', text='text', thread=thread)
128 Post.objects.create_post(title='title', text='text', thread=thread)
128 reply2 = Post.objects.create_post(title='title', text='text', thread=thread)
129 Post.objects.create_post(title='title', text='text', thread=thread)
129
130
130 replies = thread.get_replies()
131 replies = thread.get_replies()
131 self.assertTrue(len(replies) > 0, 'No replies found for thread.')
132 self.assertTrue(len(replies) > 0, 'No replies found for thread.')
132
133
133 replies = thread.get_replies(view_fields_only=True)
134 replies = thread.get_replies(view_fields_only=True)
134 self.assertTrue(len(replies) > 0,
135 self.assertTrue(len(replies) > 0,
135 'No replies found for thread with view fields only.')
136 'No replies found for thread with view fields only.')
137
138 def test_bumplimit(self):
139 """
140 Tests that the thread bumpable status is changed and post uids and
141 last update times are updated across all post threads.
142 """
143
144 op1 = Post.objects.create_post(title='title', text='text')
145 op2 = Post.objects.create_post(title='title', text='text')
146
147 thread1 = op1.get_thread()
148 thread1.max_posts = 5
149 thread1.save()
150
151 uid_1 = op1.uid
152 uid_2 = op2.uid
153
154 # Create multi reply
155 Post.objects.create_post(
156 title='title', text='text', thread=thread1,
157 opening_posts=[op1, op2])
158 thread_update_time_2 = op2.get_thread().last_edit_time
159 for i in range(6):
160 Post.objects.create_post(title='title', text='text',
161 thread=thread1)
162
163 self.assertFalse(op1.get_thread().can_bump(),
164 'Thread is bumpable when it should not be.')
165 self.assertTrue(op2.get_thread().can_bump(),
166 'Thread is not bumpable when it should be.')
167 self.assertNotEqual(
168 uid_1, Post.objects.get(id=op1.id).uid,
169 'UID of the first OP should be changed but it is not.')
170 self.assertEqual(
171 uid_2, Post.objects.get(id=op2.id).uid,
172 'UID of the first OP should not be changed but it is.')
173
174 self.assertNotEqual(
175 thread_update_time_2,
176 Thread.objects.get(id=op2.get_thread().id).last_edit_time,
177 'Thread last update time should change when the other thread '
178 'changes status.')
@@ -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 image = form.get_image()
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, image=image,
154 ip=ip, tags=tags, threads=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 image = form.get_image()
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, image=image,
113 thread=post_thread, ip=ip,
113 thread=post_thread, ip=ip,
114 threads=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