##// END OF EJS Templates
Removed multitread posts 'feature'
neko259 -
r1704:8410d497 default
parent child Browse files
Show More
@@ -0,0 +1,25 b''
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.9.5 on 2016-11-27 13:41
3 from __future__ import unicode_literals
4
5 from django.db import migrations, models
6 import django.db.models.deletion
7
8
9 class Migration(migrations.Migration):
10
11 dependencies = [
12 ('boards', '0052_auto_20161120_1344'),
13 ]
14
15 operations = [
16 migrations.RemoveField(
17 model_name='post',
18 name='threads',
19 ),
20 migrations.AlterField(
21 model_name='post',
22 name='thread',
23 field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='boards.Thread'),
24 ),
25 ]
@@ -1,160 +1,160 b''
1 1 from boards.models.attachment import FILE_TYPES_IMAGE
2 2 from django.contrib import admin
3 3 from django.utils.translation import ugettext_lazy as _
4 4 from django.core.urlresolvers import reverse
5 5 from boards.models import Post, Tag, Ban, Thread, Banner, Attachment, KeyPair, GlobalId
6 6
7 7
8 8 @admin.register(Post)
9 9 class PostAdmin(admin.ModelAdmin):
10 10
11 11 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images',
12 12 'foreign', 'tags')
13 13 list_filter = ('pub_time',)
14 14 search_fields = ('id', 'title', 'text', 'poster_ip')
15 15 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
16 readonly_fields = ('poster_ip', 'threads', 'thread', 'linked_images',
16 readonly_fields = ('poster_ip', 'thread', 'linked_images',
17 17 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
18 18 'version', 'foreign', 'tags')
19 19
20 20 def ban_poster(self, request, queryset):
21 21 bans = 0
22 22 for post in queryset:
23 23 poster_ip = post.poster_ip
24 24 ban, created = Ban.objects.get_or_create(ip=poster_ip)
25 25 if created:
26 26 bans += 1
27 27 self.message_user(request, _('{} posters were banned').format(bans))
28 28
29 29 def ban_latter_with_delete(self, request, queryset):
30 30 bans = 0
31 31 hidden = 0
32 32 for post in queryset:
33 33 poster_ip = post.poster_ip
34 34 ban, created = Ban.objects.get_or_create(ip=poster_ip)
35 35 if created:
36 36 bans += 1
37 37 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
38 38 hidden += posts.count()
39 39 posts.delete()
40 40 self.message_user(request, _('{} posters were banned, {} messages were removed.').format(bans, hidden))
41 41 ban_latter_with_delete.short_description = _('Ban user and delete posts starting from this one and later')
42 42
43 43 def linked_images(self, obj: Post):
44 44 images = obj.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
45 45 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
46 46 reverse('admin:%s_%s_change' % (image._meta.app_label,
47 47 image._meta.model_name),
48 48 args=[image.id]), image.get_thumb_url()) for image in images]
49 49 return ', '.join(image_urls)
50 50 linked_images.allow_tags = True
51 51
52 52 def linked_global_id(self, obj: Post):
53 53 global_id = obj.global_id
54 54 if global_id is not None:
55 55 return '<a href="{}">{}</a>'.format(
56 56 reverse('admin:%s_%s_change' % (global_id._meta.app_label,
57 57 global_id._meta.model_name),
58 58 args=[global_id.id]), str(global_id))
59 59 linked_global_id.allow_tags = True
60 60
61 61 def tags(self, obj: Post):
62 62 return ', '.join([tag.name for tag in obj.get_tags()])
63 63
64 64 def save_model(self, request, obj, form, change):
65 65 obj.increment_version()
66 66 obj.save()
67 67 obj.clear_cache()
68 68
69 69 def foreign(self, obj: Post):
70 70 return obj is not None and obj.global_id is not None and\
71 71 not obj.global_id.is_local()
72 72
73 73 actions = ['ban_poster', 'ban_latter_with_delete']
74 74
75 75
76 76 @admin.register(Tag)
77 77 class TagAdmin(admin.ModelAdmin):
78 78
79 79 def thread_count(self, obj: Tag) -> int:
80 80 return obj.get_thread_count()
81 81
82 82 def display_children(self, obj: Tag):
83 83 return ', '.join([str(child) for child in obj.get_children().all()])
84 84
85 85 def save_model(self, request, obj, form, change):
86 86 super().save_model(request, obj, form, change)
87 87 for thread in obj.get_threads().all():
88 88 thread.refresh_tags()
89 89 list_display = ('name', 'thread_count', 'display_children')
90 90 search_fields = ('name',)
91 91
92 92
93 93 @admin.register(Thread)
94 94 class ThreadAdmin(admin.ModelAdmin):
95 95
96 96 def title(self, obj: Thread) -> str:
97 97 return obj.get_opening_post().get_title()
98 98
99 99 def reply_count(self, obj: Thread) -> int:
100 100 return obj.get_reply_count()
101 101
102 102 def ip(self, obj: Thread):
103 103 return obj.get_opening_post().poster_ip
104 104
105 105 def display_tags(self, obj: Thread):
106 106 return ', '.join([str(tag) for tag in obj.get_tags().all()])
107 107
108 108 def op(self, obj: Thread):
109 109 return obj.get_opening_post_id()
110 110
111 111 # Save parent tags when editing tags
112 112 def save_related(self, request, form, formsets, change):
113 113 super().save_related(request, form, formsets, change)
114 114 form.instance.refresh_tags()
115 115
116 116 def save_model(self, request, obj, form, change):
117 117 op = obj.get_opening_post()
118 118 op.increment_version()
119 119 op.save(update_fields=['version'])
120 120 obj.save()
121 121 op.clear_cache()
122 122
123 123 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
124 124 'display_tags')
125 125 list_filter = ('bump_time', 'status')
126 126 search_fields = ('id', 'title')
127 127 filter_horizontal = ('tags',)
128 128
129 129
130 130 @admin.register(KeyPair)
131 131 class KeyPairAdmin(admin.ModelAdmin):
132 132 list_display = ('public_key', 'primary')
133 133 list_filter = ('primary',)
134 134 search_fields = ('public_key',)
135 135
136 136
137 137 @admin.register(Ban)
138 138 class BanAdmin(admin.ModelAdmin):
139 139 list_display = ('ip', 'can_read')
140 140 list_filter = ('can_read',)
141 141 search_fields = ('ip',)
142 142
143 143
144 144 @admin.register(Banner)
145 145 class BannerAdmin(admin.ModelAdmin):
146 146 list_display = ('title', 'text')
147 147
148 148
149 149 @admin.register(Attachment)
150 150 class AttachmentAdmin(admin.ModelAdmin):
151 151 search_fields = ('alias',)
152 152
153 153
154 154 @admin.register(GlobalId)
155 155 class GlobalIdAdmin(admin.ModelAdmin):
156 156 def is_linked(self, obj):
157 157 return Post.objects.filter(global_id=obj).exists()
158 158
159 159 list_display = ('__str__', 'is_linked',)
160 160 readonly_fields = ('content',)
@@ -1,500 +1,478 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4 import logging
5 5
6 6 import pytz
7 7
8 8 from django import forms
9 9 from django.core.files.uploadedfile import SimpleUploadedFile
10 10 from django.core.exceptions import ObjectDoesNotExist
11 11 from django.forms.utils import ErrorList
12 12 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
13 13 from django.utils import timezone
14 14
15 15 from boards.abstracts.settingsmanager import get_settings_manager
16 16 from boards.abstracts.attachment_alias import get_image_by_alias
17 17 from boards.mdx_neboard import formatters
18 18 from boards.models.attachment.downloaders import download
19 19 from boards.models.post import TITLE_MAX_LENGTH
20 20 from boards.models import Tag, Post
21 21 from boards.utils import validate_file_size, get_file_mimetype, \
22 22 FILE_EXTENSION_DELIMITER
23 23 from neboard import settings
24 24 import boards.settings as board_settings
25 25 import neboard
26 26
27 27 POW_HASH_LENGTH = 16
28 28 POW_LIFE_MINUTES = 5
29 29
30 30 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
31 31 REGEX_USERNAMES = re.compile(r'^[\w\s\d,]+$', re.UNICODE)
32 32 REGEX_URL = re.compile(r'^(http|https|ftp|magnet):\/\/', re.UNICODE)
33 33
34 34 VETERAN_POSTING_DELAY = 5
35 35
36 36 ATTRIBUTE_PLACEHOLDER = 'placeholder'
37 37 ATTRIBUTE_ROWS = 'rows'
38 38
39 39 LAST_POST_TIME = 'last_post_time'
40 40 LAST_LOGIN_TIME = 'last_login_time'
41 41 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
42 42 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
43 43
44 44 LABEL_TITLE = _('Title')
45 45 LABEL_TEXT = _('Text')
46 46 LABEL_TAG = _('Tag')
47 47 LABEL_SEARCH = _('Search')
48 48
49 49 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
50 50 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
51 51
52 52 TAG_MAX_LENGTH = 20
53 53
54 54 TEXTAREA_ROWS = 4
55 55
56 56 TRIPCODE_DELIM = '#'
57 57
58 58 # TODO Maybe this may be converted into the database table?
59 59 MIMETYPE_EXTENSIONS = {
60 60 'image/jpeg': 'jpeg',
61 61 'image/png': 'png',
62 62 'image/gif': 'gif',
63 63 'video/webm': 'webm',
64 64 'application/pdf': 'pdf',
65 65 'x-diff': 'diff',
66 66 'image/svg+xml': 'svg',
67 67 'application/x-shockwave-flash': 'swf',
68 68 'image/x-ms-bmp': 'bmp',
69 69 'image/bmp': 'bmp',
70 70 }
71 71
72 72
73 73 logger = logging.getLogger('boards.forms')
74 74
75 75
76 76 def get_timezones():
77 77 timezones = []
78 78 for tz in pytz.common_timezones:
79 79 timezones.append((tz, tz),)
80 80 return timezones
81 81
82 82
83 83 class FormatPanel(forms.Textarea):
84 84 """
85 85 Panel for text formatting. Consists of buttons to add different tags to the
86 86 form text area.
87 87 """
88 88
89 89 def render(self, name, value, attrs=None):
90 90 output = '<div id="mark-panel">'
91 91 for formatter in formatters:
92 92 output += '<span class="mark_btn"' + \
93 93 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
94 94 '\', \'' + formatter.format_right + '\')">' + \
95 95 formatter.preview_left + formatter.name + \
96 96 formatter.preview_right + '</span>'
97 97
98 98 output += '</div>'
99 99 output += super(FormatPanel, self).render(name, value, attrs=attrs)
100 100
101 101 return output
102 102
103 103
104 104 class PlainErrorList(ErrorList):
105 105 def __unicode__(self):
106 106 return self.as_text()
107 107
108 108 def as_text(self):
109 109 return ''.join(['(!) %s ' % e for e in self])
110 110
111 111
112 112 class NeboardForm(forms.Form):
113 113 """
114 114 Form with neboard-specific formatting.
115 115 """
116 116 required_css_class = 'required-field'
117 117
118 118 def as_div(self):
119 119 """
120 120 Returns this form rendered as HTML <as_div>s.
121 121 """
122 122
123 123 return self._html_output(
124 124 # TODO Do not show hidden rows in the list here
125 125 normal_row='<div class="form-row">'
126 126 '<div class="form-label">'
127 127 '%(label)s'
128 128 '</div>'
129 129 '<div class="form-input">'
130 130 '%(field)s'
131 131 '</div>'
132 132 '</div>'
133 133 '<div class="form-row">'
134 134 '%(help_text)s'
135 135 '</div>',
136 136 error_row='<div class="form-row">'
137 137 '<div class="form-label"></div>'
138 138 '<div class="form-errors">%s</div>'
139 139 '</div>',
140 140 row_ender='</div>',
141 141 help_text_html='%s',
142 142 errors_on_separate_row=True)
143 143
144 144 def as_json_errors(self):
145 145 errors = []
146 146
147 147 for name, field in list(self.fields.items()):
148 148 if self[name].errors:
149 149 errors.append({
150 150 'field': name,
151 151 'errors': self[name].errors.as_text(),
152 152 })
153 153
154 154 return errors
155 155
156 156
157 157 class PostForm(NeboardForm):
158 158
159 159 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
160 160 label=LABEL_TITLE,
161 161 widget=forms.TextInput(
162 162 attrs={ATTRIBUTE_PLACEHOLDER:
163 163 'test#tripcode'}))
164 164 text = forms.CharField(
165 165 widget=FormatPanel(attrs={
166 166 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
167 167 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
168 168 }),
169 169 required=False, label=LABEL_TEXT)
170 170 file = forms.FileField(required=False, label=_('File'),
171 171 widget=forms.ClearableFileInput(
172 172 attrs={'accept': 'file/*'}))
173 173 file_url = forms.CharField(required=False, label=_('File URL'),
174 174 widget=forms.TextInput(
175 175 attrs={ATTRIBUTE_PLACEHOLDER:
176 176 'http://example.com/image.png'}))
177 177
178 178 # This field is for spam prevention only
179 179 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
180 180 widget=forms.TextInput(attrs={
181 181 'class': 'form-email'}))
182 threads = forms.CharField(required=False, label=_('Additional threads'),
183 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
184 '123 456 789'}))
185 182 subscribe = forms.BooleanField(required=False, label=_('Subscribe to thread'))
186 183
187 184 guess = forms.CharField(widget=forms.HiddenInput(), required=False)
188 185 timestamp = forms.CharField(widget=forms.HiddenInput(), required=False)
189 186 iteration = forms.CharField(widget=forms.HiddenInput(), required=False)
190 187
191 188 session = None
192 189 need_to_ban = False
193 190 image = None
194 191
195 192 def _update_file_extension(self, file):
196 193 if file:
197 194 mimetype = get_file_mimetype(file)
198 195 extension = MIMETYPE_EXTENSIONS.get(mimetype)
199 196 if extension:
200 197 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
201 198 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
202 199
203 200 file.name = new_filename
204 201 else:
205 202 logger = logging.getLogger('boards.forms.extension')
206 203
207 204 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
208 205
209 206 def clean_title(self):
210 207 title = self.cleaned_data['title']
211 208 if title:
212 209 if len(title) > TITLE_MAX_LENGTH:
213 210 raise forms.ValidationError(_('Title must have less than %s '
214 211 'characters') %
215 212 str(TITLE_MAX_LENGTH))
216 213 return title
217 214
218 215 def clean_text(self):
219 216 text = self.cleaned_data['text'].strip()
220 217 if text:
221 218 max_length = board_settings.get_int('Forms', 'MaxTextLength')
222 219 if len(text) > max_length:
223 220 raise forms.ValidationError(_('Text must have less than %s '
224 221 'characters') % str(max_length))
225 222 return text
226 223
227 224 def clean_file(self):
228 225 file = self.cleaned_data['file']
229 226
230 227 if file:
231 228 validate_file_size(file.size)
232 229 self._update_file_extension(file)
233 230
234 231 return file
235 232
236 233 def clean_file_url(self):
237 234 url = self.cleaned_data['file_url']
238 235
239 236 file = None
240 237
241 238 if url:
242 239 try:
243 240 file = get_image_by_alias(url, self.session)
244 241 self.image = file
245 242
246 243 if file is not None:
247 244 return
248 245
249 246 if file is None:
250 247 file = self._get_file_from_url(url)
251 248 if not file:
252 249 raise forms.ValidationError(_('Invalid URL'))
253 250 else:
254 251 validate_file_size(file.size)
255 252 self._update_file_extension(file)
256 253 except forms.ValidationError as e:
257 254 # Assume we will get the plain URL instead of a file and save it
258 255 if REGEX_URL.match(url):
259 256 logger.info('Error in forms: {}'.format(e))
260 257 return url
261 258 else:
262 259 raise e
263 260
264 261 return file
265 262
266 def clean_threads(self):
267 threads_str = self.cleaned_data['threads']
268
269 if len(threads_str) > 0:
270 threads_id_list = threads_str.split(' ')
271
272 threads = list()
273
274 for thread_id in threads_id_list:
275 try:
276 thread = Post.objects.get(id=int(thread_id))
277 if not thread.is_opening() or thread.get_thread().is_archived():
278 raise ObjectDoesNotExist()
279 threads.append(thread)
280 except (ObjectDoesNotExist, ValueError):
281 raise forms.ValidationError(_('Invalid additional thread list'))
282
283 return threads
284
285 263 def clean(self):
286 264 cleaned_data = super(PostForm, self).clean()
287 265
288 266 if cleaned_data['email']:
289 267 if board_settings.get_bool('Forms', 'Autoban'):
290 268 self.need_to_ban = True
291 269 raise forms.ValidationError('A human cannot enter a hidden field')
292 270
293 271 if not self.errors:
294 272 self._clean_text_file()
295 273
296 274 limit_speed = board_settings.get_bool('Forms', 'LimitPostingSpeed')
297 275 limit_first = board_settings.get_bool('Forms', 'LimitFirstPosting')
298 276
299 277 settings_manager = get_settings_manager(self)
300 278 if not self.errors and limit_speed or (limit_first and not settings_manager.get_setting('confirmed_user')):
301 279 pow_difficulty = board_settings.get_int('Forms', 'PowDifficulty')
302 280 if pow_difficulty > 0:
303 281 # PoW-based
304 282 if cleaned_data['timestamp'] \
305 283 and cleaned_data['iteration'] and cleaned_data['guess'] \
306 284 and not settings_manager.get_setting('confirmed_user'):
307 285 self._validate_hash(cleaned_data['timestamp'], cleaned_data['iteration'], cleaned_data['guess'], cleaned_data['text'])
308 286 else:
309 287 # Time-based
310 288 self._validate_posting_speed()
311 289 settings_manager.set_setting('confirmed_user', True)
312 290
313 291 return cleaned_data
314 292
315 293 def get_file(self):
316 294 """
317 295 Gets file from form or URL.
318 296 """
319 297
320 298 file = self.cleaned_data['file']
321 299 if type(self.cleaned_data['file_url']) is not str:
322 300 file_url = self.cleaned_data['file_url']
323 301 else:
324 302 file_url = None
325 303 return file or file_url
326 304
327 305 def get_file_url(self):
328 306 if not self.get_file():
329 307 return self.cleaned_data['file_url']
330 308
331 309 def get_tripcode(self):
332 310 title = self.cleaned_data['title']
333 311 if title is not None and TRIPCODE_DELIM in title:
334 312 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
335 313 tripcode = hashlib.md5(code.encode()).hexdigest()
336 314 else:
337 315 tripcode = ''
338 316 return tripcode
339 317
340 318 def get_title(self):
341 319 title = self.cleaned_data['title']
342 320 if title is not None and TRIPCODE_DELIM in title:
343 321 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
344 322 else:
345 323 return title
346 324
347 325 def get_images(self):
348 326 if self.image:
349 327 return [self.image]
350 328 else:
351 329 return []
352 330
353 331 def is_subscribe(self):
354 332 return self.cleaned_data['subscribe']
355 333
356 334 def _clean_text_file(self):
357 335 text = self.cleaned_data.get('text')
358 336 file = self.get_file()
359 337 file_url = self.get_file_url()
360 338 images = self.get_images()
361 339
362 340 if (not text) and (not file) and (not file_url) and len(images) == 0:
363 341 error_message = _('Either text or file must be entered.')
364 342 self._errors['text'] = self.error_class([error_message])
365 343
366 344 def _validate_posting_speed(self):
367 345 can_post = True
368 346
369 347 posting_delay = board_settings.get_int('Forms', 'PostingDelay')
370 348
371 349 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
372 350 now = time.time()
373 351
374 352 current_delay = 0
375 353
376 354 if LAST_POST_TIME not in self.session:
377 355 self.session[LAST_POST_TIME] = now
378 356
379 357 need_delay = True
380 358 else:
381 359 last_post_time = self.session.get(LAST_POST_TIME)
382 360 current_delay = int(now - last_post_time)
383 361
384 362 need_delay = current_delay < posting_delay
385 363
386 364 if need_delay:
387 365 delay = posting_delay - current_delay
388 366 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
389 367 delay) % {'delay': delay}
390 368 self._errors['text'] = self.error_class([error_message])
391 369
392 370 can_post = False
393 371
394 372 if can_post:
395 373 self.session[LAST_POST_TIME] = now
396 374
397 375 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
398 376 """
399 377 Gets an file file from URL.
400 378 """
401 379
402 380 try:
403 381 return download(url)
404 382 except forms.ValidationError as e:
405 383 raise e
406 384 except Exception as e:
407 385 raise forms.ValidationError(e)
408 386
409 387 def _validate_hash(self, timestamp: str, iteration: str, guess: str, message: str):
410 388 post_time = timezone.datetime.fromtimestamp(
411 389 int(timestamp[:-3]), tz=timezone.get_current_timezone())
412 390
413 391 payload = timestamp + message.replace('\r\n', '\n')
414 392 difficulty = board_settings.get_int('Forms', 'PowDifficulty')
415 393 target = str(int(2 ** (POW_HASH_LENGTH * 3) / difficulty))
416 394 if len(target) < POW_HASH_LENGTH:
417 395 target = '0' * (POW_HASH_LENGTH - len(target)) + target
418 396
419 397 computed_guess = hashlib.sha256((payload + iteration).encode())\
420 398 .hexdigest()[0:POW_HASH_LENGTH]
421 399 if guess != computed_guess or guess > target:
422 400 self._errors['text'] = self.error_class(
423 401 [_('Invalid PoW.')])
424 402
425 403
426 404
427 405 class ThreadForm(PostForm):
428 406
429 407 tags = forms.CharField(
430 408 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
431 409 max_length=100, label=_('Tags'), required=True)
432 410 monochrome = forms.BooleanField(label=_('Monochrome'), required=False)
433 411
434 412 def clean_tags(self):
435 413 tags = self.cleaned_data['tags'].strip()
436 414
437 415 if not tags or not REGEX_TAGS.match(tags):
438 416 raise forms.ValidationError(
439 417 _('Inappropriate characters in tags.'))
440 418
441 419 default_tag_name = board_settings.get('Forms', 'DefaultTag')\
442 420 .strip().lower()
443 421
444 422 required_tag_exists = False
445 423 tag_set = set()
446 424 for tag_string in tags.split():
447 425 if tag_string.strip().lower() == default_tag_name:
448 426 required_tag_exists = True
449 427 tag, created = Tag.objects.get_or_create(
450 428 name=tag_string.strip().lower(), required=True)
451 429 else:
452 430 tag, created = Tag.objects.get_or_create(
453 431 name=tag_string.strip().lower())
454 432 tag_set.add(tag)
455 433
456 434 # If this is a new tag, don't check for its parents because nobody
457 435 # added them yet
458 436 if not created:
459 437 tag_set |= set(tag.get_all_parents())
460 438
461 439 for tag in tag_set:
462 440 if tag.required:
463 441 required_tag_exists = True
464 442 break
465 443
466 444 # Use default tag if no section exists
467 445 if not required_tag_exists:
468 446 default_tag, created = Tag.objects.get_or_create(
469 447 name=default_tag_name, required=True)
470 448 tag_set.add(default_tag)
471 449
472 450 return tag_set
473 451
474 452 def clean(self):
475 453 cleaned_data = super(ThreadForm, self).clean()
476 454
477 455 return cleaned_data
478 456
479 457 def is_monochrome(self):
480 458 return self.cleaned_data['monochrome']
481 459
482 460
483 461 class SettingsForm(NeboardForm):
484 462
485 463 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
486 464 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
487 465 username = forms.CharField(label=_('User name'), required=False)
488 466 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
489 467
490 468 def clean_username(self):
491 469 username = self.cleaned_data['username']
492 470
493 471 if username and not REGEX_USERNAMES.match(username):
494 472 raise forms.ValidationError(_('Inappropriate characters.'))
495 473
496 474 return username
497 475
498 476
499 477 class SearchForm(NeboardForm):
500 478 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,409 +1,393 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, null=True, blank=True)
73 73 pub_time = models.DateTimeField(db_index=True)
74 74 text = TextField(blank=True, null=True)
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 threads = models.ManyToManyField('Thread', db_index=True,
91 related_name='multi_replies')
92 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
93 91
94 92 url = models.TextField()
95 93 uid = models.TextField(db_index=True)
96 94
97 95 # Global ID with author key. If the message was downloaded from another
98 96 # server, this indicates the server.
99 97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
100 98 on_delete=models.CASCADE)
101 99
102 100 tripcode = models.CharField(max_length=50, blank=True, default='')
103 101 opening = models.BooleanField(db_index=True)
104 102 hidden = models.BooleanField(default=False)
105 103 version = models.IntegerField(default=1)
106 104
107 105 def __str__(self):
108 106 return 'P#{}/{}'.format(self.id, self.get_title())
109 107
110 108 def get_title(self) -> str:
111 109 return self.title
112 110
113 111 def get_title_or_text(self):
114 112 title = self.get_title()
115 113 if not title:
116 114 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
117 115
118 116 return title
119 117
120 118 def build_refmap(self, excluded_ids=None) -> None:
121 119 """
122 120 Builds a replies map string from replies list. This is a cache to stop
123 121 the server from recalculating the map on every post show.
124 122 """
125 123
126 124 replies = self.referenced_posts
127 125 if excluded_ids is not None:
128 126 replies = replies.exclude(id__in=excluded_ids)
129 127 else:
130 128 replies = replies.all()
131 129
132 130 post_urls = [refpost.get_link_view() for refpost in replies]
133 131
134 132 self.refmap = ', '.join(post_urls)
135 133
136 134 def is_referenced(self) -> bool:
137 135 return self.refmap and len(self.refmap) > 0
138 136
139 137 def is_opening(self) -> bool:
140 138 """
141 139 Checks if this is an opening post or just a reply.
142 140 """
143 141
144 142 return self.opening
145 143
146 144 def get_absolute_url(self, thread=None):
147 145 # Url is cached only for the "main" thread. When getting url
148 146 # for other threads, do it manually.
149 147 return self.url
150 148
151 149 def get_thread(self):
152 150 return self.thread
153 151
154 152 def get_thread_id(self):
155 153 return self.thread_id
156 154
157 155 def get_threads(self) -> QuerySet:
158 156 """
159 157 Gets post's thread.
160 158 """
161 159
162 160 return self.threads
163 161
164 162 def _get_cache_key(self):
165 163 return [datetime_to_epoch(self.last_edit_time)]
166 164
167 165 def get_view_params(self, *args, **kwargs):
168 166 """
169 167 Gets the parameters required for viewing the post based on the arguments
170 168 given and the post itself.
171 169 """
172 170 thread = kwargs.get('thread') or self.get_thread()
173 171
174 172 css_classes = [CSS_CLS_POST]
175 173 if thread.is_archived():
176 174 css_classes.append(CSS_CLS_ARCHIVE_POST)
177 175 elif not thread.can_bump():
178 176 css_classes.append(CSS_CLS_DEAD_POST)
179 177 if self.is_hidden():
180 178 css_classes.append(CSS_CLS_HIDDEN_POST)
181 179 if thread.is_monochrome():
182 180 css_classes.append(CSS_CLS_MONOCHROME)
183 181
184 182 params = dict()
185 183 for param in POST_VIEW_PARAMS:
186 184 if param in kwargs:
187 185 params[param] = kwargs[param]
188 186
189 187 params.update({
190 188 PARAMETER_POST: self,
191 189 PARAMETER_IS_OPENING: self.is_opening(),
192 190 PARAMETER_THREAD: thread,
193 191 PARAMETER_CSS_CLASS: ' '.join(css_classes),
194 192 })
195 193
196 194 return params
197 195
198 196 def get_view(self, *args, **kwargs) -> str:
199 197 """
200 198 Renders post's HTML view. Some of the post params can be passed over
201 199 kwargs for the means of caching (if we view the thread, some params
202 200 are same for every post and don't need to be computed over and over.
203 201 """
204 202 params = self.get_view_params(*args, **kwargs)
205 203
206 204 return render_to_string('boards/post.html', params)
207 205
208 206 def get_search_view(self, *args, **kwargs):
209 207 return self.get_view(need_op_data=True, *args, **kwargs)
210 208
211 209 def get_first_image(self) -> Attachment:
212 210 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE).earliest('id')
213 211
214 212 def set_global_id(self, key_pair=None):
215 213 """
216 214 Sets global id based on the given key pair. If no key pair is given,
217 215 default one is used.
218 216 """
219 217
220 218 if key_pair:
221 219 key = key_pair
222 220 else:
223 221 try:
224 222 key = KeyPair.objects.get(primary=True)
225 223 except KeyPair.DoesNotExist:
226 224 # Do not update the global id because there is no key defined
227 225 return
228 226 global_id = GlobalId(key_type=key.key_type,
229 227 key=key.public_key,
230 228 local_id=self.id)
231 229 global_id.save()
232 230
233 231 self.global_id = global_id
234 232
235 233 self.save(update_fields=['global_id'])
236 234
237 235 def get_pub_time_str(self):
238 236 return str(self.pub_time)
239 237
240 238 def get_replied_ids(self):
241 239 """
242 240 Gets ID list of the posts that this post replies.
243 241 """
244 242
245 243 raw_text = self.get_raw_text()
246 244
247 245 local_replied = REGEX_REPLY.findall(raw_text)
248 246 global_replied = []
249 247 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
250 248 key_type = match[0]
251 249 key = match[1]
252 250 local_id = match[2]
253 251
254 252 try:
255 253 global_id = GlobalId.objects.get(key_type=key_type,
256 254 key=key, local_id=local_id)
257 255 for post in Post.objects.filter(global_id=global_id).only('id'):
258 256 global_replied.append(post.id)
259 257 except GlobalId.DoesNotExist:
260 258 pass
261 259 return local_replied + global_replied
262 260
263 261 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
264 262 include_last_update=False) -> str:
265 263 """
266 264 Gets post HTML or JSON data that can be rendered on a page or used by
267 265 API.
268 266 """
269 267
270 268 return get_exporter(format_type).export(self, request,
271 269 include_last_update)
272 270
273 271 def notify_clients(self, recursive=True):
274 272 """
275 273 Sends post HTML data to the thread web socket.
276 274 """
277 275
278 276 if not settings.get_bool('External', 'WebsocketsEnabled'):
279 277 return
280 278
281 279 thread_ids = list()
282 for thread in self.get_threads().all():
283 thread_ids.append(thread.id)
284
285 thread.notify_clients()
280 self.get_thread().notify_clients()
286 281
287 282 if recursive:
288 283 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
289 284 post_id = reply_number.group(1)
290 285
291 286 try:
292 287 ref_post = Post.objects.get(id=post_id)
293 288
294 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
289 if ref_post.get_thread().id not in thread_ids:
295 290 # If post is in this thread, its thread was already notified.
296 291 # Otherwise, notify its thread separately.
297 292 ref_post.notify_clients(recursive=False)
298 293 except ObjectDoesNotExist:
299 294 pass
300 295
301 296 def _build_url(self):
302 297 opening = self.is_opening()
303 298 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
304 299 url = reverse('thread', kwargs={'post_id': opening_id})
305 300 if not opening:
306 301 url += '#' + str(self.id)
307 302
308 303 return url
309 304
310 305 def save(self, force_insert=False, force_update=False, using=None,
311 306 update_fields=None):
312 307 new_post = self.id is None
313 308
314 309 self.uid = str(uuid.uuid4())
315 310 if update_fields is not None and 'uid' not in update_fields:
316 311 update_fields += ['uid']
317 312
318 313 if not new_post:
319 for thread in self.get_threads().all():
314 thread = self.get_thread()
315 if thread:
320 316 thread.last_edit_time = self.last_edit_time
321
322 317 thread.save(update_fields=['last_edit_time', 'status'])
323 318
324 319 super().save(force_insert, force_update, using, update_fields)
325 320
326 321 if new_post:
327 322 self.url = self._build_url()
328 323 super().save(update_fields=['url'])
329 324
330 325 def get_text(self) -> str:
331 326 return self._text_rendered
332 327
333 328 def get_raw_text(self) -> str:
334 329 return self.text
335 330
336 331 def get_sync_text(self) -> str:
337 332 """
338 333 Returns text applicable for sync. It has absolute post reflinks.
339 334 """
340 335
341 336 replacements = dict()
342 337 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
343 338 try:
344 339 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
345 340 replacements[post_id] = absolute_post_id
346 341 except Post.DoesNotExist:
347 342 pass
348 343
349 344 text = self.get_raw_text() or ''
350 345 for key in replacements:
351 346 text = text.replace('[post]{}[/post]'.format(key),
352 347 '[post]{}[/post]'.format(replacements[key]))
353 348 text = text.replace('\r\n', '\n').replace('\r', '\n')
354 349
355 350 return text
356 351
357 def connect_threads(self, opening_posts):
358 for opening_post in opening_posts:
359 threads = opening_post.get_threads().all()
360 for thread in threads:
361 if thread.can_bump():
362 thread.update_bump_status()
363
364 thread.last_edit_time = self.last_edit_time
365 thread.save(update_fields=['last_edit_time', 'status'])
366 self.threads.add(opening_post.get_thread())
367
368 352 def get_tripcode(self):
369 353 if self.tripcode:
370 354 return Tripcode(self.tripcode)
371 355
372 356 def get_link_view(self):
373 357 """
374 358 Gets view of a reflink to the post.
375 359 """
376 360 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
377 361 self.id)
378 362 if self.is_opening():
379 363 result = '<b>{}</b>'.format(result)
380 364
381 365 return result
382 366
383 367 def is_hidden(self) -> bool:
384 368 return self.hidden
385 369
386 370 def set_hidden(self, hidden):
387 371 self.hidden = hidden
388 372
389 373 def increment_version(self):
390 374 self.version = F('version') + 1
391 375
392 376 def clear_cache(self):
393 377 """
394 378 Clears sync data (content cache, signatures etc).
395 379 """
396 380 global_id = self.global_id
397 381 if global_id is not None and global_id.is_local()\
398 382 and global_id.content is not None:
399 383 global_id.clear_cache()
400 384
401 385 def get_tags(self):
402 386 return self.get_thread().get_tags()
403 387
404 388 def get_ip_color(self):
405 389 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
406 390
407 391 def has_ip(self):
408 392 return self.poster_ip != NO_IP
409 393
@@ -1,196 +1,190 b''
1 1 import logging
2 2
3 3 from datetime import datetime, timedelta, date
4 4 from datetime import time as dtime
5 5
6 6 from boards.abstracts.exceptions import BannedException, ArchiveException
7 7 from django.db import models, transaction
8 8 from django.utils import timezone
9 9 from django.dispatch import Signal
10 10
11 11 import boards
12 12
13 13 from boards.models.user import Ban
14 14 from boards.mdx_neboard import Parser
15 15 from boards.models import Attachment
16 16 from boards import utils
17 17
18 18 __author__ = 'neko259'
19 19
20 20 POSTS_PER_DAY_RANGE = 7
21 21 NO_IP = '0.0.0.0'
22 22
23 23
24 24 post_import_deps = Signal()
25 25
26 26
27 27 class PostManager(models.Manager):
28 28 @transaction.atomic
29 29 def create_post(self, title: str, text: str, file=None, thread=None,
30 ip=NO_IP, tags: list=None, opening_posts: list=None,
30 ip=NO_IP, tags: list=None,
31 31 tripcode='', monochrome=False, images=[],
32 32 file_url=None):
33 33 """
34 34 Creates new post
35 35 """
36 36
37 37 if thread is not None and thread.is_archived():
38 38 raise ArchiveException('Cannot post into an archived thread')
39 39
40 40 if not utils.is_anonymous_mode():
41 41 is_banned = Ban.objects.filter(ip=ip).exists()
42 42 else:
43 43 is_banned = False
44 44
45 45 if is_banned:
46 46 raise BannedException("This user is banned")
47 47
48 48 if not tags:
49 49 tags = []
50 if not opening_posts:
51 opening_posts = []
52 50
53 51 posting_time = timezone.now()
54 52 new_thread = False
55 53 if not thread:
56 54 thread = boards.models.thread.Thread.objects.create(
57 55 bump_time=posting_time, last_edit_time=posting_time,
58 monochrome=monochrome)
56 monochrome=monochrome)
59 57 list(map(thread.tags.add, tags))
60 58 new_thread = True
61 59
62 60 pre_text = Parser().preparse(text)
63 61
64 62 post = self.create(title=title,
65 63 text=pre_text,
66 64 pub_time=posting_time,
67 65 poster_ip=ip,
68 66 thread=thread,
69 67 last_edit_time=posting_time,
70 68 tripcode=tripcode,
71 69 opening=new_thread)
72 post.threads.add(thread)
73 70
74 71 logger = logging.getLogger('boards.post.create')
75 72
76 73 logger.info('Created post [{}] with text [{}] by {}'.format(post,
77 74 post.get_text(),post.poster_ip))
78 75
79 76 if file:
80 77 self._add_file_to_post(file, post)
81 78 for image in images:
82 79 post.attachments.add(image)
83 80 if file_url:
84 81 post.attachments.add(Attachment.objects.create_from_url(file_url))
85 82
86 post.connect_threads(opening_posts)
87 83 post.set_global_id()
88 84
89 85 # Thread needs to be bumped only when the post is already created
90 86 if not new_thread:
91 87 thread.last_edit_time = posting_time
92 88 thread.bump()
93 89 thread.save()
94 90
95 91 return post
96 92
97 93 def delete_posts_by_ip(self, ip):
98 94 """
99 95 Deletes all posts of the author with same IP
100 96 """
101 97
102 98 posts = self.filter(poster_ip=ip)
103 99 for post in posts:
104 100 post.delete()
105 101
106 102 @utils.cached_result()
107 103 def get_posts_per_day(self) -> float:
108 104 """
109 105 Gets average count of posts per day for the last 7 days
110 106 """
111 107
112 108 day_end = date.today()
113 109 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
114 110
115 111 day_time_start = timezone.make_aware(datetime.combine(
116 112 day_start, dtime()), timezone.get_current_timezone())
117 113 day_time_end = timezone.make_aware(datetime.combine(
118 114 day_end, dtime()), timezone.get_current_timezone())
119 115
120 116 posts_per_period = float(self.filter(
121 117 pub_time__lte=day_time_end,
122 118 pub_time__gte=day_time_start).count())
123 119
124 120 ppd = posts_per_period / POSTS_PER_DAY_RANGE
125 121
126 122 return ppd
127 123
128 124 def get_post_per_days(self, days) -> int:
129 125 day_end = date.today() + timedelta(1)
130 126 day_start = day_end - timedelta(days)
131 127
132 128 day_time_start = timezone.make_aware(datetime.combine(
133 129 day_start, dtime()), timezone.get_current_timezone())
134 130 day_time_end = timezone.make_aware(datetime.combine(
135 131 day_end, dtime()), timezone.get_current_timezone())
136 132
137 133 return self.filter(
138 134 pub_time__lte=day_time_end,
139 135 pub_time__gte=day_time_start).count()
140 136
141 137
142 138 @transaction.atomic
143 139 def import_post(self, title: str, text: str, pub_time: str, global_id,
144 140 opening_post=None, tags=list(), files=list(),
145 141 tripcode=None, version=1):
146 142 is_opening = opening_post is None
147 143 if is_opening:
148 144 thread = boards.models.thread.Thread.objects.create(
149 145 bump_time=pub_time, last_edit_time=pub_time)
150 146 list(map(thread.tags.add, tags))
151 147 else:
152 148 thread = opening_post.get_thread()
153 149
154 150 post = self.create(title=title,
155 151 text=text,
156 152 pub_time=pub_time,
157 153 poster_ip=NO_IP,
158 154 last_edit_time=pub_time,
159 155 global_id=global_id,
160 156 opening=is_opening,
161 157 thread=thread,
162 158 tripcode=tripcode,
163 159 version=version)
164 160
165 161 for file in files:
166 162 self._add_file_to_post(file, post)
167 163
168 post.threads.add(thread)
169
170 164 url_to_post = '[post]{}[/post]'.format(str(global_id))
171 165 replies = self.filter(text__contains=url_to_post)
172 166 for reply in replies:
173 167 post_import_deps.send(reply)
174 168
175 169 @transaction.atomic
176 170 def update_post(self, post, title: str, text: str, pub_time: str,
177 171 tags=list(), files=list(), tripcode=None, version=1):
178 172 post.title = title
179 173 post.text = text
180 174 post.pub_time = pub_time
181 175 post.tripcode = tripcode
182 176 post.version = version
183 177 post.save()
184 178
185 179 post.clear_cache()
186 180
187 181 post.attachments.clear()
188 182 for file in files:
189 183 self._add_file_to_post(file, post)
190 184
191 185 thread = post.get_thread()
192 186 thread.tags.clear()
193 187 list(map(thread.tags.add, tags))
194 188
195 189 def _add_file_to_post(self, file, post):
196 190 post.attachments.add(Attachment.objects.create_with_hash(file))
@@ -1,150 +1,150 b''
1 1 import hashlib
2 2 from boards.models.attachment import FILE_TYPES_IMAGE
3 3 from django.template.loader import render_to_string
4 4 from django.db import models
5 5 from django.db.models import Count
6 6 from django.core.urlresolvers import reverse
7 7
8 8 from boards.models import Attachment
9 9 from boards.models.base import Viewable
10 10 from boards.models.thread import STATUS_ACTIVE, STATUS_BUMPLIMIT, STATUS_ARCHIVE
11 11 from boards.utils import cached_result
12 12 import boards
13 13
14 14 __author__ = 'neko259'
15 15
16 16
17 17 RELATED_TAGS_COUNT = 5
18 18
19 19
20 20 class TagManager(models.Manager):
21 21
22 22 def get_not_empty_tags(self):
23 23 """
24 24 Gets tags that have non-archived threads.
25 25 """
26 26
27 27 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
28 28 .order_by('-required', 'name')
29 29
30 30 def get_tag_url_list(self, tags: list) -> str:
31 31 """
32 32 Gets a comma-separated list of tag links.
33 33 """
34 34
35 35 return ', '.join([tag.get_view() for tag in tags])
36 36
37 37
38 38 class Tag(models.Model, Viewable):
39 39 """
40 40 A tag is a text node assigned to the thread. The tag serves as a board
41 41 section. There can be multiple tags for each thread
42 42 """
43 43
44 44 objects = TagManager()
45 45
46 46 class Meta:
47 47 app_label = 'boards'
48 48 ordering = ('name',)
49 49
50 50 name = models.CharField(max_length=100, db_index=True, unique=True)
51 51 required = models.BooleanField(default=False, db_index=True)
52 52 description = models.TextField(blank=True)
53 53
54 54 parent = models.ForeignKey('Tag', null=True, blank=True,
55 55 related_name='children')
56 56
57 57 def __str__(self):
58 58 return self.name
59 59
60 60 def is_empty(self) -> bool:
61 61 """
62 62 Checks if the tag has some threads.
63 63 """
64 64
65 65 return self.get_thread_count() == 0
66 66
67 67 def get_thread_count(self, status=None) -> int:
68 68 threads = self.get_threads()
69 69 if status is not None:
70 70 threads = threads.filter(status=status)
71 71 return threads.count()
72 72
73 73 def get_active_thread_count(self) -> int:
74 74 return self.get_thread_count(status=STATUS_ACTIVE)
75 75
76 76 def get_bumplimit_thread_count(self) -> int:
77 77 return self.get_thread_count(status=STATUS_BUMPLIMIT)
78 78
79 79 def get_archived_thread_count(self) -> int:
80 80 return self.get_thread_count(status=STATUS_ARCHIVE)
81 81
82 82 def get_absolute_url(self):
83 83 return reverse('tag', kwargs={'tag_name': self.name})
84 84
85 85 def get_threads(self):
86 86 return self.thread_tags.order_by('-bump_time')
87 87
88 88 def is_required(self):
89 89 return self.required
90 90
91 91 def get_view(self):
92 92 link = '<a class="tag" href="{}">{}</a>'.format(
93 93 self.get_absolute_url(), self.name)
94 94 if self.is_required():
95 95 link = '<b>{}</b>'.format(link)
96 96 return link
97 97
98 98 def get_search_view(self, *args, **kwargs):
99 99 return render_to_string('boards/tag.html', {
100 100 'tag': self,
101 101 })
102 102
103 103 @cached_result()
104 104 def get_post_count(self):
105 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
105 return self.get_threads().aggregate(num_posts=Count('replies'))['num_posts']
106 106
107 107 def get_description(self):
108 108 return self.description
109 109
110 110 def get_random_image_post(self, status=[STATUS_ACTIVE, STATUS_BUMPLIMIT]):
111 111 posts = boards.models.Post.objects.filter(attachments__mimetype__in=FILE_TYPES_IMAGE)\
112 112 .annotate(images_count=Count(
113 'attachments')).filter(images_count__gt=0, threads__tags__in=[self])
113 'attachments')).filter(images_count__gt=0, thread__tags__in=[self])
114 114 if status is not None:
115 115 posts = posts.filter(thread__status__in=status)
116 116 return posts.order_by('?').first()
117 117
118 118 def get_first_letter(self):
119 119 return self.name and self.name[0] or ''
120 120
121 121 def get_related_tags(self):
122 122 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
123 123 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
124 124
125 125 @cached_result()
126 126 def get_color(self):
127 127 """
128 128 Gets color hashed from the tag name.
129 129 """
130 130 return hashlib.md5(self.name.encode()).hexdigest()[:6]
131 131
132 132 def get_parent(self):
133 133 return self.parent
134 134
135 135 def get_all_parents(self):
136 136 parents = list()
137 137 parent = self.get_parent()
138 138 if parent and parent not in parents:
139 139 parents.insert(0, parent)
140 140 parents = parent.get_all_parents() + parents
141 141
142 142 return parents
143 143
144 144 def get_children(self):
145 145 return self.children
146 146
147 147 def get_images(self):
148 148 return Attachment.objects.filter(
149 149 attachment_posts__thread__tags__in=[self]).filter(
150 150 mimetype__in=FILE_TYPES_IMAGE).order_by('-attachment_posts__pub_time') No newline at end of file
@@ -1,327 +1,327 b''
1 1 import logging
2 2 from adjacent import Client
3 3 from datetime import timedelta
4 4
5 5
6 6 from django.db.models import Count, Sum, QuerySet, Q
7 7 from django.utils import timezone
8 8 from django.db import models, transaction
9 9
10 10 from boards.models.attachment import FILE_TYPES_IMAGE
11 11 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
12 12
13 13 from boards import settings
14 14 import boards
15 15 from boards.utils import cached_result, datetime_to_epoch
16 16 from boards.models.post import Post
17 17 from boards.models.tag import Tag
18 18
19 19 FAV_THREAD_NO_UPDATES = -1
20 20
21 21
22 22 __author__ = 'neko259'
23 23
24 24
25 25 logger = logging.getLogger(__name__)
26 26
27 27
28 28 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
29 29 WS_NOTIFICATION_TYPE = 'notification_type'
30 30
31 31 WS_CHANNEL_THREAD = "thread:"
32 32
33 33 STATUS_CHOICES = (
34 34 (STATUS_ACTIVE, STATUS_ACTIVE),
35 35 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
36 36 (STATUS_ARCHIVE, STATUS_ARCHIVE),
37 37 )
38 38
39 39
40 40 class ThreadManager(models.Manager):
41 41 def process_old_threads(self):
42 42 """
43 43 Preserves maximum thread count. If there are too many threads,
44 44 archive or delete the old ones.
45 45 """
46 46 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
47 47 old_time = timezone.now() - timedelta(days=old_time_delta)
48 48 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
49 49
50 50 for op in old_ops:
51 51 thread = op.get_thread()
52 52 if settings.get_bool('Storage', 'ArchiveThreads'):
53 53 self._archive_thread(thread)
54 54 else:
55 55 thread.delete()
56 56 logger.info('Processed old thread {}'.format(thread))
57 57
58 58
59 59 def _archive_thread(self, thread):
60 60 thread.status = STATUS_ARCHIVE
61 61 thread.last_edit_time = timezone.now()
62 62 thread.update_posts_time()
63 63 thread.save(update_fields=['last_edit_time', 'status'])
64 64
65 65 def get_new_posts(self, datas):
66 66 query = None
67 67 # TODO Use classes instead of dicts
68 68 for data in datas:
69 69 if data['last_id'] != FAV_THREAD_NO_UPDATES:
70 70 q = (Q(id=data['op'].get_thread_id())
71 71 & Q(multi_replies__id__gt=data['last_id']))
72 72 if query is None:
73 73 query = q
74 74 else:
75 75 query = query | q
76 76 if query is not None:
77 77 return self.filter(query).annotate(
78 78 new_post_count=Count('multi_replies'))
79 79
80 80 def get_new_post_count(self, datas):
81 81 new_posts = self.get_new_posts(datas)
82 82 return new_posts.aggregate(total_count=Count('multi_replies'))\
83 83 ['total_count'] if new_posts else 0
84 84
85 85
86 86 def get_thread_max_posts():
87 87 return settings.get_int('Messages', 'MaxPostsPerThread')
88 88
89 89
90 90 class Thread(models.Model):
91 91 objects = ThreadManager()
92 92
93 93 class Meta:
94 94 app_label = 'boards'
95 95
96 96 tags = models.ManyToManyField('Tag', related_name='thread_tags')
97 97 bump_time = models.DateTimeField(db_index=True)
98 98 last_edit_time = models.DateTimeField()
99 99 max_posts = models.IntegerField(default=get_thread_max_posts)
100 100 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
101 101 choices=STATUS_CHOICES, db_index=True)
102 102 monochrome = models.BooleanField(default=False)
103 103
104 104 def get_tags(self) -> QuerySet:
105 105 """
106 106 Gets a sorted tag list.
107 107 """
108 108
109 109 return self.tags.order_by('name')
110 110
111 111 def bump(self):
112 112 """
113 113 Bumps (moves to up) thread if possible.
114 114 """
115 115
116 116 if self.can_bump():
117 117 self.bump_time = self.last_edit_time
118 118
119 119 self.update_bump_status()
120 120
121 121 logger.info('Bumped thread %d' % self.id)
122 122
123 123 def has_post_limit(self) -> bool:
124 124 return self.max_posts > 0
125 125
126 126 def update_bump_status(self, exclude_posts=None):
127 127 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
128 128 self.status = STATUS_BUMPLIMIT
129 129 self.update_posts_time(exclude_posts=exclude_posts)
130 130
131 131 def _get_cache_key(self):
132 132 return [datetime_to_epoch(self.last_edit_time)]
133 133
134 134 @cached_result(key_method=_get_cache_key)
135 135 def get_reply_count(self) -> int:
136 136 return self.get_replies().count()
137 137
138 138 @cached_result(key_method=_get_cache_key)
139 139 def get_images_count(self) -> int:
140 140 return self.get_replies().filter(
141 141 attachments__mimetype__in=FILE_TYPES_IMAGE)\
142 142 .annotate(images_count=Count(
143 143 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
144 144
145 145 def can_bump(self) -> bool:
146 146 """
147 147 Checks if the thread can be bumped by replying to it.
148 148 """
149 149
150 150 return self.get_status() == STATUS_ACTIVE
151 151
152 152 def get_last_replies(self) -> QuerySet:
153 153 """
154 154 Gets several last replies, not including opening post
155 155 """
156 156
157 157 last_replies_count = settings.get_int('View', 'LastRepliesCount')
158 158
159 159 if last_replies_count > 0:
160 160 reply_count = self.get_reply_count()
161 161
162 162 if reply_count > 0:
163 163 reply_count_to_show = min(last_replies_count,
164 164 reply_count - 1)
165 165 replies = self.get_replies()
166 166 last_replies = replies[reply_count - reply_count_to_show:]
167 167
168 168 return last_replies
169 169
170 170 def get_skipped_replies_count(self) -> int:
171 171 """
172 172 Gets number of posts between opening post and last replies.
173 173 """
174 174 reply_count = self.get_reply_count()
175 175 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
176 176 reply_count - 1)
177 177 return reply_count - last_replies_count - 1
178 178
179 179 # TODO Remove argument, it is not used
180 180 def get_replies(self, view_fields_only=True) -> QuerySet:
181 181 """
182 182 Gets sorted thread posts
183 183 """
184 query = self.multi_replies.order_by('pub_time').prefetch_related(
185 'thread', 'attachments')
184 query = self.replies.order_by('pub_time').prefetch_related(
185 'attachments')
186 186 return query
187 187
188 188 def get_viewable_replies(self) -> QuerySet:
189 189 """
190 190 Gets replies with only fields that are used for viewing.
191 191 """
192 192 return self.get_replies().defer('text', 'last_edit_time', 'version')
193 193
194 194 def get_top_level_replies(self) -> QuerySet:
195 195 return self.get_replies().exclude(refposts__threads__in=[self])
196 196
197 197 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
198 198 """
199 199 Gets replies that have at least one image attached
200 200 """
201 201 return self.get_replies(view_fields_only).filter(
202 202 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
203 203 'attachments')).filter(images_count__gt=0)
204 204
205 205 def get_opening_post(self, only_id=False) -> Post:
206 206 """
207 207 Gets the first post of the thread
208 208 """
209 209
210 210 query = self.get_replies().filter(opening=True)
211 211 if only_id:
212 212 query = query.only('id')
213 213 opening_post = query.first()
214 214
215 215 return opening_post
216 216
217 217 @cached_result()
218 218 def get_opening_post_id(self) -> int:
219 219 """
220 220 Gets ID of the first thread post.
221 221 """
222 222
223 223 return self.get_opening_post(only_id=True).id
224 224
225 225 def get_pub_time(self):
226 226 """
227 227 Gets opening post's pub time because thread does not have its own one.
228 228 """
229 229
230 230 return self.get_opening_post().pub_time
231 231
232 232 def __str__(self):
233 233 return 'T#{}'.format(self.id)
234 234
235 235 def get_tag_url_list(self) -> list:
236 236 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
237 237
238 238 def update_posts_time(self, exclude_posts=None):
239 239 last_edit_time = self.last_edit_time
240 240
241 241 for post in self.multi_replies.all():
242 242 if exclude_posts is None or post not in exclude_posts:
243 243 # Manual update is required because uids are generated on save
244 244 post.last_edit_time = last_edit_time
245 245 post.save(update_fields=['last_edit_time'])
246 246
247 247 post.get_threads().update(last_edit_time=last_edit_time)
248 248
249 249 def notify_clients(self):
250 250 if not settings.get_bool('External', 'WebsocketsEnabled'):
251 251 return
252 252
253 253 client = Client()
254 254
255 255 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
256 256 client.publish(channel_name, {
257 257 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
258 258 })
259 259 client.send()
260 260
261 261 def get_absolute_url(self):
262 262 return self.get_opening_post().get_absolute_url()
263 263
264 264 def get_required_tags(self):
265 265 return self.get_tags().filter(required=True)
266 266
267 267 def get_replies_newer(self, post_id):
268 268 return self.get_replies().filter(id__gt=post_id)
269 269
270 270 def is_archived(self):
271 271 return self.get_status() == STATUS_ARCHIVE
272 272
273 273 def get_status(self):
274 274 return self.status
275 275
276 276 def is_monochrome(self):
277 277 return self.monochrome
278 278
279 279 # If tags have parent, add them to the tag list
280 280 @transaction.atomic
281 281 def refresh_tags(self):
282 282 for tag in self.get_tags().all():
283 283 parents = tag.get_all_parents()
284 284 if len(parents) > 0:
285 285 self.tags.add(*parents)
286 286
287 287 def get_reply_tree(self):
288 288 replies = self.get_replies().prefetch_related('refposts')
289 289 tree = []
290 290 for reply in replies:
291 291 parents = reply.refposts.all()
292 292
293 293 found_parent = False
294 294 searching_for_index = False
295 295
296 296 if len(parents) > 0:
297 297 index = 0
298 298 parent_depth = 0
299 299
300 300 indexes_to_insert = []
301 301
302 302 for depth, element in tree:
303 303 index += 1
304 304
305 305 # If this element is next after parent on the same level,
306 306 # insert child before it
307 307 if searching_for_index and depth <= parent_depth:
308 308 indexes_to_insert.append((index - 1, parent_depth))
309 309 searching_for_index = False
310 310
311 311 if element in parents:
312 312 found_parent = True
313 313 searching_for_index = True
314 314 parent_depth = depth
315 315
316 316 if not found_parent:
317 317 tree.append((0, reply))
318 318 else:
319 319 if searching_for_index:
320 320 tree.append((parent_depth + 1, reply))
321 321
322 322 offset = 0
323 323 for last_index, parent_depth in indexes_to_insert:
324 324 tree.insert(last_index + offset, (parent_depth + 1, reply))
325 325 offset += 1
326 326
327 327 return tree
@@ -1,81 +1,81 b''
1 1 {% extends "boards/thread.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 thread_content %}
9 9 {% get_current_language as LANGUAGE_CODE %}
10 10 {% get_current_timezone as TIME_ZONE %}
11 11
12 12 <div id="quote-button">{% trans 'Quote' %}</div>
13 13
14 14 <div class="tag_info">
15 15 <h2>
16 16 <form action="{% url 'thread' opening_post.id %}" method="post" class="post-button-form">
17 17 {% csrf_token %}
18 18 {% if is_favorite %}
19 19 <button name="method" value="unsubscribe" class="fav">β˜…</button>
20 20 {% else %}
21 21 <button name="method" value="subscribe" class="not_fav">β˜…</button>
22 22 {% endif %}
23 23 </form>
24 24 {{ opening_post.get_title_or_text }}
25 25 </h2>
26 26 </div>
27 27
28 28 {% if bumpable and thread.has_post_limit %}
29 29 <div class="bar-bg">
30 30 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
31 31 </div>
32 32 <div class="bar-text">
33 33 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
34 34 </div>
35 35 </div>
36 36 {% endif %}
37 37
38 38 <div class="thread">
39 39 {% for post in thread.get_viewable_replies %}
40 {% post_view post reply_link=True %}
40 {% post_view post reply_link=True thread=thread %}
41 41 {% endfor %}
42 42 </div>
43 43
44 44 {% if not thread.is_archived %}
45 45 <div class="post-form-w">
46 46 <script src="{% static 'js/panel.js' %}"></script>
47 47 <div class="form-title">{% trans "Reply to thread" %} #{{ opening_post.id }}<span class="reply-to-message"> {% trans "to message " %} #<span id="reply-to-message-id"></span></span></div>
48 48 <div class="post-form" id="compact-form" data-hasher="{% static 'js/3party/sha256.js' %}"
49 49 data-pow-script="{% static 'js/proof_of_work.js' %}">
50 50 <div class="swappable-form-full">
51 51 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
52 52 <div class="compact-form-text"></div>
53 53 {{ form.as_div }}
54 54 <div class="form-submit">
55 55 <input type="submit" value="{% trans "Post" %}"/>
56 56 <button id="preview-button" type="button" onclick="return false;">{% trans 'Preview' %}</button>
57 57 <button id="file-source-button" type="button" onclick="return false;">{% trans 'Change file source' %}</button>
58 58 </div>
59 59 </form>
60 60 </div>
61 61 <div id="preview-text"></div>
62 62 <div>
63 63 {% with size=max_file_size|filesizeformat %}
64 64 {% blocktrans %}Max file size is {{ size }}.{% endblocktrans %}
65 65 {% endwith %}
66 66 </div>
67 67 <div><a href="{% url "staticpage" name="help" %}">
68 68 {% trans 'Text syntax' %}</a></div>
69 69 <div><a id="form-close-button" href="#" onClick="resetFormPosition(); return false;">{% trans 'Close form' %}</a></div>
70 70 </div>
71 71 </div>
72 72
73 73 <script src="{% static 'js/form.js' %}"></script>
74 74 <script src="{% static 'js/jquery.form.min.js' %}"></script>
75 75 <script src="{% static 'js/3party/jquery.blockUI.js' %}"></script>
76 76 <script src="{% static 'js/thread.js' %}"></script>
77 77 <script src="{% static 'js/thread_update.js' %}"></script>
78 78 {% endif %}
79 79
80 80 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
81 81 {% endblock %}
@@ -1,186 +1,186 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
42 42 class AllThreadsView(PostMixin, FileUploadMixin, BaseBoardView, PaginatedMixin, DispatcherMixin):
43 43
44 44 def __init__(self):
45 45 self.settings_manager = None
46 46 super(AllThreadsView, self).__init__()
47 47
48 48 @method_decorator(csrf_protect)
49 49 def get(self, request, form: ThreadForm=None):
50 50 page = request.GET.get('page', DEFAULT_PAGE)
51 51
52 52 params = self.get_context_data(request=request)
53 53
54 54 if not form:
55 55 form = ThreadForm(error_class=PlainErrorList)
56 56
57 57 self.settings_manager = get_settings_manager(request)
58 58
59 59 threads = self.get_threads()
60 60
61 61 order = request.GET.get('order', 'bump')
62 62 if order == 'bump':
63 63 threads = threads.order_by('-bump_time')
64 64 else:
65 threads = threads.filter(multi_replies__opening=True).order_by('-multi_replies__pub_time')
65 threads = threads.filter(replies__opening=True)\
66 .order_by('-replies__pub_time')
66 67 filter = request.GET.get('filter')
67 68 threads = threads.distinct()
68 69
69 70 paginator = get_paginator(threads,
70 71 settings.get_int('View', 'ThreadsPerPage'))
71 72 paginator.current_page = int(page)
72 73
73 74 try:
74 75 threads = paginator.page(page).object_list
75 76 except EmptyPage:
76 77 raise Http404()
77 78
78 79 params[PARAMETER_THREADS] = threads
79 80 params[CONTEXT_FORM] = form
80 81 params[PARAMETER_MAX_FILE_SIZE] = self.get_max_upload_size()
81 82 params[PARAMETER_RSS_URL] = self.get_rss_url()
82 83
83 84 paginator.set_url(self.get_reverse_url(), request.GET.dict())
84 85 self.get_page_context(paginator, params, page)
85 86
86 87 return render(request, TEMPLATE, params)
87 88
88 89 @method_decorator(csrf_protect)
89 90 def post(self, request):
90 91 if PARAMETER_METHOD in request.POST:
91 92 self.dispatch_method(request)
92 93
93 94 return redirect('index') # FIXME Different for different modes
94 95
95 96 form = ThreadForm(request.POST, request.FILES,
96 97 error_class=PlainErrorList)
97 98 form.session = request.session
98 99
99 100 if form.is_valid():
100 101 return self.create_thread(request, form)
101 102 if form.need_to_ban:
102 103 # Ban user because he is suspected to be a bot
103 104 self._ban_current_user(request)
104 105
105 106 return self.get(request, form)
106 107
107 108 def get_page_context(self, paginator, params, page):
108 109 """
109 110 Get pagination context variables
110 111 """
111 112
112 113 params[PARAMETER_PAGINATOR] = paginator
113 114 current_page = paginator.page(int(page))
114 115 params[PARAMETER_CURRENT_PAGE] = current_page
115 116 self.set_page_urls(paginator, params)
116 117
117 118 def get_reverse_url(self):
118 119 return reverse('index')
119 120
120 121 @transaction.atomic
121 122 def create_thread(self, request, form: ThreadForm, html_response=True):
122 123 """
123 124 Creates a new thread with an opening post.
124 125 """
125 126
126 127 ip = utils.get_client_ip(request)
127 128 is_banned = Ban.objects.filter(ip=ip).exists()
128 129
129 130 if is_banned:
130 131 if html_response:
131 132 return redirect(BannedView().as_view())
132 133 else:
133 134 return
134 135
135 136 data = form.cleaned_data
136 137
137 138 title = form.get_title()
138 139 text = data[FORM_TEXT]
139 140 file = form.get_file()
140 141 file_url = form.get_file_url()
141 threads = data[FORM_THREADS]
142 142 images = form.get_images()
143 143
144 144 text = self._remove_invalid_links(text)
145 145
146 146 tags = data[FORM_TAGS]
147 147 monochrome = form.is_monochrome()
148 148
149 149 post = Post.objects.create_post(title=title, text=text, file=file,
150 ip=ip, tags=tags, opening_posts=threads,
150 ip=ip, tags=tags,
151 151 tripcode=form.get_tripcode(),
152 152 monochrome=monochrome, images=images,
153 153 file_url = file_url)
154 154
155 155 # This is required to update the threads to which posts we have replied
156 156 # when creating this one
157 157 post.notify_clients()
158 158
159 159 if form.is_subscribe():
160 160 settings_manager = get_settings_manager(request)
161 161 settings_manager.add_or_read_fav_thread(post)
162 162
163 163 if html_response:
164 164 return redirect(post.get_absolute_url())
165 165
166 166 def get_threads(self):
167 167 """
168 168 Gets list of threads that will be shown on a page.
169 169 """
170 170
171 171 threads = Thread.objects\
172 172 .exclude(tags__in=self.settings_manager.get_hidden_tags())
173 173 if self.settings_manager.get_setting(SETTING_ONLY_FAVORITES):
174 174 fav_tags = self.settings_manager.get_fav_tags()
175 175 if len(fav_tags) > 0:
176 176 threads = threads.filter(tags__in=fav_tags)
177 177
178 178 return threads
179 179
180 180 def get_rss_url(self):
181 181 return self.get_reverse_url() + 'rss/'
182 182
183 183 def toggle_fav(self, request):
184 184 settings_manager = get_settings_manager(request)
185 185 settings_manager.set_setting(SETTING_ONLY_FAVORITES,
186 186 not settings_manager.get_setting(SETTING_ONLY_FAVORITES, False))
@@ -1,315 +1,315 b''
1 1 import json
2 2 import logging
3 3
4 4 from django.core import serializers
5 5 from django.db import transaction
6 6 from django.http import HttpResponse
7 7 from django.shortcuts import get_object_or_404
8 8 from django.views.decorators.csrf import csrf_protect
9 9
10 10 from boards.abstracts.settingsmanager import get_settings_manager
11 11 from boards.forms import PostForm, PlainErrorList
12 12 from boards.mdx_neboard import Parser
13 13 from boards.models import Post, Thread, Tag, Attachment
14 14 from boards.models.thread import STATUS_ARCHIVE
15 15 from boards.models.user import Notification
16 16 from boards.utils import datetime_to_epoch
17 17 from boards.views.thread import ThreadView
18 18 from boards.models.attachment.viewers import FILE_TYPES_IMAGE
19 19
20 20 __author__ = 'neko259'
21 21
22 22 PARAMETER_TRUNCATED = 'truncated'
23 23 PARAMETER_TAG = 'tag'
24 24 PARAMETER_OFFSET = 'offset'
25 25 PARAMETER_DIFF_TYPE = 'type'
26 26 PARAMETER_POST = 'post'
27 27 PARAMETER_UPDATED = 'updated'
28 28 PARAMETER_LAST_UPDATE = 'last_update'
29 29 PARAMETER_THREAD = 'thread'
30 30 PARAMETER_UIDS = 'uids'
31 31 PARAMETER_SUBSCRIBED = 'subscribed'
32 32
33 33 DIFF_TYPE_HTML = 'html'
34 34 DIFF_TYPE_JSON = 'json'
35 35
36 36 STATUS_OK = 'ok'
37 37 STATUS_ERROR = 'error'
38 38
39 39 logger = logging.getLogger(__name__)
40 40
41 41
42 42 @transaction.atomic
43 43 def api_get_threaddiff(request):
44 44 """
45 45 Gets posts that were changed or added since time
46 46 """
47 47
48 48 thread_id = request.POST.get(PARAMETER_THREAD)
49 49 uids_str = request.POST.get(PARAMETER_UIDS)
50 50
51 51 if not thread_id or not uids_str:
52 52 return HttpResponse(content='Invalid request.')
53 53
54 54 uids = uids_str.strip().split(' ')
55 55
56 56 opening_post = get_object_or_404(Post, id=thread_id)
57 57 thread = opening_post.get_thread()
58 58
59 59 json_data = {
60 60 PARAMETER_UPDATED: [],
61 61 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
62 62 }
63 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
63 posts = Post.objects.filter(thread=thread).exclude(uid__in=uids)
64 64
65 65 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
66 66
67 67 for post in posts:
68 68 json_data[PARAMETER_UPDATED].append(post.get_post_data(
69 69 format_type=diff_type, request=request))
70 70 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
71 71
72 72 settings_manager = get_settings_manager(request)
73 73 json_data[PARAMETER_SUBSCRIBED] = str(settings_manager.thread_is_fav(opening_post))
74 74
75 75 # If the tag is favorite, update the counter
76 76 settings_manager = get_settings_manager(request)
77 77 favorite = settings_manager.thread_is_fav(opening_post)
78 78 if favorite:
79 79 settings_manager.add_or_read_fav_thread(opening_post)
80 80
81 81 return HttpResponse(content=json.dumps(json_data))
82 82
83 83
84 84 @csrf_protect
85 85 def api_add_post(request, opening_post_id):
86 86 """
87 87 Adds a post and return the JSON response for it
88 88 """
89 89
90 90 opening_post = get_object_or_404(Post, id=opening_post_id)
91 91
92 92 logger.info('Adding post via api...')
93 93
94 94 status = STATUS_OK
95 95 errors = []
96 96
97 97 if request.method == 'POST':
98 98 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
99 99 form.session = request.session
100 100
101 101 if form.need_to_ban:
102 102 # Ban user because he is suspected to be a bot
103 103 # _ban_current_user(request)
104 104 status = STATUS_ERROR
105 105 if form.is_valid():
106 106 post = ThreadView().new_post(request, form, opening_post,
107 107 html_response=False)
108 108 if not post:
109 109 status = STATUS_ERROR
110 110 else:
111 111 logger.info('Added post #%d via api.' % post.id)
112 112 else:
113 113 status = STATUS_ERROR
114 114 errors = form.as_json_errors()
115 115
116 116 response = {
117 117 'status': status,
118 118 'errors': errors,
119 119 }
120 120
121 121 return HttpResponse(content=json.dumps(response))
122 122
123 123
124 124 def get_post(request, post_id):
125 125 """
126 126 Gets the html of a post. Used for popups. Post can be truncated if used
127 127 in threads list with 'truncated' get parameter.
128 128 """
129 129
130 130 post = get_object_or_404(Post, id=post_id)
131 131 truncated = PARAMETER_TRUNCATED in request.GET
132 132
133 133 return HttpResponse(content=post.get_view(truncated=truncated, need_op_data=True))
134 134
135 135
136 136 def api_get_threads(request, count):
137 137 """
138 138 Gets the JSON thread opening posts list.
139 139 Parameters that can be used for filtering:
140 140 tag, offset (from which thread to get results)
141 141 """
142 142
143 143 if PARAMETER_TAG in request.GET:
144 144 tag_name = request.GET[PARAMETER_TAG]
145 145 if tag_name is not None:
146 146 tag = get_object_or_404(Tag, name=tag_name)
147 147 threads = tag.get_threads().exclude(status=STATUS_ARCHIVE)
148 148 else:
149 149 threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
150 150
151 151 if PARAMETER_OFFSET in request.GET:
152 152 offset = request.GET[PARAMETER_OFFSET]
153 153 offset = int(offset) if offset is not None else 0
154 154 else:
155 155 offset = 0
156 156
157 157 threads = threads.order_by('-bump_time')
158 158 threads = threads[offset:offset + int(count)]
159 159
160 160 opening_posts = []
161 161 for thread in threads:
162 162 opening_post = thread.get_opening_post()
163 163
164 164 # TODO Add tags, replies and images count
165 165 post_data = opening_post.get_post_data(include_last_update=True)
166 166 post_data['status'] = thread.get_status()
167 167
168 168 opening_posts.append(post_data)
169 169
170 170 return HttpResponse(content=json.dumps(opening_posts))
171 171
172 172
173 173 # TODO Test this
174 174 def api_get_tags(request):
175 175 """
176 176 Gets all tags or user tags.
177 177 """
178 178
179 179 # TODO Get favorite tags for the given user ID
180 180
181 181 tags = Tag.objects.get_not_empty_tags()
182 182
183 183 term = request.GET.get('term')
184 184 if term is not None:
185 185 tags = tags.filter(name__contains=term)
186 186
187 187 tag_names = [tag.name for tag in tags]
188 188
189 189 return HttpResponse(content=json.dumps(tag_names))
190 190
191 191
192 192 def api_get_stickers(request):
193 193 attachments = Attachment.objects.filter(mimetype__in=FILE_TYPES_IMAGE)\
194 194 .exclude(alias='').exclude(alias=None)
195 195
196 196 term = request.GET.get('term')
197 197 if term:
198 198 attachments = attachments.filter(alias__contains=term)
199 199
200 200 image_dict = [{'thumb': attachment.get_thumb_url(),
201 201 'alias': attachment.alias}
202 202 for attachment in attachments]
203 203
204 204 return HttpResponse(content=json.dumps(image_dict))
205 205
206 206
207 207 # TODO The result can be cached by the thread last update time
208 208 # TODO Test this
209 209 def api_get_thread_posts(request, opening_post_id):
210 210 """
211 211 Gets the JSON array of thread posts
212 212 """
213 213
214 214 opening_post = get_object_or_404(Post, id=opening_post_id)
215 215 thread = opening_post.get_thread()
216 216 posts = thread.get_replies()
217 217
218 218 json_data = {
219 219 'posts': [],
220 220 'last_update': None,
221 221 }
222 222 json_post_list = []
223 223
224 224 for post in posts:
225 225 json_post_list.append(post.get_post_data())
226 226 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
227 227 json_data['posts'] = json_post_list
228 228
229 229 return HttpResponse(content=json.dumps(json_data))
230 230
231 231
232 232 def api_get_notifications(request, username):
233 233 last_notification_id_str = request.GET.get('last', None)
234 234 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
235 235
236 236 posts = Notification.objects.get_notification_posts(usernames=[username],
237 237 last=last_id)
238 238
239 239 json_post_list = []
240 240 for post in posts:
241 241 json_post_list.append(post.get_post_data())
242 242 return HttpResponse(content=json.dumps(json_post_list))
243 243
244 244
245 245 def api_get_post(request, post_id):
246 246 """
247 247 Gets the JSON of a post. This can be
248 248 used as and API for external clients.
249 249 """
250 250
251 251 post = get_object_or_404(Post, id=post_id)
252 252
253 253 json = serializers.serialize("json", [post], fields=(
254 254 "pub_time", "_text_rendered", "title", "text", "image",
255 255 "image_width", "image_height", "replies", "tags"
256 256 ))
257 257
258 258 return HttpResponse(content=json)
259 259
260 260
261 261 def api_get_preview(request):
262 262 raw_text = request.POST['raw_text']
263 263
264 264 parser = Parser()
265 265 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
266 266
267 267
268 268 def api_get_new_posts(request):
269 269 """
270 270 Gets favorite threads and unread posts count.
271 271 """
272 272 posts = list()
273 273
274 274 include_posts = 'include_posts' in request.GET
275 275
276 276 settings_manager = get_settings_manager(request)
277 277 fav_threads = settings_manager.get_fav_threads()
278 278 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
279 279 .order_by('-pub_time').prefetch_related('thread')
280 280
281 281 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
282 282 if include_posts:
283 283 new_post_threads = Thread.objects.get_new_posts(ops)
284 284 if new_post_threads:
285 285 thread_ids = {thread.id: thread for thread in new_post_threads}
286 286 else:
287 287 thread_ids = dict()
288 288
289 289 for op in fav_thread_ops:
290 290 fav_thread_dict = dict()
291 291
292 292 op_thread = op.get_thread()
293 293 if op_thread.id in thread_ids:
294 294 thread = thread_ids[op_thread.id]
295 295 new_post_count = thread.new_post_count
296 296 fav_thread_dict['newest_post_link'] = thread.get_replies()\
297 297 .filter(id__gt=fav_threads[str(op.id)])\
298 298 .first().get_absolute_url(thread=thread)
299 299 else:
300 300 new_post_count = 0
301 301 fav_thread_dict['new_post_count'] = new_post_count
302 302
303 303 fav_thread_dict['id'] = op.id
304 304
305 305 fav_thread_dict['post_url'] = op.get_link_view()
306 306 fav_thread_dict['title'] = op.title
307 307
308 308 posts.append(fav_thread_dict)
309 309 else:
310 310 fav_thread_dict = dict()
311 311 fav_thread_dict['new_post_count'] = \
312 312 Thread.objects.get_new_post_count(ops)
313 313 posts.append(fav_thread_dict)
314 314
315 315 return HttpResponse(content=json.dumps(posts))
@@ -1,74 +1,74 b''
1 1 from django.core.urlresolvers import reverse
2 2 from django.shortcuts import render
3 3
4 4 from boards import settings
5 5 from boards.abstracts.paginator import get_paginator
6 6 from boards.abstracts.settingsmanager import get_settings_manager
7 7 from boards.models import Post
8 8 from boards.views.base import BaseBoardView
9 9 from boards.views.posting_mixin import PostMixin
10 10
11 11 POSTS_PER_PAGE = settings.get_int('View', 'PostsPerPage')
12 12
13 13 PARAMETER_CURRENT_PAGE = 'current_page'
14 14 PARAMETER_PAGINATOR = 'paginator'
15 15 PARAMETER_POSTS = 'posts'
16 16
17 17 PARAMETER_PREV_LINK = 'prev_page_link'
18 18 PARAMETER_NEXT_LINK = 'next_page_link'
19 19
20 20 TEMPLATE = 'boards/feed.html'
21 21 DEFAULT_PAGE = 1
22 22
23 23
24 24 class FeedView(PostMixin, BaseBoardView):
25 25
26 26 def get(self, request):
27 27 page = request.GET.get('page', DEFAULT_PAGE)
28 28 tripcode = request.GET.get('tripcode', None)
29 29 favorites = 'favorites' in request.GET
30 30 ip = request.GET.get('ip', None)
31 31
32 32 params = self.get_context_data(request=request)
33 33
34 34 settings_manager = get_settings_manager(request)
35 35
36 36 posts = Post.objects.exclude(
37 threads__tags__in=settings_manager.get_hidden_tags()).order_by(
38 '-pub_time').prefetch_related('attachments', 'thread', 'threads')
37 thread__tags__in=settings_manager.get_hidden_tags()).order_by(
38 '-pub_time').prefetch_related('attachments', 'thread')
39 39 if tripcode:
40 40 posts = posts.filter(tripcode=tripcode)
41 41 if favorites:
42 42 fav_thread_ops = Post.objects.filter(id__in=settings_manager.get_fav_threads().keys())
43 43 fav_threads = [op.get_thread() for op in fav_thread_ops]
44 44 posts = posts.filter(threads__in=fav_threads)
45 45 if ip and request.user.has_perm('post_delete'):
46 46 posts = posts.filter(poster_ip=ip)
47 47
48 48 paginator = get_paginator(posts, POSTS_PER_PAGE)
49 49 paginator.current_page = int(page)
50 50
51 51 params[PARAMETER_POSTS] = paginator.page(page).object_list
52 52
53 53 paginator.set_url(reverse('feed'), request.GET.dict())
54 54
55 55 self.get_page_context(paginator, params, page)
56 56
57 57 return render(request, TEMPLATE, params)
58 58
59 59 # TODO Dedup this into PagedMixin
60 60 def get_page_context(self, paginator, params, page):
61 61 """
62 62 Get pagination context variables
63 63 """
64 64
65 65 params[PARAMETER_PAGINATOR] = paginator
66 66 current_page = paginator.page(int(page))
67 67 params[PARAMETER_CURRENT_PAGE] = current_page
68 68 if current_page.has_previous():
69 69 params[PARAMETER_PREV_LINK] = paginator.get_page_url(
70 70 current_page.previous_page_number())
71 71 if current_page.has_next():
72 72 params[PARAMETER_NEXT_LINK] = paginator.get_page_url(
73 73 current_page.next_page_number())
74 74
@@ -1,183 +1,181 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 27 CONTEXT_WS_TOKEN = 'ws_token'
28 28 CONTEXT_WS_PROJECT = 'ws_project'
29 29 CONTEXT_WS_HOST = 'ws_host'
30 30 CONTEXT_WS_PORT = 'ws_port'
31 31 CONTEXT_WS_TIME = 'ws_token_time'
32 32 CONTEXT_MODE = 'mode'
33 33 CONTEXT_OP = 'opening_post'
34 34 CONTEXT_FAVORITE = 'is_favorite'
35 35 CONTEXT_RSS_URL = 'rss_url'
36 36
37 37 FORM_TITLE = 'title'
38 38 FORM_TEXT = 'text'
39 39 FORM_IMAGE = 'image'
40 40 FORM_THREADS = 'threads'
41 41
42 42
43 43 class ThreadView(BaseBoardView, PostMixin, FormMixin, DispatcherMixin):
44 44
45 45 @method_decorator(csrf_protect)
46 46 def get(self, request, post_id, form: PostForm=None):
47 47 try:
48 48 opening_post = Post.objects.get(id=post_id)
49 49 except ObjectDoesNotExist:
50 50 raise Http404
51 51
52 52 # If the tag is favorite, update the counter
53 53 settings_manager = get_settings_manager(request)
54 54 favorite = settings_manager.thread_is_fav(opening_post)
55 55 if favorite:
56 56 settings_manager.add_or_read_fav_thread(opening_post)
57 57
58 58 # If this is not OP, don't show it as it is
59 59 if not opening_post.is_opening():
60 60 return redirect(opening_post.get_thread().get_opening_post()
61 61 .get_absolute_url())
62 62
63 63 if not form:
64 64 form = PostForm(error_class=PlainErrorList)
65 65
66 66 thread_to_show = opening_post.get_thread()
67 67
68 68 params = dict()
69 69
70 70 params[CONTEXT_FORM] = form
71 71 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
72 72 params[CONTEXT_THREAD] = thread_to_show
73 73 params[CONTEXT_MODE] = self.get_mode()
74 74 params[CONTEXT_OP] = opening_post
75 75 params[CONTEXT_FAVORITE] = favorite
76 76 params[CONTEXT_RSS_URL] = self.get_rss_url(post_id)
77 77
78 78 if settings.get_bool('External', 'WebsocketsEnabled'):
79 79 token_time = format(timezone.now(), u'U')
80 80
81 81 params[CONTEXT_WS_TIME] = token_time
82 82 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
83 83 timestamp=token_time)
84 84 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
85 85 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
86 86 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
87 87
88 88 params.update(self.get_data(thread_to_show))
89 89
90 90 return render(request, self.get_template(), params)
91 91
92 92 @method_decorator(csrf_protect)
93 93 def post(self, request, post_id):
94 94 opening_post = get_object_or_404(Post, id=post_id)
95 95
96 96 # If this is not OP, don't show it as it is
97 97 if not opening_post.is_opening():
98 98 raise Http404
99 99
100 100 if PARAMETER_METHOD in request.POST:
101 101 self.dispatch_method(request, opening_post)
102 102
103 103 return redirect('thread', post_id) # FIXME Different for different modes
104 104
105 105 if not opening_post.get_thread().is_archived():
106 106 form = PostForm(request.POST, request.FILES,
107 107 error_class=PlainErrorList)
108 108 form.session = request.session
109 109
110 110 if form.is_valid():
111 111 return self.new_post(request, form, opening_post)
112 112 if form.need_to_ban:
113 113 # Ban user because he is suspected to be a bot
114 114 self._ban_current_user(request)
115 115
116 116 return self.get(request, post_id, form)
117 117
118 118 def new_post(self, request, form: PostForm, opening_post: Post=None,
119 119 html_response=True):
120 120 """
121 121 Adds a new post (in thread or as a reply).
122 122 """
123 123
124 124 ip = utils.get_client_ip(request)
125 125
126 126 data = form.cleaned_data
127 127
128 128 title = form.get_title()
129 129 text = data[FORM_TEXT]
130 130 file = form.get_file()
131 131 file_url = form.get_file_url()
132 threads = data[FORM_THREADS]
133 132 images = form.get_images()
134 133
135 134 text = self._remove_invalid_links(text)
136 135
137 136 post_thread = opening_post.get_thread()
138 137
139 138 post = Post.objects.create_post(title=title, text=text, file=file,
140 139 thread=post_thread, ip=ip,
141 opening_posts=threads,
142 140 tripcode=form.get_tripcode(),
143 141 images=images, file_url=file_url)
144 142 post.notify_clients()
145 143
146 144 if form.is_subscribe():
147 145 settings_manager = get_settings_manager(request)
148 146 settings_manager.add_or_read_fav_thread(
149 147 post_thread.get_opening_post())
150 148
151 149 if html_response:
152 150 if opening_post:
153 151 return redirect(post.get_absolute_url())
154 152 else:
155 153 return post
156 154
157 155 def get_data(self, thread) -> dict:
158 156 """
159 157 Returns context params for the view.
160 158 """
161 159
162 160 return dict()
163 161
164 162 def get_template(self) -> str:
165 163 """
166 164 Gets template to show the thread mode on.
167 165 """
168 166
169 167 pass
170 168
171 169 def get_mode(self) -> str:
172 170 pass
173 171
174 172 def subscribe(self, request, opening_post):
175 173 settings_manager = get_settings_manager(request)
176 174 settings_manager.add_or_read_fav_thread(opening_post)
177 175
178 176 def unsubscribe(self, request, opening_post):
179 177 settings_manager = get_settings_manager(request)
180 178 settings_manager.del_fav_thread(opening_post)
181 179
182 180 def get_rss_url(self, opening_id):
183 181 return reverse('thread', kwargs={'post_id': opening_id}) + 'rss/'
General Comments 0
You need to be logged in to leave comments. Login now