##// END OF EJS Templates
Thread status field instead of bumpable and archived fields (per BB-73)
neko259 -
r1414:cbf56940 default
parent child Browse files
Show More
@@ -1,75 +1,75 b''
1 1 from django.contrib import admin
2 2 from boards.models import Post, Tag, Ban, Thread, Banner
3 3 from django.utils.translation import ugettext_lazy as _
4 4
5 5
6 6 @admin.register(Post)
7 7 class PostAdmin(admin.ModelAdmin):
8 8
9 9 list_display = ('id', 'title', 'text', 'poster_ip')
10 10 list_filter = ('pub_time',)
11 11 search_fields = ('id', 'title', 'text', 'poster_ip')
12 12 exclude = ('referenced_posts', 'refmap')
13 13 readonly_fields = ('poster_ip', 'threads', 'thread', 'images',
14 14 'attachments', 'uid', 'url', 'pub_time', 'opening')
15 15
16 16 def ban_poster(self, request, queryset):
17 17 bans = 0
18 18 for post in queryset:
19 19 poster_ip = post.poster_ip
20 20 ban, created = Ban.objects.get_or_create(ip=poster_ip)
21 21 if created:
22 22 bans += 1
23 23 self.message_user(request, _('{} posters were banned').format(bans))
24 24
25 25 actions = ['ban_poster']
26 26
27 27
28 28 @admin.register(Tag)
29 29 class TagAdmin(admin.ModelAdmin):
30 30
31 31 def thread_count(self, obj: Tag) -> int:
32 32 return obj.get_thread_count()
33 33
34 34 def display_children(self, obj: Tag):
35 35 return ', '.join([str(child) for child in obj.get_children().all()])
36 36
37 37 list_display = ('name', 'thread_count', 'display_children')
38 38 search_fields = ('name',)
39 39
40 40
41 41 @admin.register(Thread)
42 42 class ThreadAdmin(admin.ModelAdmin):
43 43
44 44 def title(self, obj: Thread) -> str:
45 45 return obj.get_opening_post().get_title()
46 46
47 47 def reply_count(self, obj: Thread) -> int:
48 48 return obj.get_reply_count()
49 49
50 50 def ip(self, obj: Thread):
51 51 return obj.get_opening_post().poster_ip
52 52
53 53 def display_tags(self, obj: Thread):
54 54 return ', '.join([str(tag) for tag in obj.get_tags().all()])
55 55
56 56 def op(self, obj: Thread):
57 57 return obj.get_opening_post_id()
58 58
59 list_display = ('id', 'op', 'title', 'reply_count', 'archived', 'ip',
59 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
60 60 'display_tags')
61 list_filter = ('bump_time', 'archived', 'bumpable')
61 list_filter = ('bump_time', 'status')
62 62 search_fields = ('id', 'title')
63 63 filter_horizontal = ('tags',)
64 64
65 65
66 66 @admin.register(Ban)
67 67 class BanAdmin(admin.ModelAdmin):
68 68 list_display = ('ip', 'can_read')
69 69 list_filter = ('can_read',)
70 70 search_fields = ('ip',)
71 71
72 72
73 73 @admin.register(Banner)
74 74 class BannerAdmin(admin.ModelAdmin):
75 75 list_display = ('title', 'text')
@@ -1,406 +1,406 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4 import logging
5 5 import pytz
6 6
7 7 from django import forms
8 8 from django.core.files.uploadedfile import SimpleUploadedFile
9 9 from django.core.exceptions import ObjectDoesNotExist
10 10 from django.forms.util import ErrorList
11 11 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
12 12
13 13 from boards.mdx_neboard import formatters
14 14 from boards.models.attachment.downloaders import Downloader
15 15 from boards.models.post import TITLE_MAX_LENGTH
16 16 from boards.models import Tag, Post
17 17 from boards.utils import validate_file_size, get_file_mimetype, \
18 18 FILE_EXTENSION_DELIMITER
19 19 from neboard import settings
20 20 import boards.settings as board_settings
21 21 import neboard
22 22
23 23 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
24 24
25 25 VETERAN_POSTING_DELAY = 5
26 26
27 27 ATTRIBUTE_PLACEHOLDER = 'placeholder'
28 28 ATTRIBUTE_ROWS = 'rows'
29 29
30 30 LAST_POST_TIME = 'last_post_time'
31 31 LAST_LOGIN_TIME = 'last_login_time'
32 32 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
33 33 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
34 34
35 35 LABEL_TITLE = _('Title')
36 36 LABEL_TEXT = _('Text')
37 37 LABEL_TAG = _('Tag')
38 38 LABEL_SEARCH = _('Search')
39 39
40 40 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
41 41 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
42 42
43 43 TAG_MAX_LENGTH = 20
44 44
45 45 TEXTAREA_ROWS = 4
46 46
47 47 TRIPCODE_DELIM = '#'
48 48
49 49 # TODO Maybe this may be converted into the database table?
50 50 MIMETYPE_EXTENSIONS = {
51 51 'image/jpeg': 'jpeg',
52 52 'image/png': 'png',
53 53 'image/gif': 'gif',
54 54 'video/webm': 'webm',
55 55 'application/pdf': 'pdf',
56 56 'x-diff': 'diff',
57 57 'image/svg+xml': 'svg',
58 58 'application/x-shockwave-flash': 'swf',
59 59 }
60 60
61 61
62 62 def get_timezones():
63 63 timezones = []
64 64 for tz in pytz.common_timezones:
65 65 timezones.append((tz, tz),)
66 66 return timezones
67 67
68 68
69 69 class FormatPanel(forms.Textarea):
70 70 """
71 71 Panel for text formatting. Consists of buttons to add different tags to the
72 72 form text area.
73 73 """
74 74
75 75 def render(self, name, value, attrs=None):
76 76 output = '<div id="mark-panel">'
77 77 for formatter in formatters:
78 78 output += '<span class="mark_btn"' + \
79 79 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
80 80 '\', \'' + formatter.format_right + '\')">' + \
81 81 formatter.preview_left + formatter.name + \
82 82 formatter.preview_right + '</span>'
83 83
84 84 output += '</div>'
85 85 output += super(FormatPanel, self).render(name, value, attrs=attrs)
86 86
87 87 return output
88 88
89 89
90 90 class PlainErrorList(ErrorList):
91 91 def __unicode__(self):
92 92 return self.as_text()
93 93
94 94 def as_text(self):
95 95 return ''.join(['(!) %s ' % e for e in self])
96 96
97 97
98 98 class NeboardForm(forms.Form):
99 99 """
100 100 Form with neboard-specific formatting.
101 101 """
102 102
103 103 def as_div(self):
104 104 """
105 105 Returns this form rendered as HTML <as_div>s.
106 106 """
107 107
108 108 return self._html_output(
109 109 # TODO Do not show hidden rows in the list here
110 110 normal_row='<div class="form-row">'
111 111 '<div class="form-label">'
112 112 '%(label)s'
113 113 '</div>'
114 114 '<div class="form-input">'
115 115 '%(field)s'
116 116 '</div>'
117 117 '</div>'
118 118 '<div class="form-row">'
119 119 '%(help_text)s'
120 120 '</div>',
121 121 error_row='<div class="form-row">'
122 122 '<div class="form-label"></div>'
123 123 '<div class="form-errors">%s</div>'
124 124 '</div>',
125 125 row_ender='</div>',
126 126 help_text_html='%s',
127 127 errors_on_separate_row=True)
128 128
129 129 def as_json_errors(self):
130 130 errors = []
131 131
132 132 for name, field in list(self.fields.items()):
133 133 if self[name].errors:
134 134 errors.append({
135 135 'field': name,
136 136 'errors': self[name].errors.as_text(),
137 137 })
138 138
139 139 return errors
140 140
141 141
142 142 class PostForm(NeboardForm):
143 143
144 144 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
145 145 label=LABEL_TITLE,
146 146 widget=forms.TextInput(
147 147 attrs={ATTRIBUTE_PLACEHOLDER:
148 148 'test#tripcode'}))
149 149 text = forms.CharField(
150 150 widget=FormatPanel(attrs={
151 151 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
152 152 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
153 153 }),
154 154 required=False, label=LABEL_TEXT)
155 155 file = forms.FileField(required=False, label=_('File'),
156 156 widget=forms.ClearableFileInput(
157 157 attrs={'accept': 'file/*'}))
158 158 file_url = forms.CharField(required=False, label=_('File URL'),
159 159 widget=forms.TextInput(
160 160 attrs={ATTRIBUTE_PLACEHOLDER:
161 161 'http://example.com/image.png'}))
162 162
163 163 # This field is for spam prevention only
164 164 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
165 165 widget=forms.TextInput(attrs={
166 166 'class': 'form-email'}))
167 167 threads = forms.CharField(required=False, label=_('Additional threads'),
168 168 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
169 169 '123 456 789'}))
170 170
171 171 session = None
172 172 need_to_ban = False
173 173
174 174 def _update_file_extension(self, file):
175 175 if file:
176 176 mimetype = get_file_mimetype(file)
177 177 extension = MIMETYPE_EXTENSIONS.get(mimetype)
178 178 if extension:
179 179 filename = file.name.split(FILE_EXTENSION_DELIMITER, 1)[0]
180 180 new_filename = filename + FILE_EXTENSION_DELIMITER + extension
181 181
182 182 file.name = new_filename
183 183 else:
184 184 logger = logging.getLogger('boards.forms.extension')
185 185
186 186 logger.info('Unrecognized file mimetype: {}'.format(mimetype))
187 187
188 188 def clean_title(self):
189 189 title = self.cleaned_data['title']
190 190 if title:
191 191 if len(title) > TITLE_MAX_LENGTH:
192 192 raise forms.ValidationError(_('Title must have less than %s '
193 193 'characters') %
194 194 str(TITLE_MAX_LENGTH))
195 195 return title
196 196
197 197 def clean_text(self):
198 198 text = self.cleaned_data['text'].strip()
199 199 if text:
200 200 max_length = board_settings.get_int('Forms', 'MaxTextLength')
201 201 if len(text) > max_length:
202 202 raise forms.ValidationError(_('Text must have less than %s '
203 203 'characters') % str(max_length))
204 204 return text
205 205
206 206 def clean_file(self):
207 207 file = self.cleaned_data['file']
208 208
209 209 if file:
210 210 validate_file_size(file.size)
211 211 self._update_file_extension(file)
212 212
213 213 return file
214 214
215 215 def clean_file_url(self):
216 216 url = self.cleaned_data['file_url']
217 217
218 218 file = None
219 219 if url:
220 220 file = self._get_file_from_url(url)
221 221
222 222 if not file:
223 223 raise forms.ValidationError(_('Invalid URL'))
224 224 else:
225 225 validate_file_size(file.size)
226 226 self._update_file_extension(file)
227 227
228 228 return file
229 229
230 230 def clean_threads(self):
231 231 threads_str = self.cleaned_data['threads']
232 232
233 233 if len(threads_str) > 0:
234 234 threads_id_list = threads_str.split(' ')
235 235
236 236 threads = list()
237 237
238 238 for thread_id in threads_id_list:
239 239 try:
240 240 thread = Post.objects.get(id=int(thread_id))
241 if not thread.is_opening() or thread.get_thread().archived:
241 if not thread.is_opening() or thread.get_thread().is_archived():
242 242 raise ObjectDoesNotExist()
243 243 threads.append(thread)
244 244 except (ObjectDoesNotExist, ValueError):
245 245 raise forms.ValidationError(_('Invalid additional thread list'))
246 246
247 247 return threads
248 248
249 249 def clean(self):
250 250 cleaned_data = super(PostForm, self).clean()
251 251
252 252 if cleaned_data['email']:
253 253 self.need_to_ban = True
254 254 raise forms.ValidationError('A human cannot enter a hidden field')
255 255
256 256 if not self.errors:
257 257 self._clean_text_file()
258 258
259 259 if not self.errors and self.session:
260 260 self._validate_posting_speed()
261 261
262 262 return cleaned_data
263 263
264 264 def get_file(self):
265 265 """
266 266 Gets file from form or URL.
267 267 """
268 268
269 269 file = self.cleaned_data['file']
270 270 return file or self.cleaned_data['file_url']
271 271
272 272 def get_tripcode(self):
273 273 title = self.cleaned_data['title']
274 274 if title is not None and TRIPCODE_DELIM in title:
275 275 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
276 276 tripcode = hashlib.md5(code.encode()).hexdigest()
277 277 else:
278 278 tripcode = ''
279 279 return tripcode
280 280
281 281 def get_title(self):
282 282 title = self.cleaned_data['title']
283 283 if title is not None and TRIPCODE_DELIM in title:
284 284 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
285 285 else:
286 286 return title
287 287
288 288 def _clean_text_file(self):
289 289 text = self.cleaned_data.get('text')
290 290 file = self.get_file()
291 291
292 292 if (not text) and (not file):
293 293 error_message = _('Either text or file must be entered.')
294 294 self._errors['text'] = self.error_class([error_message])
295 295
296 296 def _validate_posting_speed(self):
297 297 can_post = True
298 298
299 299 posting_delay = settings.POSTING_DELAY
300 300
301 301 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
302 302 now = time.time()
303 303
304 304 current_delay = 0
305 305
306 306 if LAST_POST_TIME not in self.session:
307 307 self.session[LAST_POST_TIME] = now
308 308
309 309 need_delay = True
310 310 else:
311 311 last_post_time = self.session.get(LAST_POST_TIME)
312 312 current_delay = int(now - last_post_time)
313 313
314 314 need_delay = current_delay < posting_delay
315 315
316 316 if need_delay:
317 317 delay = posting_delay - current_delay
318 318 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
319 319 delay) % {'delay': delay}
320 320 self._errors['text'] = self.error_class([error_message])
321 321
322 322 can_post = False
323 323
324 324 if can_post:
325 325 self.session[LAST_POST_TIME] = now
326 326
327 327 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
328 328 """
329 329 Gets an file file from URL.
330 330 """
331 331
332 332 img_temp = None
333 333
334 334 try:
335 335 for downloader in Downloader.__subclasses__():
336 336 if downloader.handles(url):
337 337 return downloader.download(url)
338 338 # If nobody of the specific downloaders handles this, use generic
339 339 # one
340 340 return Downloader.download(url)
341 341 except forms.ValidationError as e:
342 342 raise e
343 343 except Exception as e:
344 344 # Just return no file
345 345 pass
346 346
347 347
348 348 class ThreadForm(PostForm):
349 349
350 350 tags = forms.CharField(
351 351 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
352 352 max_length=100, label=_('Tags'), required=True)
353 353
354 354 def clean_tags(self):
355 355 tags = self.cleaned_data['tags'].strip()
356 356
357 357 if not tags or not REGEX_TAGS.match(tags):
358 358 raise forms.ValidationError(
359 359 _('Inappropriate characters in tags.'))
360 360
361 361 required_tag_exists = False
362 362 tag_set = set()
363 363 for tag_string in tags.split():
364 364 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
365 365 tag_set.add(tag)
366 366
367 367 # If this is a new tag, don't check for its parents because nobody
368 368 # added them yet
369 369 if not created:
370 370 tag_set |= set(tag.get_all_parents())
371 371
372 372 for tag in tag_set:
373 373 if tag.required:
374 374 required_tag_exists = True
375 375 break
376 376
377 377 if not required_tag_exists:
378 378 raise forms.ValidationError(
379 379 _('Need at least one section.'))
380 380
381 381 return tag_set
382 382
383 383 def clean(self):
384 384 cleaned_data = super(ThreadForm, self).clean()
385 385
386 386 return cleaned_data
387 387
388 388
389 389 class SettingsForm(NeboardForm):
390 390
391 391 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
392 392 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
393 393 username = forms.CharField(label=_('User name'), required=False)
394 394 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
395 395
396 396 def clean_username(self):
397 397 username = self.cleaned_data['username']
398 398
399 399 if username and not REGEX_TAGS.match(username):
400 400 raise forms.ValidationError(_('Inappropriate characters.'))
401 401
402 402 return username
403 403
404 404
405 405 class SearchForm(NeboardForm):
406 406 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,95 +1,95 b''
1 1 from django.db import models
2 2 from django.template.defaultfilters import filesizeformat
3 3
4 4 from boards import thumbs, utils
5 5 import boards
6 6 from boards.models.base import Viewable
7 7 from boards.utils import get_upload_filename
8 8
9 9 __author__ = 'neko259'
10 10
11 11
12 12 IMAGE_THUMB_SIZE = (200, 150)
13 13 HASH_LENGTH = 36
14 14
15 15 CSS_CLASS_IMAGE = 'image'
16 16 CSS_CLASS_THUMB = 'thumb'
17 17
18 18
19 19 class PostImageManager(models.Manager):
20 20 def create_with_hash(self, image):
21 21 image_hash = utils.get_file_hash(image)
22 22 existing = self.filter(hash=image_hash)
23 23 if len(existing) > 0:
24 24 post_image = existing[0]
25 25 else:
26 26 post_image = PostImage.objects.create(image=image)
27 27
28 28 return post_image
29 29
30 def get_random_images(self, count, include_archived=False, tags=None):
31 images = self.filter(post_images__thread__archived=include_archived)
30 def get_random_images(self, count, tags=None):
31 images = self
32 32 if tags is not None:
33 33 images = images.filter(post_images__threads__tags__in=tags)
34 34 return images.order_by('?')[:count]
35 35
36 36
37 37 class PostImage(models.Model, Viewable):
38 38 objects = PostImageManager()
39 39
40 40 class Meta:
41 41 app_label = 'boards'
42 42 ordering = ('id',)
43 43
44 44 width = models.IntegerField(default=0)
45 45 height = models.IntegerField(default=0)
46 46
47 47 pre_width = models.IntegerField(default=0)
48 48 pre_height = models.IntegerField(default=0)
49 49
50 50 image = thumbs.ImageWithThumbsField(upload_to=get_upload_filename,
51 51 blank=True, sizes=(IMAGE_THUMB_SIZE,),
52 52 width_field='width',
53 53 height_field='height',
54 54 preview_width_field='pre_width',
55 55 preview_height_field='pre_height')
56 56 hash = models.CharField(max_length=HASH_LENGTH)
57 57
58 58 def save(self, *args, **kwargs):
59 59 """
60 60 Saves the model and computes the image hash for deduplication purposes.
61 61 """
62 62
63 63 if not self.pk and self.image:
64 64 self.hash = utils.get_file_hash(self.image)
65 65 super(PostImage, self).save(*args, **kwargs)
66 66
67 67 def __str__(self):
68 68 return self.image.url
69 69
70 70 def get_view(self):
71 71 metadata = '{}, {}'.format(self.image.name.split('.')[-1],
72 72 filesizeformat(self.image.size))
73 73 return '<div class="{}">' \
74 74 '<a class="{}" href="{full}">' \
75 75 '<img class="post-image-preview"' \
76 76 ' src="{}"' \
77 77 ' alt="{}"' \
78 78 ' width="{}"' \
79 79 ' height="{}"' \
80 80 ' data-width="{}"' \
81 81 ' data-height="{}" />' \
82 82 '</a>' \
83 83 '<div class="image-metadata">'\
84 84 '<a href="{full}" download>{image_meta}</a>'\
85 85 '</div>' \
86 86 '</div>'\
87 87 .format(CSS_CLASS_IMAGE, CSS_CLASS_THUMB,
88 88 self.image.url_200x150,
89 89 str(self.hash), str(self.pre_width),
90 90 str(self.pre_height), str(self.width), str(self.height),
91 91 full=self.image.url, image_meta=metadata)
92 92
93 93 def get_random_associated_post(self):
94 94 posts = boards.models.Post.objects.filter(images__in=[self])
95 95 return posts.order_by('?').first()
@@ -1,360 +1,360 b''
1 1 import logging
2 2 import re
3 3 import uuid
4 4
5 5 from django.core.exceptions import ObjectDoesNotExist
6 6 from django.core.urlresolvers import reverse
7 7 from django.db import models
8 8 from django.db.models import TextField, QuerySet
9 9 from django.template.defaultfilters import striptags, truncatewords
10 10 from django.template.loader import render_to_string
11 11 from django.utils import timezone
12 12
13 13 from boards import settings
14 14 from boards.abstracts.tripcode import Tripcode
15 15 from boards.mdx_neboard import Parser
16 16 from boards.models import PostImage, Attachment
17 17 from boards.models.base import Viewable
18 18 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
19 19 from boards.models.post.manager import PostManager
20 20 from boards.models.user import Notification
21 21
22 22 CSS_CLS_HIDDEN_POST = 'hidden_post'
23 23 CSS_CLS_DEAD_POST = 'dead_post'
24 24 CSS_CLS_ARCHIVE_POST = 'archive_post'
25 25 CSS_CLS_POST = 'post'
26 26
27 27 TITLE_MAX_WORDS = 10
28 28
29 29 APP_LABEL_BOARDS = 'boards'
30 30
31 31 BAN_REASON_AUTO = 'Auto'
32 32
33 33 IMAGE_THUMB_SIZE = (200, 150)
34 34
35 35 TITLE_MAX_LENGTH = 200
36 36
37 37 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 38 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
39 39
40 40 PARAMETER_TRUNCATED = 'truncated'
41 41 PARAMETER_TAG = 'tag'
42 42 PARAMETER_OFFSET = 'offset'
43 43 PARAMETER_DIFF_TYPE = 'type'
44 44 PARAMETER_CSS_CLASS = 'css_class'
45 45 PARAMETER_THREAD = 'thread'
46 46 PARAMETER_IS_OPENING = 'is_opening'
47 47 PARAMETER_POST = 'post'
48 48 PARAMETER_OP_ID = 'opening_post_id'
49 49 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
50 50 PARAMETER_REPLY_LINK = 'reply_link'
51 51 PARAMETER_NEED_OP_DATA = 'need_op_data'
52 52
53 53 POST_VIEW_PARAMS = (
54 54 'need_op_data',
55 55 'reply_link',
56 56 'need_open_link',
57 57 'truncated',
58 58 'mode_tree',
59 59 'perms',
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()
74 74 text = TextField(blank=True, null=True)
75 75 _text_rendered = TextField(blank=True, null=True, editable=False)
76 76
77 77 images = models.ManyToManyField(PostImage, null=True, blank=True,
78 78 related_name='post_images', db_index=True)
79 79 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
80 80 related_name='attachment_posts')
81 81
82 82 poster_ip = models.GenericIPAddressField()
83 83
84 84 # TODO This field can be removed cause UID is used for update now
85 85 last_edit_time = models.DateTimeField()
86 86
87 87 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
88 88 null=True,
89 89 blank=True, related_name='refposts',
90 90 db_index=True)
91 91 refmap = models.TextField(null=True, blank=True)
92 92 threads = models.ManyToManyField('Thread', db_index=True,
93 93 related_name='multi_replies')
94 94 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
95 95
96 96 url = models.TextField()
97 97 uid = models.TextField(db_index=True)
98 98
99 99 tripcode = models.CharField(max_length=50, blank=True, default='')
100 100 opening = models.BooleanField(db_index=True)
101 101 hidden = models.BooleanField(default=False)
102 102
103 103 def __str__(self):
104 104 return 'P#{}/{}'.format(self.id, self.get_title())
105 105
106 106 def get_referenced_posts(self):
107 107 threads = self.get_threads().all()
108 108 return self.referenced_posts.filter(threads__in=threads)\
109 109 .order_by('pub_time').distinct().all()
110 110
111 111 def get_title(self) -> str:
112 112 return self.title
113 113
114 114 def get_title_or_text(self):
115 115 title = self.get_title()
116 116 if not title:
117 117 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
118 118
119 119 return title
120 120
121 121 def build_refmap(self) -> None:
122 122 """
123 123 Builds a replies map string from replies list. This is a cache to stop
124 124 the server from recalculating the map on every post show.
125 125 """
126 126
127 127 post_urls = [refpost.get_link_view()
128 128 for refpost in self.referenced_posts.all()]
129 129
130 130 self.refmap = ', '.join(post_urls)
131 131
132 132 def is_referenced(self) -> bool:
133 133 return self.refmap and len(self.refmap) > 0
134 134
135 135 def is_opening(self) -> bool:
136 136 """
137 137 Checks if this is an opening post or just a reply.
138 138 """
139 139
140 140 return self.opening
141 141
142 142 def get_absolute_url(self, thread=None):
143 143 url = None
144 144
145 145 if thread is None:
146 146 thread = self.get_thread()
147 147
148 148 # Url is cached only for the "main" thread. When getting url
149 149 # for other threads, do it manually.
150 150 if self.url:
151 151 url = self.url
152 152
153 153 if url is None:
154 154 opening_id = thread.get_opening_post_id()
155 155 url = reverse('thread', kwargs={'post_id': opening_id})
156 156 if self.id != opening_id:
157 157 url += '#' + str(self.id)
158 158
159 159 return url
160 160
161 161 def get_thread(self):
162 162 return self.thread
163 163
164 164 def get_threads(self) -> QuerySet:
165 165 """
166 166 Gets post's thread.
167 167 """
168 168
169 169 return self.threads
170 170
171 171 def get_view(self, *args, **kwargs) -> str:
172 172 """
173 173 Renders post's HTML view. Some of the post params can be passed over
174 174 kwargs for the means of caching (if we view the thread, some params
175 175 are same for every post and don't need to be computed over and over.
176 176 """
177 177
178 178 thread = self.get_thread()
179 179
180 180 css_classes = [CSS_CLS_POST]
181 if thread.archived:
181 if thread.is_archived():
182 182 css_classes.append(CSS_CLS_ARCHIVE_POST)
183 183 elif not thread.can_bump():
184 184 css_classes.append(CSS_CLS_DEAD_POST)
185 185 if self.is_hidden():
186 186 css_classes.append(CSS_CLS_HIDDEN_POST)
187 187
188 188 params = dict()
189 189 for param in POST_VIEW_PARAMS:
190 190 if param in kwargs:
191 191 params[param] = kwargs[param]
192 192
193 193 params.update({
194 194 PARAMETER_POST: self,
195 195 PARAMETER_IS_OPENING: self.is_opening(),
196 196 PARAMETER_THREAD: thread,
197 197 PARAMETER_CSS_CLASS: ' '.join(css_classes),
198 198 })
199 199
200 200 return render_to_string('boards/post.html', params)
201 201
202 202 def get_search_view(self, *args, **kwargs):
203 203 return self.get_view(need_op_data=True, *args, **kwargs)
204 204
205 205 def get_first_image(self) -> PostImage:
206 206 return self.images.earliest('id')
207 207
208 208 def delete(self, using=None):
209 209 """
210 210 Deletes all post images and the post itself.
211 211 """
212 212
213 213 for image in self.images.all():
214 214 image_refs_count = image.post_images.count()
215 215 if image_refs_count == 1:
216 216 image.delete()
217 217
218 218 for attachment in self.attachments.all():
219 219 attachment_refs_count = attachment.attachment_posts.count()
220 220 if attachment_refs_count == 1:
221 221 attachment.delete()
222 222
223 223 thread = self.get_thread()
224 224 thread.last_edit_time = timezone.now()
225 225 thread.save()
226 226
227 227 super(Post, self).delete(using)
228 228
229 229 logging.getLogger('boards.post.delete').info(
230 230 'Deleted post {}'.format(self))
231 231
232 232 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
233 233 include_last_update=False) -> str:
234 234 """
235 235 Gets post HTML or JSON data that can be rendered on a page or used by
236 236 API.
237 237 """
238 238
239 239 return get_exporter(format_type).export(self, request,
240 240 include_last_update)
241 241
242 242 def notify_clients(self, recursive=True):
243 243 """
244 244 Sends post HTML data to the thread web socket.
245 245 """
246 246
247 247 if not settings.get_bool('External', 'WebsocketsEnabled'):
248 248 return
249 249
250 250 thread_ids = list()
251 251 for thread in self.get_threads().all():
252 252 thread_ids.append(thread.id)
253 253
254 254 thread.notify_clients()
255 255
256 256 if recursive:
257 257 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
258 258 post_id = reply_number.group(1)
259 259
260 260 try:
261 261 ref_post = Post.objects.get(id=post_id)
262 262
263 263 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
264 264 # If post is in this thread, its thread was already notified.
265 265 # Otherwise, notify its thread separately.
266 266 ref_post.notify_clients(recursive=False)
267 267 except ObjectDoesNotExist:
268 268 pass
269 269
270 270 def build_url(self):
271 271 self.url = self.get_absolute_url()
272 272 self.save(update_fields=['url'])
273 273
274 274 def save(self, force_insert=False, force_update=False, using=None,
275 275 update_fields=None):
276 276 self._text_rendered = Parser().parse(self.get_raw_text())
277 277
278 278 self.uid = str(uuid.uuid4())
279 279 if update_fields is not None and 'uid' not in update_fields:
280 280 update_fields += ['uid']
281 281
282 282 if self.id:
283 283 for thread in self.get_threads().all():
284 284 thread.last_edit_time = self.last_edit_time
285 285
286 thread.save(update_fields=['last_edit_time', 'bumpable'])
286 thread.save(update_fields=['last_edit_time', 'status'])
287 287
288 288 super().save(force_insert, force_update, using, update_fields)
289 289
290 290 def get_text(self) -> str:
291 291 return self._text_rendered
292 292
293 293 def get_raw_text(self) -> str:
294 294 return self.text
295 295
296 296 def get_absolute_id(self) -> str:
297 297 """
298 298 If the post has many threads, shows its main thread OP id in the post
299 299 ID.
300 300 """
301 301
302 302 if self.get_threads().count() > 1:
303 303 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
304 304 else:
305 305 return str(self.id)
306 306
307 307 def connect_notifications(self):
308 308 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
309 309 user_name = reply_number.group(1).lower()
310 310 Notification.objects.get_or_create(name=user_name, post=self)
311 311
312 312 def connect_replies(self):
313 313 """
314 314 Connects replies to a post to show them as a reflink map
315 315 """
316 316
317 317 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
318 318 post_id = reply_number.group(1)
319 319
320 320 try:
321 321 referenced_post = Post.objects.get(id=post_id)
322 322
323 323 referenced_post.referenced_posts.add(self)
324 324 referenced_post.last_edit_time = self.pub_time
325 325 referenced_post.build_refmap()
326 326 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
327 327 except ObjectDoesNotExist:
328 328 pass
329 329
330 330 def connect_threads(self, opening_posts):
331 331 for opening_post in opening_posts:
332 332 threads = opening_post.get_threads().all()
333 333 for thread in threads:
334 334 if thread.can_bump():
335 335 thread.update_bump_status()
336 336
337 337 thread.last_edit_time = self.last_edit_time
338 thread.save(update_fields=['last_edit_time', 'bumpable'])
338 thread.save(update_fields=['last_edit_time', 'status'])
339 339 self.threads.add(opening_post.get_thread())
340 340
341 341 def get_tripcode(self):
342 342 if self.tripcode:
343 343 return Tripcode(self.tripcode)
344 344
345 345 def get_link_view(self):
346 346 """
347 347 Gets view of a reflink to the post.
348 348 """
349 349 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
350 350 self.id)
351 351 if self.is_opening():
352 352 result = '<b>{}</b>'.format(result)
353 353
354 354 return result
355 355
356 356 def is_hidden(self) -> bool:
357 357 return self.hidden
358 358
359 359 def set_hidden(self, hidden):
360 360 self.hidden = hidden
@@ -1,143 +1,142 b''
1 1 import hashlib
2 2 from django.template.loader import render_to_string
3 3 from django.db import models
4 4 from django.db.models import Count
5 5 from django.core.urlresolvers import reverse
6 6
7 7 from boards.models.base import Viewable
8 from boards.models.thread import STATUS_ACTIVE, STATUS_BUMPLIMIT, STATUS_ARCHIVE
8 9 from boards.utils import cached_result
9 10 import boards
10 11
11 12 __author__ = 'neko259'
12 13
13 14
14 15 RELATED_TAGS_COUNT = 5
15 16
16 17
17 18 class TagManager(models.Manager):
18 19
19 20 def get_not_empty_tags(self):
20 21 """
21 22 Gets tags that have non-archived threads.
22 23 """
23 24
24 25 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
25 26 .order_by('-required', 'name')
26 27
27 28 def get_tag_url_list(self, tags: list) -> str:
28 29 """
29 30 Gets a comma-separated list of tag links.
30 31 """
31 32
32 33 return ', '.join([tag.get_view() for tag in tags])
33 34
34 35
35 36 class Tag(models.Model, Viewable):
36 37 """
37 38 A tag is a text node assigned to the thread. The tag serves as a board
38 39 section. There can be multiple tags for each thread
39 40 """
40 41
41 42 objects = TagManager()
42 43
43 44 class Meta:
44 45 app_label = 'boards'
45 46 ordering = ('name',)
46 47
47 48 name = models.CharField(max_length=100, db_index=True, unique=True)
48 49 required = models.BooleanField(default=False, db_index=True)
49 50 description = models.TextField(blank=True)
50 51
51 52 parent = models.ForeignKey('Tag', null=True, blank=True,
52 53 related_name='children')
53 54
54 55 def __str__(self):
55 56 return self.name
56 57
57 58 def is_empty(self) -> bool:
58 59 """
59 60 Checks if the tag has some threads.
60 61 """
61 62
62 63 return self.get_thread_count() == 0
63 64
64 def get_thread_count(self, archived=None, bumpable=None) -> int:
65 def get_thread_count(self, status=None) -> int:
65 66 threads = self.get_threads()
66 if archived is not None:
67 threads = threads.filter(archived=archived)
68 if bumpable is not None:
69 threads = threads.filter(bumpable=bumpable)
67 if status is not None:
68 threads = threads.filter(status=status)
70 69 return threads.count()
71 70
72 71 def get_active_thread_count(self) -> int:
73 return self.get_thread_count(archived=False, bumpable=True)
72 return self.get_thread_count(status=STATUS_ACTIVE)
74 73
75 74 def get_bumplimit_thread_count(self) -> int:
76 return self.get_thread_count(archived=False, bumpable=False)
75 return self.get_thread_count(status=STATUS_BUMPLIMIT)
77 76
78 77 def get_archived_thread_count(self) -> int:
79 return self.get_thread_count(archived=True)
78 return self.get_thread_count(status=STATUS_ARCHIVE)
80 79
81 80 def get_absolute_url(self):
82 81 return reverse('tag', kwargs={'tag_name': self.name})
83 82
84 83 def get_threads(self):
85 84 return self.thread_tags.order_by('-bump_time')
86 85
87 86 def is_required(self):
88 87 return self.required
89 88
90 89 def get_view(self):
91 90 link = '<a class="tag" href="{}">{}</a>'.format(
92 91 self.get_absolute_url(), self.name)
93 92 if self.is_required():
94 93 link = '<b>{}</b>'.format(link)
95 94 return link
96 95
97 96 def get_search_view(self, *args, **kwargs):
98 97 return render_to_string('boards/tag.html', {
99 98 'tag': self,
100 99 })
101 100
102 101 @cached_result()
103 102 def get_post_count(self):
104 103 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
105 104
106 105 def get_description(self):
107 106 return self.description
108 107
109 def get_random_image_post(self, archived=False):
108 def get_random_image_post(self, status=False):
110 109 posts = boards.models.Post.objects.annotate(images_count=Count(
111 110 'images')).filter(images_count__gt=0, threads__tags__in=[self])
112 if archived is not None:
113 posts = posts.filter(thread__archived=archived)
111 if status is not None:
112 posts = posts.filter(thread__status=status)
114 113 return posts.order_by('?').first()
115 114
116 115 def get_first_letter(self):
117 116 return self.name and self.name[0] or ''
118 117
119 118 def get_related_tags(self):
120 119 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
121 120 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
122 121
123 122 @cached_result()
124 123 def get_color(self):
125 124 """
126 125 Gets color hashed from the tag name.
127 126 """
128 127 return hashlib.md5(self.name.encode()).hexdigest()[:6]
129 128
130 129 def get_parent(self):
131 130 return self.parent
132 131
133 132 def get_all_parents(self):
134 133 parents = list()
135 134 parent = self.get_parent()
136 135 if parent and parent not in parents:
137 136 parents.insert(0, parent)
138 137 parents = parent.get_all_parents() + parents
139 138
140 139 return parents
141 140
142 141 def get_children(self):
143 142 return self.children
@@ -1,258 +1,263 b''
1 1 import logging
2 2 from adjacent import Client
3 3
4 4 from django.db.models import Count, Sum, QuerySet, Q
5 5 from django.utils import timezone
6 6 from django.db import models
7 7
8 STATUS_ACTIVE = 'active'
9 STATUS_BUMPLIMIT = 'bumplimit'
10 STATUS_ARCHIVE = 'archived'
11
8 12 from boards import settings
9 13 import boards
10 14 from boards.utils import cached_result, datetime_to_epoch
11 15 from boards.models.post import Post
12 16 from boards.models.tag import Tag
13 17
14 18 FAV_THREAD_NO_UPDATES = -1
15 19
16 20
17 21 __author__ = 'neko259'
18 22
19 23
20 24 logger = logging.getLogger(__name__)
21 25
22 26
23 27 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
24 28 WS_NOTIFICATION_TYPE = 'notification_type'
25 29
26 30 WS_CHANNEL_THREAD = "thread:"
27 31
28 32
29 33 class ThreadManager(models.Manager):
30 34 def process_oldest_threads(self):
31 35 """
32 36 Preserves maximum thread count. If there are too many threads,
33 37 archive or delete the old ones.
34 38 """
35 39
36 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
40 threads = Thread.objects.exclude(status=STATUS_ARCHIVE).order_by('-bump_time')
37 41 thread_count = threads.count()
38 42
39 43 max_thread_count = settings.get_int('Messages', 'MaxThreadCount')
40 44 if thread_count > max_thread_count:
41 45 num_threads_to_delete = thread_count - max_thread_count
42 46 old_threads = threads[thread_count - num_threads_to_delete:]
43 47
44 48 for thread in old_threads:
45 49 if settings.get_bool('Storage', 'ArchiveThreads'):
46 50 self._archive_thread(thread)
47 51 else:
48 52 thread.delete()
49 53
50 54 logger.info('Processed %d old threads' % num_threads_to_delete)
51 55
52 56 def _archive_thread(self, thread):
53 thread.archived = True
54 thread.bumpable = False
57 thread.status = STATUS_ARCHIVE
55 58 thread.last_edit_time = timezone.now()
56 59 thread.update_posts_time()
57 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
60 thread.save(update_fields=['last_edit_time', 'status'])
58 61
59 62 def get_new_posts(self, datas):
60 63 query = None
61 64 # TODO Use classes instead of dicts
62 65 for data in datas:
63 66 if data['last_id'] != FAV_THREAD_NO_UPDATES:
64 67 q = (Q(id=data['op'].get_thread().id)
65 68 & Q(multi_replies__id__gt=data['last_id']))
66 69 if query is None:
67 70 query = q
68 71 else:
69 72 query = query | q
70 73 if query is not None:
71 74 return self.filter(query).annotate(
72 75 new_post_count=Count('multi_replies'))
73 76
74 77 def get_new_post_count(self, datas):
75 78 new_posts = self.get_new_posts(datas)
76 79 return new_posts.aggregate(total_count=Count('multi_replies'))\
77 80 ['total_count'] if new_posts else 0
78 81
79 82
80 83 def get_thread_max_posts():
81 84 return settings.get_int('Messages', 'MaxPostsPerThread')
82 85
83 86
84 87 class Thread(models.Model):
85 88 objects = ThreadManager()
86 89
87 90 class Meta:
88 91 app_label = 'boards'
89 92
90 93 tags = models.ManyToManyField('Tag', related_name='thread_tags')
91 94 bump_time = models.DateTimeField(db_index=True)
92 95 last_edit_time = models.DateTimeField()
93 archived = models.BooleanField(default=False)
94 bumpable = models.BooleanField(default=True)
95 96 max_posts = models.IntegerField(default=get_thread_max_posts)
97 status = models.CharField(max_length=50, default=STATUS_ACTIVE)
96 98
97 99 def get_tags(self) -> QuerySet:
98 100 """
99 101 Gets a sorted tag list.
100 102 """
101 103
102 104 return self.tags.order_by('name')
103 105
104 106 def bump(self):
105 107 """
106 108 Bumps (moves to up) thread if possible.
107 109 """
108 110
109 111 if self.can_bump():
110 112 self.bump_time = self.last_edit_time
111 113
112 114 self.update_bump_status()
113 115
114 116 logger.info('Bumped thread %d' % self.id)
115 117
116 118 def has_post_limit(self) -> bool:
117 119 return self.max_posts > 0
118 120
119 121 def update_bump_status(self, exclude_posts=None):
120 122 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
121 self.bumpable = False
123 self.status = STATUS_BUMPLIMIT
122 124 self.update_posts_time(exclude_posts=exclude_posts)
123 125
124 126 def _get_cache_key(self):
125 127 return [datetime_to_epoch(self.last_edit_time)]
126 128
127 129 @cached_result(key_method=_get_cache_key)
128 130 def get_reply_count(self) -> int:
129 131 return self.get_replies().count()
130 132
131 133 @cached_result(key_method=_get_cache_key)
132 134 def get_images_count(self) -> int:
133 135 return self.get_replies().annotate(images_count=Count(
134 136 'images')).aggregate(Sum('images_count'))['images_count__sum']
135 137
136 138 def can_bump(self) -> bool:
137 139 """
138 140 Checks if the thread can be bumped by replying to it.
139 141 """
140 142
141 return self.bumpable and not self.is_archived()
143 return self.get_status() == STATUS_ACTIVE
142 144
143 145 def get_last_replies(self) -> QuerySet:
144 146 """
145 147 Gets several last replies, not including opening post
146 148 """
147 149
148 150 last_replies_count = settings.get_int('View', 'LastRepliesCount')
149 151
150 152 if last_replies_count > 0:
151 153 reply_count = self.get_reply_count()
152 154
153 155 if reply_count > 0:
154 156 reply_count_to_show = min(last_replies_count,
155 157 reply_count - 1)
156 158 replies = self.get_replies()
157 159 last_replies = replies[reply_count - reply_count_to_show:]
158 160
159 161 return last_replies
160 162
161 163 def get_skipped_replies_count(self) -> int:
162 164 """
163 165 Gets number of posts between opening post and last replies.
164 166 """
165 167 reply_count = self.get_reply_count()
166 168 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
167 169 reply_count - 1)
168 170 return reply_count - last_replies_count - 1
169 171
170 172 def get_replies(self, view_fields_only=False) -> QuerySet:
171 173 """
172 174 Gets sorted thread posts
173 175 """
174 176
175 177 query = self.multi_replies.order_by('pub_time').prefetch_related(
176 178 'images', 'thread', 'threads', 'attachments')
177 179 if view_fields_only:
178 180 query = query.defer('poster_ip')
179 181 return query.all()
180 182
181 183 def get_top_level_replies(self) -> QuerySet:
182 184 return self.get_replies().exclude(refposts__threads__in=[self])
183 185
184 186 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
185 187 """
186 188 Gets replies that have at least one image attached
187 189 """
188 190
189 191 return self.get_replies(view_fields_only).annotate(images_count=Count(
190 192 'images')).filter(images_count__gt=0)
191 193
192 194 def get_opening_post(self, only_id=False) -> Post:
193 195 """
194 196 Gets the first post of the thread
195 197 """
196 198
197 199 query = self.get_replies().filter(opening=True)
198 200 if only_id:
199 201 query = query.only('id')
200 202 opening_post = query.first()
201 203
202 204 return opening_post
203 205
204 206 @cached_result()
205 207 def get_opening_post_id(self) -> int:
206 208 """
207 209 Gets ID of the first thread post.
208 210 """
209 211
210 212 return self.get_opening_post(only_id=True).id
211 213
212 214 def get_pub_time(self):
213 215 """
214 216 Gets opening post's pub time because thread does not have its own one.
215 217 """
216 218
217 219 return self.get_opening_post().pub_time
218 220
219 221 def __str__(self):
220 222 return 'T#{}/{}'.format(self.id, self.get_opening_post_id())
221 223
222 224 def get_tag_url_list(self) -> list:
223 225 return boards.models.Tag.objects.get_tag_url_list(self.get_tags())
224 226
225 227 def update_posts_time(self, exclude_posts=None):
226 228 last_edit_time = self.last_edit_time
227 229
228 230 for post in self.multi_replies.all():
229 231 if exclude_posts is None or post not in exclude_posts:
230 232 # Manual update is required because uids are generated on save
231 233 post.last_edit_time = last_edit_time
232 234 post.save(update_fields=['last_edit_time'])
233 235
234 236 post.get_threads().update(last_edit_time=last_edit_time)
235 237
236 238 def notify_clients(self):
237 239 if not settings.get_bool('External', 'WebsocketsEnabled'):
238 240 return
239 241
240 242 client = Client()
241 243
242 244 channel_name = WS_CHANNEL_THREAD + str(self.get_opening_post_id())
243 245 client.publish(channel_name, {
244 246 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
245 247 })
246 248 client.send()
247 249
248 250 def get_absolute_url(self):
249 251 return self.get_opening_post().get_absolute_url()
250 252
251 253 def get_required_tags(self):
252 254 return self.get_tags().filter(required=True)
253 255
254 256 def get_replies_newer(self, post_id):
255 257 return self.get_replies().filter(id__gt=post_id)
256 258
257 259 def is_archived(self):
258 return self.archived
260 return self.get_status() == STATUS_ARCHIVE
261
262 def get_status(self):
263 return self.status
@@ -1,83 +1,84 b''
1 1 from django.contrib.syndication.views import Feed
2 2 from django.core.urlresolvers import reverse
3 3 from django.shortcuts import get_object_or_404
4 4 from boards.models import Post, Tag, Thread
5 5 from boards import settings
6 from boards.models.thread import STATUS_ARCHIVE
6 7
7 8 __author__ = 'nekorin'
8 9
9 10
10 11 MAX_ITEMS = settings.get_int('RSS', 'MaxItems')
11 12
12 13
13 14 # TODO Make tests for all of these
14 15 class AllThreadsFeed(Feed):
15 16
16 17 title = settings.get('Version', 'SiteName') + ' - All threads'
17 18 link = '/'
18 19 description_template = 'boards/rss/post.html'
19 20
20 21 def items(self):
21 return Thread.objects.filter(archived=False).order_by('-id')[:MAX_ITEMS]
22 return Thread.objects.exclude(status=STATUS_ARCHIVE).order_by('-id')[:MAX_ITEMS]
22 23
23 24 def item_title(self, item):
24 25 return item.get_opening_post().title
25 26
26 27 def item_link(self, item):
27 28 return reverse('thread', args={item.get_opening_post_id()})
28 29
29 30 def item_pubdate(self, item):
30 31 return item.get_pub_time()
31 32
32 33
33 34 class TagThreadsFeed(Feed):
34 35
35 36 link = '/'
36 37 description_template = 'boards/rss/post.html'
37 38
38 39 def items(self, obj):
39 return obj.get_threads().filter(archived=False).order_by('-id')[:MAX_ITEMS]
40 return obj.get_threads().exclude(status=STATUS_ARCHIVE).order_by('-id')[:MAX_ITEMS]
40 41
41 42 def get_object(self, request, tag_name):
42 43 return get_object_or_404(Tag, name=tag_name)
43 44
44 45 def item_title(self, item):
45 46 return item.get_opening_post().title
46 47
47 48 def item_link(self, item):
48 49 return reverse('thread', args={item.get_opening_post_id()})
49 50
50 51 def item_pubdate(self, item):
51 52 return item.get_pub_time()
52 53
53 54 def title(self, obj):
54 55 return obj.name
55 56
56 57
57 58 class ThreadPostsFeed(Feed):
58 59
59 60 link = '/'
60 61 description_template = 'boards/rss/post.html'
61 62
62 63 def items(self, obj):
63 64 return obj.get_thread().get_replies().order_by('-pub_time')[:MAX_ITEMS]
64 65
65 66 def get_object(self, request, post_id):
66 67 return get_object_or_404(Post, id=post_id)
67 68
68 69 def item_title(self, item):
69 70 return item.title
70 71
71 72 def item_link(self, item):
72 73 if not item.is_opening():
73 74 return reverse('thread', args={
74 75 item.get_thread().get_opening_post_id()
75 76 }) + "#" + str(item.id)
76 77 else:
77 78 return reverse('thread', args={item.id})
78 79
79 80 def item_pubdate(self, item):
80 81 return item.pub_time
81 82
82 83 def title(self, obj):
83 84 return obj.title
@@ -1,114 +1,114 b''
1 1 {% load i18n %}
2 2 {% load board %}
3 3
4 4 {% get_current_language as LANGUAGE_CODE %}
5 5
6 6 <div class="{{ css_class }}" id="{{ post.id }}" data-uid="{{ post.uid }}">
7 7 <div class="post-info">
8 8 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.get_absolute_id }}</a>
9 9 <span class="title">{{ post.title }}</span>
10 10 <span class="pub_time"><time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
11 11 {% if post.tripcode %}
12 12 /
13 13 {% with tripcode=post.get_tripcode %}
14 14 <a href="{% url 'feed' %}?tripcode={{ tripcode.get_full_text }}"
15 15 class="tripcode" title="{{ tripcode.get_full_text }}"
16 16 style="border: solid 2px #{{ tripcode.get_color }}; border-left: solid 1ex #{{ tripcode.get_color }};">{{ tripcode.get_short_text }}</a>
17 17 {% endwith %}
18 18 {% endif %}
19 19 {% comment %}
20 20 Thread death time needs to be shown only if the thread is alredy archived
21 21 and this is an opening post (thread death time) or a post for popup
22 22 (we don't see OP here so we show the death time in the post itself).
23 23 {% endcomment %}
24 {% if thread.archived %}
24 {% if thread.is_archived %}
25 25 {% if is_opening %}
26 26 β€” <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
27 27 {% endif %}
28 28 {% endif %}
29 29 {% if is_opening %}
30 30 {% if need_open_link %}
31 {% if thread.archived %}
31 {% if thread.is_archived %}
32 32 <a class="link" href="{% url 'thread' post.id %}">{% trans "Open" %}</a>
33 33 {% else %}
34 34 <a class="link" href="{% url 'thread' post.id %}#form">{% trans "Reply" %}</a>
35 35 {% endif %}
36 36 {% endif %}
37 37 {% else %}
38 38 {% if need_op_data %}
39 39 {% with thread.get_opening_post as op %}
40 40 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
41 41 {% endwith %}
42 42 {% endif %}
43 43 {% endif %}
44 {% if reply_link and not thread.archived %}
44 {% if reply_link and not thread.is_archived %}
45 45 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
46 46 {% endif %}
47 47
48 48 {% if perms.boards.change_post or perms.boards.delete_post or perms.boards.change_thread or perms_boards.delete_thread %}
49 49 <span class="moderator_info">
50 50 {% if perms.boards.change_post or perms.boards.delete_post %}
51 51 | <a href="{% url 'admin:boards_post_change' post.id %}">{% trans 'Edit' %}</a>
52 52 <form action="{% url 'thread' thread.get_opening_post_id %}?post_id={{ post.id }}" method="post" class="post-button-form">
53 53 | <button name="method" value="toggle_hide_post">H</button>
54 54 </form>
55 55 {% endif %}
56 56 {% if perms.boards.change_thread or perms_boards.delete_thread %}
57 57 {% if is_opening %}
58 58 | <a href="{% url 'admin:boards_thread_change' thread.id %}">{% trans 'Edit thread' %}</a>
59 59 {% endif %}
60 60 {% endif %}
61 61 </form>
62 62 </span>
63 63 {% endif %}
64 64 </div>
65 65 {% comment %}
66 66 Post images. Currently only 1 image can be posted and shown, but post model
67 67 supports multiple.
68 68 {% endcomment %}
69 69 {% for image in post.images.all %}
70 70 {{ image.get_view|safe }}
71 71 {% endfor %}
72 72 {% for file in post.attachments.all %}
73 73 {{ file.get_view|safe }}
74 74 {% endfor %}
75 75 {% comment %}
76 76 Post message (text)
77 77 {% endcomment %}
78 78 <div class="message">
79 79 {% autoescape off %}
80 80 {% if truncated %}
81 81 {{ post.get_text|truncatewords_html:50 }}
82 82 {% else %}
83 83 {{ post.get_text }}
84 84 {% endif %}
85 85 {% endautoescape %}
86 86 </div>
87 87 {% if post.is_referenced %}
88 88 {% if mode_tree %}
89 89 <div class="tree_reply">
90 90 {% for refpost in post.get_referenced_posts %}
91 91 {% post_view refpost mode_tree=True %}
92 92 {% endfor %}
93 93 </div>
94 94 {% else %}
95 95 <div class="refmap">
96 96 {% trans "Replies" %}: {{ post.refmap|safe }}
97 97 </div>
98 98 {% endif %}
99 99 {% endif %}
100 100 {% comment %}
101 101 Thread metadata: counters, tags etc
102 102 {% endcomment %}
103 103 {% if is_opening %}
104 104 <div class="metadata">
105 105 {% if is_opening and need_open_link %}
106 106 {% blocktrans count count=thread.get_reply_count %}{{ count }} message{% plural %}{{ count }} messages{% endblocktrans %},
107 107 {% blocktrans count count=thread.get_images_count %}{{ count }} image{% plural %}{{ count }} images{% endblocktrans %}.
108 108 {% endif %}
109 109 <span class="tags">
110 110 {{ thread.get_tag_url_list|safe }}
111 111 </span>
112 112 </div>
113 113 {% endif %}
114 114 </div>
@@ -1,75 +1,75 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 class="tag_info">
13 13 <h2>
14 14 <form action="{% url 'thread' opening_post.id %}" method="post" class="post-button-form">
15 15 {% if is_favorite %}
16 16 <button name="method" value="unsubscribe" class="fav">β˜…</button>
17 17 {% else %}
18 18 <button name="method" value="subscribe" class="not_fav">β˜…</button>
19 19 {% endif %}
20 20 </form>
21 21 {{ opening_post.get_title_or_text }}
22 22 </h2>
23 23 </div>
24 24
25 25 {% if bumpable and thread.has_post_limit %}
26 26 <div class="bar-bg">
27 27 <div class="bar-value" style="width:{{ bumplimit_progress }}%" id="bumplimit_progress">
28 28 </div>
29 29 <div class="bar-text">
30 30 <span id="left_to_limit">{{ posts_left }}</span> {% trans 'posts to bumplimit' %}
31 31 </div>
32 32 </div>
33 33 {% endif %}
34 34
35 35 <div class="thread">
36 36 {% for post in thread.get_replies %}
37 37 {% post_view post reply_link=True %}
38 38 {% endfor %}
39 39 </div>
40 40
41 {% if not thread.archived %}
41 {% if not thread.is_archived %}
42 42 <div class="post-form-w">
43 43 <script src="{% static 'js/panel.js' %}"></script>
44 44 <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>
45 45 <div class="post-form" id="compact-form">
46 46 <div class="swappable-form-full">
47 47 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
48 48 <div class="compact-form-text"></div>
49 49 {{ form.as_div }}
50 50 <div class="form-submit">
51 51 <input type="submit" value="{% trans "Post" %}"/>
52 52 <button id="preview-button" onclick="return false;">{% trans 'Preview' %}</button>
53 53 </div>
54 54 </form>
55 55 </div>
56 56 <div id="preview-text"></div>
57 57 <div>
58 58 {% with size=max_file_size|filesizeformat %}
59 59 {% blocktrans %}Max file size is {{ size }}.{% endblocktrans %}
60 60 {% endwith %}
61 61 </div>
62 62 <div><a href="{% url "staticpage" name="help" %}">
63 63 {% trans 'Text syntax' %}</a></div>
64 64 <div><a id="form-close-button" href="#" onClick="resetFormPosition(); return false;">{% trans 'Close form' %}</a></div>
65 65 </div>
66 66 </div>
67 67
68 68 <script src="{% static 'js/jquery.form.min.js' %}"></script>
69 69 {% endif %}
70 70
71 71 <script src="{% static 'js/form.js' %}"></script>
72 72 <script src="{% static 'js/thread.js' %}"></script>
73 73 <script src="{% static 'js/thread_update.js' %}"></script>
74 74 <script src="{% static 'js/3party/centrifuge.js' %}"></script>
75 75 {% endblock %}
@@ -1,69 +1,70 b''
1 1 import simplejson
2 2
3 3 from django.test import TestCase
4 4 from boards.views import api
5 5
6 6 from boards.models import Tag, Post
7 7 from boards.tests.mocks import MockRequest
8 8 from boards.utils import datetime_to_epoch
9 9
10 10
11 11 class ApiTest(TestCase):
12 12 def test_thread_diff(self):
13 13 tag = Tag.objects.create(name='test_tag')
14 14 opening_post = Post.objects.create_post(title='title', text='text',
15 15 tags=[tag])
16 16
17 17 req = MockRequest()
18 18 req.POST['thread'] = opening_post.id
19 19 req.POST['uids'] = opening_post.uid
20 20 # Check the exact timestamp post was added
21 21 empty_response = api.api_get_threaddiff(req)
22 22 diff = simplejson.loads(empty_response.content)
23 23 self.assertEqual(0, len(diff['updated']),
24 24 'There must be no updated posts in the diff.')
25 25
26 26 uids = [opening_post.uid]
27 27
28 28 reply = Post.objects.create_post(title='',
29 29 text='[post]%d[/post]\ntext' % opening_post.id,
30 30 thread=opening_post.get_thread())
31 31 req = MockRequest()
32 32 req.POST['thread'] = opening_post.id
33 33 req.POST['uids'] = ' '.join(uids)
34 req.user = None
34 35 # Check the timestamp before post was added
35 36 response = api.api_get_threaddiff(req)
36 37 diff = simplejson.loads(response.content)
37 38 self.assertEqual(2, len(diff['updated']),
38 39 'There must be 2 updated posts in the diff.')
39 40
40 41 # Reload post to get the new UID
41 42 opening_post = Post.objects.get(id=opening_post.id)
42 43 req = MockRequest()
43 44 req.POST['thread'] = opening_post.id
44 45 req.POST['uids'] = ' '.join([opening_post.uid, reply.uid])
45 46 empty_response = api.api_get_threaddiff(req)
46 47 diff = simplejson.loads(empty_response.content)
47 48 self.assertEqual(0, len(diff['updated']),
48 49 'There must be no updated posts in the diff.')
49 50
50 51 def test_get_threads(self):
51 52 # Create 10 threads
52 53 tag = Tag.objects.create(name='test_tag')
53 54 for i in range(5):
54 55 Post.objects.create_post(title='title', text='text', tags=[tag])
55 56
56 57 # Get all threads
57 58 response = api.api_get_threads(MockRequest(), 5)
58 59 diff = simplejson.loads(response.content)
59 60 self.assertEqual(5, len(diff), 'Invalid thread list response.')
60 61
61 62 # Get less threads then exist
62 63 response = api.api_get_threads(MockRequest(), 3)
63 64 diff = simplejson.loads(response.content)
64 65 self.assertEqual(3, len(diff), 'Invalid thread list response.')
65 66
66 67 # Get more threads then exist
67 68 response = api.api_get_threads(MockRequest(), 10)
68 69 diff = simplejson.loads(response.content)
69 70 self.assertEqual(5, len(diff), 'Invalid thread list response.')
@@ -1,178 +1,179 b''
1 1 from django.core.paginator import Paginator
2 2 from django.test import TestCase
3 3
4 4 from boards import settings
5 5 from boards.models import Tag, Post, Thread
6 from boards.models.thread import STATUS_ARCHIVE
6 7
7 8
8 9 class PostTests(TestCase):
9 10
10 11 def _create_post(self):
11 12 tag, created = Tag.objects.get_or_create(name='test_tag')
12 13 return Post.objects.create_post(title='title', text='text',
13 14 tags=[tag])
14 15
15 16 def test_post_add(self):
16 17 """Test adding post"""
17 18
18 19 post = self._create_post()
19 20
20 21 self.assertIsNotNone(post, 'No post was created.')
21 22 self.assertEqual('test_tag', post.get_thread().tags.all()[0].name,
22 23 'No tags were added to the post.')
23 24
24 25 def test_delete_post(self):
25 26 """Test post deletion"""
26 27
27 28 post = self._create_post()
28 29 post_id = post.id
29 30
30 31 post.delete()
31 32
32 33 self.assertFalse(Post.objects.filter(id=post_id).exists())
33 34
34 35 def test_delete_thread(self):
35 36 """Test thread deletion"""
36 37
37 38 opening_post = self._create_post()
38 39 thread = opening_post.get_thread()
39 40 reply = Post.objects.create_post("", "", thread=thread)
40 41
41 42 thread.delete()
42 43
43 44 self.assertFalse(Post.objects.filter(id=reply.id).exists(),
44 45 'Reply was not deleted with the thread.')
45 46 self.assertFalse(Post.objects.filter(id=opening_post.id).exists(),
46 47 'Opening post was not deleted with the thread.')
47 48
48 49 def test_post_to_thread(self):
49 50 """Test adding post to a thread"""
50 51
51 52 op = self._create_post()
52 53 post = Post.objects.create_post("", "", thread=op.get_thread())
53 54
54 55 self.assertIsNotNone(post, 'Reply to thread wasn\'t created')
55 56 self.assertEqual(op.get_thread().last_edit_time, post.pub_time,
56 57 'Post\'s create time doesn\'t match thread last edit'
57 58 ' time')
58 59
59 60 def test_delete_posts_by_ip(self):
60 61 """Test deleting posts with the given ip"""
61 62
62 63 post = self._create_post()
63 64 post_id = post.id
64 65
65 66 Post.objects.delete_posts_by_ip('0.0.0.0')
66 67
67 68 self.assertFalse(Post.objects.filter(id=post_id).exists())
68 69
69 70 def test_get_thread(self):
70 71 """Test getting all posts of a thread"""
71 72
72 73 opening_post = self._create_post()
73 74
74 75 for i in range(2):
75 76 Post.objects.create_post('title', 'text',
76 77 thread=opening_post.get_thread())
77 78
78 79 thread = opening_post.get_thread()
79 80
80 81 self.assertEqual(3, thread.get_replies().count())
81 82
82 83 def test_create_post_with_tag(self):
83 84 """Test adding tag to post"""
84 85
85 86 tag = Tag.objects.create(name='test_tag')
86 87 post = Post.objects.create_post(title='title', text='text', tags=[tag])
87 88
88 89 thread = post.get_thread()
89 90 self.assertIsNotNone(post, 'Post not created')
90 91 self.assertTrue(tag in thread.tags.all(), 'Tag not added to thread')
91 92
92 93 def test_thread_max_count(self):
93 94 """Test deletion of old posts when the max thread count is reached"""
94 95
95 96 for i in range(settings.get_int('Messages', 'MaxThreadCount') + 1):
96 97 self._create_post()
97 98
98 99 self.assertEqual(settings.get_int('Messages', 'MaxThreadCount'),
99 len(Thread.objects.filter(archived=False)))
100 len(Thread.objects.exclude(status=STATUS_ARCHIVE)))
100 101
101 102 def test_pages(self):
102 103 """Test that the thread list is properly split into pages"""
103 104
104 105 for i in range(settings.get_int('Messages', 'MaxThreadCount')):
105 106 self._create_post()
106 107
107 all_threads = Thread.objects.filter(archived=False)
108 all_threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
108 109
109 paginator = Paginator(Thread.objects.filter(archived=False),
110 paginator = Paginator(Thread.objects.exclude(status=STATUS_ARCHIVE),
110 111 settings.get_int('View', 'ThreadsPerPage'))
111 112 posts_in_second_page = paginator.page(2).object_list
112 113 first_post = posts_in_second_page[0]
113 114
114 115 self.assertEqual(all_threads[settings.get_int('View', 'ThreadsPerPage')].id,
115 116 first_post.id)
116 117
117 118 def test_thread_replies(self):
118 119 """
119 120 Tests that the replies can be queried from a thread in all possible
120 121 ways.
121 122 """
122 123
123 124 tag = Tag.objects.create(name='test_tag')
124 125 opening_post = Post.objects.create_post(title='title', text='text',
125 126 tags=[tag])
126 127 thread = opening_post.get_thread()
127 128
128 129 Post.objects.create_post(title='title', text='text', thread=thread)
129 130 Post.objects.create_post(title='title', text='text', thread=thread)
130 131
131 132 replies = thread.get_replies()
132 133 self.assertTrue(len(replies) > 0, 'No replies found for thread.')
133 134
134 135 replies = thread.get_replies(view_fields_only=True)
135 136 self.assertTrue(len(replies) > 0,
136 137 'No replies found for thread with view fields only.')
137 138
138 139 def test_bumplimit(self):
139 140 """
140 141 Tests that the thread bumpable status is changed and post uids and
141 142 last update times are updated across all post threads.
142 143 """
143 144
144 145 op1 = Post.objects.create_post(title='title', text='text')
145 146 op2 = Post.objects.create_post(title='title', text='text')
146 147
147 148 thread1 = op1.get_thread()
148 149 thread1.max_posts = 5
149 150 thread1.save()
150 151
151 152 uid_1 = op1.uid
152 153 uid_2 = op2.uid
153 154
154 155 # Create multi reply
155 156 Post.objects.create_post(
156 157 title='title', text='text', thread=thread1,
157 158 opening_posts=[op1, op2])
158 159 thread_update_time_2 = op2.get_thread().last_edit_time
159 160 for i in range(6):
160 161 Post.objects.create_post(title='title', text='text',
161 162 thread=thread1)
162 163
163 164 self.assertFalse(op1.get_thread().can_bump(),
164 165 'Thread is bumpable when it should not be.')
165 166 self.assertTrue(op2.get_thread().can_bump(),
166 167 'Thread is not bumpable when it should be.')
167 168 self.assertNotEqual(
168 169 uid_1, Post.objects.get(id=op1.id).uid,
169 170 'UID of the first OP should be changed but it is not.')
170 171 self.assertEqual(
171 172 uid_2, Post.objects.get(id=op2.id).uid,
172 173 'UID of the first OP should not be changed but it is.')
173 174
174 175 self.assertNotEqual(
175 176 thread_update_time_2,
176 177 Thread.objects.get(id=op2.get_thread().id).last_edit_time,
177 178 'Thread last update time should change when the other thread '
178 179 'changes status.')
@@ -1,293 +1,293 b''
1 1 from collections import OrderedDict
2 2 import json
3 3 import logging
4 4
5 5 from django.db import transaction
6 6 from django.db.models import Count
7 7 from django.http import HttpResponse
8 8 from django.shortcuts import get_object_or_404
9 9 from django.core import serializers
10 10 from boards.abstracts.settingsmanager import get_settings_manager,\
11 11 FAV_THREAD_NO_UPDATES
12 12
13 13 from boards.forms import PostForm, PlainErrorList
14 14 from boards.models import Post, Thread, Tag
15 from boards.models.thread import STATUS_ARCHIVE
15 16 from boards.utils import datetime_to_epoch
16 17 from boards.views.thread import ThreadView
17 18 from boards.models.user import Notification
18 19 from boards.mdx_neboard import Parser
19 20
20 21
21 22 __author__ = 'neko259'
22 23
23 24 PARAMETER_TRUNCATED = 'truncated'
24 25 PARAMETER_TAG = 'tag'
25 26 PARAMETER_OFFSET = 'offset'
26 27 PARAMETER_DIFF_TYPE = 'type'
27 28 PARAMETER_POST = 'post'
28 29 PARAMETER_UPDATED = 'updated'
29 30 PARAMETER_LAST_UPDATE = 'last_update'
30 31 PARAMETER_THREAD = 'thread'
31 32 PARAMETER_UIDS = 'uids'
32 33
33 34 DIFF_TYPE_HTML = 'html'
34 35 DIFF_TYPE_JSON = 'json'
35 36
36 37 STATUS_OK = 'ok'
37 38 STATUS_ERROR = 'error'
38 39
39 40 logger = logging.getLogger(__name__)
40 41
41 42
42 43 @transaction.atomic
43 44 def api_get_threaddiff(request):
44 45 """
45 46 Gets posts that were changed or added since time
46 47 """
47 48
48 49 thread_id = request.POST.get(PARAMETER_THREAD)
49 50 uids_str = request.POST.get(PARAMETER_UIDS).strip()
50 51 uids = uids_str.split(' ')
51 52
52 53 opening_post = get_object_or_404(Post, id=thread_id)
53 54 thread = opening_post.get_thread()
54 55
55 56 json_data = {
56 57 PARAMETER_UPDATED: [],
57 58 PARAMETER_LAST_UPDATE: None, # TODO Maybe this can be removed already?
58 59 }
59 60 posts = Post.objects.filter(threads__in=[thread]).exclude(uid__in=uids)
60 61
61 62 diff_type = request.GET.get(PARAMETER_DIFF_TYPE, DIFF_TYPE_HTML)
62 63
63 64 for post in posts:
64 65 json_data[PARAMETER_UPDATED].append(post.get_post_data(
65 66 format_type=diff_type, request=request))
66 67 json_data[PARAMETER_LAST_UPDATE] = str(thread.last_edit_time)
67 68
68 69 # If the tag is favorite, update the counter
69 70 settings_manager = get_settings_manager(request)
70 71 favorite = settings_manager.thread_is_fav(opening_post)
71 72 if favorite:
72 73 settings_manager.add_or_read_fav_thread(opening_post)
73 74
74 75 return HttpResponse(content=json.dumps(json_data))
75 76
76 77
77 78 def api_add_post(request, opening_post_id):
78 79 """
79 80 Adds a post and return the JSON response for it
80 81 """
81 82
82 83 opening_post = get_object_or_404(Post, id=opening_post_id)
83 84
84 85 logger.info('Adding post via api...')
85 86
86 87 status = STATUS_OK
87 88 errors = []
88 89
89 90 if request.method == 'POST':
90 91 form = PostForm(request.POST, request.FILES, error_class=PlainErrorList)
91 92 form.session = request.session
92 93
93 94 if form.need_to_ban:
94 95 # Ban user because he is suspected to be a bot
95 96 # _ban_current_user(request)
96 97 status = STATUS_ERROR
97 98 if form.is_valid():
98 99 post = ThreadView().new_post(request, form, opening_post,
99 100 html_response=False)
100 101 if not post:
101 102 status = STATUS_ERROR
102 103 else:
103 104 logger.info('Added post #%d via api.' % post.id)
104 105 else:
105 106 status = STATUS_ERROR
106 107 errors = form.as_json_errors()
107 108
108 109 response = {
109 110 'status': status,
110 111 'errors': errors,
111 112 }
112 113
113 114 return HttpResponse(content=json.dumps(response))
114 115
115 116
116 117 def get_post(request, post_id):
117 118 """
118 119 Gets the html of a post. Used for popups. Post can be truncated if used
119 120 in threads list with 'truncated' get parameter.
120 121 """
121 122
122 123 post = get_object_or_404(Post, id=post_id)
123 124 truncated = PARAMETER_TRUNCATED in request.GET
124 125
125 126 return HttpResponse(content=post.get_view(truncated=truncated, need_op_data=True))
126 127
127 128
128 129 def api_get_threads(request, count):
129 130 """
130 131 Gets the JSON thread opening posts list.
131 132 Parameters that can be used for filtering:
132 133 tag, offset (from which thread to get results)
133 134 """
134 135
135 136 if PARAMETER_TAG in request.GET:
136 137 tag_name = request.GET[PARAMETER_TAG]
137 138 if tag_name is not None:
138 139 tag = get_object_or_404(Tag, name=tag_name)
139 threads = tag.get_threads().filter(archived=False)
140 threads = tag.get_threads().exclude(status=STATUS_ARCHIVE)
140 141 else:
141 threads = Thread.objects.filter(archived=False)
142 threads = Thread.objects.exclude(status=STATUS_ARCHIVE)
142 143
143 144 if PARAMETER_OFFSET in request.GET:
144 145 offset = request.GET[PARAMETER_OFFSET]
145 146 offset = int(offset) if offset is not None else 0
146 147 else:
147 148 offset = 0
148 149
149 150 threads = threads.order_by('-bump_time')
150 151 threads = threads[offset:offset + int(count)]
151 152
152 153 opening_posts = []
153 154 for thread in threads:
154 155 opening_post = thread.get_opening_post()
155 156
156 157 # TODO Add tags, replies and images count
157 158 post_data = opening_post.get_post_data(include_last_update=True)
158 post_data['bumpable'] = thread.can_bump()
159 post_data['archived'] = thread.archived
159 post_data['status'] = thread.get_status()
160 160
161 161 opening_posts.append(post_data)
162 162
163 163 return HttpResponse(content=json.dumps(opening_posts))
164 164
165 165
166 166 # TODO Test this
167 167 def api_get_tags(request):
168 168 """
169 169 Gets all tags or user tags.
170 170 """
171 171
172 172 # TODO Get favorite tags for the given user ID
173 173
174 174 tags = Tag.objects.get_not_empty_tags()
175 175
176 176 term = request.GET.get('term')
177 177 if term is not None:
178 178 tags = tags.filter(name__contains=term)
179 179
180 180 tag_names = [tag.name for tag in tags]
181 181
182 182 return HttpResponse(content=json.dumps(tag_names))
183 183
184 184
185 185 # TODO The result can be cached by the thread last update time
186 186 # TODO Test this
187 187 def api_get_thread_posts(request, opening_post_id):
188 188 """
189 189 Gets the JSON array of thread posts
190 190 """
191 191
192 192 opening_post = get_object_or_404(Post, id=opening_post_id)
193 193 thread = opening_post.get_thread()
194 194 posts = thread.get_replies()
195 195
196 196 json_data = {
197 197 'posts': [],
198 198 'last_update': None,
199 199 }
200 200 json_post_list = []
201 201
202 202 for post in posts:
203 203 json_post_list.append(post.get_post_data())
204 204 json_data['last_update'] = datetime_to_epoch(thread.last_edit_time)
205 205 json_data['posts'] = json_post_list
206 206
207 207 return HttpResponse(content=json.dumps(json_data))
208 208
209 209
210 210 def api_get_notifications(request, username):
211 211 last_notification_id_str = request.GET.get('last', None)
212 212 last_id = int(last_notification_id_str) if last_notification_id_str is not None else None
213 213
214 214 posts = Notification.objects.get_notification_posts(username=username,
215 215 last=last_id)
216 216
217 217 json_post_list = []
218 218 for post in posts:
219 219 json_post_list.append(post.get_post_data())
220 220 return HttpResponse(content=json.dumps(json_post_list))
221 221
222 222
223 223 def api_get_post(request, post_id):
224 224 """
225 225 Gets the JSON of a post. This can be
226 226 used as and API for external clients.
227 227 """
228 228
229 229 post = get_object_or_404(Post, id=post_id)
230 230
231 231 json = serializers.serialize("json", [post], fields=(
232 232 "pub_time", "_text_rendered", "title", "text", "image",
233 233 "image_width", "image_height", "replies", "tags"
234 234 ))
235 235
236 236 return HttpResponse(content=json)
237 237
238 238
239 239 def api_get_preview(request):
240 240 raw_text = request.POST['raw_text']
241 241
242 242 parser = Parser()
243 243 return HttpResponse(content=parser.parse(parser.preparse(raw_text)))
244 244
245 245
246 246 def api_get_new_posts(request):
247 247 """
248 248 Gets favorite threads and unread posts count.
249 249 """
250 250 posts = list()
251 251
252 252 include_posts = 'include_posts' in request.GET
253 253
254 254 settings_manager = get_settings_manager(request)
255 255 fav_threads = settings_manager.get_fav_threads()
256 256 fav_thread_ops = Post.objects.filter(id__in=fav_threads.keys())\
257 257 .order_by('-pub_time').prefetch_related('thread')
258 258
259 259 ops = [{'op': op, 'last_id': fav_threads[str(op.id)]} for op in fav_thread_ops]
260 260 if include_posts:
261 261 new_post_threads = Thread.objects.get_new_posts(ops)
262 262 if new_post_threads:
263 263 thread_ids = {thread.id: thread for thread in new_post_threads}
264 264 else:
265 265 thread_ids = dict()
266 266
267 267 for op in fav_thread_ops:
268 268 fav_thread_dict = dict()
269 269
270 270 op_thread = op.get_thread()
271 271 if op_thread.id in thread_ids:
272 272 thread = thread_ids[op_thread.id]
273 273 new_post_count = thread.new_post_count
274 274 fav_thread_dict['newest_post_link'] = thread.get_replies()\
275 275 .filter(id__gt=fav_threads[str(op.id)])\
276 276 .first().get_absolute_url(thread=thread)
277 277 else:
278 278 new_post_count = 0
279 279 fav_thread_dict['new_post_count'] = new_post_count
280 280
281 281 fav_thread_dict['id'] = op.id
282 282
283 283 fav_thread_dict['post_url'] = op.get_link_view()
284 284 fav_thread_dict['title'] = op.title
285 285
286 286 posts.append(fav_thread_dict)
287 287 else:
288 288 fav_thread_dict = dict()
289 289 fav_thread_dict['new_post_count'] = \
290 290 Thread.objects.get_new_post_count(ops)
291 291 posts.append(fav_thread_dict)
292 292
293 293 return HttpResponse(content=json.dumps(posts))
@@ -1,179 +1,179 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.views.generic.edit import FormMixin
8 8 from django.utils import timezone
9 9 from django.utils.dateformat import format
10 10
11 11 from boards import utils, settings
12 12 from boards.abstracts.settingsmanager import get_settings_manager
13 13 from boards.forms import PostForm, PlainErrorList
14 14 from boards.models import Post
15 15 from boards.views.base import BaseBoardView, CONTEXT_FORM
16 16 from boards.views.mixins import DispatcherMixin, PARAMETER_METHOD
17 17 from boards.views.posting_mixin import PostMixin
18 18 import neboard
19 19
20 20 REQ_POST_ID = 'post_id'
21 21
22 22 CONTEXT_LASTUPDATE = "last_update"
23 23 CONTEXT_THREAD = 'thread'
24 24 CONTEXT_WS_TOKEN = 'ws_token'
25 25 CONTEXT_WS_PROJECT = 'ws_project'
26 26 CONTEXT_WS_HOST = 'ws_host'
27 27 CONTEXT_WS_PORT = 'ws_port'
28 28 CONTEXT_WS_TIME = 'ws_token_time'
29 29 CONTEXT_MODE = 'mode'
30 30 CONTEXT_OP = 'opening_post'
31 31 CONTEXT_FAVORITE = 'is_favorite'
32 32 CONTEXT_RSS_URL = 'rss_url'
33 33
34 34 FORM_TITLE = 'title'
35 35 FORM_TEXT = 'text'
36 36 FORM_IMAGE = 'image'
37 37 FORM_THREADS = 'threads'
38 38
39 39
40 40 class ThreadView(BaseBoardView, PostMixin, FormMixin, DispatcherMixin):
41 41
42 42 def get(self, request, post_id, form: PostForm=None):
43 43 try:
44 44 opening_post = Post.objects.get(id=post_id)
45 45 except ObjectDoesNotExist:
46 46 raise Http404
47 47
48 48 # If the tag is favorite, update the counter
49 49 settings_manager = get_settings_manager(request)
50 50 favorite = settings_manager.thread_is_fav(opening_post)
51 51 if favorite:
52 52 settings_manager.add_or_read_fav_thread(opening_post)
53 53
54 54 # If this is not OP, don't show it as it is
55 55 if not opening_post.is_opening():
56 56 return redirect(opening_post.get_thread().get_opening_post()
57 57 .get_absolute_url())
58 58
59 59 if not form:
60 60 form = PostForm(error_class=PlainErrorList)
61 61
62 62 thread_to_show = opening_post.get_thread()
63 63
64 64 params = dict()
65 65
66 66 params[CONTEXT_FORM] = form
67 67 params[CONTEXT_LASTUPDATE] = str(thread_to_show.last_edit_time)
68 68 params[CONTEXT_THREAD] = thread_to_show
69 69 params[CONTEXT_MODE] = self.get_mode()
70 70 params[CONTEXT_OP] = opening_post
71 71 params[CONTEXT_FAVORITE] = favorite
72 72 params[CONTEXT_RSS_URL] = self.get_rss_url(post_id)
73 73
74 74 if settings.get_bool('External', 'WebsocketsEnabled'):
75 75 token_time = format(timezone.now(), u'U')
76 76
77 77 params[CONTEXT_WS_TIME] = token_time
78 78 params[CONTEXT_WS_TOKEN] = utils.get_websocket_token(
79 79 timestamp=token_time)
80 80 params[CONTEXT_WS_PROJECT] = neboard.settings.CENTRIFUGE_PROJECT_ID
81 81 params[CONTEXT_WS_HOST] = request.get_host().split(':')[0]
82 82 params[CONTEXT_WS_PORT] = neboard.settings.CENTRIFUGE_PORT
83 83
84 84 params.update(self.get_data(thread_to_show))
85 85
86 86 return render(request, self.get_template(), params)
87 87
88 88 def post(self, request, post_id):
89 89 opening_post = get_object_or_404(Post, id=post_id)
90 90
91 91 # If this is not OP, don't show it as it is
92 92 if not opening_post.is_opening():
93 93 raise Http404
94 94
95 95 if PARAMETER_METHOD in request.POST:
96 96 self.dispatch_method(request, opening_post)
97 97
98 98 return redirect('thread', post_id) # FIXME Different for different modes
99 99
100 if not opening_post.get_thread().archived:
100 if not opening_post.get_thread().is_archived():
101 101 form = PostForm(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.new_post(request, form, opening_post)
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, post_id, form)
112 112
113 113 def new_post(self, request, form: PostForm, opening_post: Post=None,
114 114 html_response=True):
115 115 """
116 116 Adds a new post (in thread or as a reply).
117 117 """
118 118
119 119 ip = utils.get_client_ip(request)
120 120
121 121 data = form.cleaned_data
122 122
123 123 title = form.get_title()
124 124 text = data[FORM_TEXT]
125 125 file = form.get_file()
126 126 threads = data[FORM_THREADS]
127 127
128 128 text = self._remove_invalid_links(text)
129 129
130 130 post_thread = opening_post.get_thread()
131 131
132 132 post = Post.objects.create_post(title=title, text=text, file=file,
133 133 thread=post_thread, ip=ip,
134 134 opening_posts=threads,
135 135 tripcode=form.get_tripcode())
136 136 post.notify_clients()
137 137
138 138 if html_response:
139 139 if opening_post:
140 140 return redirect(post.get_absolute_url())
141 141 else:
142 142 return post
143 143
144 144 def get_data(self, thread) -> dict:
145 145 """
146 146 Returns context params for the view.
147 147 """
148 148
149 149 return dict()
150 150
151 151 def get_template(self) -> str:
152 152 """
153 153 Gets template to show the thread mode on.
154 154 """
155 155
156 156 pass
157 157
158 158 def get_mode(self) -> str:
159 159 pass
160 160
161 161 def subscribe(self, request, opening_post):
162 162 settings_manager = get_settings_manager(request)
163 163 settings_manager.add_or_read_fav_thread(opening_post)
164 164
165 165 def unsubscribe(self, request, opening_post):
166 166 settings_manager = get_settings_manager(request)
167 167 settings_manager.del_fav_thread(opening_post)
168 168
169 169 @permission_required('boards.post_hide_unhide')
170 170 def toggle_hide_post(self, request, opening_post):
171 171 post_id = request.GET.get(REQ_POST_ID)
172 172
173 173 if post_id:
174 174 post = get_object_or_404(Post, id=post_id)
175 175 post.set_hidden(not post.is_hidden())
176 176 post.save(update_fields=['hidden'])
177 177
178 178 def get_rss_url(self, opening_id):
179 179 return reverse('thread', kwargs={'post_id': opening_id}) + 'rss/'
General Comments 0
You need to be logged in to leave comments. Login now