##// END OF EJS Templates
Added next id list, previous id list and thread to the post XML output
neko259 -
r829:5301b1d8 decentral
parent child Browse files
Show More
@@ -1,467 +1,491 b''
1 1 from datetime import datetime, timedelta, date
2 2 from datetime import time as dtime
3 3 import logging
4 4 import re
5 5 import xml.etree.ElementTree as et
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.template.loader import render_to_string
11 11 from django.utils import timezone
12 12
13 13 from markupfield.fields import MarkupField
14 14
15 15 from boards.models import PostImage, KeyPair, GlobalId
16 16 from boards.models.base import Viewable
17 17 from boards.models.thread import Thread
18 18 from boards import utils
19 19
20 20
21 21 APP_LABEL_BOARDS = 'boards'
22 22
23 23 CACHE_KEY_PPD = 'ppd'
24 24 CACHE_KEY_POST_URL = 'post_url'
25 25
26 26 POSTS_PER_DAY_RANGE = 7
27 27
28 28 BAN_REASON_AUTO = 'Auto'
29 29
30 30 IMAGE_THUMB_SIZE = (200, 150)
31 31
32 32 TITLE_MAX_LENGTH = 200
33 33
34 34 DEFAULT_MARKUP_TYPE = 'bbcode'
35 35
36 36 # TODO This should be removed
37 37 NO_IP = '0.0.0.0'
38 38
39 39 # TODO Real user agent should be saved instead of this
40 40 UNKNOWN_UA = ''
41 41
42 42 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
43 43
44 44 TAG_MODEL = 'model'
45 45 TAG_REQUEST = 'request'
46 46 TAG_RESPONSE = 'response'
47 47 TAG_ID = 'id'
48 48 TAG_STATUS = 'status'
49 49 TAG_MODELS = 'models'
50 50 TAG_TITLE = 'title'
51 51 TAG_TEXT = 'text'
52 52 TAG_THREAD = 'thread'
53 53 TAG_PUB_TIME = 'pub-time'
54 54 TAG_EDIT_TIME = 'edit-time'
55 TAG_PREVIOUS = 'previous'
56 TAG_NEXT = 'next'
55 57
56 58 TYPE_GET = 'get'
57 59
58 60 ATTR_VERSION = 'version'
59 61 ATTR_TYPE = 'type'
60 62 ATTR_NAME = 'name'
61 63 ATTR_REF_ID = 'ref-id'
62 64
63 65 STATUS_SUCCESS = 'success'
64 66
65 67 logger = logging.getLogger(__name__)
66 68
67 69
68 70 class PostManager(models.Manager):
69 71 def create_post(self, title, text, image=None, thread=None, ip=NO_IP,
70 72 tags=None):
71 73 """
72 74 Creates new post
73 75 """
74 76
75 77 if not tags:
76 78 tags = []
77 79
78 80 posting_time = timezone.now()
79 81 if not thread:
80 82 thread = Thread.objects.create(bump_time=posting_time,
81 83 last_edit_time=posting_time)
82 84 new_thread = True
83 85 else:
84 86 thread.bump()
85 87 thread.last_edit_time = posting_time
86 88 thread.save()
87 89 new_thread = False
88 90
89 91 post = self.create(title=title,
90 92 text=text,
91 93 pub_time=posting_time,
92 94 thread_new=thread,
93 95 poster_ip=ip,
94 96 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
95 97 # last!
96 98 last_edit_time=posting_time)
97 99
98 100 post.set_global_id()
99 101
100 102 if image:
101 103 post_image = PostImage.objects.create(image=image)
102 104 post.images.add(post_image)
103 105 logger.info('Created image #%d for post #%d' % (post_image.id,
104 106 post.id))
105 107
106 108 thread.replies.add(post)
107 109 list(map(thread.add_tag, tags))
108 110
109 111 if new_thread:
110 112 Thread.objects.process_oldest_threads()
111 113 self.connect_replies(post)
112 114
113 logger.info('Created post #%d with title %s' % (post.id,
114 post.get_title()))
115 logger.info('Created post #%d with title %s and key %s'
116 % (post.id, post.get_title(), post.global_id.key))
115 117
116 118 return post
117 119
118 120 def delete_post(self, post):
119 121 """
120 122 Deletes post and update or delete its thread
121 123 """
122 124
123 125 post_id = post.id
124 126
125 127 thread = post.get_thread()
126 128
127 129 if post.is_opening():
128 130 thread.delete()
129 131 else:
130 132 thread.last_edit_time = timezone.now()
131 133 thread.save()
132 134
133 135 post.delete()
134 136
135 137 logger.info('Deleted post #%d (%s)' % (post_id, post.get_title()))
136 138
137 139 def delete_posts_by_ip(self, ip):
138 140 """
139 141 Deletes all posts of the author with same IP
140 142 """
141 143
142 144 posts = self.filter(poster_ip=ip)
143 145 for post in posts:
144 146 self.delete_post(post)
145 147
146 148 def connect_replies(self, post):
147 149 """
148 150 Connects replies to a post to show them as a reflink map
149 151 """
150 152
151 for reply_number in re.finditer(REGEX_REPLY, post.text.raw):
152 post_id = reply_number.group(1)
153 ref_post = self.filter(id=post_id)
153 for reply_number in post.get_replied_ids():
154 ref_post = self.filter(id=reply_number)
154 155 if ref_post.count() > 0:
155 156 referenced_post = ref_post[0]
156 157 referenced_post.referenced_posts.add(post)
157 158 referenced_post.last_edit_time = post.pub_time
158 159 referenced_post.build_refmap()
159 160 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
160 161
161 162 referenced_thread = referenced_post.get_thread()
162 163 referenced_thread.last_edit_time = post.pub_time
163 164 referenced_thread.save(update_fields=['last_edit_time'])
164 165
165 166 def get_posts_per_day(self):
166 167 """
167 168 Gets average count of posts per day for the last 7 days
168 169 """
169 170
170 171 day_end = date.today()
171 172 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
172 173
173 174 cache_key = CACHE_KEY_PPD + str(day_end)
174 175 ppd = cache.get(cache_key)
175 176 if ppd:
176 177 return ppd
177 178
178 179 day_time_start = timezone.make_aware(datetime.combine(
179 180 day_start, dtime()), timezone.get_current_timezone())
180 181 day_time_end = timezone.make_aware(datetime.combine(
181 182 day_end, dtime()), timezone.get_current_timezone())
182 183
183 184 posts_per_period = float(self.filter(
184 185 pub_time__lte=day_time_end,
185 186 pub_time__gte=day_time_start).count())
186 187
187 188 ppd = posts_per_period / POSTS_PER_DAY_RANGE
188 189
189 190 cache.set(cache_key, ppd)
190 191 return ppd
191 192
192 193
193 194 def generate_request_get(self, model_list: list):
194 195 """
195 196 Form a get request from a list of ModelId objects.
196 197 """
197 198
198 199 request = et.Element(TAG_REQUEST)
199 200 request.set(ATTR_TYPE, TYPE_GET)
200 201 request.set(ATTR_VERSION, '1.0')
201 202
202 203 model = et.SubElement(request, TAG_MODEL)
203 204 model.set(ATTR_VERSION, '1.0')
204 205 model.set(ATTR_NAME, 'post')
205 206
206 207 for post in model_list:
207 208 tag_id = et.SubElement(model, TAG_ID)
208 209 post.global_id.to_xml_element(tag_id)
209 210
210 211 return et.tostring(request, 'unicode')
211 212
212 213 def generate_response_get(self, model_list: list):
213 214 response = et.Element(TAG_RESPONSE)
214 215
215 216 status = et.SubElement(response, TAG_STATUS)
216 217 status.text = STATUS_SUCCESS
217 218
218 219 models = et.SubElement(response, TAG_MODELS)
219 220
220 221 ref_id = 1
221 222 for post in model_list:
222 223 model = et.SubElement(models, TAG_MODEL)
223 224 model.set(ATTR_NAME, 'post')
224 225 model.set(ATTR_REF_ID, str(ref_id))
225 226 ref_id += 1
226 227
227 228 tag_id = et.SubElement(model, TAG_ID)
228 229 post.global_id.to_xml_element(tag_id)
229 230
230 231 title = et.SubElement(model, TAG_TITLE)
231 232 title.text = post.title
232 233
233 234 text = et.SubElement(model, TAG_TEXT)
234 235 text.text = post.text.raw
235 236
236 237 if not post.is_opening():
237 238 thread = et.SubElement(model, TAG_THREAD)
238 thread.text = post.get_opening_post_id()
239 thread.text = str(post.get_thread().get_opening_post_id())
239 240
240 241 pub_time = et.SubElement(model, TAG_PUB_TIME)
241 242 pub_time.text = str(post.get_pub_time_epoch())
242 243
243 244 edit_time = et.SubElement(model, TAG_EDIT_TIME)
244 245 edit_time.text = str(post.get_edit_time_epoch())
245 246
247 previous_ids = post.get_replied_ids()
248 if len(previous_ids) > 0:
249 previous = et.SubElement(model, TAG_PREVIOUS)
250 for id in previous_ids:
251 prev_id = et.SubElement(previous, TAG_ID)
252 replied_post = Post.objects.get(id=id)
253 replied_post.global_id.to_xml_element(prev_id)
254
255
256 next_ids = post.referenced_posts.order_by('id').all()
257 if len(next_ids) > 0:
258 next_el = et.SubElement(model, TAG_NEXT)
259 for ref_post in next_ids:
260 next_id = et.SubElement(next_el, TAG_ID)
261 ref_post.global_id.to_xml_element(next_id)
262
246 263 return et.tostring(response, 'unicode')
247 264
248 265
249 266 class Post(models.Model, Viewable):
250 267 """A post is a message."""
251 268
252 269 objects = PostManager()
253 270
254 271 class Meta:
255 272 app_label = APP_LABEL_BOARDS
256 273 ordering = ('id',)
257 274
258 275 title = models.CharField(max_length=TITLE_MAX_LENGTH)
259 276 pub_time = models.DateTimeField()
260 277 text = MarkupField(default_markup_type=DEFAULT_MARKUP_TYPE,
261 278 escape_html=False)
262 279
263 280 images = models.ManyToManyField(PostImage, null=True, blank=True,
264 281 related_name='ip+', db_index=True)
265 282
266 283 poster_ip = models.GenericIPAddressField()
267 284 poster_user_agent = models.TextField()
268 285
269 286 thread_new = models.ForeignKey('Thread', null=True, default=None,
270 287 db_index=True)
271 288 last_edit_time = models.DateTimeField()
272 289
273 290 # Replies to the post
274 291 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
275 292 null=True,
276 293 blank=True, related_name='rfp+',
277 294 db_index=True)
278 295
279 296 # Replies map. This is built from the referenced posts list to speed up
280 297 # page loading (no need to get all the referenced posts from the database).
281 298 refmap = models.TextField(null=True, blank=True)
282 299
283 300 # Global ID with author key. If the message was downloaded from another
284 301 # server, this indicates the server.
285 302 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
286 303
287 304 # One post can be signed by many nodes that give their trust to it
288 305 signature = models.ManyToManyField('Signature', null=True, blank=True)
289 306
290 307 def __unicode__(self):
291 308 return '#' + str(self.id) + ' ' + self.title + ' (' + \
292 309 self.text.raw[:50] + ')'
293 310
294 311 def get_title(self):
295 312 """
296 313 Gets original post title or part of its text.
297 314 """
298 315
299 316 title = self.title
300 317 if not title:
301 318 title = self.text.rendered
302 319
303 320 return title
304 321
305 322 def build_refmap(self):
306 323 """
307 324 Builds a replies map string from replies list. This is a cache to stop
308 325 the server from recalculating the map on every post show.
309 326 """
310 327 map_string = ''
311 328
312 329 first = True
313 330 for refpost in self.referenced_posts.all():
314 331 if not first:
315 332 map_string += ', '
316 333 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
317 334 refpost.id)
318 335 first = False
319 336
320 337 self.refmap = map_string
321 338
322 339 def get_sorted_referenced_posts(self):
323 340 return self.refmap
324 341
325 342 def is_referenced(self):
326 343 return len(self.refmap) > 0
327 344
328 345 def is_opening(self):
329 346 """
330 347 Checks if this is an opening post or just a reply.
331 348 """
332 349
333 350 return self.get_thread().get_opening_post_id() == self.id
334 351
335 352 @transaction.atomic
336 353 def add_tag(self, tag):
337 354 edit_time = timezone.now()
338 355
339 356 thread = self.get_thread()
340 357 thread.add_tag(tag)
341 358 self.last_edit_time = edit_time
342 359 self.save(update_fields=['last_edit_time'])
343 360
344 361 thread.last_edit_time = edit_time
345 362 thread.save(update_fields=['last_edit_time'])
346 363
347 364 @transaction.atomic
348 365 def remove_tag(self, tag):
349 366 edit_time = timezone.now()
350 367
351 368 thread = self.get_thread()
352 369 thread.remove_tag(tag)
353 370 self.last_edit_time = edit_time
354 371 self.save(update_fields=['last_edit_time'])
355 372
356 373 thread.last_edit_time = edit_time
357 374 thread.save(update_fields=['last_edit_time'])
358 375
359 376 def get_url(self, thread=None):
360 377 """
361 378 Gets full url to the post.
362 379 """
363 380
364 381 cache_key = CACHE_KEY_POST_URL + str(self.id)
365 382 link = cache.get(cache_key)
366 383
367 384 if not link:
368 385 if not thread:
369 386 thread = self.get_thread()
370 387
371 388 opening_id = thread.get_opening_post_id()
372 389
373 390 if self.id != opening_id:
374 391 link = reverse('thread', kwargs={
375 392 'post_id': opening_id}) + '#' + str(self.id)
376 393 else:
377 394 link = reverse('thread', kwargs={'post_id': self.id})
378 395
379 396 cache.set(cache_key, link)
380 397
381 398 return link
382 399
383 400 def get_thread(self):
384 401 """
385 402 Gets post's thread.
386 403 """
387 404
388 405 return self.thread_new
389 406
390 407 def get_referenced_posts(self):
391 408 return self.referenced_posts.only('id', 'thread_new')
392 409
393 410 def get_text(self):
394 411 return self.text
395 412
396 413 def get_view(self, moderator=False, need_open_link=False,
397 414 truncated=False, *args, **kwargs):
398 415 if 'is_opening' in kwargs:
399 416 is_opening = kwargs['is_opening']
400 417 else:
401 418 is_opening = self.is_opening()
402 419
403 420 if 'thread' in kwargs:
404 421 thread = kwargs['thread']
405 422 else:
406 423 thread = self.get_thread()
407 424
408 425 if 'can_bump' in kwargs:
409 426 can_bump = kwargs['can_bump']
410 427 else:
411 428 can_bump = thread.can_bump()
412 429
413 430 if is_opening:
414 431 opening_post_id = self.id
415 432 else:
416 433 opening_post_id = thread.get_opening_post_id()
417 434
418 435 return render_to_string('boards/post.html', {
419 436 'post': self,
420 437 'moderator': moderator,
421 438 'is_opening': is_opening,
422 439 'thread': thread,
423 440 'bumpable': can_bump,
424 441 'need_open_link': need_open_link,
425 442 'truncated': truncated,
426 443 'opening_post_id': opening_post_id,
427 444 })
428 445
429 446 def get_first_image(self):
430 447 return self.images.earliest('id')
431 448
432 449 def delete(self, using=None):
433 450 """
434 451 Deletes all post images and the post itself.
435 452 """
436 453
437 454 self.images.all().delete()
455 self.signature.all().delete()
456 if self.global_id:
457 self.global_id.delete()
438 458
439 459 super(Post, self).delete(using)
440 460
441 461 def set_global_id(self, key_pair=None):
442 462 """
443 463 Sets global id based on the given key pair. If no key pair is given,
444 464 default one is used.
445 465 """
446 466
447 467 if key_pair:
448 468 key = key_pair
449 469 else:
450 470 try:
451 471 key = KeyPair.objects.get(primary=True)
452 472 except KeyPair.DoesNotExist:
453 473 # Do not update the global id because there is no key defined
454 474 return
455 475 global_id = GlobalId(key_type=key.key_type,
456 476 key=key.public_key,
457 477 local_id = self.id)
478 global_id.save()
458 479
459 480 self.global_id = global_id
460 481
461 482 self.save(update_fields=['global_id'])
462 483
463 484 def get_pub_time_epoch(self):
464 485 return utils.datetime_to_epoch(self.pub_time)
465 486
466 487 def get_edit_time_epoch(self):
467 488 return utils.datetime_to_epoch(self.last_edit_time)
489
490 def get_replied_ids(self):
491 return re.findall(REGEX_REPLY, self.text.raw)
@@ -1,80 +1,97 b''
1 1 import logging
2 2
3 3 from django.test import TestCase
4 4 from boards.models import KeyPair, GlobalId, Post
5 5
6 6
7 7 logger = logging.getLogger(__name__)
8 8
9 9
10 10 class KeyTest(TestCase):
11 11 def test_create_key(self):
12 12 key = KeyPair.objects.generate_key('ecdsa')
13 13
14 14 self.assertIsNotNone(key, 'The key was not created.')
15 15
16 16 def test_validation(self):
17 17 key = KeyPair.objects.generate_key(key_type='ecdsa')
18 18 message = 'msg'
19 19 signature = key.sign(message)
20 20 valid = KeyPair.objects.verify(key.public_key, message, signature,
21 21 key_type='ecdsa')
22 22
23 23 self.assertTrue(valid, 'Message verification failed.')
24 24
25 25 def test_primary_constraint(self):
26 26 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
27 27
28 28 try:
29 29 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
30 30 self.fail('Exception should be thrown indicating there can be only'
31 31 ' one primary key.')
32 32 except Exception:
33 33 pass
34 34
35 35 def test_model_id_save(self):
36 36 model_id = GlobalId(key_type='test', key='test key', local_id='1')
37 37 model_id.save()
38 38
39 39 def test_request_get(self):
40 40 post = self._create_post_with_key()
41 41
42 42 request = Post.objects.generate_request_get([post])
43 43 logger.debug(request)
44 44
45 45 self.assertTrue('<request type="get" version="1.0">'
46 46 '<model name="post" version="1.0">'
47 47 '<id key="pubkey" local-id="1" type="test_key_type" />'
48 48 '</model>'
49 49 '</request>' in request,
50 50 'Wrong XML generated for the GET request.')
51 51
52 52 def test_response_get(self):
53 53 post = self._create_post_with_key()
54 reply_post = Post.objects.create_post(title='test_title',
55 text='[post]%d[/post]' % post.id, thread=post.get_thread())
56 reply_reply_post = Post.objects.create_post(title='',
57 text='[post]%d[/post]' % reply_post.id,
58 thread=post.get_thread())
54 59
55 response = Post.objects.generate_response_get([post])
60 response = Post.objects.generate_response_get([reply_post])
56 61 logger.debug(response)
57 62
58 63 self.assertTrue('<response>'
59 64 '<status>success</status>'
60 65 '<models>'
61 66 '<model name="post" ref-id="1">'
62 '<id key="pubkey" local-id="1" type="test_key_type" />'
67 '<id key="pubkey" local-id="%d" type="test_key_type" />'
63 68 '<title>test_title</title>'
64 '<text>test_text</text>'
69 '<text>[post]%d[/post]</text>'
70 '<thread>%d</thread>'
65 71 '<pub-time>%s</pub-time>'
66 72 '<edit-time>%s</edit-time>'
73 '<previous>'
74 '<id key="pubkey" local-id="%d" type="test_key_type" />'
75 '</previous>'
76 '<next>'
77 '<id key="pubkey" local-id="%d" type="test_key_type" />'
78 '</next>'
67 79 '</model>'
68 80 '</models>'
69 81 '</response>' % (
70 str(post.get_edit_time_epoch()),
71 str(post.get_pub_time_epoch())
82 reply_post.id,
83 post.id,
84 post.id,
85 str(reply_post.get_edit_time_epoch()),
86 str(reply_post.get_pub_time_epoch()),
87 post.id,
88 reply_reply_post.id,
72 89 ) in response,
73 90 'Wrong XML generated for the GET response.')
74 91
75 92 def _create_post_with_key(self):
76 93 key = KeyPair(public_key='pubkey', private_key='privkey',
77 94 key_type='test_key_type', primary=True)
78 95 key.save()
79 96
80 97 return Post.objects.create_post(title='test_title', text='test_text')
General Comments 0
You need to be logged in to leave comments. Login now