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