##// END OF EJS Templates
Store UUID for posts and get thread diff by UUIDs instead of update time or...
neko259 -
r1118:01343b9e default
parent child Browse files
Show More
@@ -0,0 +1,19 b''
1 from django.core.management import BaseCommand
2 from django.db import transaction
3 from django.db.models import Count
4
5 from boards.models import Tag
6
7
8 __author__ = 'neko259'
9
10
11 class Command(BaseCommand):
12 help = 'Removed tags that have no threads'
13
14 @transaction.atomic
15 def handle(self, *args, **options):
16 empty = Tag.objects.annotate(num_threads=Count('thread'))\
17 .filter(num_threads=0).order_by('-required', 'name')
18 print('Removing {} empty tags'.format(empty.count()))
19 empty.delete()
@@ -0,0 +1,29 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 import uuid
5
6 from django.db import models, migrations
7
8
9 class Migration(migrations.Migration):
10
11 def assign_uids(apps, schema_editor):
12 Post = apps.get_model('boards', 'Post')
13 for post in Post.objects.all():
14 post.uid = str(uuid.uuid4())
15 post.save(update_fields=['uid'])
16
17 dependencies = [
18 ('boards', '0014_auto_20150418_1749'),
19 ]
20
21 operations = [
22 migrations.AddField(
23 model_name='post',
24 name='uid',
25 field=models.TextField(default=''),
26 preserve_default=False,
27 ),
28 migrations.RunPython(assign_uids),
29 ]
@@ -1,59 +1,59 b''
1 1 from django.contrib import admin
2 2 from boards.models import Post, Tag, Ban, Thread
3 3 from django.utils.translation import ugettext_lazy as _
4 4
5 5
6 6 @admin.register(Post)
7 7 class PostAdmin(admin.ModelAdmin):
8 8
9 9 list_display = ('id', 'title', 'text')
10 10 list_filter = ('pub_time',)
11 11 search_fields = ('id', 'title', 'text')
12 12 exclude = ('referenced_posts', 'refmap')
13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images')
13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images', 'uid')
14 14
15 15 def ban_poster(self, request, queryset):
16 16 bans = 0
17 17 for post in queryset:
18 18 poster_ip = post.poster_ip
19 19 ban, created = Ban.objects.get_or_create(ip=poster_ip)
20 20 if created:
21 21 bans += 1
22 22 self.message_user(request, _('{} posters were banned').format(bans))
23 23
24 24 actions = ['ban_poster']
25 25
26 26
27 27 @admin.register(Tag)
28 28 class TagAdmin(admin.ModelAdmin):
29 29
30 30 def thread_count(self, obj: Tag) -> int:
31 31 return obj.get_thread_count()
32 32
33 33 list_display = ('name', 'thread_count')
34 34 search_fields = ('name',)
35 35
36 36
37 37 @admin.register(Thread)
38 38 class ThreadAdmin(admin.ModelAdmin):
39 39
40 40 def title(self, obj: Thread) -> str:
41 41 return obj.get_opening_post().get_title()
42 42
43 43 def reply_count(self, obj: Thread) -> int:
44 44 return obj.get_reply_count()
45 45
46 46 def ip(self, obj: Thread):
47 47 return obj.get_opening_post().poster_ip
48 48
49 49 list_display = ('id', 'title', 'reply_count', 'archived', 'ip')
50 50 list_filter = ('bump_time', 'archived', 'bumpable')
51 51 search_fields = ('id', 'title')
52 52 filter_horizontal = ('tags',)
53 53
54 54
55 55 @admin.register(Ban)
56 56 class BanAdmin(admin.ModelAdmin):
57 57 list_display = ('ip', 'can_read')
58 58 list_filter = ('can_read',)
59 59 search_fields = ('ip',)
@@ -1,426 +1,433 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 import uuid
5 6
6 7 from django.core.exceptions import ObjectDoesNotExist
7 8 from django.core.urlresolvers import reverse
8 9 from django.db import models, transaction
9 10 from django.db.models import TextField
10 11 from django.template.loader import render_to_string
11 12 from django.utils import timezone
12 13
13 14 from boards import settings
14 15 from boards.mdx_neboard import Parser
15 16 from boards.models import PostImage
16 17 from boards.models.base import Viewable
17 18 from boards import utils
18 19 from boards.models.user import Notification, Ban
19 20 import boards.models.thread
20 21
21 22
22 23 APP_LABEL_BOARDS = 'boards'
23 24
24 25 POSTS_PER_DAY_RANGE = 7
25 26
26 27 BAN_REASON_AUTO = 'Auto'
27 28
28 29 IMAGE_THUMB_SIZE = (200, 150)
29 30
30 31 TITLE_MAX_LENGTH = 200
31 32
32 33 # TODO This should be removed
33 34 NO_IP = '0.0.0.0'
34 35
35 36 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
36 37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
37 38
38 39 PARAMETER_TRUNCATED = 'truncated'
39 40 PARAMETER_TAG = 'tag'
40 41 PARAMETER_OFFSET = 'offset'
41 42 PARAMETER_DIFF_TYPE = 'type'
42 43 PARAMETER_CSS_CLASS = 'css_class'
43 44 PARAMETER_THREAD = 'thread'
44 45 PARAMETER_IS_OPENING = 'is_opening'
45 46 PARAMETER_MODERATOR = 'moderator'
46 47 PARAMETER_POST = 'post'
47 48 PARAMETER_OP_ID = 'opening_post_id'
48 49 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
49 50 PARAMETER_REPLY_LINK = 'reply_link'
50 51
51 52 DIFF_TYPE_HTML = 'html'
52 53 DIFF_TYPE_JSON = 'json'
53 54
54 55 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
55 56
56 57
57 58 class PostManager(models.Manager):
58 59 @transaction.atomic
59 60 def create_post(self, title: str, text: str, image=None, thread=None,
60 61 ip=NO_IP, tags: list=None, threads: list=None):
61 62 """
62 63 Creates new post
63 64 """
64 65
65 66 is_banned = Ban.objects.filter(ip=ip).exists()
66 67
67 68 # TODO Raise specific exception and catch it in the views
68 69 if is_banned:
69 70 raise Exception("This user is banned")
70 71
71 72 if not tags:
72 73 tags = []
73 74 if not threads:
74 75 threads = []
75 76
76 77 posting_time = timezone.now()
77 78 if not thread:
78 79 thread = boards.models.thread.Thread.objects.create(
79 80 bump_time=posting_time, last_edit_time=posting_time)
80 81 new_thread = True
81 82 else:
82 83 new_thread = False
83 84
84 85 pre_text = Parser().preparse(text)
85 86
86 87 post = self.create(title=title,
87 88 text=pre_text,
88 89 pub_time=posting_time,
89 90 poster_ip=ip,
90 91 thread=thread,
91 92 last_edit_time=posting_time)
92 93 post.threads.add(thread)
93 94
94 95 logger = logging.getLogger('boards.post.create')
95 96
96 97 logger.info('Created post {} by {}'.format(post, post.poster_ip))
97 98
98 99 if image:
99 100 post.images.add(PostImage.objects.create_with_hash(image))
100 101
101 102 list(map(thread.add_tag, tags))
102 103
103 104 if new_thread:
104 105 boards.models.thread.Thread.objects.process_oldest_threads()
105 106 else:
106 107 thread.last_edit_time = posting_time
107 108 thread.bump()
108 109 thread.save()
109 110
110 111 post.connect_replies()
111 112 post.connect_threads(threads)
112 113 post.connect_notifications()
113 114
114 115 post.build_url()
115 116
116 117 return post
117 118
118 119 def delete_posts_by_ip(self, ip):
119 120 """
120 121 Deletes all posts of the author with same IP
121 122 """
122 123
123 124 posts = self.filter(poster_ip=ip)
124 125 for post in posts:
125 126 post.delete()
126 127
127 128 @utils.cached_result()
128 129 def get_posts_per_day(self) -> float:
129 130 """
130 131 Gets average count of posts per day for the last 7 days
131 132 """
132 133
133 134 day_end = date.today()
134 135 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
135 136
136 137 day_time_start = timezone.make_aware(datetime.combine(
137 138 day_start, dtime()), timezone.get_current_timezone())
138 139 day_time_end = timezone.make_aware(datetime.combine(
139 140 day_end, dtime()), timezone.get_current_timezone())
140 141
141 142 posts_per_period = float(self.filter(
142 143 pub_time__lte=day_time_end,
143 144 pub_time__gte=day_time_start).count())
144 145
145 146 ppd = posts_per_period / POSTS_PER_DAY_RANGE
146 147
147 148 return ppd
148 149
149 150
150 151 class Post(models.Model, Viewable):
151 152 """A post is a message."""
152 153
153 154 objects = PostManager()
154 155
155 156 class Meta:
156 157 app_label = APP_LABEL_BOARDS
157 158 ordering = ('id',)
158 159
159 160 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
160 161 pub_time = models.DateTimeField()
161 162 text = TextField(blank=True, null=True)
162 163 _text_rendered = TextField(blank=True, null=True, editable=False)
163 164
164 165 images = models.ManyToManyField(PostImage, null=True, blank=True,
165 166 related_name='ip+', db_index=True)
166 167
167 168 poster_ip = models.GenericIPAddressField()
168 169
169 170 last_edit_time = models.DateTimeField()
170 171
171 172 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
172 173 null=True,
173 174 blank=True, related_name='rfp+',
174 175 db_index=True)
175 176 refmap = models.TextField(null=True, blank=True)
176 177 threads = models.ManyToManyField('Thread', db_index=True)
177 178 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
179
178 180 url = models.TextField()
181 uid = models.TextField()
179 182
180 183 def __str__(self):
181 184 return 'P#{}/{}'.format(self.id, self.title)
182 185
183 186 def get_title(self) -> str:
184 187 """
185 188 Gets original post title or part of its text.
186 189 """
187 190
188 191 title = self.title
189 192 if not title:
190 193 title = self.get_text()
191 194
192 195 return title
193 196
194 197 def build_refmap(self) -> None:
195 198 """
196 199 Builds a replies map string from replies list. This is a cache to stop
197 200 the server from recalculating the map on every post show.
198 201 """
199 202
200 203 post_urls = [REFMAP_STR.format(refpost.get_url(), refpost.id)
201 204 for refpost in self.referenced_posts.all()]
202 205
203 206 self.refmap = ', '.join(post_urls)
204 207
205 208 def is_referenced(self) -> bool:
206 209 return self.refmap and len(self.refmap) > 0
207 210
208 211 def is_opening(self) -> bool:
209 212 """
210 213 Checks if this is an opening post or just a reply.
211 214 """
212 215
213 216 return self.get_thread().get_opening_post_id() == self.id
214 217
215 218 def get_url(self):
216 219 return self.url
217 220
218 221 def get_thread(self):
219 222 return self.thread
220 223
221 224 def get_threads(self) -> list:
222 225 """
223 226 Gets post's thread.
224 227 """
225 228
226 229 return self.threads
227 230
228 231 def get_view(self, moderator=False, need_open_link=False,
229 232 truncated=False, reply_link=False, *args, **kwargs) -> str:
230 233 """
231 234 Renders post's HTML view. Some of the post params can be passed over
232 235 kwargs for the means of caching (if we view the thread, some params
233 236 are same for every post and don't need to be computed over and over.
234 237 """
235 238
236 239 thread = self.get_thread()
237 240 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
238 241
239 242 if is_opening:
240 243 opening_post_id = self.id
241 244 else:
242 245 opening_post_id = thread.get_opening_post_id()
243 246
244 247 css_class = 'post'
245 248 if thread.archived:
246 249 css_class += ' archive_post'
247 250 elif not thread.can_bump():
248 251 css_class += ' dead_post'
249 252
250 253 return render_to_string('boards/post.html', {
251 254 PARAMETER_POST: self,
252 255 PARAMETER_MODERATOR: moderator,
253 256 PARAMETER_IS_OPENING: is_opening,
254 257 PARAMETER_THREAD: thread,
255 258 PARAMETER_CSS_CLASS: css_class,
256 259 PARAMETER_NEED_OPEN_LINK: need_open_link,
257 260 PARAMETER_TRUNCATED: truncated,
258 261 PARAMETER_OP_ID: opening_post_id,
259 262 PARAMETER_REPLY_LINK: reply_link,
260 263 })
261 264
262 265 def get_search_view(self, *args, **kwargs):
263 266 return self.get_view(args, kwargs)
264 267
265 268 def get_first_image(self) -> PostImage:
266 269 return self.images.earliest('id')
267 270
268 271 def delete(self, using=None):
269 272 """
270 273 Deletes all post images and the post itself.
271 274 """
272 275
273 276 for image in self.images.all():
274 277 image_refs_count = Post.objects.filter(images__in=[image]).count()
275 278 if image_refs_count == 1:
276 279 image.delete()
277 280
278 281 thread = self.get_thread()
279 282 thread.last_edit_time = timezone.now()
280 283 thread.save()
281 284
282 285 super(Post, self).delete(using)
283 286
284 287 logging.getLogger('boards.post.delete').info(
285 288 'Deleted post {}'.format(self))
286 289
287 290 # TODO Implement this with OOP, e.g. use the factory and HtmlPostData class
288 291 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
289 292 include_last_update=False) -> str:
290 293 """
291 294 Gets post HTML or JSON data that can be rendered on a page or used by
292 295 API.
293 296 """
294 297
295 298 if format_type == DIFF_TYPE_HTML:
296 299 if request is not None and PARAMETER_TRUNCATED in request.GET:
297 300 truncated = True
298 301 reply_link = False
299 302 else:
300 303 truncated = False
301 304 reply_link = True
302 305
303 306 return self.get_view(truncated=truncated, reply_link=reply_link,
304 307 moderator=utils.is_moderator(request))
305 308 elif format_type == DIFF_TYPE_JSON:
306 309 post_json = {
307 310 'id': self.id,
308 311 'title': self.title,
309 312 'text': self._text_rendered,
310 313 }
311 314 if self.images.exists():
312 315 post_image = self.get_first_image()
313 316 post_json['image'] = post_image.image.url
314 317 post_json['image_preview'] = post_image.image.url_200x150
315 318 if include_last_update:
316 319 post_json['bump_time'] = utils.datetime_to_epoch(
317 320 self.get_thread().bump_time)
318 321 return post_json
319 322
320 323 def notify_clients(self, recursive=True):
321 324 """
322 325 Sends post HTML data to the thread web socket.
323 326 """
324 327
325 328 if not settings.WEBSOCKETS_ENABLED:
326 329 return
327 330
328 331 thread_ids = list()
329 332 for thread in self.get_threads().all():
330 333 thread_ids.append(thread.id)
331 334
332 335 thread.notify_clients()
333 336
334 337 if recursive:
335 338 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
336 339 post_id = reply_number.group(1)
337 340
338 341 try:
339 342 ref_post = Post.objects.get(id=post_id)
340 343
341 344 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
342 345 # If post is in this thread, its thread was already notified.
343 346 # Otherwise, notify its thread separately.
344 347 ref_post.notify_clients(recursive=False)
345 348 except ObjectDoesNotExist:
346 349 pass
347 350
348 351 def build_url(self):
349 352 thread = self.get_thread()
350 353 opening_id = thread.get_opening_post_id()
351 354 post_url = reverse('thread', kwargs={'post_id': opening_id})
352 355 if self.id != opening_id:
353 356 post_url += '#' + str(self.id)
354 357 self.url = post_url
355 358 self.save(update_fields=['url'])
356 359
357 360 def save(self, force_insert=False, force_update=False, using=None,
358 361 update_fields=None):
359 362 self._text_rendered = Parser().parse(self.get_raw_text())
360 363
364 self.uid = str(uuid.uuid4())
365 if update_fields is not None and 'uid' not in update_fields:
366 update_fields += ['uid']
367
361 368 if self.id:
362 369 for thread in self.get_threads().all():
363 370 if thread.can_bump():
364 371 thread.update_bump_status()
365 372 thread.last_edit_time = self.last_edit_time
366 373
367 374 thread.save(update_fields=['last_edit_time', 'bumpable'])
368 375
369 376 super().save(force_insert, force_update, using, update_fields)
370 377
371 378 def get_text(self) -> str:
372 379 return self._text_rendered
373 380
374 381 def get_raw_text(self) -> str:
375 382 return self.text
376 383
377 384 def get_absolute_id(self) -> str:
378 385 """
379 386 If the post has many threads, shows its main thread OP id in the post
380 387 ID.
381 388 """
382 389
383 390 if self.get_threads().count() > 1:
384 391 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
385 392 else:
386 393 return str(self.id)
387 394
388 395 def connect_notifications(self):
389 396 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
390 397 user_name = reply_number.group(1).lower()
391 398 Notification.objects.get_or_create(name=user_name, post=self)
392 399
393 400 def connect_replies(self):
394 401 """
395 402 Connects replies to a post to show them as a reflink map
396 403 """
397 404
398 405 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
399 406 post_id = reply_number.group(1)
400 407
401 408 try:
402 409 referenced_post = Post.objects.get(id=post_id)
403 410
404 411 referenced_post.referenced_posts.add(self)
405 412 referenced_post.last_edit_time = self.pub_time
406 413 referenced_post.build_refmap()
407 414 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
408 415 except ObjectDoesNotExist:
409 416 pass
410 417
411 418 def connect_threads(self, opening_posts):
412 419 """
413 420 If the referenced post is an OP in another thread,
414 421 make this post multi-thread.
415 422 """
416 423
417 424 for opening_post in opening_posts:
418 425 threads = opening_post.get_threads().all()
419 426 for thread in threads:
420 427 if thread.can_bump():
421 428 thread.update_bump_status()
422 429
423 430 thread.last_edit_time = self.last_edit_time
424 431 thread.save(update_fields=['last_edit_time', 'bumpable'])
425 432
426 433 self.threads.add(thread)
@@ -1,339 +1,339 b''
1 1 /*
2 2 @licstart The following is the entire license notice for the
3 3 JavaScript code in this page.
4 4
5 5
6 6 Copyright (C) 2013-2014 neko259
7 7
8 8 The JavaScript code in this page is free software: you can
9 9 redistribute it and/or modify it under the terms of the GNU
10 10 General Public License (GNU GPL) as published by the Free Software
11 11 Foundation, either version 3 of the License, or (at your option)
12 12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15 15
16 16 As additional permission under GNU GPL version 3 section 7, you
17 17 may distribute non-source (e.g., minimized or compacted) forms of
18 18 that code without the copy of the GNU GPL normally required by
19 19 section 4, provided you include this license notice and a URL
20 20 through which recipients can access the Corresponding Source.
21 21
22 22 @licend The above is the entire license notice
23 23 for the JavaScript code in this page.
24 24 */
25 25
26 26 var CLASS_POST = '.post'
27 27
28 28 var wsUser = '';
29 29
30 30 var unreadPosts = 0;
31 31 var documentOriginalTitle = '';
32 32
33 33 // Thread ID does not change, can be stored one time
34 34 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
35 35
36 36 /**
37 37 * Connect to websocket server and subscribe to thread updates. On any update we
38 38 * request a thread diff.
39 39 *
40 40 * @returns {boolean} true if connected, false otherwise
41 41 */
42 42 function connectWebsocket() {
43 43 var metapanel = $('.metapanel')[0];
44 44
45 45 var wsHost = metapanel.getAttribute('data-ws-host');
46 46 var wsPort = metapanel.getAttribute('data-ws-port');
47 47
48 48 if (wsHost.length > 0 && wsPort.length > 0)
49 49 var centrifuge = new Centrifuge({
50 50 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
51 51 "project": metapanel.getAttribute('data-ws-project'),
52 52 "user": wsUser,
53 53 "timestamp": metapanel.getAttribute('data-ws-token-time'),
54 54 "token": metapanel.getAttribute('data-ws-token'),
55 55 "debug": false
56 56 });
57 57
58 58 centrifuge.on('error', function(error_message) {
59 59 console.log("Error connecting to websocket server.");
60 60 console.log(error_message);
61 61 return false;
62 62 });
63 63
64 64 centrifuge.on('connect', function() {
65 65 var channelName = 'thread:' + threadId;
66 66 centrifuge.subscribe(channelName, function(message) {
67 67 getThreadDiff();
68 68 });
69 69
70 70 // For the case we closed the browser and missed some updates
71 71 getThreadDiff();
72 72 $('#autoupdate').hide();
73 73 });
74 74
75 75 centrifuge.connect();
76 76
77 77 return true;
78 78 }
79 79
80 80 /**
81 81 * Get diff of the posts from the current thread timestamp.
82 82 * This is required if the browser was closed and some post updates were
83 83 * missed.
84 84 */
85 85 function getThreadDiff() {
86 86 var lastUpdateTime = $('.metapanel').attr('data-last-update');
87 87 var lastPostId = $('.post').last().attr('id');
88 88
89 var diffUrl = '/api/diff_thread?thread=' + threadId + '&last_update=' + encodeURIComponent(lastUpdateTime)
90 + '&last_post=' + lastPostId;
91
92 $.getJSON(diffUrl)
93 .success(function(data) {
94 var addedPosts = data.added;
89 var uids = '';
90 var posts = $('.post');
91 for (var i = 0; i < posts.length; i++) {
92 uids += posts[i].getAttribute('data-uid') + ' ';
93 }
95 94
96 for (var i = 0; i < addedPosts.length; i++) {
97 var postText = addedPosts[i];
98 var post = $(postText);
99
100 updatePost(post);
101 }
95 var data = {
96 uids: uids
97 }
102 98
103 var addedPostsCount = addedPosts.length;
104 if (addedPostsCount > 0) {
105 updateBumplimitProgress(addedPostsCount);
106 showNewPostsTitle(addedPostsCount);
107 }
99 var diffUrl = '/api/diff_thread?thread=' + threadId;
108 100
101 $.post(diffUrl,
102 data,
103 function(data) {
109 104 var updatedPosts = data.updated;
110 105
111 106 for (var i = 0; i < updatedPosts.length; i++) {
112 107 var postText = updatedPosts[i];
113 108 var post = $(postText);
114 109
115 110 updatePost(post);
116 111 }
117 112
118 var hasMetaUpdates = addedPostsCount > 0 || updatedPosts.length > 0;
113 var hasMetaUpdates = updatedPosts.length > 0;
119 114 if (hasMetaUpdates) {
120 115 updateMetadataPanel();
121 116 }
122 117
123 118 // TODO Process removed posts if any
124 119 $('.metapanel').attr('data-last-update', data.last_update);
125 })
120 },
121 'json'
122 )
126 123 }
127 124
128 125 /**
129 126 * Add or update the post on html page.
130 127 */
131 128 function updatePost(postHtml) {
132 129 // This needs to be set on start because the page is scrolled after posts
133 130 // are added or updated
134 131 var bottom = isPageBottom();
135 132
136 133 var post = $(postHtml);
137 134
138 135 var threadBlock = $('div.thread');
139 136
140 137 var postId = post.attr('id');
141 138
142 139 // If the post already exists, replace it. Otherwise add as a new one.
143 140 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
144 141
145 142 if (existingPosts.size() > 0) {
146 143 existingPosts.replaceWith(post);
147 144 } else {
148 145 post.appendTo(threadBlock);
149 146
150 147 if (bottom) {
151 148 scrollToBottom();
152 149 }
150
151 updateBumplimitProgress(1);
152 showNewPostsTitle(1);
153 153 }
154 154
155 155 processNewPost(post);
156 156 }
157 157
158 158 /**
159 159 * Initiate a blinking animation on a node to show it was updated.
160 160 */
161 161 function blink(node) {
162 162 var blinkCount = 2;
163 163
164 164 var nodeToAnimate = node;
165 165 for (var i = 0; i < blinkCount; i++) {
166 166 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
167 167 }
168 168 }
169 169
170 170 function isPageBottom() {
171 171 var scroll = $(window).scrollTop() / ($(document).height()
172 172 - $(window).height());
173 173
174 174 return scroll == 1
175 175 }
176 176
177 177 function initAutoupdate() {
178 178 return connectWebsocket();
179 179 }
180 180
181 181 function getReplyCount() {
182 182 return $('.thread').children(CLASS_POST).length
183 183 }
184 184
185 185 function getImageCount() {
186 186 return $('.thread').find('img').length
187 187 }
188 188
189 189 /**
190 190 * Update post count, images count and last update time in the metadata
191 191 * panel.
192 192 */
193 193 function updateMetadataPanel() {
194 194 var replyCountField = $('#reply-count');
195 195 var imageCountField = $('#image-count');
196 196
197 197 replyCountField.text(getReplyCount());
198 198 imageCountField.text(getImageCount());
199 199
200 200 var lastUpdate = $('.post:last').children('.post-info').first()
201 201 .children('.pub_time').first().html();
202 202 if (lastUpdate !== '') {
203 203 var lastUpdateField = $('#last-update');
204 204 lastUpdateField.html(lastUpdate);
205 205 blink(lastUpdateField);
206 206 }
207 207
208 208 blink(replyCountField);
209 209 blink(imageCountField);
210 210 }
211 211
212 212 /**
213 213 * Update bumplimit progress bar
214 214 */
215 215 function updateBumplimitProgress(postDelta) {
216 216 var progressBar = $('#bumplimit_progress');
217 217 if (progressBar) {
218 218 var postsToLimitElement = $('#left_to_limit');
219 219
220 220 var oldPostsToLimit = parseInt(postsToLimitElement.text());
221 221 var postCount = getReplyCount();
222 222 var bumplimit = postCount - postDelta + oldPostsToLimit;
223 223
224 224 var newPostsToLimit = bumplimit - postCount;
225 225 if (newPostsToLimit <= 0) {
226 226 $('.bar-bg').remove();
227 227 } else {
228 228 postsToLimitElement.text(newPostsToLimit);
229 229 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
230 230 }
231 231 }
232 232 }
233 233
234 234 /**
235 235 * Show 'new posts' text in the title if the document is not visible to a user
236 236 */
237 237 function showNewPostsTitle(newPostCount) {
238 238 if (document.hidden) {
239 239 if (documentOriginalTitle === '') {
240 240 documentOriginalTitle = document.title;
241 241 }
242 242 unreadPosts = unreadPosts + newPostCount;
243 243 document.title = '[' + unreadPosts + '] ' + documentOriginalTitle;
244 244
245 245 document.addEventListener('visibilitychange', function() {
246 246 if (documentOriginalTitle !== '') {
247 247 document.title = documentOriginalTitle;
248 248 documentOriginalTitle = '';
249 249 unreadPosts = 0;
250 250 }
251 251
252 252 document.removeEventListener('visibilitychange', null);
253 253 });
254 254 }
255 255 }
256 256
257 257 /**
258 258 * Clear all entered values in the form fields
259 259 */
260 260 function resetForm(form) {
261 261 form.find('input:text, input:password, input:file, select, textarea').val('');
262 262 form.find('input:radio, input:checkbox')
263 263 .removeAttr('checked').removeAttr('selected');
264 264 $('.file_wrap').find('.file-thumb').remove();
265 265 }
266 266
267 267 /**
268 268 * When the form is posted, this method will be run as a callback
269 269 */
270 270 function updateOnPost(response, statusText, xhr, form) {
271 271 var json = $.parseJSON(response);
272 272 var status = json.status;
273 273
274 274 showAsErrors(form, '');
275 275
276 276 if (status === 'ok') {
277 277 resetFormPosition();
278 278 resetForm(form);
279 279 getThreadDiff();
280 280 scrollToBottom();
281 281 } else {
282 282 var errors = json.errors;
283 283 for (var i = 0; i < errors.length; i++) {
284 284 var fieldErrors = errors[i];
285 285
286 286 var error = fieldErrors.errors;
287 287
288 288 showAsErrors(form, error);
289 289 }
290 290 }
291 291 }
292 292
293 293 /**
294 294 * Show text in the errors row of the form.
295 295 * @param form
296 296 * @param text
297 297 */
298 298 function showAsErrors(form, text) {
299 299 form.children('.form-errors').remove();
300 300
301 301 if (text.length > 0) {
302 302 var errorList = $('<div class="form-errors">' + text + '<div>');
303 303 errorList.appendTo(form);
304 304 }
305 305 }
306 306
307 307 /**
308 308 * Run js methods that are usually run on the document, on the new post
309 309 */
310 310 function processNewPost(post) {
311 311 addRefLinkPreview(post[0]);
312 312 highlightCode(post);
313 313 blink(post);
314 314 }
315 315
316 316 $(document).ready(function(){
317 317 if (initAutoupdate()) {
318 318 // Post form data over AJAX
319 319 var threadId = $('div.thread').children('.post').first().attr('id');
320 320
321 321 var form = $('#form');
322 322
323 323 if (form.length > 0) {
324 324 var options = {
325 325 beforeSubmit: function(arr, $form, options) {
326 326 showAsErrors($('form'), gettext('Sending message...'));
327 327 },
328 328 success: updateOnPost,
329 329 url: '/api/add_post/' + threadId + '/'
330 330 };
331 331
332 332 form.ajaxForm(options);
333 333
334 334 resetForm(form);
335 335 }
336 336 }
337 337
338 338 $('#autoupdate').click(getThreadDiff);
339 339 });
@@ -1,87 +1,87 b''
1 1 {% load i18n %}
2 2 {% load board %}
3 3
4 4 {% get_current_language as LANGUAGE_CODE %}
5 5
6 <div class="{{ css_class }}" id="{{ post.id }}">
6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
7 7 <div class="post-info">
8 8 <a class="post_id" href="{{ post.get_url }}">({{ post.get_absolute_id }})</a>
9 9 <span class="title">{{ post.title }}</span>
10 10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
11 11 {% comment %}
12 12 Thread death time needs to be shown only if the thread is alredy archived
13 13 and this is an opening post (thread death time) or a post for popup
14 14 (we don't see OP here so we show the death time in the post itself).
15 15 {% endcomment %}
16 16 {% if thread.archived %}
17 17 {% if is_opening %}
18 18 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
19 19 {% endif %}
20 20 {% endif %}
21 21 {% if is_opening and need_open_link %}
22 22 {% if thread.archived %}
23 23 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
24 24 {% else %}
25 25 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
26 26 {% endif %}
27 27 {% endif %}
28 28 {% if reply_link and not thread.archived %}
29 29 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
30 30 {% endif %}
31 31
32 32 {% if moderator %}
33 33 <span class="moderator_info">
34 34 <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
35 35 {% if is_opening %}
36 36 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
37 37 {% endif %}
38 38 </span>
39 39 {% endif %}
40 40 </div>
41 41 {% comment %}
42 42 Post images. Currently only 1 image can be posted and shown, but post model
43 43 supports multiple.
44 44 {% endcomment %}
45 45 {% if post.images.exists %}
46 46 {% with post.images.all.0 as image %}
47 47 {% autoescape off %}
48 48 {{ image.get_view }}
49 49 {% endautoescape %}
50 50 {% endwith %}
51 51 {% endif %}
52 52 {% comment %}
53 53 Post message (text)
54 54 {% endcomment %}
55 55 <div class="message">
56 56 {% autoescape off %}
57 57 {% if truncated %}
58 58 {{ post.get_text|truncatewords_html:50 }}
59 59 {% else %}
60 60 {{ post.get_text }}
61 61 {% endif %}
62 62 {% endautoescape %}
63 63 {% if post.is_referenced %}
64 64 <div class="refmap">
65 65 {% autoescape off %}
66 66 {% trans "Replies" %}: {{ post.refmap }}
67 67 {% endautoescape %}
68 68 </div>
69 69 {% endif %}
70 70 </div>
71 71 {% comment %}
72 72 Thread metadata: counters, tags etc
73 73 {% endcomment %}
74 74 {% if is_opening %}
75 75 <div class="metadata">
76 76 {% if is_opening and need_open_link %}
77 77 {{ thread.get_reply_count }} {% trans 'messages' %},
78 78 {{ thread.get_images_count }} {% trans 'images' %}.
79 79 {% endif %}
80 80 <span class="tags">
81 81 {% autoescape off %}
82 82 {{ thread.get_tag_url_list }}
83 83 {% endautoescape %}
84 84 </span>
85 85 </div>
86 86 {% endif %}
87 87 </div>
@@ -1,238 +1,230 b''
1 1 import json
2 2 import logging
3 3
4 4 from django.db import transaction
5 5 from django.http import HttpResponse
6 6 from django.shortcuts import get_object_or_404
7 7 from django.core import serializers
8 8
9 9 from boards.forms import PostForm, PlainErrorList
10 10 from boards.models import Post, Thread, Tag
11 11 from boards.utils import datetime_to_epoch
12 12 from boards.views.thread import ThreadView
13 13 from boards.models.user import Notification
14 14
15 15
16 16 __author__ = 'neko259'
17 17
18 18 PARAMETER_TRUNCATED = 'truncated'
19 19 PARAMETER_TAG = 'tag'
20 20 PARAMETER_OFFSET = 'offset'
21 21 PARAMETER_DIFF_TYPE = 'type'
22 22 PARAMETER_POST = 'post'
23 23 PARAMETER_ADDED = 'added'
24 24 PARAMETER_UPDATED = 'updated'
25 25 PARAMETER_LAST_UPDATE = 'last_update'
26 26
27 27 DIFF_TYPE_HTML = 'html'
28 28 DIFF_TYPE_JSON = 'json'
29 29
30 30 STATUS_OK = 'ok'
31 31 STATUS_ERROR = 'error'
32 32
33 33 logger = logging.getLogger(__name__)
34 34
35 35
36 36 @transaction.atomic
37 37 def api_get_threaddiff(request):
38 38 """
39 39 Gets posts that were changed or added since time
40 40 """
41 41
42 42 thread_id = request.GET.get('thread')
43 last_update_time = request.GET.get('last_update')
44 last_post = request.GET.get('last_post')
43 uids_str = request.POST.get('uids').strip()
44 uids = uids_str.split(' ')
45 45
46 46 thread = get_object_or_404(Post, id=thread_id).get_thread()
47 47
48 48 json_data = {
49 PARAMETER_ADDED: [],
50 49 PARAMETER_UPDATED: [],
51 50 'last_update': None,
52 51 }
53 added_posts = Post.objects.filter(threads__in=[thread],
54 id__gt=int(last_post)) \
55 .order_by('pub_time')
56 updated_posts = Post.objects.filter(threads__in=[thread],
57 pub_time__lte=last_update_time,
58 last_edit_time__gt=last_update_time)
52 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
59 53
60 54 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
61 55
62 for post in added_posts:
63 json_data[PARAMETER_ADDED].append(get_post_data(post.id, diff_type, request))
64 for post in updated_posts:
56 for post in posts:
65 57 json_data[PARAMETER_UPDATED].append(get_post_data(post.id, diff_type, request))
66 58 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
67 59
68 60 return HttpResponse(content=json.dumps(json_data))
69 61
70 62
71 63 def api_add_post(request, opening_post_id):
72 64 """
73 65 Adds a post and return the JSON response for it
74 66 """
75 67
76 68 opening_post = get_object_or_404(Post, id=opening_post_id)
77 69
78 70 logger.info('Adding post via api...')
79 71
80 72 status = STATUS_OK
81 73 errors = []
82 74
83 75 if request.method == 'POST':
84 76 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
85 77 form.session = request.session
86 78
87 79 if form.need_to_ban:
88 80 # Ban user because he is suspected to be a bot
89 81 # _ban_current_user(request)
90 82 status = STATUS_ERROR
91 83 if form.is_valid():
92 84 post = ThreadView().new_post(request, form, opening_post,
93 85 html_response=False)
94 86 if not post:
95 87 status = STATUS_ERROR
96 88 else:
97 89 logger.info('Added post #%d via api.' % post.id)
98 90 else:
99 91 status = STATUS_ERROR
100 92 errors = form.as_json_errors()
101 93
102 94 response = {
103 95 'status': status,
104 96 'errors': errors,
105 97 }
106 98
107 99 return HttpResponse(content=json.dumps(response))
108 100
109 101
110 102 def get_post(request, post_id):
111 103 """
112 104 Gets the html of a post. Used for popups. Post can be truncated if used
113 105 in threads list with 'truncated' get parameter.
114 106 """
115 107
116 108 post = get_object_or_404(Post, id=post_id)
117 109 truncated = PARAMETER_TRUNCATED in request.GET
118 110
119 111 return HttpResponse(content=post.get_view(truncated=truncated))
120 112
121 113
122 114 def api_get_threads(request, count):
123 115 """
124 116 Gets the JSON thread opening posts list.
125 117 Parameters that can be used for filtering:
126 118 tag, offset (from which thread to get results)
127 119 """
128 120
129 121 if PARAMETER_TAG in request.GET:
130 122 tag_name = request.GET[PARAMETER_TAG]
131 123 if tag_name is not None:
132 124 tag = get_object_or_404(Tag, name=tag_name)
133 125 threads = tag.get_threads().filter(archived=False)
134 126 else:
135 127 threads = Thread.objects.filter(archived=False)
136 128
137 129 if PARAMETER_OFFSET in request.GET:
138 130 offset = request.GET[PARAMETER_OFFSET]
139 131 offset = int(offset) if offset is not None else 0
140 132 else:
141 133 offset = 0
142 134
143 135 threads = threads.order_by('-bump_time')
144 136 threads = threads[offset:offset + int(count)]
145 137
146 138 opening_posts = []
147 139 for thread in threads:
148 140 opening_post = thread.get_opening_post()
149 141
150 142 # TODO Add tags, replies and images count
151 143 post_data = get_post_data(opening_post.id, include_last_update=True)
152 144 post_data['bumpable'] = thread.can_bump()
153 145 post_data['archived'] = thread.archived
154 146
155 147 opening_posts.append(post_data)
156 148
157 149 return HttpResponse(content=json.dumps(opening_posts))
158 150
159 151
160 152 # TODO Test this
161 153 def api_get_tags(request):
162 154 """
163 155 Gets all tags or user tags.
164 156 """
165 157
166 158 # TODO Get favorite tags for the given user ID
167 159
168 160 tags = Tag.objects.get_not_empty_tags()
169 161
170 162 term = request.GET.get('term')
171 163 if term is not None:
172 164 tags = tags.filter(name__contains=term)
173 165
174 166 tag_names = [tag.name for tag in tags]
175 167
176 168 return HttpResponse(content=json.dumps(tag_names))
177 169
178 170
179 171 # TODO The result can be cached by the thread last update time
180 172 # TODO Test this
181 173 def api_get_thread_posts(request, opening_post_id):
182 174 """
183 175 Gets the JSON array of thread posts
184 176 """
185 177
186 178 opening_post = get_object_or_404(Post, id=opening_post_id)
187 179 thread = opening_post.get_thread()
188 180 posts = thread.get_replies()
189 181
190 182 json_data = {
191 183 'posts': [],
192 184 'last_update': None,
193 185 }
194 186 json_post_list = []
195 187
196 188 for post in posts:
197 189 json_post_list.append(get_post_data(post.id))
198 190 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
199 191 json_data['posts'] = json_post_list
200 192
201 193 return HttpResponse(content=json.dumps(json_data))
202 194
203 195
204 196 def api_get_notifications(request, username):
205 197 last_notification_id_str = request.GET.get('last', None)
206 198 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
207 199
208 200 posts = Notification.objects.get_notification_posts(username=username,
209 201 last=last_id)
210 202
211 203 json_post_list = []
212 204 for post in posts:
213 205 json_post_list.append(get_post_data(post.id))
214 206 return HttpResponse(content=json.dumps(json_post_list))
215 207
216 208
217 209 def api_get_post(request, post_id):
218 210 """
219 211 Gets the JSON of a post. This can be
220 212 used as and API for external clients.
221 213 """
222 214
223 215 post = get_object_or_404(Post, id=post_id)
224 216
225 217 json = serializers.serialize("json", [post], fields=(
226 218 "pub_time", "_text_rendered", "title", "text", "image",
227 219 "image_width", "image_height", "replies", "tags"
228 220 ))
229 221
230 222 return HttpResponse(content=json)
231 223
232 224
233 225 # TODO Remove this method and use post method directly
234 226 def get_post_data(post_id, format_type=DIFF_TYPE_JSON, request=None,
235 227 include_last_update=False):
236 228 post = get_object_or_404(Post, id=post_id)
237 229 return post.get_post_data(format_type=format_type, request=request,
238 230 include_last_update=include_last_update)
General Comments 0
You need to be logged in to leave comments. Login now