##// END OF EJS Templates
Added 'bumpable' field. Disable thread bump once and for all, not counting it...
neko259 -
r863:0dc734ff default
parent child Browse files
Show More
@@ -0,0 +1,74 b''
1 # -*- coding: utf-8 -*-
2 from south.utils import datetime_utils as datetime
3 from south.db import db
4 from south.v2 import SchemaMigration
5 from django.db import models
6
7
8 class Migration(SchemaMigration):
9
10 def forwards(self, orm):
11 # Adding field 'Thread.bumpable'
12 db.add_column('boards_thread', 'bumpable',
13 self.gf('django.db.models.fields.BooleanField')(default=True),
14 keep_default=False)
15
16
17 def backwards(self, orm):
18 # Deleting field 'Thread.bumpable'
19 db.delete_column('boards_thread', 'bumpable')
20
21
22 models = {
23 'boards.ban': {
24 'Meta': {'object_name': 'Ban'},
25 'can_read': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
26 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
27 'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
28 'reason': ('django.db.models.fields.CharField', [], {'max_length': '200', 'default': "'Auto'"})
29 },
30 'boards.post': {
31 'Meta': {'object_name': 'Post', 'ordering': "('id',)"},
32 '_text_rendered': ('django.db.models.fields.TextField', [], {}),
33 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
34 'images': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'null': 'True', 'db_index': 'True', 'blank': 'True', 'related_name': "'ip+'", 'to': "orm['boards.PostImage']"}),
35 'last_edit_time': ('django.db.models.fields.DateTimeField', [], {}),
36 'poster_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
37 'poster_user_agent': ('django.db.models.fields.TextField', [], {}),
38 'pub_time': ('django.db.models.fields.DateTimeField', [], {}),
39 'referenced_posts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'null': 'True', 'db_index': 'True', 'blank': 'True', 'related_name': "'rfp+'", 'to': "orm['boards.Post']"}),
40 'refmap': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
41 'text': ('markupfield.fields.MarkupField', [], {'rendered_field': 'True'}),
42 'text_markup_type': ('django.db.models.fields.CharField', [], {'max_length': '30', 'default': "'bbcode'"}),
43 'thread_new': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'null': 'True', 'to': "orm['boards.Thread']"}),
44 'title': ('django.db.models.fields.CharField', [], {'max_length': '200'})
45 },
46 'boards.postimage': {
47 'Meta': {'object_name': 'PostImage', 'ordering': "('id',)"},
48 'hash': ('django.db.models.fields.CharField', [], {'max_length': '36'}),
49 'height': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
50 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
51 'image': ('boards.thumbs.ImageWithThumbsField', [], {'max_length': '100', 'blank': 'True'}),
52 'pre_height': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
53 'pre_width': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
54 'width': ('django.db.models.fields.IntegerField', [], {'default': '0'})
55 },
56 'boards.tag': {
57 'Meta': {'object_name': 'Tag', 'ordering': "('name',)"},
58 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
59 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '100'}),
60 'threads': ('django.db.models.fields.related.ManyToManyField', [], {'null': 'True', 'symmetrical': 'False', 'blank': 'True', 'related_name': "'tag+'", 'to': "orm['boards.Thread']"})
61 },
62 'boards.thread': {
63 'Meta': {'object_name': 'Thread'},
64 'archived': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
65 'bump_time': ('django.db.models.fields.DateTimeField', [], {}),
66 'bumpable': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
67 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
68 'last_edit_time': ('django.db.models.fields.DateTimeField', [], {}),
69 'replies': ('django.db.models.fields.related.ManyToManyField', [], {'null': 'True', 'symmetrical': 'False', 'blank': 'True', 'related_name': "'tre+'", 'to': "orm['boards.Post']"}),
70 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['boards.Tag']"})
71 }
72 }
73
74 complete_apps = ['boards']
@@ -0,0 +1,81 b''
1 # -*- coding: utf-8 -*-
2 from south.utils import datetime_utils as datetime
3 from south.db import db
4 from south.v2 import DataMigration
5 from django.db import models
6 import boards
7
8 class Migration(DataMigration):
9
10 def forwards(self, orm):
11 changed_count = 0
12
13 for thread in orm['boards.Thread'].objects.all():
14 if thread.replies.count() >= boards.settings.MAX_POSTS_PER_THREAD:
15 thread.bumpable = False
16 print('Disabled bump on thread {}'.format(thread.id))
17 changed_count += 1
18 else:
19 thread.bumpable = True
20 thread.save(update_fields=['bumpable'])
21
22
23 print('Changed {} threads.'.format(changed_count))
24
25 def backwards(self, orm):
26 "Write your backwards methods here."
27
28 models = {
29 'boards.ban': {
30 'Meta': {'object_name': 'Ban'},
31 'can_read': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
32 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
33 'ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
34 'reason': ('django.db.models.fields.CharField', [], {'default': "'Auto'", 'max_length': '200'})
35 },
36 'boards.post': {
37 'Meta': {'object_name': 'Post', 'ordering': "('id',)"},
38 '_text_rendered': ('django.db.models.fields.TextField', [], {}),
39 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
40 'images': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'ip+'", 'null': 'True', 'to': "orm['boards.PostImage']", 'db_index': 'True', 'blank': 'True'}),
41 'last_edit_time': ('django.db.models.fields.DateTimeField', [], {}),
42 'poster_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
43 'poster_user_agent': ('django.db.models.fields.TextField', [], {}),
44 'pub_time': ('django.db.models.fields.DateTimeField', [], {}),
45 'referenced_posts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'rfp+'", 'null': 'True', 'to': "orm['boards.Post']", 'db_index': 'True', 'blank': 'True'}),
46 'refmap': ('django.db.models.fields.TextField', [], {'blank': 'True', 'null': 'True'}),
47 'text': ('markupfield.fields.MarkupField', [], {'rendered_field': 'True'}),
48 'text_markup_type': ('django.db.models.fields.CharField', [], {'default': "'bbcode'", 'max_length': '30'}),
49 'thread_new': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['boards.Thread']", 'null': 'True'}),
50 'title': ('django.db.models.fields.CharField', [], {'max_length': '200'})
51 },
52 'boards.postimage': {
53 'Meta': {'object_name': 'PostImage', 'ordering': "('id',)"},
54 'hash': ('django.db.models.fields.CharField', [], {'max_length': '36'}),
55 'height': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
56 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
57 'image': ('boards.thumbs.ImageWithThumbsField', [], {'blank': 'True', 'max_length': '100'}),
58 'pre_height': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
59 'pre_width': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
60 'width': ('django.db.models.fields.IntegerField', [], {'default': '0'})
61 },
62 'boards.tag': {
63 'Meta': {'object_name': 'Tag', 'ordering': "('name',)"},
64 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
65 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '100'}),
66 'threads': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'null': 'True', 'to': "orm['boards.Thread']", 'related_name': "'tag+'", 'blank': 'True'})
67 },
68 'boards.thread': {
69 'Meta': {'object_name': 'Thread'},
70 'archived': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
71 'bump_time': ('django.db.models.fields.DateTimeField', [], {}),
72 'bumpable': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
73 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
74 'last_edit_time': ('django.db.models.fields.DateTimeField', [], {}),
75 'replies': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'null': 'True', 'to': "orm['boards.Post']", 'related_name': "'tre+'", 'blank': 'True'}),
76 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': "orm['boards.Tag']"})
77 }
78 }
79
80 complete_apps = ['boards']
81 symmetrical = True
@@ -1,417 +1,421 b''
1 1 from datetime import datetime, timedelta, date
2 2 from datetime import time as dtime
3 3 from adjacent import Client
4 4 import logging
5 5 import re
6 6
7 7 from django.core.cache import cache
8 8 from django.core.urlresolvers import reverse
9 9 from django.db import models, transaction
10 10 from django.shortcuts import get_object_or_404
11 11 from django.template import RequestContext
12 12 from django.template.loader import render_to_string
13 13 from django.utils import timezone
14 14 from markupfield.fields import MarkupField
15 15 from boards import settings
16 16
17 17 from boards.models import PostImage
18 18 from boards.models.base import Viewable
19 19 from boards.models.thread import Thread
20 20 from boards.utils import datetime_to_epoch
21 21
22 22 WS_CHANNEL_THREAD = "thread:"
23 23
24 24 APP_LABEL_BOARDS = 'boards'
25 25
26 26 CACHE_KEY_PPD = 'ppd'
27 27 CACHE_KEY_POST_URL = 'post_url'
28 28
29 29 POSTS_PER_DAY_RANGE = 7
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 DEFAULT_MARKUP_TYPE = 'bbcode'
38 38
39 39 # TODO This should be removed
40 40 NO_IP = '0.0.0.0'
41 41
42 42 # TODO Real user agent should be saved instead of this
43 43 UNKNOWN_UA = ''
44 44
45 45 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
46 46
47 47 PARAMETER_TRUNCATED = 'truncated'
48 48 PARAMETER_TAG = 'tag'
49 49 PARAMETER_OFFSET = 'offset'
50 50 PARAMETER_DIFF_TYPE = 'type'
51 51
52 52 DIFF_TYPE_HTML = 'html'
53 53 DIFF_TYPE_JSON = 'json'
54 54
55 55 logger = logging.getLogger(__name__)
56 56
57 57
58 58 class PostManager(models.Manager):
59 59 def create_post(self, title, text, image=None, thread=None, ip=NO_IP,
60 60 tags=None):
61 61 """
62 62 Creates new post
63 63 """
64 64
65 65 if not tags:
66 66 tags = []
67 67
68 68 posting_time = timezone.now()
69 69 if not thread:
70 70 thread = Thread.objects.create(bump_time=posting_time,
71 71 last_edit_time=posting_time)
72 72 new_thread = True
73 73 else:
74 74 thread.bump()
75 75 thread.last_edit_time = posting_time
76 thread.save()
76 if thread.can_bump() and (
77 thread.get_reply_count() >= settings.MAX_POSTS_PER_THREAD):
78 thread.bumpable = False
79 thread.save(update_fields=['last_edit_time', 'bumpable'])
77 80 new_thread = False
78 81
79 82 post = self.create(title=title,
80 83 text=text,
81 84 pub_time=posting_time,
82 85 thread_new=thread,
83 86 poster_ip=ip,
84 87 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
85 88 # last!
86 89 last_edit_time=posting_time)
87 90
88 91 logger.info('Created post #%d with title "%s"' % (post.id,
89 92 post.title))
90 93
91 94 if image:
92 95 post_image = PostImage.objects.create(image=image)
93 96 post.images.add(post_image)
94 97 logger.info('Created image #%d for post #%d' % (post_image.id,
95 98 post.id))
96 99
97 100 thread.replies.add(post)
98 101 list(map(thread.add_tag, tags))
99 102
100 103 if new_thread:
101 104 Thread.objects.process_oldest_threads()
102 105 self.connect_replies(post)
103 106
107
104 108 return post
105 109
106 110 def delete_post(self, post):
107 111 """
108 112 Deletes post and update or delete its thread
109 113 """
110 114
111 115 post_id = post.id
112 116
113 117 thread = post.get_thread()
114 118
115 119 if post.is_opening():
116 120 thread.delete()
117 121 else:
118 122 thread.last_edit_time = timezone.now()
119 123 thread.save()
120 124
121 125 post.delete()
122 126
123 127 logger.info('Deleted post #%d (%s)' % (post_id, post.get_title()))
124 128
125 129 def delete_posts_by_ip(self, ip):
126 130 """
127 131 Deletes all posts of the author with same IP
128 132 """
129 133
130 134 posts = self.filter(poster_ip=ip)
131 135 for post in posts:
132 136 self.delete_post(post)
133 137
134 138 def connect_replies(self, post):
135 139 """
136 140 Connects replies to a post to show them as a reflink map
137 141 """
138 142
139 143 for reply_number in re.finditer(REGEX_REPLY, post.text.raw):
140 144 post_id = reply_number.group(1)
141 145 ref_post = self.filter(id=post_id)
142 146 if ref_post.count() > 0:
143 147 referenced_post = ref_post[0]
144 148 referenced_post.referenced_posts.add(post)
145 149 referenced_post.last_edit_time = post.pub_time
146 150 referenced_post.build_refmap()
147 151 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
148 152
149 153 referenced_thread = referenced_post.get_thread()
150 154 referenced_thread.last_edit_time = post.pub_time
151 155 referenced_thread.save(update_fields=['last_edit_time'])
152 156
153 157 def get_posts_per_day(self):
154 158 """
155 159 Gets average count of posts per day for the last 7 days
156 160 """
157 161
158 162 day_end = date.today()
159 163 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
160 164
161 165 cache_key = CACHE_KEY_PPD + str(day_end)
162 166 ppd = cache.get(cache_key)
163 167 if ppd:
164 168 return ppd
165 169
166 170 day_time_start = timezone.make_aware(datetime.combine(
167 171 day_start, dtime()), timezone.get_current_timezone())
168 172 day_time_end = timezone.make_aware(datetime.combine(
169 173 day_end, dtime()), timezone.get_current_timezone())
170 174
171 175 posts_per_period = float(self.filter(
172 176 pub_time__lte=day_time_end,
173 177 pub_time__gte=day_time_start).count())
174 178
175 179 ppd = posts_per_period / POSTS_PER_DAY_RANGE
176 180
177 181 cache.set(cache_key, ppd)
178 182 return ppd
179 183
180 184
181 185 class Post(models.Model, Viewable):
182 186 """A post is a message."""
183 187
184 188 objects = PostManager()
185 189
186 190 class Meta:
187 191 app_label = APP_LABEL_BOARDS
188 192 ordering = ('id',)
189 193
190 194 title = models.CharField(max_length=TITLE_MAX_LENGTH)
191 195 pub_time = models.DateTimeField()
192 196 text = MarkupField(default_markup_type=DEFAULT_MARKUP_TYPE,
193 197 escape_html=False)
194 198
195 199 images = models.ManyToManyField(PostImage, null=True, blank=True,
196 200 related_name='ip+', db_index=True)
197 201
198 202 poster_ip = models.GenericIPAddressField()
199 203 poster_user_agent = models.TextField()
200 204
201 205 thread_new = models.ForeignKey('Thread', null=True, default=None,
202 206 db_index=True)
203 207 last_edit_time = models.DateTimeField()
204 208
205 209 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
206 210 null=True,
207 211 blank=True, related_name='rfp+',
208 212 db_index=True)
209 213 refmap = models.TextField(null=True, blank=True)
210 214
211 215 def __unicode__(self):
212 216 return '#' + str(self.id) + ' ' + self.title + ' (' + \
213 217 self.text.raw[:50] + ')'
214 218
215 219 def get_title(self):
216 220 """
217 221 Gets original post title or part of its text.
218 222 """
219 223
220 224 title = self.title
221 225 if not title:
222 226 title = self.text.rendered
223 227
224 228 return title
225 229
226 230 def build_refmap(self):
227 231 """
228 232 Builds a replies map string from replies list. This is a cache to stop
229 233 the server from recalculating the map on every post show.
230 234 """
231 235 map_string = ''
232 236
233 237 first = True
234 238 for refpost in self.referenced_posts.all():
235 239 if not first:
236 240 map_string += ', '
237 241 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
238 242 refpost.id)
239 243 first = False
240 244
241 245 self.refmap = map_string
242 246
243 247 def get_sorted_referenced_posts(self):
244 248 return self.refmap
245 249
246 250 def is_referenced(self):
247 251 return len(self.refmap) > 0
248 252
249 253 def is_opening(self):
250 254 """
251 255 Checks if this is an opening post or just a reply.
252 256 """
253 257
254 258 return self.get_thread().get_opening_post_id() == self.id
255 259
256 260 @transaction.atomic
257 261 def add_tag(self, tag):
258 262 edit_time = timezone.now()
259 263
260 264 thread = self.get_thread()
261 265 thread.add_tag(tag)
262 266 self.last_edit_time = edit_time
263 267 self.save(update_fields=['last_edit_time'])
264 268
265 269 thread.last_edit_time = edit_time
266 270 thread.save(update_fields=['last_edit_time'])
267 271
268 272 @transaction.atomic
269 273 def remove_tag(self, tag):
270 274 edit_time = timezone.now()
271 275
272 276 thread = self.get_thread()
273 277 thread.remove_tag(tag)
274 278 self.last_edit_time = edit_time
275 279 self.save(update_fields=['last_edit_time'])
276 280
277 281 thread.last_edit_time = edit_time
278 282 thread.save(update_fields=['last_edit_time'])
279 283
280 284 def get_url(self, thread=None):
281 285 """
282 286 Gets full url to the post.
283 287 """
284 288
285 289 cache_key = CACHE_KEY_POST_URL + str(self.id)
286 290 link = cache.get(cache_key)
287 291
288 292 if not link:
289 293 if not thread:
290 294 thread = self.get_thread()
291 295
292 296 opening_id = thread.get_opening_post_id()
293 297
294 298 if self.id != opening_id:
295 299 link = reverse('thread', kwargs={
296 300 'post_id': opening_id}) + '#' + str(self.id)
297 301 else:
298 302 link = reverse('thread', kwargs={'post_id': self.id})
299 303
300 304 cache.set(cache_key, link)
301 305
302 306 return link
303 307
304 308 def get_thread(self):
305 309 """
306 310 Gets post's thread.
307 311 """
308 312
309 313 return self.thread_new
310 314
311 315 def get_referenced_posts(self):
312 316 return self.referenced_posts.only('id', 'thread_new')
313 317
314 318 def get_text(self):
315 319 return self.text
316 320
317 321 def get_view(self, moderator=False, need_open_link=False,
318 322 truncated=False, *args, **kwargs):
319 323 if 'is_opening' in kwargs:
320 324 is_opening = kwargs['is_opening']
321 325 else:
322 326 is_opening = self.is_opening()
323 327
324 328 if 'thread' in kwargs:
325 329 thread = kwargs['thread']
326 330 else:
327 331 thread = self.get_thread()
328 332
329 333 if 'can_bump' in kwargs:
330 334 can_bump = kwargs['can_bump']
331 335 else:
332 336 can_bump = thread.can_bump()
333 337
334 338 if is_opening:
335 339 opening_post_id = self.id
336 340 else:
337 341 opening_post_id = thread.get_opening_post_id()
338 342
339 343 return render_to_string('boards/post.html', {
340 344 'post': self,
341 345 'moderator': moderator,
342 346 'is_opening': is_opening,
343 347 'thread': thread,
344 348 'bumpable': can_bump,
345 349 'need_open_link': need_open_link,
346 350 'truncated': truncated,
347 351 'opening_post_id': opening_post_id,
348 352 })
349 353
350 354 def get_first_image(self):
351 355 return self.images.earliest('id')
352 356
353 357 def delete(self, using=None):
354 358 """
355 359 Deletes all post images and the post itself.
356 360 """
357 361
358 362 self.images.all().delete()
359 363
360 364 super(Post, self).delete(using)
361 365
362 366 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
363 367 include_last_update=False):
364 368 """
365 369 Gets post HTML or JSON data that can be rendered on a page or used by
366 370 API.
367 371 """
368 372
369 373 if format_type == DIFF_TYPE_HTML:
370 374 context = RequestContext(request)
371 375 context['post'] = self
372 376 if PARAMETER_TRUNCATED in request.GET:
373 377 context[PARAMETER_TRUNCATED] = True
374 378
375 379 return render_to_string('boards/api_post.html', context)
376 380 elif format_type == DIFF_TYPE_JSON:
377 381 post_json = {
378 382 'id': self.id,
379 383 'title': self.title,
380 384 'text': self.text.rendered,
381 385 }
382 386 if self.images.exists():
383 387 post_image = self.get_first_image()
384 388 post_json['image'] = post_image.image.url
385 389 post_json['image_preview'] = post_image.image.url_200x150
386 390 if include_last_update:
387 391 post_json['bump_time'] = datetime_to_epoch(
388 392 self.thread_new.bump_time)
389 393 return post_json
390 394
391 395 def send_to_websocket(self, request, recursive=True):
392 396 """
393 397 Sends post HTML data to the thread web socket.
394 398 """
395 399
396 400 if not settings.WEBSOCKETS_ENABLED:
397 401 return
398 402
399 403 client = Client()
400 404
401 405 channel_name = WS_CHANNEL_THREAD + str(self.get_thread().get_opening_post_id())
402 406 client.publish(channel_name, {
403 407 'html': self.get_post_data(
404 408 format_type=DIFF_TYPE_HTML,
405 409 request=request),
406 410 'diff_type': 'added' if recursive else 'updated',
407 411 })
408 412 client.send()
409 413
410 414 logger.info('Sent post #{} to channel {}'.format(self.id, channel_name))
411 415
412 416 if recursive:
413 417 for reply_number in re.finditer(REGEX_REPLY, self.text.raw):
414 418 post_id = reply_number.group(1)
415 419 ref_post = Post.objects.filter(id=post_id)[0]
416 420
417 ref_post.send_to_websocket(request, recursive=False) No newline at end of file
421 ref_post.send_to_websocket(request, recursive=False)
@@ -1,188 +1,185 b''
1 1 import logging
2 2 from django.db.models import Count
3 3 from django.utils import timezone
4 4 from django.core.cache import cache
5 5 from django.db import models
6 6 from boards import settings
7 7
8 8 __author__ = 'neko259'
9 9
10 10
11 11 logger = logging.getLogger(__name__)
12 12
13 13
14 14 CACHE_KEY_OPENING_POST = 'opening_post_id'
15 15
16 16
17 17 class ThreadManager(models.Manager):
18 18 def process_oldest_threads(self):
19 19 """
20 20 Preserves maximum thread count. If there are too many threads,
21 21 archive or delete the old ones.
22 22 """
23 23
24 24 threads = Thread.objects.filter(archived=False).order_by('-bump_time')
25 25 thread_count = threads.count()
26 26
27 27 if thread_count > settings.MAX_THREAD_COUNT:
28 28 num_threads_to_delete = thread_count - settings.MAX_THREAD_COUNT
29 29 old_threads = threads[thread_count - num_threads_to_delete:]
30 30
31 31 for thread in old_threads:
32 32 if settings.ARCHIVE_THREADS:
33 33 self._archive_thread(thread)
34 34 else:
35 35 thread.delete()
36 36
37 37 logger.info('Processed %d old threads' % num_threads_to_delete)
38 38
39 39 def _archive_thread(self, thread):
40 40 thread.archived = True
41 thread.bumpable = False
41 42 thread.last_edit_time = timezone.now()
42 thread.save(update_fields=['archived', 'last_edit_time'])
43 thread.save(update_fields=['archived', 'last_edit_time', 'bumpable'])
43 44
44 45
45 46 class Thread(models.Model):
46 47 objects = ThreadManager()
47 48
48 49 class Meta:
49 50 app_label = 'boards'
50 51
51 52 tags = models.ManyToManyField('Tag')
52 53 bump_time = models.DateTimeField()
53 54 last_edit_time = models.DateTimeField()
54 55 replies = models.ManyToManyField('Post', symmetrical=False, null=True,
55 56 blank=True, related_name='tre+')
56 57 archived = models.BooleanField(default=False)
58 bumpable = models.BooleanField(default=True)
57 59
58 60 def get_tags(self):
59 61 """
60 62 Gets a sorted tag list.
61 63 """
62 64
63 65 return self.tags.order_by('name')
64 66
65 67 def bump(self):
66 68 """
67 69 Bumps (moves to up) thread if possible.
68 70 """
69 71
70 72 if self.can_bump():
71 73 self.bump_time = timezone.now()
72 74
73 75 logger.info('Bumped thread %d' % self.id)
74 76
75 77 def get_reply_count(self):
76 78 return self.replies.count()
77 79
78 80 def get_images_count(self):
79 81 # TODO Use sum
80 82 total_count = 0
81 83 for post_with_image in self.replies.annotate(images_count=Count(
82 84 'images')):
83 85 total_count += post_with_image.images_count
84 86 return total_count
85 87
86 88 def can_bump(self):
87 89 """
88 90 Checks if the thread can be bumped by replying to it.
89 91 """
90 92
91 if self.archived:
92 return False
93
94 post_count = self.get_reply_count()
95
96 return post_count < settings.MAX_POSTS_PER_THREAD
93 return self.bumpable
97 94
98 95 def get_last_replies(self):
99 96 """
100 97 Gets several last replies, not including opening post
101 98 """
102 99
103 100 if settings.LAST_REPLIES_COUNT > 0:
104 101 reply_count = self.get_reply_count()
105 102
106 103 if reply_count > 0:
107 104 reply_count_to_show = min(settings.LAST_REPLIES_COUNT,
108 105 reply_count - 1)
109 106 replies = self.get_replies()
110 107 last_replies = replies[reply_count - reply_count_to_show:]
111 108
112 109 return last_replies
113 110
114 111 def get_skipped_replies_count(self):
115 112 """
116 113 Gets number of posts between opening post and last replies.
117 114 """
118 115 reply_count = self.get_reply_count()
119 116 last_replies_count = min(settings.LAST_REPLIES_COUNT,
120 117 reply_count - 1)
121 118 return reply_count - last_replies_count - 1
122 119
123 120 def get_replies(self, view_fields_only=False):
124 121 """
125 122 Gets sorted thread posts
126 123 """
127 124
128 125 query = self.replies.order_by('pub_time').prefetch_related('images')
129 126 if view_fields_only:
130 127 query = query.defer('poster_user_agent', 'text_markup_type')
131 128 return query.all()
132 129
133 130 def get_replies_with_images(self, view_fields_only=False):
134 131 return self.get_replies(view_fields_only).annotate(images_count=Count(
135 132 'images')).filter(images_count__gt=0)
136 133
137 134 def add_tag(self, tag):
138 135 """
139 136 Connects thread to a tag and tag to a thread
140 137 """
141 138
142 139 self.tags.add(tag)
143 140 tag.threads.add(self)
144 141
145 142 def remove_tag(self, tag):
146 143 self.tags.remove(tag)
147 144 tag.threads.remove(self)
148 145
149 146 def get_opening_post(self, only_id=False):
150 147 """
151 148 Gets the first post of the thread
152 149 """
153 150
154 151 query = self.replies.order_by('pub_time')
155 152 if only_id:
156 153 query = query.only('id')
157 154 opening_post = query.first()
158 155
159 156 return opening_post
160 157
161 158 def get_opening_post_id(self):
162 159 """
163 160 Gets ID of the first thread post.
164 161 """
165 162
166 163 cache_key = CACHE_KEY_OPENING_POST + str(self.id)
167 164 opening_post_id = cache.get(cache_key)
168 165 if not opening_post_id:
169 166 opening_post_id = self.get_opening_post(only_id=True).id
170 167 cache.set(cache_key, opening_post_id)
171 168
172 169 return opening_post_id
173 170
174 171 def __unicode__(self):
175 172 return str(self.id)
176 173
177 174 def get_pub_time(self):
178 175 """
179 176 Gets opening post's pub time because thread does not have its own one.
180 177 """
181 178
182 179 return self.get_opening_post().pub_time
183 180
184 181 def delete(self, using=None):
185 182 if self.replies.exists():
186 183 self.replies.all().delete()
187 184
188 super(Thread, self).delete(using) No newline at end of file
185 super(Thread, self).delete(using)
@@ -1,282 +1,285 b''
1 1 /*
2 2 @licstart The following is the entire license notice for the
3 3 JavaScript code in this page.
4 4
5 5
6 6 Copyright (C) 2013 neko259
7 7
8 8 The JavaScript code in this page is free software: you can
9 9 redistribute it and/or modify it under the terms of the GNU
10 10 General Public License (GNU GPL) as published by the Free Software
11 11 Foundation, either version 3 of the License, or (at your option)
12 12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15 15
16 16 As additional permission under GNU GPL version 3 section 7, you
17 17 may distribute non-source (e.g., minimized or compacted) forms of
18 18 that code without the copy of the GNU GPL normally required by
19 19 section 4, provided you include this license notice and a URL
20 20 through which recipients can access the Corresponding Source.
21 21
22 22 @licend The above is the entire license notice
23 23 for the JavaScript code in this page.
24 24 */
25 25
26 26 var wsUrl = 'ws://localhost:9090/connection/websocket';
27 27 var wsUser = '';
28 28
29 29 var loading = false;
30 30 var lastUpdateTime = null;
31 31 var unreadPosts = 0;
32 32
33 33 // Thread ID does not change, can be stored one time
34 34 var threadId = $('div.thread').children('.post').first().attr('id');
35 35
36 36 function connectWebsocket() {
37 37 var metapanel = $('.metapanel')[0];
38 38
39 39 var wsHost = metapanel.getAttribute('data-ws-host');
40 40 var wsPort = metapanel.getAttribute('data-ws-port');
41 41
42 42 if (wsHost.length > 0 && wsPort.length > 0)
43 43 var centrifuge = new Centrifuge({
44 44 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
45 45 "project": metapanel.getAttribute('data-ws-project'),
46 46 "user": wsUser,
47 47 "timestamp": metapanel.getAttribute('data-last-update'),
48 48 "token": metapanel.getAttribute('data-ws-token'),
49 49 "debug": false
50 50 });
51 51
52 52 centrifuge.on('error', function(error_message) {
53 alert("Error connecting to websocket server.");
53 console.log("Error connecting to websocket server.");
54 return false;
54 55 });
55 56
56 57 centrifuge.on('connect', function() {
57 58 var channelName = 'thread:' + threadId;
58 59 centrifuge.subscribe(channelName, function(message) {
59 60 var postHtml = message.data['html'];
60 61 var isAdded = (message.data['diff_type'] === 'added');
61 62
62 63 if (postHtml) {
63 64 updatePost(postHtml, isAdded);
64 65 }
65 66 });
66 67
67 68 $('#autoupdate').text('[+]');
68 69 });
69 70
70 71 centrifuge.connect();
72
73 return true;
71 74 }
72 75
73 76 function updatePost(postHtml, isAdded) {
74 77 // This needs to be set on start because the page is scrolled after posts
75 78 // are added or updated
76 79 var bottom = isPageBottom();
77 80
78 81 var post = $(postHtml);
79 82
80 83 var threadPosts = $('div.thread').children('.post');
81 84
82 85 var lastUpdate = '';
83 86
84 87 if (isAdded) {
85 88 var lastPost = threadPosts.last();
86 89
87 90 post.appendTo(lastPost.parent());
88 91
89 92 updateBumplimitProgress(1);
90 93 showNewPostsTitle(1);
91 94
92 95 lastUpdate = post.children('.post-info').first()
93 96 .children('.pub_time').first().text();
94 97
95 98 if (bottom) {
96 99 scrollToBottom();
97 100 }
98 101 } else {
99 102 var postId = post.attr('id');
100 103
101 104 var oldPost = $('div.thread').children('.post[id=' + postId + ']');
102 105
103 106 oldPost.replaceWith(post);
104 107 }
105 108
106 109 processNewPost(post);
107 110 updateMetadataPanel(lastUpdate)
108 111 }
109 112
110 113 function blink(node) {
111 114 var blinkCount = 2;
112 115
113 116 var nodeToAnimate = node;
114 117 for (var i = 0; i < blinkCount; i++) {
115 118 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
116 119 }
117 120 }
118 121
119 122 function isPageBottom() {
120 123 var scroll = $(window).scrollTop() / ($(document).height()
121 124 - $(window).height());
122 125
123 126 return scroll == 1
124 127 }
125 128
126 129 function initAutoupdate() {
127 130 connectWebsocket();
128 131 }
129 132
130 133 function getReplyCount() {
131 134 return $('.thread').children('.post').length
132 135 }
133 136
134 137 function getImageCount() {
135 138 return $('.thread').find('img').length
136 139 }
137 140
138 141 function updateMetadataPanel(lastUpdate) {
139 142 var replyCountField = $('#reply-count');
140 143 var imageCountField = $('#image-count');
141 144
142 145 replyCountField.text(getReplyCount());
143 146 imageCountField.text(getImageCount());
144 147
145 148 if (lastUpdate !== '') {
146 149 var lastUpdateField = $('#last-update');
147 150 lastUpdateField.text(lastUpdate);
148 151 blink(lastUpdateField);
149 152 }
150 153
151 154 blink(replyCountField);
152 155 blink(imageCountField);
153 156 }
154 157
155 158 /**
156 159 * Update bumplimit progress bar
157 160 */
158 161 function updateBumplimitProgress(postDelta) {
159 162 var progressBar = $('#bumplimit_progress');
160 163 if (progressBar) {
161 164 var postsToLimitElement = $('#left_to_limit');
162 165
163 166 var oldPostsToLimit = parseInt(postsToLimitElement.text());
164 167 var postCount = getReplyCount();
165 168 var bumplimit = postCount - postDelta + oldPostsToLimit;
166 169
167 170 var newPostsToLimit = bumplimit - postCount;
168 171 if (newPostsToLimit <= 0) {
169 172 $('.bar-bg').remove();
170 173 $('.thread').children('.post').addClass('dead_post');
171 174 } else {
172 175 postsToLimitElement.text(newPostsToLimit);
173 176 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
174 177 }
175 178 }
176 179 }
177 180
178 181 var documentOriginalTitle = '';
179 182 /**
180 183 * Show 'new posts' text in the title if the document is not visible to a user
181 184 */
182 185 function showNewPostsTitle(newPostCount) {
183 186 if (document.hidden) {
184 187 if (documentOriginalTitle === '') {
185 188 documentOriginalTitle = document.title;
186 189 }
187 190 unreadPosts = unreadPosts + newPostCount;
188 191 document.title = '[' + unreadPosts + '] ' + documentOriginalTitle;
189 192
190 193 document.addEventListener('visibilitychange', function() {
191 194 if (documentOriginalTitle !== '') {
192 195 document.title = documentOriginalTitle;
193 196 documentOriginalTitle = '';
194 197 unreadPosts = 0;
195 198 }
196 199
197 200 document.removeEventListener('visibilitychange', null);
198 201 });
199 202 }
200 203 }
201 204
202 205 /**
203 206 * Clear all entered values in the form fields
204 207 */
205 208 function resetForm(form) {
206 209 form.find('input:text, input:password, input:file, select, textarea').val('');
207 210 form.find('input:radio, input:checkbox')
208 211 .removeAttr('checked').removeAttr('selected');
209 212 $('.file_wrap').find('.file-thumb').remove();
210 213 }
211 214
212 215 /**
213 216 * When the form is posted, this method will be run as a callback
214 217 */
215 218 function updateOnPost(response, statusText, xhr, form) {
216 219 var json = $.parseJSON(response);
217 220 var status = json.status;
218 221
219 222 showAsErrors(form, '');
220 223
221 224 if (status === 'ok') {
222 225 resetForm(form);
223 226 } else {
224 227 var errors = json.errors;
225 228 for (var i = 0; i < errors.length; i++) {
226 229 var fieldErrors = errors[i];
227 230
228 231 var error = fieldErrors.errors;
229 232
230 233 showAsErrors(form, error);
231 234 }
232 235 }
233 236
234 237 scrollToBottom();
235 238 }
236 239
237 240 /**
238 241 * Show text in the errors row of the form.
239 242 * @param form
240 243 * @param text
241 244 */
242 245 function showAsErrors(form, text) {
243 246 form.children('.form-errors').remove();
244 247
245 248 if (text.length > 0) {
246 249 var errorList = $('<div class="form-errors">' + text
247 250 + '<div>');
248 251 errorList.appendTo(form);
249 252 }
250 253 }
251 254
252 255 /**
253 256 * Run js methods that are usually run on the document, on the new post
254 257 */
255 258 function processNewPost(post) {
256 259 addRefLinkPreview(post[0]);
257 260 highlightCode(post);
258 261 blink(post);
259 262 }
260 263
261 264 $(document).ready(function(){
262 265 if ('WebSocket' in window) {
263 initAutoupdate();
266 if (initAutoupdate()) {
267 // Post form data over AJAX
268 var threadId = $('div.thread').children('.post').first().attr('id');
264 269
265 // Post form data over AJAX
266 var threadId = $('div.thread').children('.post').first().attr('id');
267
268 var form = $('#form');
270 var form = $('#form');
269 271
270 var options = {
271 beforeSubmit: function(arr, $form, options) {
272 showAsErrors($('form'), gettext('Sending message...'));
273 },
274 success: updateOnPost,
275 url: '/api/add_post/' + threadId + '/'
276 };
272 var options = {
273 beforeSubmit: function(arr, $form, options) {
274 showAsErrors($('form'), gettext('Sending message...'));
275 },
276 success: updateOnPost,
277 url: '/api/add_post/' + threadId + '/'
278 };
277 279
278 form.ajaxForm(options);
280 form.ajaxForm(options);
279 281
280 resetForm(form);
282 resetForm(form);
283 }
281 284 }
282 285 });
General Comments 0
You need to be logged in to leave comments. Login now