##// END OF EJS Templates
Remove websocket support. JS internal auto-update works fine enough
neko259 -
r1760:641fa167 default
parent child Browse files
Show More
@@ -1,46 +1,42 b''
1 1 [Version]
2 2 Version = 3.5.0 Dolores
3 3 SiteName = Neboard DEV
4 4
5 5 [Cache]
6 6 # Timeout for caching, if cache is used
7 7 CacheTimeout = 600
8 8
9 9 [Forms]
10 10 # Max post length in characters
11 11 MaxTextLength = 30000
12 12 MaxFileSize = 8000000
13 13 LimitFirstPosting = true
14 14 LimitPostingSpeed = false
15 15 PowDifficulty = 0
16 16 # Delay in seconds
17 17 PostingDelay = 30
18 18 Autoban = false
19 19 DefaultTag = test
20 20
21 21 [Messages]
22 22 # Thread bumplimit
23 23 MaxPostsPerThread = 10
24 24 ThreadArchiveDays = 300
25 25 AnonymousMode = false
26 26
27 27 [View]
28 28 DefaultTheme = md
29 29 DefaultImageViewer = simple
30 30 LastRepliesCount = 3
31 31 ThreadsPerPage = 3
32 32 PostsPerPage = 10
33 33 ImagesPerPageGallery = 20
34 34 MaxFavoriteThreads = 20
35 35 MaxLandingThreads = 20
36 36
37 37 [Storage]
38 38 # Enable archiving threads instead of deletion when the thread limit is reached
39 39 ArchiveThreads = true
40 40
41 [External]
42 # Thread update
43 WebsocketsEnabled = false
44
45 41 [RSS]
46 42 MaxItems = 20
@@ -1,89 +1,85 b''
1 1 from boards.abstracts.settingsmanager import get_settings_manager, \
2 2 SETTING_LAST_NOTIFICATION_ID, SETTING_IMAGE_VIEWER, SETTING_ONLY_FAVORITES
3 from boards.models import Banner
3 4 from boards.models.user import Notification
4 from boards.models import Banner
5
6 __author__ = 'neko259'
7
8 import neboard
9 5 from boards import settings
10 6 from boards.models import Post, Tag, Thread
11 7
12 8 CONTEXT_SITE_NAME = 'site_name'
13 9 CONTEXT_VERSION = 'version'
14 10 CONTEXT_THEME_CSS = 'theme_css'
15 11 CONTEXT_THEME = 'theme'
16 12 CONTEXT_PPD = 'posts_per_day'
17 13 CONTEXT_USER = 'user'
18 14 CONTEXT_NEW_NOTIFICATIONS_COUNT = 'new_notifications_count'
19 15 CONTEXT_USERNAMES = 'usernames'
20 16 CONTEXT_TAGS_STR = 'tags_str'
21 17 CONTEXT_IMAGE_VIEWER = 'image_viewer'
22 18 CONTEXT_HAS_FAV_THREADS = 'has_fav_threads'
23 19 CONTEXT_POW_DIFFICULTY = 'pow_difficulty'
24 20 CONTEXT_NEW_POST_COUNT = 'new_post_count'
25 21 CONTEXT_BANNERS = 'banners'
26 22 CONTEXT_ONLY_FAVORITES = 'only_favorites'
27 23
28 24
29 25 def get_notifications(context, settings_manager):
30 26 usernames = settings_manager.get_notification_usernames()
31 27 new_notifications_count = 0
32 28 if usernames:
33 29 last_notification_id = settings_manager.get_setting(
34 30 SETTING_LAST_NOTIFICATION_ID)
35 31
36 32 new_notifications_count = Notification.objects.get_notification_posts(
37 33 usernames=usernames, last=last_notification_id).only('id').count()
38 34 context[CONTEXT_NEW_NOTIFICATIONS_COUNT] = new_notifications_count
39 35 context[CONTEXT_USERNAMES] = usernames
40 36
41 37
42 38 def get_new_post_count(context, settings_manager):
43 39 fav_threads = settings_manager.get_fav_threads()
44 40 if fav_threads:
45 41 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys()) \
46 42 .order_by('-pub_time').only('thread_id', 'pub_time')
47 43 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
48 44 count = Thread.objects.get_new_post_count(ops)
49 45 if count > 0:
50 46 context[CONTEXT_NEW_POST_COUNT] = '(+{})'.format(count)
51 47
52 48
53 49 def user_and_ui_processor(request):
54 50 context = dict()
55 51
56 52 context[CONTEXT_PPD] = float(Post.objects.get_posts_per_day())
57 53
58 54 settings_manager = get_settings_manager(request)
59 55 fav_tags = settings_manager.get_fav_tags()
60 56
61 57 context[CONTEXT_TAGS_STR] = Tag.objects.get_tag_url_list(fav_tags)
62 58 theme = settings_manager.get_theme()
63 59 context[CONTEXT_THEME] = theme
64 60
65 61 # TODO Use static here
66 62 context[CONTEXT_THEME_CSS] = 'css/' + theme + '/base_page.css'
67 63
68 64 context[CONTEXT_VERSION] = settings.get('Version', 'Version')
69 65 context[CONTEXT_SITE_NAME] = settings.get('Version', 'SiteName')
70 66
71 67 if (settings.get_bool('Forms', 'LimitFirstPosting') and not settings_manager.get_setting('confirmed_user'))\
72 68 or settings.get_bool('Forms', 'LimitPostingSpeed'):
73 69 context[CONTEXT_POW_DIFFICULTY] = settings.get_int('Forms', 'PowDifficulty')
74 70
75 71 context[CONTEXT_IMAGE_VIEWER] = settings_manager.get_setting(
76 72 SETTING_IMAGE_VIEWER,
77 73 default=settings.get('View', 'DefaultImageViewer'))
78 74
79 75 context[CONTEXT_HAS_FAV_THREADS] =\
80 76 len(settings_manager.get_fav_threads()) > 0
81 77
82 78 context[CONTEXT_BANNERS] = Banner.objects.order_by('-id')
83 79 context[CONTEXT_ONLY_FAVORITES] = settings_manager.get_setting(
84 80 SETTING_ONLY_FAVORITES, False)
85 81
86 82 get_notifications(context, settings_manager)
87 83 get_new_post_count(context, settings_manager)
88 84
89 85 return context
@@ -1,389 +1,364 b''
1 1 import uuid
2 2 import hashlib
3 3 import re
4 4
5 5 from boards import settings
6 6 from boards.abstracts.tripcode import Tripcode
7 7 from boards.models import Attachment, KeyPair, GlobalId
8 8 from boards.models.attachment import FILE_TYPES_IMAGE
9 9 from boards.models.base import Viewable
10 10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
11 11 from boards.models.post.manager import PostManager, NO_IP
12 12 from boards.utils import datetime_to_epoch
13 13 from django.core.exceptions import ObjectDoesNotExist
14 14 from django.core.urlresolvers import reverse
15 15 from django.db import models
16 16 from django.db.models import TextField, QuerySet, F
17 17 from django.template.defaultfilters import truncatewords, striptags
18 18 from django.template.loader import render_to_string
19 19
20 20 CSS_CLS_HIDDEN_POST = 'hidden_post'
21 21 CSS_CLS_DEAD_POST = 'dead_post'
22 22 CSS_CLS_ARCHIVE_POST = 'archive_post'
23 23 CSS_CLS_POST = 'post'
24 24 CSS_CLS_MONOCHROME = 'monochrome'
25 25
26 26 TITLE_MAX_WORDS = 10
27 27
28 28 APP_LABEL_BOARDS = 'boards'
29 29
30 30 BAN_REASON_AUTO = 'Auto'
31 31
32 32 TITLE_MAX_LENGTH = 200
33 33
34 34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
35 35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
36 36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
37 37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
38 38
39 39 PARAMETER_TRUNCATED = 'truncated'
40 40 PARAMETER_TAG = 'tag'
41 41 PARAMETER_OFFSET = 'offset'
42 42 PARAMETER_DIFF_TYPE = 'type'
43 43 PARAMETER_CSS_CLASS = 'css_class'
44 44 PARAMETER_THREAD = 'thread'
45 45 PARAMETER_IS_OPENING = 'is_opening'
46 46 PARAMETER_POST = 'post'
47 47 PARAMETER_OP_ID = 'opening_post_id'
48 48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
49 49 PARAMETER_REPLY_LINK = 'reply_link'
50 50 PARAMETER_NEED_OP_DATA = 'need_op_data'
51 51
52 52 POST_VIEW_PARAMS = (
53 53 'need_op_data',
54 54 'reply_link',
55 55 'need_open_link',
56 56 'truncated',
57 57 'mode_tree',
58 58 'perms',
59 59 'tree_depth',
60 60 )
61 61
62 62
63 63 class Post(models.Model, Viewable):
64 64 """A post is a message."""
65 65
66 66 objects = PostManager()
67 67
68 68 class Meta:
69 69 app_label = APP_LABEL_BOARDS
70 70 ordering = ('id',)
71 71
72 72 title = models.CharField(max_length=TITLE_MAX_LENGTH, blank=True, default='')
73 73 pub_time = models.DateTimeField(db_index=True)
74 74 text = TextField(blank=True, default='')
75 75 _text_rendered = TextField(blank=True, null=True, editable=False)
76 76
77 77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
78 78 related_name='attachment_posts')
79 79
80 80 poster_ip = models.GenericIPAddressField()
81 81
82 82 # Used for cache and threads updating
83 83 last_edit_time = models.DateTimeField()
84 84
85 85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
86 86 null=True,
87 87 blank=True, related_name='refposts',
88 88 db_index=True)
89 89 refmap = models.TextField(null=True, blank=True)
90 90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
91 91
92 92 url = models.TextField()
93 93 uid = models.TextField(db_index=True)
94 94
95 95 # Global ID with author key. If the message was downloaded from another
96 96 # server, this indicates the server.
97 97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
98 98 on_delete=models.CASCADE)
99 99
100 100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 101 opening = models.BooleanField(db_index=True)
102 102 hidden = models.BooleanField(default=False)
103 103 version = models.IntegerField(default=1)
104 104
105 105 def __str__(self):
106 106 return 'P#{}/{}'.format(self.id, self.get_title())
107 107
108 108 def get_title(self) -> str:
109 109 return self.title
110 110
111 111 def get_title_or_text(self):
112 112 title = self.get_title()
113 113 if not title:
114 114 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
115 115
116 116 return title
117 117
118 118 def build_refmap(self, excluded_ids=None) -> None:
119 119 """
120 120 Builds a replies map string from replies list. This is a cache to stop
121 121 the server from recalculating the map on every post show.
122 122 """
123 123
124 124 replies = self.referenced_posts
125 125 if excluded_ids is not None:
126 126 replies = replies.exclude(id__in=excluded_ids)
127 127 else:
128 128 replies = replies.all()
129 129
130 130 post_urls = [refpost.get_link_view() for refpost in replies]
131 131
132 132 self.refmap = ', '.join(post_urls)
133 133
134 134 def is_referenced(self) -> bool:
135 135 return self.refmap and len(self.refmap) > 0
136 136
137 137 def is_opening(self) -> bool:
138 138 """
139 139 Checks if this is an opening post or just a reply.
140 140 """
141 141
142 142 return self.opening
143 143
144 144 def get_absolute_url(self, thread=None):
145 145 # Url is cached only for the "main" thread. When getting url
146 146 # for other threads, do it manually.
147 147 return self.url
148 148
149 149 def get_thread(self):
150 150 return self.thread
151 151
152 152 def get_thread_id(self):
153 153 return self.thread_id
154 154
155 155 def _get_cache_key(self):
156 156 return [datetime_to_epoch(self.last_edit_time)]
157 157
158 158 def get_view_params(self, *args, **kwargs):
159 159 """
160 160 Gets the parameters required for viewing the post based on the arguments
161 161 given and the post itself.
162 162 """
163 163 thread = kwargs.get('thread') or self.get_thread()
164 164
165 165 css_classes = [CSS_CLS_POST]
166 166 if thread.is_archived():
167 167 css_classes.append(CSS_CLS_ARCHIVE_POST)
168 168 elif not thread.can_bump():
169 169 css_classes.append(CSS_CLS_DEAD_POST)
170 170 if self.is_hidden():
171 171 css_classes.append(CSS_CLS_HIDDEN_POST)
172 172 if thread.is_monochrome():
173 173 css_classes.append(CSS_CLS_MONOCHROME)
174 174
175 175 params = dict()
176 176 for param in POST_VIEW_PARAMS:
177 177 if param in kwargs:
178 178 params[param] = kwargs[param]
179 179
180 180 params.update({
181 181 PARAMETER_POST: self,
182 182 PARAMETER_IS_OPENING: self.is_opening(),
183 183 PARAMETER_THREAD: thread,
184 184 PARAMETER_CSS_CLASS: ' '.join(css_classes),
185 185 })
186 186
187 187 return params
188 188
189 189 def get_view(self, *args, **kwargs) -> str:
190 190 """
191 191 Renders post's HTML view. Some of the post params can be passed over
192 192 kwargs for the means of caching (if we view the thread, some params
193 193 are same for every post and don't need to be computed over and over.
194 194 """
195 195 params = self.get_view_params(*args, **kwargs)
196 196
197 197 return render_to_string('boards/post.html', params)
198 198
199 199 def get_images(self) -> Attachment:
200 200 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
201 201
202 202 def get_first_image(self) -> Attachment:
203 203 try:
204 204 return self.get_images().earliest('-id')
205 205 except Attachment.DoesNotExist:
206 206 return None
207 207
208 208 def set_global_id(self, key_pair=None):
209 209 """
210 210 Sets global id based on the given key pair. If no key pair is given,
211 211 default one is used.
212 212 """
213 213
214 214 if key_pair:
215 215 key = key_pair
216 216 else:
217 217 try:
218 218 key = KeyPair.objects.get(primary=True)
219 219 except KeyPair.DoesNotExist:
220 220 # Do not update the global id because there is no key defined
221 221 return
222 222 global_id = GlobalId(key_type=key.key_type,
223 223 key=key.public_key,
224 224 local_id=self.id)
225 225 global_id.save()
226 226
227 227 self.global_id = global_id
228 228
229 229 self.save(update_fields=['global_id'])
230 230
231 231 def get_pub_time_str(self):
232 232 return str(self.pub_time)
233 233
234 234 def get_replied_ids(self):
235 235 """
236 236 Gets ID list of the posts that this post replies.
237 237 """
238 238
239 239 raw_text = self.get_raw_text()
240 240
241 241 local_replied = REGEX_REPLY.findall(raw_text)
242 242 global_replied = []
243 243 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
244 244 key_type = match[0]
245 245 key = match[1]
246 246 local_id = match[2]
247 247
248 248 try:
249 249 global_id = GlobalId.objects.get(key_type=key_type,
250 250 key=key, local_id=local_id)
251 251 for post in Post.objects.filter(global_id=global_id).only('id'):
252 252 global_replied.append(post.id)
253 253 except GlobalId.DoesNotExist:
254 254 pass
255 255 return local_replied + global_replied
256 256
257 257 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
258 258 include_last_update=False) -> str:
259 259 """
260 260 Gets post HTML or JSON data that can be rendered on a page or used by
261 261 API.
262 262 """
263 263
264 264 return get_exporter(format_type).export(self, request,
265 265 include_last_update)
266 266
267 def notify_clients(self, recursive=True):
268 """
269 Sends post HTML data to the thread web socket.
270 """
271
272 if not settings.get_bool('External', 'WebsocketsEnabled'):
273 return
274
275 thread_ids = list()
276 self.get_thread().notify_clients()
277
278 if recursive:
279 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
280 post_id = reply_number.group(1)
281
282 try:
283 ref_post = Post.objects.get(id=post_id)
284
285 if ref_post.get_thread().id not in thread_ids:
286 # If post is in this thread, its thread was already notified.
287 # Otherwise, notify its thread separately.
288 ref_post.notify_clients(recursive=False)
289 except ObjectDoesNotExist:
290 pass
291
292 267 def _build_url(self):
293 268 opening = self.is_opening()
294 269 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
295 270 url = reverse('thread', kwargs={'post_id': opening_id})
296 271 if not opening:
297 272 url += '#' + str(self.id)
298 273
299 274 return url
300 275
301 276 def save(self, force_insert=False, force_update=False, using=None,
302 277 update_fields=None):
303 278 new_post = self.id is None
304 279
305 280 self.uid = str(uuid.uuid4())
306 281 if update_fields is not None and 'uid' not in update_fields:
307 282 update_fields += ['uid']
308 283
309 284 if not new_post:
310 285 thread = self.get_thread()
311 286 if thread:
312 287 thread.last_edit_time = self.last_edit_time
313 288 thread.save(update_fields=['last_edit_time', 'status'])
314 289
315 290 super().save(force_insert, force_update, using, update_fields)
316 291
317 292 if new_post:
318 293 self.url = self._build_url()
319 294 super().save(update_fields=['url'])
320 295
321 296 def get_text(self) -> str:
322 297 return self._text_rendered
323 298
324 299 def get_raw_text(self) -> str:
325 300 return self.text
326 301
327 302 def get_sync_text(self) -> str:
328 303 """
329 304 Returns text applicable for sync. It has absolute post reflinks.
330 305 """
331 306
332 307 replacements = dict()
333 308 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
334 309 try:
335 310 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
336 311 replacements[post_id] = absolute_post_id
337 312 except Post.DoesNotExist:
338 313 pass
339 314
340 315 text = self.get_raw_text() or ''
341 316 for key in replacements:
342 317 text = text.replace('[post]{}[/post]'.format(key),
343 318 '[post]{}[/post]'.format(replacements[key]))
344 319 text = text.replace('\r\n', '\n').replace('\r', '\n')
345 320
346 321 return text
347 322
348 323 def get_tripcode(self):
349 324 if self.tripcode:
350 325 return Tripcode(self.tripcode)
351 326
352 327 def get_link_view(self):
353 328 """
354 329 Gets view of a reflink to the post.
355 330 """
356 331 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
357 332 self.id)
358 333 if self.is_opening():
359 334 result = '<b>{}</b>'.format(result)
360 335
361 336 return result
362 337
363 338 def is_hidden(self) -> bool:
364 339 return self.hidden
365 340
366 341 def set_hidden(self, hidden):
367 342 self.hidden = hidden
368 343
369 344 def increment_version(self):
370 345 self.version = F('version') + 1
371 346
372 347 def clear_cache(self):
373 348 """
374 349 Clears sync data (content cache, signatures etc).
375 350 """
376 351 global_id = self.global_id
377 352 if global_id is not None and global_id.is_local()\
378 353 and global_id.content is not None:
379 354 global_id.clear_cache()
380 355
381 356 def get_tags(self):
382 357 return self.get_thread().get_tags()
383 358
384 359 def get_ip_color(self):
385 360 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
386 361
387 362 def has_ip(self):
388 363 return self.poster_ip != NO_IP
389 364
@@ -1,328 +1,313 b''
1 1 import logging
2 from adjacent import Client
3 2 from datetime import timedelta
4 3
5
4 from django.db import models, transaction
6 5 from django.db.models import Count, Sum, QuerySet, Q
7 6 from django.utils import timezone
8 from django.db import models, transaction
9 7
10 from boards.models.attachment import FILE_TYPES_IMAGE
8 import boards
9 from boards import settings
11 10 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
12
13 from boards import settings
14 import boards
15 from boards.utils import cached_result, datetime_to_epoch
11 from boards.models.attachment import FILE_TYPES_IMAGE
16 12 from boards.models.post import Post
17 13 from boards.models.tag import Tag
14 from boards.utils import cached_result, datetime_to_epoch
18 15
19 16 FAV_THREAD_NO_UPDATES = -1
20 17
21 18
22 19 __author__ = 'neko259'
23 20
24 21
25 22 logger = logging.getLogger(__name__)
26 23
27 24
28 25 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
29 26 WS_NOTIFICATION_TYPE = 'notification_type'
30 27
31 28 WS_CHANNEL_THREAD = "thread:"
32 29
33 30 STATUS_CHOICES = (
34 31 (STATUS_ACTIVE, STATUS_ACTIVE),
35 32 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
36 33 (STATUS_ARCHIVE, STATUS_ARCHIVE),
37 34 )
38 35
39 36
40 37 class ThreadManager(models.Manager):
41 38 def process_old_threads(self):
42 39 """
43 40 Preserves maximum thread count. If there are too many threads,
44 41 archive or delete the old ones.
45 42 """
46 43 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
47 44 old_time = timezone.now() - timedelta(days=old_time_delta)
48 45 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
49 46
50 47 for op in old_ops:
51 48 thread = op.get_thread()
52 49 if settings.get_bool('Storage', 'ArchiveThreads'):
53 50 self._archive_thread(thread)
54 51 else:
55 52 thread.delete()
56 53 logger.info('Processed old thread {}'.format(thread))
57 54
58 55
59 56 def _archive_thread(self, thread):
60 57 thread.status = STATUS_ARCHIVE
61 58 thread.last_edit_time = timezone.now()
62 59 thread.update_posts_time()
63 60 thread.save(update_fields=['last_edit_time', 'status'])
64 61
65 62 def get_new_posts(self, datas):
66 63 query = None
67 64 # TODO Use classes instead of dicts
68 65 for data in datas:
69 66 if data['last_id'] != FAV_THREAD_NO_UPDATES:
70 67 q = (Q(id=data['op'].get_thread_id())
71 68 & Q(replies__id__gt=data['last_id']))
72 69 if query is None:
73 70 query = q
74 71 else:
75 72 query = query | q
76 73 if query is not None:
77 74 return self.filter(query).annotate(
78 75 new_post_count=Count('replies'))
79 76
80 77 def get_new_post_count(self, datas):
81 78 new_posts = self.get_new_posts(datas)
82 79 return new_posts.aggregate(total_count=Count('replies'))\
83 80 ['total_count'] if new_posts else 0
84 81
85 82
86 83 def get_thread_max_posts():
87 84 return settings.get_int('Messages', 'MaxPostsPerThread')
88 85
89 86
90 87 class Thread(models.Model):
91 88 objects = ThreadManager()
92 89
93 90 class Meta:
94 91 app_label = 'boards'
95 92
96 93 tags = models.ManyToManyField('Tag', related_name='thread_tags')
97 94 bump_time = models.DateTimeField(db_index=True)
98 95 last_edit_time = models.DateTimeField()
99 96 max_posts = models.IntegerField(default=get_thread_max_posts)
100 97 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
101 98 choices=STATUS_CHOICES, db_index=True)
102 99 monochrome = models.BooleanField(default=False)
103 100
104 101 def get_tags(self) -> QuerySet:
105 102 """
106 103 Gets a sorted tag list.
107 104 """
108 105
109 106 return self.tags.order_by('name')
110 107
111 108 def bump(self):
112 109 """
113 110 Bumps (moves to up) thread if possible.
114 111 """
115 112
116 113 if self.can_bump():
117 114 self.bump_time = self.last_edit_time
118 115
119 116 self.update_bump_status()
120 117
121 118 logger.info('Bumped thread %d' % self.id)
122 119
123 120 def has_post_limit(self) -> bool:
124 121 return self.max_posts > 0
125 122
126 123 def update_bump_status(self, exclude_posts=None):
127 124 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
128 125 self.status = STATUS_BUMPLIMIT
129 126 self.update_posts_time(exclude_posts=exclude_posts)
130 127
131 128 def _get_cache_key(self):
132 129 return [datetime_to_epoch(self.last_edit_time)]
133 130
134 131 @cached_result(key_method=_get_cache_key)
135 132 def get_reply_count(self) -> int:
136 133 return self.get_replies().count()
137 134
138 135 @cached_result(key_method=_get_cache_key)
139 136 def get_images_count(self) -> int:
140 137 return self.get_replies().filter(
141 138 attachments__mimetype__in=FILE_TYPES_IMAGE)\
142 139 .annotate(images_count=Count(
143 140 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
144 141
145 142 def can_bump(self) -> bool:
146 143 """
147 144 Checks if the thread can be bumped by replying to it.
148 145 """
149 146
150 147 return self.get_status() == STATUS_ACTIVE
151 148
152 149 def get_last_replies(self) -> QuerySet:
153 150 """
154 151 Gets several last replies, not including opening post
155 152 """
156 153
157 154 last_replies_count = settings.get_int('View', 'LastRepliesCount')
158 155
159 156 if last_replies_count > 0:
160 157 reply_count = self.get_reply_count()
161 158
162 159 if reply_count > 0:
163 160 reply_count_to_show = min(last_replies_count,
164 161 reply_count - 1)
165 162 replies = self.get_replies()
166 163 last_replies = replies[reply_count - reply_count_to_show:]
167 164
168 165 return last_replies
169 166
170 167 def get_skipped_replies_count(self) -> int:
171 168 """
172 169 Gets number of posts between opening post and last replies.
173 170 """
174 171 reply_count = self.get_reply_count()
175 172 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
176 173 reply_count - 1)
177 174 return reply_count - last_replies_count - 1
178 175
179 176 # TODO Remove argument, it is not used
180 177 def get_replies(self, view_fields_only=True) -> QuerySet:
181 178 """
182 179 Gets sorted thread posts
183 180 """
184 181 query = self.replies.order_by('pub_time').prefetch_related(
185 182 'attachments')
186 183 return query
187 184
188 185 def get_viewable_replies(self) -> QuerySet:
189 186 """
190 187 Gets replies with only fields that are used for viewing.
191 188 """
192 189 return self.get_replies().defer('text', 'last_edit_time', 'version')
193 190
194 191 def get_top_level_replies(self) -> QuerySet:
195 192 return self.get_replies().exclude(refposts__threads__in=[self])
196 193
197 194 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
198 195 """
199 196 Gets replies that have at least one image attached
200 197 """
201 198 return self.get_replies(view_fields_only).filter(
202 199 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
203 200 'attachments')).filter(images_count__gt=0)
204 201
205 202 def get_opening_post(self, only_id=False) -> Post:
206 203 """
207 204 Gets the first post of the thread
208 205 """
209 206
210 207 query = self.get_replies().filter(opening=True)
211 208 if only_id:
212 209 query = query.only('id')
213 210 opening_post = query.first()
214 211
215 212 return opening_post
216 213
217 214 @cached_result()
218 215 def get_opening_post_id(self) -> int:
219 216 """
220 217 Gets ID of the first thread post.
221 218 """
222 219
223 220 return self.get_opening_post(only_id=True).id
224 221
225 222 def get_pub_time(self):
226 223 """
227 224 Gets opening post's pub time because thread does not have its own one.
228 225 """
229 226
230 227 return self.get_opening_post().pub_time
231 228
232 229 def __str__(self):
233 230 return 'T#{}'.format(self.id)
234 231
235 232 def get_tag_url_list(self) -> list:
236 233 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
237 234
238 235 def update_posts_time(self, exclude_posts=None):
239 236 last_edit_time = self.last_edit_time
240 237
241 238 for post in self.replies.all():
242 239 if exclude_posts is None or post not in exclude_posts:
243 240 # Manual update is required because uids are generated on save
244 241 post.last_edit_time = last_edit_time
245 242 post.save(update_fields=['last_edit_time'])
246 243
247 def notify_clients(self):
248 if not settings.get_bool('External', 'WebsocketsEnabled'):
249 return
250
251 client = Client()
252
253 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
254 client.publish(channel_name, {
255 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
256 })
257 client.send()
258
259 244 def get_absolute_url(self):
260 245 return self.get_opening_post().get_absolute_url()
261 246
262 247 def get_required_tags(self):
263 248 return self.get_tags().filter(required=True)
264 249
265 250 def get_sections_str(self):
266 251 return Tag.objects.get_tag_url_list(self.get_required_tags())
267 252
268 253 def get_replies_newer(self, post_id):
269 254 return self.get_replies().filter(id__gt=post_id)
270 255
271 256 def is_archived(self):
272 257 return self.get_status() == STATUS_ARCHIVE
273 258
274 259 def get_status(self):
275 260 return self.status
276 261
277 262 def is_monochrome(self):
278 263 return self.monochrome
279 264
280 265 # If tags have parent, add them to the tag list
281 266 @transaction.atomic
282 267 def refresh_tags(self):
283 268 for tag in self.get_tags().all():
284 269 parents = tag.get_all_parents()
285 270 if len(parents) > 0:
286 271 self.tags.add(*parents)
287 272
288 273 def get_reply_tree(self):
289 274 replies = self.get_replies().prefetch_related('refposts')
290 275 tree = []
291 276 for reply in replies:
292 277 parents = reply.refposts.all()
293 278
294 279 found_parent = False
295 280 searching_for_index = False
296 281
297 282 if len(parents) > 0:
298 283 index = 0
299 284 parent_depth = 0
300 285
301 286 indexes_to_insert = []
302 287
303 288 for depth, element in tree:
304 289 index += 1
305 290
306 291 # If this element is next after parent on the same level,
307 292 # insert child before it
308 293 if searching_for_index and depth <= parent_depth:
309 294 indexes_to_insert.append((index - 1, parent_depth))
310 295 searching_for_index = False
311 296
312 297 if element in parents:
313 298 found_parent = True
314 299 searching_for_index = True
315 300 parent_depth = depth
316 301
317 302 if not found_parent:
318 303 tree.append((0, reply))
319 304 else:
320 305 if searching_for_index:
321 306 tree.append((parent_depth + 1, reply))
322 307
323 308 offset = 0
324 309 for last_index, parent_depth in indexes_to_insert:
325 310 tree.insert(last_index + offset, (parent_depth + 1, reply))
326 311 offset += 1
327 312
328 313 return tree
@@ -1,462 +1,402 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 POST_ADDED = 0;
29 29 var POST_UPDATED = 1;
30 30
31 31 // TODO These need to be syncronized with board settings.
32 32 var JS_AUTOUPDATE_PERIOD = 20000;
33 33 // TODO This needs to be the same for attachment download time limit.
34 34 var POST_AJAX_TIMEOUT = 30000;
35 35 var BLINK_SPEED = 500;
36 36
37 37 var ALLOWED_FOR_PARTIAL_UPDATE = [
38 38 'refmap',
39 39 'post-info'
40 40 ];
41 41
42 42 var ATTR_CLASS = 'class';
43 43 var ATTR_UID = 'data-uid';
44 44
45 var wsUser = '';
46
47 45 var unreadPosts = 0;
48 46 var documentOriginalTitle = '';
49 47
50 48 // Thread ID does not change, can be stored one time
51 49 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
52 50 var blinkColor = $('<div class="post-blink"></div>').css('background-color');
53 51
54 52 /**
55 * Connect to websocket server and subscribe to thread updates. On any update we
56 * request a thread diff.
57 *
58 * @returns {boolean} true if connected, false otherwise
59 */
60 function connectWebsocket() {
61 var metapanel = $('.metapanel')[0];
62
63 var wsHost = metapanel.getAttribute('data-ws-host');
64 var wsPort = metapanel.getAttribute('data-ws-port');
65
66 if (wsHost.length > 0 && wsPort.length > 0) {
67 var centrifuge = new Centrifuge({
68 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
69 "project": metapanel.getAttribute('data-ws-project'),
70 "user": wsUser,
71 "timestamp": metapanel.getAttribute('data-ws-token-time'),
72 "token": metapanel.getAttribute('data-ws-token'),
73 "debug": false
74 });
75
76 centrifuge.on('error', function(error_message) {
77 console.log("Error connecting to websocket server.");
78 console.log(error_message);
79 console.log("Using javascript update instead.");
80
81 // If websockets don't work, enable JS update instead
82 enableJsUpdate()
83 });
84
85 centrifuge.on('connect', function() {
86 var channelName = 'thread:' + threadId;
87 centrifuge.subscribe(channelName, function(message) {
88 getThreadDiff();
89 });
90
91 // For the case we closed the browser and missed some updates
92 getThreadDiff();
93 $('#autoupdate').hide();
94 });
95
96 centrifuge.connect();
97
98 return true;
99 } else {
100 return false;
101 }
102 }
103
104 /**
105 53 * Get diff of the posts from the current thread timestamp.
106 54 * This is required if the browser was closed and some post updates were
107 55 * missed.
108 56 */
109 57 function getThreadDiff() {
110 58 var all_posts = $('.post');
111 59
112 60 var uids = '';
113 61 var posts = all_posts;
114 62 for (var i = 0; i < posts.length; i++) {
115 63 uids += posts[i].getAttribute('data-uid') + ' ';
116 64 }
117 65
118 66 var data = {
119 67 uids: uids,
120 68 thread: threadId
121 69 };
122 70
123 71 var diffUrl = '/api/diff_thread/';
124 72
125 73 $.post(diffUrl,
126 74 data,
127 75 function(data) {
128 76 var updatedPosts = data.updated;
129 77 var addedPostCount = 0;
130 78
131 79 for (var i = 0; i < updatedPosts.length; i++) {
132 80 var postText = updatedPosts[i];
133 81 var post = $(postText);
134 82
135 83 if (updatePost(post) == POST_ADDED) {
136 84 addedPostCount++;
137 85 }
138 86 }
139 87
140 88 var hasMetaUpdates = updatedPosts.length > 0;
141 89 if (hasMetaUpdates) {
142 90 updateMetadataPanel();
143 91 }
144 92
145 93 if (addedPostCount > 0) {
146 94 updateBumplimitProgress(addedPostCount);
147 95 }
148 96
149 97 if (updatedPosts.length > 0) {
150 98 showNewPostsTitle(addedPostCount);
151 99 }
152 100
153 101 // TODO Process removed posts if any
154 102 $('.metapanel').attr('data-last-update', data.last_update);
155 103
156 104 if (data.subscribed == 'True') {
157 105 var favButton = $('#thread-fav-button .not_fav');
158 106
159 107 if (favButton.length > 0) {
160 108 favButton.attr('value', 'unsubscribe');
161 109 favButton.removeClass('not_fav');
162 110 favButton.addClass('fav');
163 111 }
164 112 }
165 113 },
166 114 'json'
167 115 )
168 116 }
169 117
170 118 /**
171 119 * Add or update the post on html page.
172 120 */
173 121 function updatePost(postHtml) {
174 122 // This needs to be set on start because the page is scrolled after posts
175 123 // are added or updated
176 124 var bottom = isPageBottom();
177 125
178 126 var post = $(postHtml);
179 127
180 128 var threadBlock = $('div.thread');
181 129
182 130 var postId = post.attr('id');
183 131
184 132 // If the post already exists, replace it. Otherwise add as a new one.
185 133 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
186 134
187 135 var type;
188 136
189 137 if (existingPosts.size() > 0) {
190 138 replacePartial(existingPosts.first(), post, false);
191 139 post = existingPosts.first();
192 140
193 141 type = POST_UPDATED;
194 142 } else {
195 143 post.appendTo(threadBlock);
196 144
197 145 if (bottom) {
198 146 scrollToBottom();
199 147 }
200 148
201 149 type = POST_ADDED;
202 150 }
203 151
204 152 processNewPost(post);
205 153
206 154 return type;
207 155 }
208 156
209 157 /**
210 158 * Initiate a blinking animation on a node to show it was updated.
211 159 */
212 160 function blink(node) {
213 161 node.effect('highlight', { color: blinkColor }, BLINK_SPEED);
214 162 }
215 163
216 164 function isPageBottom() {
217 165 var scroll = $(window).scrollTop() / ($(document).height()
218 166 - $(window).height());
219 167
220 168 return scroll == 1
221 169 }
222 170
223 171 function enableJsUpdate() {
224 172 setInterval(getThreadDiff, JS_AUTOUPDATE_PERIOD);
225 173 return true;
226 174 }
227 175
228 176 function initAutoupdate() {
229 if (location.protocol === 'https:') {
230 return enableJsUpdate();
231 } else {
232 if (connectWebsocket()) {
233 return true;
234 } else {
235 return enableJsUpdate();
236 }
237 }
177 return enableJsUpdate();
238 178 }
239 179
240 180 function getReplyCount() {
241 181 return $('.thread').children(CLASS_POST).length
242 182 }
243 183
244 184 function getImageCount() {
245 185 return $('.thread').find('img').length
246 186 }
247 187
248 188 /**
249 189 * Update post count, images count and last update time in the metadata
250 190 * panel.
251 191 */
252 192 function updateMetadataPanel() {
253 193 var replyCountField = $('#reply-count');
254 194 var imageCountField = $('#image-count');
255 195
256 196 var replyCount = getReplyCount();
257 197 replyCountField.text(replyCount);
258 198 var imageCount = getImageCount();
259 199 imageCountField.text(imageCount);
260 200
261 201 var lastUpdate = $('.post:last').children('.post-info').first()
262 202 .children('.pub_time').first().html();
263 203 if (lastUpdate !== '') {
264 204 var lastUpdateField = $('#last-update');
265 205 lastUpdateField.html(lastUpdate);
266 206 blink(lastUpdateField);
267 207 }
268 208
269 209 blink(replyCountField);
270 210 blink(imageCountField);
271 211
272 212 $('#message-count-text').text(ngettext('message', 'messages', replyCount));
273 213 $('#image-count-text').text(ngettext('image', 'images', imageCount));
274 214 }
275 215
276 216 /**
277 217 * Update bumplimit progress bar
278 218 */
279 219 function updateBumplimitProgress(postDelta) {
280 220 var progressBar = $('#bumplimit_progress');
281 221 if (progressBar) {
282 222 var postsToLimitElement = $('#left_to_limit');
283 223
284 224 var oldPostsToLimit = parseInt(postsToLimitElement.text());
285 225 var postCount = getReplyCount();
286 226 var bumplimit = postCount - postDelta + oldPostsToLimit;
287 227
288 228 var newPostsToLimit = bumplimit - postCount;
289 229 if (newPostsToLimit <= 0) {
290 230 $('.bar-bg').remove();
291 231 } else {
292 232 postsToLimitElement.text(newPostsToLimit);
293 233 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
294 234 }
295 235 }
296 236 }
297 237
298 238 /**
299 239 * Show 'new posts' text in the title if the document is not visible to a user
300 240 */
301 241 function showNewPostsTitle(newPostCount) {
302 242 if (document.hidden) {
303 243 if (documentOriginalTitle === '') {
304 244 documentOriginalTitle = document.title;
305 245 }
306 246 unreadPosts = unreadPosts + newPostCount;
307 247
308 248 var newTitle = null;
309 249 if (unreadPosts > 0) {
310 250 newTitle = '[' + unreadPosts + '] ';
311 251 } else {
312 252 newTitle = '* ';
313 253 }
314 254 newTitle += documentOriginalTitle;
315 255
316 256 document.title = newTitle;
317 257
318 258 document.addEventListener('visibilitychange', function() {
319 259 if (documentOriginalTitle !== '') {
320 260 document.title = documentOriginalTitle;
321 261 documentOriginalTitle = '';
322 262 unreadPosts = 0;
323 263 }
324 264
325 265 document.removeEventListener('visibilitychange', null);
326 266 });
327 267 }
328 268 }
329 269
330 270 /**
331 271 * Clear all entered values in the form fields
332 272 */
333 273 function resetForm(form) {
334 274 form.find('input:text, input:password, input:file, select, textarea').val('');
335 275 form.find('input:radio, input:checkbox')
336 276 .removeAttr('checked').removeAttr('selected');
337 277 $('.file_wrap').find('.file-thumb').remove();
338 278 $('#preview-text').hide();
339 279 }
340 280
341 281 /**
342 282 * When the form is posted, this method will be run as a callback
343 283 */
344 284 function updateOnPost(response, statusText, xhr, form) {
345 285 var json = $.parseJSON(response);
346 286 var status = json.status;
347 287
348 288 showAsErrors(form, '');
349 289 $('.post-form-w').unblock();
350 290
351 291 if (status === 'ok') {
352 292 resetFormPosition();
353 293 resetForm(form);
354 294 getThreadDiff();
355 295 scrollToBottom();
356 296 } else {
357 297 var errors = json.errors;
358 298 for (var i = 0; i < errors.length; i++) {
359 299 var fieldErrors = errors[i];
360 300
361 301 var error = fieldErrors.errors;
362 302
363 303 showAsErrors(form, error);
364 304 }
365 305 }
366 306 }
367 307
368 308
369 309 /**
370 310 * Run js methods that are usually run on the document, on the new post
371 311 */
372 312 function processNewPost(post) {
373 313 addScriptsToPost(post);
374 314 blink(post);
375 315 }
376 316
377 317 function replacePartial(oldNode, newNode, recursive) {
378 318 if (!equalNodes(oldNode, newNode)) {
379 319 // Update parent node attributes
380 320 updateNodeAttr(oldNode, newNode, ATTR_CLASS);
381 321 updateNodeAttr(oldNode, newNode, ATTR_UID);
382 322
383 323 // Replace children
384 324 var children = oldNode.children();
385 325 if (children.length == 0) {
386 326 oldNode.replaceWith(newNode);
387 327 } else {
388 328 var newChildren = newNode.children();
389 329 newChildren.each(function(i) {
390 330 var newChild = newChildren.eq(i);
391 331 var newChildClass = newChild.attr(ATTR_CLASS);
392 332
393 333 // Update only certain allowed blocks (e.g. not images)
394 334 if (ALLOWED_FOR_PARTIAL_UPDATE.indexOf(newChildClass) > -1) {
395 335 var oldChild = oldNode.children('.' + newChildClass);
396 336
397 337 if (oldChild.length == 0) {
398 338 oldNode.append(newChild);
399 339 } else {
400 340 if (!equalNodes(oldChild, newChild)) {
401 341 if (recursive) {
402 342 replacePartial(oldChild, newChild, false);
403 343 } else {
404 344 oldChild.replaceWith(newChild);
405 345 }
406 346 }
407 347 }
408 348 }
409 349 });
410 350 }
411 351 }
412 352 }
413 353
414 354 /**
415 355 * Compare nodes by content
416 356 */
417 357 function equalNodes(node1, node2) {
418 358 return node1[0].outerHTML == node2[0].outerHTML;
419 359 }
420 360
421 361 /**
422 362 * Update attribute of a node if it has changed
423 363 */
424 364 function updateNodeAttr(oldNode, newNode, attrName) {
425 365 var oldAttr = oldNode.attr(attrName);
426 366 var newAttr = newNode.attr(attrName);
427 367 if (oldAttr != newAttr) {
428 368 oldNode.attr(attrName, newAttr);
429 369 }
430 370 }
431 371
432 372 $(document).ready(function() {
433 373 if (initAutoupdate()) {
434 374 // Post form data over AJAX
435 375 var threadId = $('div.thread').children('.post').first().attr('id');
436 376
437 377 var form = $('#form');
438 378
439 379 if (form.length > 0) {
440 380 var options = {
441 381 beforeSubmit: function(arr, form, options) {
442 382 $('.post-form-w').block({ message: gettext('Sending message...') });
443 383 },
444 384 success: updateOnPost,
445 385 error: function(xhr, textStatus, errorString) {
446 386 var errorText = gettext('Server error: ') + textStatus;
447 387 if (errorString) {
448 388 errorText += ' / ' + errorString;
449 389 }
450 390 showAsErrors(form, errorText);
451 391 $('.post-form-w').unblock();
452 392 },
453 393 url: '/api/add_post/' + threadId + '/',
454 394 timeout: POST_AJAX_TIMEOUT
455 395 };
456 396
457 397 form.ajaxForm(options);
458 398
459 399 resetForm(form);
460 400 }
461 401 }
462 402 });
@@ -1,42 +1,38 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load static from staticfiles %}
5 5 {% load board %}
6 6 {% load tz %}
7 7
8 8 {% block head %}
9 9 <title>{{ opening_post.get_title_or_text }} - {{ site_name }}</title>
10 10 {% endblock %}
11 11
12 12 {% block content %}
13 13 <div class="image-mode-tab">
14 14 <a {% ifequal mode 'normal' %}class="current_mode"{% endifequal %} href="{% url 'thread' opening_post.id %}">{% trans 'Normal' %}</a>,
15 15 <a {% ifequal mode 'gallery' %}class="current_mode"{% endifequal %} href="{% url 'thread_gallery' opening_post.id %}">{% trans 'Gallery' %}</a>,
16 16 <a {% ifequal mode 'tree' %}class="current_mode"{% endifequal %} href="{% url 'thread_tree' opening_post.id %}">{% trans 'Tree' %}</a>
17 17 </div>
18 18
19 19 {% block thread_content %}
20 20 {% endblock %}
21 21 {% endblock %}
22 22
23 23 {% block metapanel %}
24 24
25 25 <span class="metapanel"
26 26 data-last-update="{{ last_update }}"
27 data-ws-token-time="{{ ws_token_time }}"
28 data-ws-token="{{ ws_token }}"
29 data-ws-project="{{ ws_project }}"
30 data-ws-host="{{ ws_host }}"
31 data-ws-port="{{ ws_port }}">
27 data-ws-token-time="{{ ws_token_time }}">
32 28
33 29 {% with replies_count=thread.get_reply_count%}
34 30 <span id="reply-count">{{ thread.get_reply_count }}</span>{% if thread.has_post_limit %}/{{ thread.max_posts }}{% endif %}
35 31 {% endwith %}
36 32 {% with images_count=thread.get_images_count%}
37 33 <span id="image-count">{{ images_count }}</span> <span id="image-count-text">
38 34 {% endwith %}
39 35 {% trans 'Last update: ' %}<span id="last-update"><time datetime="{{ thread.last_edit_time|date:'c' }}">{{ thread.last_edit_time }}</time></span>
40 36 </span>
41 37
42 38 {% endblock %}
@@ -1,191 +1,187 b''
1 1 from django.core.urlresolvers import reverse
2 2 from django.core.files import File
3 3 from django.core.files.temp import NamedTemporaryFile
4 4 from django.core.paginator import EmptyPage
5 5 from django.db import transaction
6 6 from django.http import Http404
7 7 from django.shortcuts import render, redirect
8 8 from django.utils.decorators import method_decorator
9 9 from django.views.decorators.csrf import csrf_protect
10 10
11 11 from boards import utils, settings
12 12 from boards.abstracts.paginator import get_paginator
13 13 from boards.abstracts.settingsmanager import get_settings_manager,\
14 14 SETTING_ONLY_FAVORITES
15 15 from boards.forms import ThreadForm, PlainErrorList
16 16 from boards.models import Post, Thread, Ban
17 17 from boards.views.banned import BannedView
18 18 from boards.views.base import BaseBoardView, CONTEXT_FORM
19 19 from boards.views.posting_mixin import PostMixin
20 20 from boards.views.mixins import FileUploadMixin, PaginatedMixin,\
21 21 DispatcherMixin, PARAMETER_METHOD
22 22
23 23 FORM_TAGS = 'tags'
24 24 FORM_TEXT = 'text'
25 25 FORM_TITLE = 'title'
26 26 FORM_IMAGE = 'image'
27 27 FORM_THREADS = 'threads'
28 28
29 29 TAG_DELIMITER = ' '
30 30
31 31 PARAMETER_CURRENT_PAGE = 'current_page'
32 32 PARAMETER_PAGINATOR = 'paginator'
33 33 PARAMETER_THREADS = 'threads'
34 34 PARAMETER_ADDITIONAL = 'additional_params'
35 35 PARAMETER_MAX_FILE_SIZE = 'max_file_size'
36 36 PARAMETER_RSS_URL = 'rss_url'
37 37
38 38 TEMPLATE = 'boards/all_threads.html'
39 39 DEFAULT_PAGE = 1
40 40
41 41 FORM_TAGS = 'tags'
42 42
43 43
44 44 class AllThreadsView(PostMixin, FileUploadMixin, BaseBoardView, PaginatedMixin, DispatcherMixin):
45 45
46 46 tag_name = ''
47 47
48 48 def __init__(self):
49 49 self.settings_manager = None
50 50 super(AllThreadsView, self).__init__()
51 51
52 52 @method_decorator(csrf_protect)
53 53 def get(self, request, form: ThreadForm=None):
54 54 page = request.GET.get('page', DEFAULT_PAGE)
55 55
56 56 params = self.get_context_data(request=request)
57 57
58 58 if not form:
59 59 form = ThreadForm(error_class=PlainErrorList,
60 60 initial={FORM_TAGS: self.tag_name})
61 61
62 62 self.settings_manager = get_settings_manager(request)
63 63
64 64 threads = self.get_threads()
65 65
66 66 order = request.GET.get('order', 'bump')
67 67 if order == 'bump':
68 68 threads = threads.order_by('-bump_time')
69 69 else:
70 70 threads = threads.filter(replies__opening=True)\
71 71 .order_by('-replies__pub_time')
72 72 filter = request.GET.get('filter')
73 73 threads = threads.distinct()
74 74
75 75 paginator = get_paginator(threads,
76 76 settings.get_int('View', 'ThreadsPerPage'))
77 77 paginator.current_page = int(page)
78 78
79 79 try:
80 80 threads = paginator.page(page).object_list
81 81 except EmptyPage:
82 82 raise Http404()
83 83
84 84 params[PARAMETER_THREADS] = threads
85 85 params[CONTEXT_FORM] = form
86 86 params[PARAMETER_MAX_FILE_SIZE] = self.get_max_upload_size()
87 87 params[PARAMETER_RSS_URL] = self.get_rss_url()
88 88
89 89 paginator.set_url(self.get_reverse_url(), request.GET.dict())
90 90 self.get_page_context(paginator, params, page)
91 91
92 92 return render(request, TEMPLATE, params)
93 93
94 94 @method_decorator(csrf_protect)
95 95 def post(self, request):
96 96 if PARAMETER_METHOD in request.POST:
97 97 self.dispatch_method(request)
98 98
99 99 return redirect('index') # FIXME Different for different modes
100 100
101 101 form = ThreadForm(request.POST, request.FILES,
102 102 error_class=PlainErrorList)
103 103 form.session = request.session
104 104
105 105 if form.is_valid():
106 106 return self.create_thread(request, form)
107 107 if form.need_to_ban:
108 108 # Ban user because he is suspected to be a bot
109 109 self._ban_current_user(request)
110 110
111 111 return self.get(request, form)
112 112
113 113 def get_page_context(self, paginator, params, page):
114 114 """
115 115 Get pagination context variables
116 116 """
117 117
118 118 params[PARAMETER_PAGINATOR] = paginator
119 119 current_page = paginator.page(int(page))
120 120 params[PARAMETER_CURRENT_PAGE] = current_page
121 121 self.set_page_urls(paginator, params)
122 122
123 123 def get_reverse_url(self):
124 124 return reverse('index')
125 125
126 126 @transaction.atomic
127 127 def create_thread(self, request, form: ThreadForm, html_response=True):
128 128 """
129 129 Creates a new thread with an opening post.
130 130 """
131 131
132 132 ip = utils.get_client_ip(request)
133 133 is_banned = Ban.objects.filter(ip=ip).exists()
134 134
135 135 if is_banned:
136 136 if html_response:
137 137 return redirect(BannedView().as_view())
138 138 else:
139 139 return
140 140
141 141 data = form.cleaned_data
142 142
143 143 title = form.get_title()
144 144 text = data[FORM_TEXT]
145 145 files = form.get_files()
146 146 file_urls = form.get_file_urls()
147 147 images = form.get_images()
148 148
149 149 text = self._remove_invalid_links(text)
150 150
151 151 tags = data[FORM_TAGS]
152 152 monochrome = form.is_monochrome()
153 153
154 154 post = Post.objects.create_post(title=title, text=text, files=files,
155 155 ip=ip, tags=tags,
156 156 tripcode=form.get_tripcode(),
157 157 monochrome=monochrome, images=images,
158 file_urls = file_urls)
159
160 # This is required to update the threads to which posts we have replied
161 # when creating this one
162 post.notify_clients()
158 file_urls=file_urls)
163 159
164 160 if form.is_subscribe():
165 161 settings_manager = get_settings_manager(request)
166 162 settings_manager.add_or_read_fav_thread(post)
167 163
168 164 if html_response:
169 165 return redirect(post.get_absolute_url())
170 166
171 167 def get_threads(self):
172 168 """
173 169 Gets list of threads that will be shown on a page.
174 170 """
175 171
176 172 threads = Thread.objects\
177 173 .exclude(tags__in=self.settings_manager.get_hidden_tags())
178 174 if self.settings_manager.get_setting(SETTING_ONLY_FAVORITES):
179 175 fav_tags = self.settings_manager.get_fav_tags()
180 176 if len(fav_tags) > 0:
181 177 threads = threads.filter(tags__in=fav_tags)
182 178
183 179 return threads
184 180
185 181 def get_rss_url(self):
186 182 return self.get_reverse_url() + 'rss/'
187 183
188 184 def toggle_fav(self, request):
189 185 settings_manager = get_settings_manager(request)
190 186 settings_manager.set_setting(SETTING_ONLY_FAVORITES,
191 187 not settings_manager.get_setting(SETTING_ONLY_FAVORITES, False))
@@ -1,181 +1,165 b''
1 1 from django.contrib.auth.decorators import permission_required
2 2
3 3 from django.core.exceptions import ObjectDoesNotExist
4 4 from django.core.urlresolvers import reverse
5 5 from django.http import Http404
6 6 from django.shortcuts import get_object_or_404, render, redirect
7 7 from django.template.context_processors import csrf
8 8 from django.utils.decorators import method_decorator
9 9 from django.views.decorators.csrf import csrf_protect
10 10 from django.views.generic.edit import FormMixin
11 11 from django.utils import timezone
12 12 from django.utils.dateformat import format
13 13
14 14 from boards import utils, settings
15 15 from boards.abstracts.settingsmanager import get_settings_manager
16 16 from boards.forms import PostForm, PlainErrorList
17 17 from boards.models import Post
18 18 from boards.views.base import BaseBoardView, CONTEXT_FORM
19 19 from boards.views.mixins import DispatcherMixin, PARAMETER_METHOD
20 20 from boards.views.posting_mixin import PostMixin
21 21 import neboard
22 22
23 23 REQ_POST_ID = 'post_id'
24 24
25 25 CONTEXT_LASTUPDATE = "last_update"
26 26 CONTEXT_THREAD = 'thread'
27 CONTEXT_WS_TOKEN = 'ws_token'
28 CONTEXT_WS_PROJECT = 'ws_project'
29 CONTEXT_WS_HOST = 'ws_host'
30 CONTEXT_WS_PORT = 'ws_port'
31 CONTEXT_WS_TIME = 'ws_token_time'
32 27 CONTEXT_MODE = 'mode'
33 28 CONTEXT_OP = 'opening_post'
34 29 CONTEXT_FAVORITE = 'is_favorite'
35 30 CONTEXT_RSS_URL = 'rss_url'
36 31
37 32 FORM_TITLE = 'title'
38 33 FORM_TEXT = 'text'
39 34 FORM_IMAGE = 'image'
40 35 FORM_THREADS = 'threads'
41 36
42 37
43 38 class ThreadView(BaseBoardView, PostMixin, FormMixin, DispatcherMixin):
44 39
45 40 @method_decorator(csrf_protect)
46 41 def get(self, request, post_id, form: PostForm=None):
47 42 try:
48 43 opening_post = Post.objects.get(id=post_id)
49 44 except ObjectDoesNotExist:
50 45 raise Http404
51 46
52 47 # If the tag is favorite, update the counter
53 48 settings_manager = get_settings_manager(request)
54 49 favorite = settings_manager.thread_is_fav(opening_post)
55 50 if favorite:
56 51 settings_manager.add_or_read_fav_thread(opening_post)
57 52
58 53 # If this is not OP, don't show it as it is
59 54 if not opening_post.is_opening():
60 55 return redirect(opening_post.get_thread().get_opening_post()
61 56 .get_absolute_url())
62 57
63 58 if not form:
64 59 form = PostForm(error_class=PlainErrorList)
65 60
66 61 thread_to_show = opening_post.get_thread()
67 62
68 63 params = dict()
69 64
70 65 params[CONTEXT_FORM] = form
71 66 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
72 67 params[CONTEXT_THREAD] = thread_to_show
73 68 params[CONTEXT_MODE] = self.get_mode()
74 69 params[CONTEXT_OP] = opening_post
75 70 params[CONTEXT_FAVORITE] = favorite
76 71 params[CONTEXT_RSS_URL] = self.get_rss_url(post_id)
77 72
78 if settings.get_bool('External', 'WebsocketsEnabled'):
79 token_time = format(timezone.now(), u'U')
80
81 params[CONTEXT_WS_TIME] = token_time
82 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
83 timestamp=token_time)
84 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
85 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
86 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
87
88 73 params.update(self.get_data(thread_to_show))
89 74
90 75 return render(request, self.get_template(), params)
91 76
92 77 @method_decorator(csrf_protect)
93 78 def post(self, request, post_id):
94 79 opening_post = get_object_or_404(Post, id=post_id)
95 80
96 81 # If this is not OP, don't show it as it is
97 82 if not opening_post.is_opening():
98 83 raise Http404
99 84
100 85 if PARAMETER_METHOD in request.POST:
101 86 self.dispatch_method(request, opening_post)
102 87
103 88 return redirect('thread', post_id) # FIXME Different for different modes
104 89
105 90 if not opening_post.get_thread().is_archived():
106 91 form = PostForm(request.POST, request.FILES,
107 92 error_class=PlainErrorList)
108 93 form.session = request.session
109 94
110 95 if form.is_valid():
111 96 return self.new_post(request, form, opening_post)
112 97 if form.need_to_ban:
113 98 # Ban user because he is suspected to be a bot
114 99 self._ban_current_user(request)
115 100
116 101 return self.get(request, post_id, form)
117 102
118 103 def new_post(self, request, form: PostForm, opening_post: Post=None,
119 104 html_response=True):
120 105 """
121 106 Adds a new post (in thread or as a reply).
122 107 """
123 108
124 109 ip = utils.get_client_ip(request)
125 110
126 111 data = form.cleaned_data
127 112
128 113 title = form.get_title()
129 114 text = data[FORM_TEXT]
130 115 files = form.get_files()
131 116 file_urls = form.get_file_urls()
132 117 images = form.get_images()
133 118
134 119 text = self._remove_invalid_links(text)
135 120
136 121 post_thread = opening_post.get_thread()
137 122
138 123 post = Post.objects.create_post(title=title, text=text, files=files,
139 124 thread=post_thread, ip=ip,
140 125 tripcode=form.get_tripcode(),
141 126 images=images, file_urls=file_urls)
142 post.notify_clients()
143 127
144 128 if form.is_subscribe():
145 129 settings_manager = get_settings_manager(request)
146 130 settings_manager.add_or_read_fav_thread(
147 131 post_thread.get_opening_post())
148 132
149 133 if html_response:
150 134 if opening_post:
151 135 return redirect(post.get_absolute_url())
152 136 else:
153 137 return post
154 138
155 139 def get_data(self, thread) -> dict:
156 140 """
157 141 Returns context params for the view.
158 142 """
159 143
160 144 return dict()
161 145
162 146 def get_template(self) -> str:
163 147 """
164 148 Gets template to show the thread mode on.
165 149 """
166 150
167 151 pass
168 152
169 153 def get_mode(self) -> str:
170 154 pass
171 155
172 156 def subscribe(self, request, opening_post):
173 157 settings_manager = get_settings_manager(request)
174 158 settings_manager.add_or_read_fav_thread(opening_post)
175 159
176 160 def unsubscribe(self, request, opening_post):
177 161 settings_manager = get_settings_manager(request)
178 162 settings_manager.del_fav_thread(opening_post)
179 163
180 164 def get_rss_url(self, opening_id):
181 165 return reverse('thread', kwargs={'post_id': opening_id}) + 'rss/'
@@ -1,219 +1,210 b''
1 1 # Django settings for neboard project.
2 2 import os
3 3
4 4 DEBUG = True
5 5
6 6 ADMINS = (
7 7 # ('Your Name', 'your_email@example.com'),
8 8 ('admin', 'admin@example.com')
9 9 )
10 10
11 11 MANAGERS = ADMINS
12 12
13 13 DATABASES = {
14 14 'default': {
15 15 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
16 16 'NAME': 'database.db', # Or path to database file if using sqlite3.
17 17 'USER': '', # Not used with sqlite3.
18 18 'PASSWORD': '', # Not used with sqlite3.
19 19 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
20 20 'PORT': '', # Set to empty string for default. Not used with sqlite3.
21 21 'CONN_MAX_AGE': None,
22 22 }
23 23 }
24 24
25 25 # Local time zone for this installation. Choices can be found here:
26 26 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
27 27 # although not all choices may be available on all operating systems.
28 28 # In a Windows environment this must be set to your system time zone.
29 29 TIME_ZONE = 'Europe/Kiev'
30 30
31 31 # Language code for this installation. All choices can be found here:
32 32 # http://www.i18nguy.com/unicode/language-identifiers.html
33 33 LANGUAGE_CODE = 'en'
34 34
35 35 SITE_ID = 1
36 36
37 37 # If you set this to False, Django will make some optimizations so as not
38 38 # to load the internationalization machinery.
39 39 USE_I18N = True
40 40
41 41 # If you set this to False, Django will not format dates, numbers and
42 42 # calendars according to the current locale.
43 43 USE_L10N = True
44 44
45 45 # If you set this to False, Django will not use timezone-aware datetimes.
46 46 USE_TZ = True
47 47
48 48 USE_ETAGS = True
49 49
50 50 # Absolute filesystem path to the directory that will hold user-uploaded files.
51 51 # Example: "/home/media/media.lawrence.com/media/"
52 52 MEDIA_ROOT = './media/'
53 53
54 54 # URL that handles the media served from MEDIA_ROOT. Make sure to use a
55 55 # trailing slash.
56 56 # Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
57 57 MEDIA_URL = '/media/'
58 58
59 59 # Absolute path to the directory static files should be collected to.
60 60 # Don't put anything in this directory yourself; store your static files
61 61 # in apps' "static/" subdirectories and in STATICFILES_DIRS.
62 62 # Example: "/home/media/media.lawrence.com/static/"
63 63 STATIC_ROOT = ''
64 64
65 65 # URL prefix for static files.
66 66 # Example: "http://media.lawrence.com/static/"
67 67 STATIC_URL = '/static/'
68 68
69 69 STATICFILES_DIRS = []
70 70
71 71 # List of finder classes that know how to find static files in
72 72 # various locations.
73 73 STATICFILES_FINDERS = (
74 74 'django.contrib.staticfiles.finders.FileSystemFinder',
75 75 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
76 76 )
77 77
78 78 if DEBUG:
79 79 STATICFILES_STORAGE = \
80 80 'django.contrib.staticfiles.storage.StaticFilesStorage'
81 81 else:
82 82 STATICFILES_STORAGE = \
83 83 'django.contrib.staticfiles.storage.CachedStaticFilesStorage'
84 84
85 85 # Make this unique, and don't share it with anybody.
86 86 SECRET_KEY = '@1rc$o(7=tt#kd+4s$u6wchm**z^)4x90)7f6z(i&amp;55@o11*8o'
87 87
88 88 TEMPLATES = [{
89 89 'BACKEND': 'django.template.backends.django.DjangoTemplates',
90 90 'DIRS': ['templates'],
91 91 'OPTIONS': {
92 92 'loaders': [
93 93 ('django.template.loaders.cached.Loader', [
94 94 'django.template.loaders.filesystem.Loader',
95 95 'django.template.loaders.app_directories.Loader',
96 96 ]),
97 97 ],
98 98 'context_processors': [
99 99 'django.template.context_processors.csrf',
100 100 'django.contrib.auth.context_processors.auth',
101 101 'boards.context_processors.user_and_ui_processor',
102 102 ],
103 103 },
104 104 }]
105 105
106 106
107 107 MIDDLEWARE_CLASSES = [
108 108 'django.middleware.http.ConditionalGetMiddleware',
109 109 'django.contrib.sessions.middleware.SessionMiddleware',
110 110 'django.middleware.locale.LocaleMiddleware',
111 111 'django.middleware.common.CommonMiddleware',
112 112 'django.contrib.auth.middleware.AuthenticationMiddleware',
113 113 'django.contrib.messages.middleware.MessageMiddleware',
114 114 'boards.middlewares.BanMiddleware',
115 115 'boards.middlewares.TimezoneMiddleware',
116 116 ]
117 117
118 118 ROOT_URLCONF = 'neboard.urls'
119 119
120 120 # Python dotted path to the WSGI application used by Django's runserver.
121 121 WSGI_APPLICATION = 'neboard.wsgi.application'
122 122
123 123 INSTALLED_APPS = (
124 124 'django.contrib.auth',
125 125 'django.contrib.contenttypes',
126 126 'django.contrib.sessions',
127 127 'django.contrib.staticfiles',
128 128 # Uncomment the next line to enable the admin:
129 129 'django.contrib.admin',
130 130 # Uncomment the next line to enable admin documentation:
131 131 # 'django.contrib.admindocs',
132 132 'django.contrib.messages',
133 133
134 134 'debug_toolbar',
135 135
136 136 'boards',
137 137 )
138 138
139 139 # A sample logging configuration. The only tangible logging
140 140 # performed by this configuration is to send an email to
141 141 # the site admins on every HTTP 500 error when DEBUG=False.
142 142 # See http://docs.djangoproject.com/en/dev/topics/logging for
143 143 # more details on how to customize your logging configuration.
144 144 LOGGING = {
145 145 'version': 1,
146 146 'disable_existing_loggers': False,
147 147 'formatters': {
148 148 'verbose': {
149 149 'format': '%(levelname)s %(asctime)s %(name)s %(process)d %(thread)d %(message)s'
150 150 },
151 151 'simple': {
152 152 'format': '%(levelname)s %(asctime)s [%(name)s] %(message)s'
153 153 },
154 154 },
155 155 'filters': {
156 156 'require_debug_false': {
157 157 '()': 'django.utils.log.RequireDebugFalse'
158 158 }
159 159 },
160 160 'handlers': {
161 161 'console': {
162 162 'level': 'DEBUG',
163 163 'class': 'logging.StreamHandler',
164 164 'formatter': 'simple'
165 165 },
166 166 },
167 167 'loggers': {
168 168 'boards': {
169 169 'handlers': ['console'],
170 170 'level': 'DEBUG',
171 171 }
172 172 },
173 173 }
174 174
175 175 THEMES = [
176 176 ('md', 'Mystic Dark'),
177 177 ('md_centered', 'Mystic Dark (centered)'),
178 178 ('sw', 'Snow White'),
179 179 ('pg', 'Photon Gray'),
180 180 ]
181 181
182 182 IMAGE_VIEWERS = [
183 183 ('simple', 'Simple'),
184 184 ('popup', 'Popup'),
185 185 ]
186 186
187 187 ALLOWED_HOSTS = ['*']
188 188
189 189 POSTING_DELAY = 20 # seconds
190 190
191 # Websocket settins
192 CENTRIFUGE_HOST = 'localhost'
193 CENTRIFUGE_PORT = '9090'
194
195 CENTRIFUGE_ADDRESS = 'http://{}:{}'.format(CENTRIFUGE_HOST, CENTRIFUGE_PORT)
196 CENTRIFUGE_PROJECT_ID = '<project id here>'
197 CENTRIFUGE_PROJECT_SECRET = '<project secret here>'
198 CENTRIFUGE_TIMEOUT = 5
199
200 191 SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
201 192
202 193 # Debug middlewares
203 194 MIDDLEWARE_CLASSES += [
204 195 'debug_toolbar.middleware.DebugToolbarMiddleware',
205 196 ]
206 197
207 198
208 199 def custom_show_toolbar(request):
209 200 return request.user.has_perm('admin.debug')
210 201
211 202 DEBUG_TOOLBAR_CONFIG = {
212 203 'ENABLE_STACKTRACES': True,
213 204 'SHOW_TOOLBAR_CALLBACK': 'neboard.settings.custom_show_toolbar',
214 205 }
215 206
216 207 # FIXME Uncommenting this fails somehow. Need to investigate this
217 208 #DEBUG_TOOLBAR_PANELS += (
218 209 # 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
219 210 #)
@@ -1,12 +1,11 b''
1 1 python-magic
2 2 httplib2
3 3 simplejson
4 4 pytube
5 5 requests
6 adjacent
7 6 pillow
8 7 django>=1.8
9 8 bbcode
10 9 django-debug-toolbar
11 10 pytz
12 11 ecdsa
General Comments 0
You need to be logged in to leave comments. Login now