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