##// END OF EJS Templates
Fixed connecting replies to posts
neko259 -
r935:114b6f2e decentral
parent child Browse files
Show More
@@ -1,623 +1,621 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 adjacent import Client
8 8 from django.core.cache import cache
9 9 from django.core.urlresolvers import reverse
10 10 from django.db import models, transaction
11 11 from django.db.models import TextField
12 12 from django.template.loader import render_to_string
13 13 from django.utils import timezone
14 14
15 15 from boards.models import PostImage, KeyPair, GlobalId, Signature
16 16 from boards import settings
17 17 from boards.mdx_neboard import bbcode_extended
18 18 from boards.models import PostImage
19 19 from boards.models.base import Viewable
20 20 from boards.models.thread import Thread
21 21 from boards import utils
22 22 from boards.utils import datetime_to_epoch
23 23
24 24 ENCODING_UNICODE = 'unicode'
25 25
26 26 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
27 27 WS_NOTIFICATION_TYPE = 'notification_type'
28 28
29 29 WS_CHANNEL_THREAD = "thread:"
30 30
31 31 APP_LABEL_BOARDS = 'boards'
32 32
33 33 CACHE_KEY_PPD = 'ppd'
34 34 CACHE_KEY_POST_URL = 'post_url'
35 35
36 36 POSTS_PER_DAY_RANGE = 7
37 37
38 38 BAN_REASON_AUTO = 'Auto'
39 39
40 40 IMAGE_THUMB_SIZE = (200, 150)
41 41
42 42 TITLE_MAX_LENGTH = 200
43 43
44 44 # TODO This should be removed
45 45 NO_IP = '0.0.0.0'
46 46
47 47 # TODO Real user agent should be saved instead of this
48 48 UNKNOWN_UA = ''
49 49
50 50 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
51 51 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
52 52
53 53 TAG_MODEL = 'model'
54 54 TAG_REQUEST = 'request'
55 55 TAG_RESPONSE = 'response'
56 56 TAG_ID = 'id'
57 57 TAG_STATUS = 'status'
58 58 TAG_MODELS = 'models'
59 59 TAG_TITLE = 'title'
60 60 TAG_TEXT = 'text'
61 61 TAG_THREAD = 'thread'
62 62 TAG_PUB_TIME = 'pub-time'
63 63 TAG_SIGNATURES = 'signatures'
64 64 TAG_SIGNATURE = 'signature'
65 65 TAG_CONTENT = 'content'
66 66 TAG_ATTACHMENTS = 'attachments'
67 67 TAG_ATTACHMENT = 'attachment'
68 68
69 69 TYPE_GET = 'get'
70 70
71 71 ATTR_VERSION = 'version'
72 72 ATTR_TYPE = 'type'
73 73 ATTR_NAME = 'name'
74 74 ATTR_VALUE = 'value'
75 75 ATTR_MIMETYPE = 'mimetype'
76 76
77 77 STATUS_SUCCESS = 'success'
78 78
79 79 PARAMETER_TRUNCATED = 'truncated'
80 80 PARAMETER_TAG = 'tag'
81 81 PARAMETER_OFFSET = 'offset'
82 82 PARAMETER_DIFF_TYPE = 'type'
83 83 PARAMETER_BUMPABLE = 'bumpable'
84 84 PARAMETER_THREAD = 'thread'
85 85 PARAMETER_IS_OPENING = 'is_opening'
86 86 PARAMETER_MODERATOR = 'moderator'
87 87 PARAMETER_POST = 'post'
88 88 PARAMETER_OP_ID = 'opening_post_id'
89 89 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
90 90
91 91 DIFF_TYPE_HTML = 'html'
92 92 DIFF_TYPE_JSON = 'json'
93 93
94 94 PREPARSE_PATTERNS = {
95 95 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
96 96 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
97 97 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
98 98 }
99 99
100 100
101 101 class PostManager(models.Manager):
102 102 @transaction.atomic
103 103 def create_post(self, title: str, text: str, image=None, thread=None,
104 104 ip=NO_IP, tags: list=None):
105 105 """
106 106 Creates new post
107 107 """
108 108
109 109 if not tags:
110 110 tags = []
111 111
112 112 posting_time = timezone.now()
113 113 if not thread:
114 114 thread = Thread.objects.create(bump_time=posting_time,
115 115 last_edit_time=posting_time)
116 116 new_thread = True
117 117 else:
118 118 new_thread = False
119 119
120 120 pre_text = self._preparse_text(text)
121 121
122 122 post = self.create(title=title,
123 123 text=pre_text,
124 124 pub_time=posting_time,
125 125 thread_new=thread,
126 126 poster_ip=ip,
127 127 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
128 128 # last!
129 129 last_edit_time=posting_time)
130 130
131 131 post.set_global_id()
132 132
133 133 logger = logging.getLogger('boards.post.create')
134 134
135 135 logger.info('Created post {} by {}'.format(
136 136 post, post.poster_ip))
137 137
138 138 if image:
139 139 post_image = PostImage.objects.create(image=image)
140 140 post.images.add(post_image)
141 141 logger.info('Created image #{} for post #{}'.format(
142 142 post_image.id, post.id))
143 143
144 144 thread.replies.add(post)
145 145 list(map(thread.add_tag, tags))
146 146
147 147 if new_thread:
148 148 Thread.objects.process_oldest_threads()
149 149 else:
150 150 thread.bump()
151 151 thread.last_edit_time = posting_time
152 152 thread.save()
153 153
154 154 self.connect_replies(post)
155 155
156 156 return post
157 157
158 158 def delete_posts_by_ip(self, ip):
159 159 """
160 160 Deletes all posts of the author with same IP
161 161 """
162 162
163 163 posts = self.filter(poster_ip=ip)
164 164 for post in posts:
165 165 post.delete()
166 166
167 167 # TODO This can be moved into a post
168 168 def connect_replies(self, post):
169 169 """
170 170 Connects replies to a post to show them as a reflink map
171 171 """
172 172
173 for reply_number in re.finditer(REGEX_REPLY, post.get_raw_text()):
174 post_id = reply_number.group(1)
175 ref_post = self.filter(id=post_id)
173 for reply_number in post.get_replied_ids():
174 ref_post = self.filter(id=reply_number)
176 175 if ref_post.count() > 0:
177 176 referenced_post = ref_post[0]
178 177 referenced_post.referenced_posts.add(post)
179 178 referenced_post.last_edit_time = post.pub_time
180 179 referenced_post.build_refmap()
181 180 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
182 181
183 182 referenced_thread = referenced_post.get_thread()
184 183 referenced_thread.last_edit_time = post.pub_time
185 184 referenced_thread.save(update_fields=['last_edit_time'])
186 185
187 186 def get_posts_per_day(self):
188 187 """
189 188 Gets average count of posts per day for the last 7 days
190 189 """
191 190
192 191 day_end = date.today()
193 192 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
194 193
195 194 cache_key = CACHE_KEY_PPD + str(day_end)
196 195 ppd = cache.get(cache_key)
197 196 if ppd:
198 197 return ppd
199 198
200 199 day_time_start = timezone.make_aware(datetime.combine(
201 200 day_start, dtime()), timezone.get_current_timezone())
202 201 day_time_end = timezone.make_aware(datetime.combine(
203 202 day_end, dtime()), timezone.get_current_timezone())
204 203
205 204 posts_per_period = float(self.filter(
206 205 pub_time__lte=day_time_end,
207 206 pub_time__gte=day_time_start).count())
208 207
209 208 ppd = posts_per_period / POSTS_PER_DAY_RANGE
210 209
211 210 cache.set(cache_key, ppd)
212 211 return ppd
213 212
214 213 # TODO Make a separate sync facade?
215 214 def generate_response_get(self, model_list: list):
216 215 response = et.Element(TAG_RESPONSE)
217 216
218 217 status = et.SubElement(response, TAG_STATUS)
219 218 status.text = STATUS_SUCCESS
220 219
221 220 models = et.SubElement(response, TAG_MODELS)
222 221
223 222 for post in model_list:
224 223 model = et.SubElement(models, TAG_MODEL)
225 224 model.set(ATTR_NAME, 'post')
226 225
227 226 content_tag = et.SubElement(model, TAG_CONTENT)
228 227
229 228 tag_id = et.SubElement(content_tag, TAG_ID)
230 229 post.global_id.to_xml_element(tag_id)
231 230
232 231 title = et.SubElement(content_tag, TAG_TITLE)
233 232 title.text = post.title
234 233
235 234 text = et.SubElement(content_tag, TAG_TEXT)
236 235 # TODO Replace local links by global ones in the text
237 text.text = post.text.raw
236 text.text = post.get_raw_text()
238 237
239 238 if not post.is_opening():
240 239 thread = et.SubElement(content_tag, TAG_THREAD)
241 240 thread.text = str(post.get_thread().get_opening_post_id())
242 241 else:
243 242 # TODO Output tags here
244 243 pass
245 244
246 245 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
247 246 pub_time.text = str(post.get_pub_time_epoch())
248 247
249 248 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
250 249 post_signatures = post.signature.all()
251 250 if post_signatures:
252 251 signatures = post.signatures
253 252 else:
254 253 # TODO Maybe the signature can be computed only once after
255 254 # the post is added? Need to add some on_save signal queue
256 255 # and add this there.
257 256 key = KeyPair.objects.get(public_key=post.global_id.key)
258 257 signatures = [Signature(
259 258 key_type=key.key_type,
260 259 key=key.public_key,
261 260 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
262 261 )]
263 262 for signature in signatures:
264 263 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
265 264 signature_tag.set(ATTR_TYPE, signature.key_type)
266 265 signature_tag.set(ATTR_VALUE, signature.signature)
267 266
268 267 return et.tostring(response, ENCODING_UNICODE)
269 268
270 269 def parse_response_get(self, response_xml):
271 270 tag_root = et.fromstring(response_xml)
272 271 tag_status = tag_root[0]
273 272 if 'success' == tag_status.text:
274 273 tag_models = tag_root[1]
275 274 for tag_model in tag_models:
276 275 tag_content = tag_model[0]
277 276 tag_id = tag_content[1]
278 277 try:
279 278 GlobalId.from_xml_element(tag_id, existing=True)
280 279 # If this post already exists, just continue
281 280 # TODO Compare post content and update the post if necessary
282 281 pass
283 282 except GlobalId.DoesNotExist:
284 283 global_id = GlobalId.from_xml_element(tag_id)
285 284
286 285 title = tag_content.find(TAG_TITLE).text
287 286 text = tag_content.find(TAG_TEXT).text
288 287 # TODO Check that the replied posts are already present
289 288 # before adding new ones
290 289
291 290 # TODO Pub time, thread, tags
292 291
293 292 post = Post.objects.create(title=title, text=text)
294 293 else:
295 294 # TODO Throw an exception?
296 295 pass
297 296
298 297 def _preparse_text(self, text):
299 298 """
300 299 Preparses text to change patterns like '>>' to a proper bbcode
301 300 tags.
302 301 """
303 302
304 303 for key, value in PREPARSE_PATTERNS.items():
305 304 text = re.sub(key, value, text, flags=re.MULTILINE)
306 305
307 306 return text
308 307
309 308
310 309 class Post(models.Model, Viewable):
311 310 """A post is a message."""
312 311
313 312 objects = PostManager()
314 313
315 314 class Meta:
316 315 app_label = APP_LABEL_BOARDS
317 316 ordering = ('id',)
318 317
319 318 title = models.CharField(max_length=TITLE_MAX_LENGTH)
320 319 pub_time = models.DateTimeField()
321 320 text = TextField(blank=True, null=True)
322 321 _text_rendered = TextField(blank=True, null=True, editable=False)
323 322
324 323 images = models.ManyToManyField(PostImage, null=True, blank=True,
325 324 related_name='ip+', db_index=True)
326 325
327 326 poster_ip = models.GenericIPAddressField()
328 327 poster_user_agent = models.TextField()
329 328
330 329 thread_new = models.ForeignKey('Thread', null=True, default=None,
331 330 db_index=True)
332 331 last_edit_time = models.DateTimeField()
333 332
334 333 # Replies to the post
335 334 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
336 335 null=True,
337 336 blank=True, related_name='rfp+',
338 337 db_index=True)
339 338
340 339 # Replies map. This is built from the referenced posts list to speed up
341 340 # page loading (no need to get all the referenced posts from the database).
342 341 refmap = models.TextField(null=True, blank=True)
343 342
344 343 # Global ID with author key. If the message was downloaded from another
345 344 # server, this indicates the server.
346 345 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
347 346
348 347 # One post can be signed by many nodes that give their trust to it
349 348 signature = models.ManyToManyField('Signature', null=True, blank=True)
350 349
351 350 def __str__(self):
352 351 return 'P#{}/{}'.format(self.id, self.title)
353 352
354 353 def get_title(self) -> str:
355 354 """
356 355 Gets original post title or part of its text.
357 356 """
358 357
359 358 title = self.title
360 359 if not title:
361 360 title = self.get_text()
362 361
363 362 return title
364 363
365 364 def build_refmap(self) -> None:
366 365 """
367 366 Builds a replies map string from replies list. This is a cache to stop
368 367 the server from recalculating the map on every post show.
369 368 """
370 369 map_string = ''
371 370
372 371 first = True
373 372 for refpost in self.referenced_posts.all():
374 373 if not first:
375 374 map_string += ', '
376 375 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
377 376 refpost.id)
378 377 first = False
379 378
380 379 self.refmap = map_string
381 380
382 381 def get_sorted_referenced_posts(self):
383 382 return self.refmap
384 383
385 384 def is_referenced(self) -> bool:
386 385 if not self.refmap:
387 386 return False
388 387 else:
389 388 return len(self.refmap) > 0
390 389
391 390 def is_opening(self) -> bool:
392 391 """
393 392 Checks if this is an opening post or just a reply.
394 393 """
395 394
396 395 return self.get_thread().get_opening_post_id() == self.id
397 396
398 397 @transaction.atomic
399 398 def add_tag(self, tag):
400 399 edit_time = timezone.now()
401 400
402 401 thread = self.get_thread()
403 402 thread.add_tag(tag)
404 403 self.last_edit_time = edit_time
405 404 self.save(update_fields=['last_edit_time'])
406 405
407 406 thread.last_edit_time = edit_time
408 407 thread.save(update_fields=['last_edit_time'])
409 408
410 409 def get_url(self, thread=None):
411 410 """
412 411 Gets full url to the post.
413 412 """
414 413
415 414 cache_key = CACHE_KEY_POST_URL + str(self.id)
416 415 link = cache.get(cache_key)
417 416
418 417 if not link:
419 418 if not thread:
420 419 thread = self.get_thread()
421 420
422 421 opening_id = thread.get_opening_post_id()
423 422
424 423 if self.id != opening_id:
425 424 link = reverse('thread', kwargs={
426 425 'post_id': opening_id}) + '#' + str(self.id)
427 426 else:
428 427 link = reverse('thread', kwargs={'post_id': self.id})
429 428
430 429 cache.set(cache_key, link)
431 430
432 431 return link
433 432
434 433 def get_thread(self) -> Thread:
435 434 """
436 435 Gets post's thread.
437 436 """
438 437
439 438 return self.thread_new
440 439
441 440 def get_referenced_posts(self):
442 441 return self.referenced_posts.only('id', 'thread_new')
443 442
444 443 def get_view(self, moderator=False, need_open_link=False,
445 444 truncated=False, *args, **kwargs):
446 445 """
447 446 Renders post's HTML view. Some of the post params can be passed over
448 447 kwargs for the means of caching (if we view the thread, some params
449 448 are same for every post and don't need to be computed over and over.
450 449 """
451 450
452 451 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
453 452 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
454 453 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
455 454
456 455 if is_opening:
457 456 opening_post_id = self.id
458 457 else:
459 458 opening_post_id = thread.get_opening_post_id()
460 459
461 460 return render_to_string('boards/post.html', {
462 461 PARAMETER_POST: self,
463 462 PARAMETER_MODERATOR: moderator,
464 463 PARAMETER_IS_OPENING: is_opening,
465 464 PARAMETER_THREAD: thread,
466 465 PARAMETER_BUMPABLE: can_bump,
467 466 PARAMETER_NEED_OPEN_LINK: need_open_link,
468 467 PARAMETER_TRUNCATED: truncated,
469 468 PARAMETER_OP_ID: opening_post_id,
470 469 })
471 470
472 471 def get_search_view(self, *args, **kwargs):
473 472 return self.get_view(args, kwargs)
474 473
475 474 def get_first_image(self) -> PostImage:
476 475 return self.images.earliest('id')
477 476
478 477 def delete(self, using=None):
479 478 """
480 479 Deletes all post images and the post itself. If the post is opening,
481 480 thread with all posts is deleted.
482 481 """
483 482
484 483 self.images.all().delete()
485 484 self.signature.all().delete()
486 485 if self.global_id:
487 486 self.global_id.delete()
488 487
489 488 if self.is_opening():
490 489 self.get_thread().delete()
491 490 else:
492 491 thread = self.get_thread()
493 492 thread.last_edit_time = timezone.now()
494 493 thread.save()
495 494
496 495 super(Post, self).delete(using)
497 496 logging.getLogger('boards.post.delete').info(
498 497 'Deleted post {}'.format(self))
499 498
500 499 def set_global_id(self, key_pair=None):
501 500 """
502 501 Sets global id based on the given key pair. If no key pair is given,
503 502 default one is used.
504 503 """
505 504
506 505 if key_pair:
507 506 key = key_pair
508 507 else:
509 508 try:
510 509 key = KeyPair.objects.get(primary=True)
511 510 except KeyPair.DoesNotExist:
512 511 # Do not update the global id because there is no key defined
513 512 return
514 513 global_id = GlobalId(key_type=key.key_type,
515 514 key=key.public_key,
516 515 local_id = self.id)
517 516 global_id.save()
518 517
519 518 self.global_id = global_id
520 519
521 520 self.save(update_fields=['global_id'])
522 521
523 522 def get_pub_time_epoch(self):
524 523 return utils.datetime_to_epoch(self.pub_time)
525 524
526 # TODO Use this to connect replies
527 525 def get_replied_ids(self):
528 526 """
529 527 Gets ID list of the posts that this post replies.
530 528 """
531 529
532 local_replied = REGEX_REPLY.findall(self.text.raw)
530 raw_text = self.get_raw_text()
531
532 local_replied = REGEX_REPLY.findall(raw_text)
533 533 global_replied = []
534 # TODO Similar code is used in mdx_neboard, maybe it can be extracted
535 # into a method?
536 for match in REGEX_GLOBAL_REPLY.findall(self.text.raw):
534 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
537 535 key_type = match[0]
538 536 key = match[1]
539 537 local_id = match[2]
540 538
541 539 try:
542 540 global_id = GlobalId.objects.get(key_type=key_type,
543 541 key=key, local_id=local_id)
544 542 for post in Post.objects.filter(global_id=global_id).only('id'):
545 543 global_replied.append(post.id)
546 544 except GlobalId.DoesNotExist:
547 545 pass
548 546 return local_replied + global_replied
549 547
550 548
551 549 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
552 550 include_last_update=False):
553 551 """
554 552 Gets post HTML or JSON data that can be rendered on a page or used by
555 553 API.
556 554 """
557 555
558 556 if format_type == DIFF_TYPE_HTML:
559 557 params = dict()
560 558 params['post'] = self
561 559 if PARAMETER_TRUNCATED in request.GET:
562 560 params[PARAMETER_TRUNCATED] = True
563 561
564 562 return render_to_string('boards/api_post.html', params)
565 563 elif format_type == DIFF_TYPE_JSON:
566 564 post_json = {
567 565 'id': self.id,
568 566 'title': self.title,
569 567 'text': self._text_rendered,
570 568 }
571 569 if self.images.exists():
572 570 post_image = self.get_first_image()
573 571 post_json['image'] = post_image.image.url
574 572 post_json['image_preview'] = post_image.image.url_200x150
575 573 if include_last_update:
576 574 post_json['bump_time'] = datetime_to_epoch(
577 575 self.thread_new.bump_time)
578 576 return post_json
579 577
580 578 def send_to_websocket(self, request, recursive=True):
581 579 """
582 580 Sends post HTML data to the thread web socket.
583 581 """
584 582
585 583 if not settings.WEBSOCKETS_ENABLED:
586 584 return
587 585
588 586 client = Client()
589 587
590 588 thread = self.get_thread()
591 589 thread_id = thread.id
592 590 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
593 591 client.publish(channel_name, {
594 592 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
595 593 })
596 594 client.send()
597 595
598 596 logger = logging.getLogger('boards.post.websocket')
599 597
600 598 logger.info('Sent notification from post #{} to channel {}'.format(
601 599 self.id, channel_name))
602 600
603 601 if recursive:
604 602 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
605 603 post_id = reply_number.group(1)
606 604 ref_post = Post.objects.filter(id=post_id)[0]
607 605
608 606 # If post is in this thread, its thread was already notified.
609 607 # Otherwise, notify its thread separately.
610 608 if ref_post.thread_new_id != thread_id:
611 609 ref_post.send_to_websocket(request, recursive=False)
612 610
613 611 def save(self, force_insert=False, force_update=False, using=None,
614 612 update_fields=None):
615 613 self._text_rendered = bbcode_extended(self.get_raw_text())
616 614
617 615 super().save(force_insert, force_update, using, update_fields)
618 616
619 617 def get_text(self) -> str:
620 618 return self._text_rendered
621 619
622 620 def get_raw_text(self) -> str:
623 621 return self.text
@@ -1,48 +1,48 b''
1 1 from boards.models import KeyPair, Post
2 2 from boards.tests.mocks import MockRequest
3 3 from boards.views.sync import respond_get
4 4
5 5 __author__ = 'neko259'
6 6
7 7
8 8 from django.test import TestCase
9 9
10 10
11 11 class SyncTest(TestCase):
12 12 def test_get(self):
13 13 """
14 14 Forms a GET request of a post and checks the response.
15 15 """
16 16
17 17 KeyPair.objects.generate_key(primary=True)
18 18 post = Post.objects.create_post(title='test_title', text='test_text')
19 19
20 20 request = MockRequest()
21 21 request.POST['xml'] = (
22 22 '<request type="get" version="1.0">'
23 23 '<model name="post" version="1.0">'
24 24 '<id key="%s" local-id="%d" type="%s" />'
25 25 '</model>'
26 26 '</request>' % (post.global_id.key,
27 27 post.id,
28 28 post.global_id.key_type)
29 29 )
30 30
31 31 self.assertTrue(
32 32 '<status>success</status>'
33 33 '<models>'
34 34 '<model name="post">'
35 35 '<content>'
36 36 '<id key="%s" local-id="%d" type="%s" />'
37 37 '<title>%s</title>'
38 38 '<text>%s</text>'
39 39 '<pub-time>%d</pub-time>'
40 40 '</content>' % (
41 41 post.global_id.key,
42 42 post.id,
43 43 post.global_id.key_type,
44 44 post.title,
45 post.text.raw,
45 post.get_raw_text(),
46 46 post.get_pub_time_epoch(),
47 47 ) in respond_get(request).content.decode(),
48 'Wrong response generated for the GET request.') No newline at end of file
48 'Wrong response generated for the GET request.')
General Comments 0
You need to be logged in to leave comments. Login now