##// END OF EJS Templates
Convert local post IDs to global when generating responses
neko259 -
r1228:4f3aad00 decentral
parent child Browse files
Show More
@@ -1,489 +1,506 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 uuid
5 import uuid
6
6
7 from django.core.exceptions import ObjectDoesNotExist
7 from django.core.exceptions import ObjectDoesNotExist
8 from django.core.urlresolvers import reverse
8 from django.core.urlresolvers import reverse
9 from django.db import models, transaction
9 from django.db import models, transaction
10 from django.db.models import TextField, QuerySet
10 from django.db.models import TextField, QuerySet
11 from django.template.loader import render_to_string
11 from django.template.loader import render_to_string
12 from django.utils import timezone
12 from django.utils import timezone
13
13
14 from boards.mdx_neboard import Parser
14 from boards.mdx_neboard import Parser
15 from boards.models import KeyPair, GlobalId
15 from boards.models import KeyPair, GlobalId
16 from boards import settings
16 from boards import settings
17 from boards.models import PostImage
17 from boards.models import PostImage
18 from boards.models.base import Viewable
18 from boards.models.base import Viewable
19 from boards import utils
19 from boards import utils
20 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
20 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
21 from boards.models.user import Notification, Ban
21 from boards.models.user import Notification, Ban
22 import boards.models.thread
22 import boards.models.thread
23
23
24 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
24 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
25 WS_NOTIFICATION_TYPE = 'notification_type'
25 WS_NOTIFICATION_TYPE = 'notification_type'
26
26
27 WS_CHANNEL_THREAD = "thread:"
27 WS_CHANNEL_THREAD = "thread:"
28
28
29 APP_LABEL_BOARDS = 'boards'
29 APP_LABEL_BOARDS = 'boards'
30
30
31 POSTS_PER_DAY_RANGE = 7
31 POSTS_PER_DAY_RANGE = 7
32
32
33 BAN_REASON_AUTO = 'Auto'
33 BAN_REASON_AUTO = 'Auto'
34
34
35 IMAGE_THUMB_SIZE = (200, 150)
35 IMAGE_THUMB_SIZE = (200, 150)
36
36
37 TITLE_MAX_LENGTH = 200
37 TITLE_MAX_LENGTH = 200
38
38
39 # TODO This should be removed
39 # TODO This should be removed
40 NO_IP = '0.0.0.0'
40 NO_IP = '0.0.0.0'
41
41
42 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
42 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
43 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
43 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
44 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
44 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
45 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
45 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
46
46
47 PARAMETER_TRUNCATED = 'truncated'
47 PARAMETER_TRUNCATED = 'truncated'
48 PARAMETER_TAG = 'tag'
48 PARAMETER_TAG = 'tag'
49 PARAMETER_OFFSET = 'offset'
49 PARAMETER_OFFSET = 'offset'
50 PARAMETER_DIFF_TYPE = 'type'
50 PARAMETER_DIFF_TYPE = 'type'
51 PARAMETER_CSS_CLASS = 'css_class'
51 PARAMETER_CSS_CLASS = 'css_class'
52 PARAMETER_THREAD = 'thread'
52 PARAMETER_THREAD = 'thread'
53 PARAMETER_IS_OPENING = 'is_opening'
53 PARAMETER_IS_OPENING = 'is_opening'
54 PARAMETER_MODERATOR = 'moderator'
54 PARAMETER_MODERATOR = 'moderator'
55 PARAMETER_POST = 'post'
55 PARAMETER_POST = 'post'
56 PARAMETER_OP_ID = 'opening_post_id'
56 PARAMETER_OP_ID = 'opening_post_id'
57 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
57 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
58 PARAMETER_REPLY_LINK = 'reply_link'
58 PARAMETER_REPLY_LINK = 'reply_link'
59 PARAMETER_NEED_OP_DATA = 'need_op_data'
59 PARAMETER_NEED_OP_DATA = 'need_op_data'
60
60
61 POST_VIEW_PARAMS = (
61 POST_VIEW_PARAMS = (
62 'need_op_data',
62 'need_op_data',
63 'reply_link',
63 'reply_link',
64 'moderator',
64 'moderator',
65 'need_open_link',
65 'need_open_link',
66 'truncated',
66 'truncated',
67 'mode_tree',
67 'mode_tree',
68 )
68 )
69
69
70 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
70 REFMAP_STR = '<a href="{}">&gt;&gt;{}</a>'
71
71
72
72
73 class PostManager(models.Manager):
73 class PostManager(models.Manager):
74 @transaction.atomic
74 @transaction.atomic
75 def create_post(self, title: str, text: str, image=None, thread=None,
75 def create_post(self, title: str, text: str, image=None, thread=None,
76 ip=NO_IP, tags: list=None, opening_posts: list=None):
76 ip=NO_IP, tags: list=None, opening_posts: list=None):
77 """
77 """
78 Creates new post
78 Creates new post
79 """
79 """
80
80
81 is_banned = Ban.objects.filter(ip=ip).exists()
81 is_banned = Ban.objects.filter(ip=ip).exists()
82
82
83 # TODO Raise specific exception and catch it in the views
83 # TODO Raise specific exception and catch it in the views
84 if is_banned:
84 if is_banned:
85 raise Exception("This user is banned")
85 raise Exception("This user is banned")
86
86
87 if not tags:
87 if not tags:
88 tags = []
88 tags = []
89 if not opening_posts:
89 if not opening_posts:
90 opening_posts = []
90 opening_posts = []
91
91
92 posting_time = timezone.now()
92 posting_time = timezone.now()
93 if not thread:
93 if not thread:
94 thread = boards.models.thread.Thread.objects.create(
94 thread = boards.models.thread.Thread.objects.create(
95 bump_time=posting_time, last_edit_time=posting_time)
95 bump_time=posting_time, last_edit_time=posting_time)
96 list(map(thread.tags.add, tags))
96 list(map(thread.tags.add, tags))
97 new_thread = True
97 new_thread = True
98 else:
98 else:
99 new_thread = False
99 new_thread = False
100
100
101 pre_text = Parser().preparse(text)
101 pre_text = Parser().preparse(text)
102
102
103 post = self.create(title=title,
103 post = self.create(title=title,
104 text=pre_text,
104 text=pre_text,
105 pub_time=posting_time,
105 pub_time=posting_time,
106 poster_ip=ip,
106 poster_ip=ip,
107 thread=thread,
107 thread=thread,
108 last_edit_time=posting_time)
108 last_edit_time=posting_time)
109 post.threads.add(thread)
109 post.threads.add(thread)
110
110
111 post.set_global_id()
111 post.set_global_id()
112
112
113 logger = logging.getLogger('boards.post.create')
113 logger = logging.getLogger('boards.post.create')
114
114
115 logger.info('Created post {} by {}'.format(post, post.poster_ip))
115 logger.info('Created post {} by {}'.format(post, post.poster_ip))
116
116
117 if image:
117 if image:
118 post.images.add(PostImage.objects.create_with_hash(image))
118 post.images.add(PostImage.objects.create_with_hash(image))
119
119
120 if new_thread:
120 if new_thread:
121 boards.models.thread.Thread.objects.process_oldest_threads()
121 boards.models.thread.Thread.objects.process_oldest_threads()
122 else:
122 else:
123 thread.last_edit_time = posting_time
123 thread.last_edit_time = posting_time
124 thread.bump()
124 thread.bump()
125 thread.save()
125 thread.save()
126
126
127 post.build_url()
127 post.build_url()
128 post.connect_replies()
128 post.connect_replies()
129 post.connect_threads(opening_posts)
129 post.connect_threads(opening_posts)
130 post.connect_notifications()
130 post.connect_notifications()
131
131
132 post.build_url()
132 post.build_url()
133
133
134 return post
134 return post
135
135
136 def delete_posts_by_ip(self, ip):
136 def delete_posts_by_ip(self, ip):
137 """
137 """
138 Deletes all posts of the author with same IP
138 Deletes all posts of the author with same IP
139 """
139 """
140
140
141 posts = self.filter(poster_ip=ip)
141 posts = self.filter(poster_ip=ip)
142 for post in posts:
142 for post in posts:
143 post.delete()
143 post.delete()
144
144
145 @utils.cached_result()
145 @utils.cached_result()
146 def get_posts_per_day(self) -> float:
146 def get_posts_per_day(self) -> float:
147 """
147 """
148 Gets average count of posts per day for the last 7 days
148 Gets average count of posts per day for the last 7 days
149 """
149 """
150
150
151 day_end = date.today()
151 day_end = date.today()
152 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
152 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
153
153
154 day_time_start = timezone.make_aware(datetime.combine(
154 day_time_start = timezone.make_aware(datetime.combine(
155 day_start, dtime()), timezone.get_current_timezone())
155 day_start, dtime()), timezone.get_current_timezone())
156 day_time_end = timezone.make_aware(datetime.combine(
156 day_time_end = timezone.make_aware(datetime.combine(
157 day_end, dtime()), timezone.get_current_timezone())
157 day_end, dtime()), timezone.get_current_timezone())
158
158
159 posts_per_period = float(self.filter(
159 posts_per_period = float(self.filter(
160 pub_time__lte=day_time_end,
160 pub_time__lte=day_time_end,
161 pub_time__gte=day_time_start).count())
161 pub_time__gte=day_time_start).count())
162
162
163 ppd = posts_per_period / POSTS_PER_DAY_RANGE
163 ppd = posts_per_period / POSTS_PER_DAY_RANGE
164
164
165 return ppd
165 return ppd
166
166
167
167
168 class Post(models.Model, Viewable):
168 class Post(models.Model, Viewable):
169 """A post is a message."""
169 """A post is a message."""
170
170
171 objects = PostManager()
171 objects = PostManager()
172
172
173 class Meta:
173 class Meta:
174 app_label = APP_LABEL_BOARDS
174 app_label = APP_LABEL_BOARDS
175 ordering = ('id',)
175 ordering = ('id',)
176
176
177 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
177 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
178 pub_time = models.DateTimeField()
178 pub_time = models.DateTimeField()
179 text = TextField(blank=True, null=True)
179 text = TextField(blank=True, null=True)
180 _text_rendered = TextField(blank=True, null=True, editable=False)
180 _text_rendered = TextField(blank=True, null=True, editable=False)
181
181
182 images = models.ManyToManyField(PostImage, null=True, blank=True,
182 images = models.ManyToManyField(PostImage, null=True, blank=True,
183 related_name='ip+', db_index=True)
183 related_name='ip+', db_index=True)
184
184
185 poster_ip = models.GenericIPAddressField()
185 poster_ip = models.GenericIPAddressField()
186
186
187 # TODO This field can be removed cause UID is used for update now
187 # TODO This field can be removed cause UID is used for update now
188 last_edit_time = models.DateTimeField()
188 last_edit_time = models.DateTimeField()
189
189
190 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
190 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
191 null=True,
191 null=True,
192 blank=True, related_name='refposts',
192 blank=True, related_name='refposts',
193 db_index=True)
193 db_index=True)
194 refmap = models.TextField(null=True, blank=True)
194 refmap = models.TextField(null=True, blank=True)
195 threads = models.ManyToManyField('Thread', db_index=True)
195 threads = models.ManyToManyField('Thread', db_index=True)
196 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
196 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
197
197
198 url = models.TextField()
198 url = models.TextField()
199 uid = models.TextField(db_index=True)
199 uid = models.TextField(db_index=True)
200
200
201 # Global ID with author key. If the message was downloaded from another
201 # Global ID with author key. If the message was downloaded from another
202 # server, this indicates the server.
202 # server, this indicates the server.
203 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
203 global_id = models.OneToOneField('GlobalId', null=True, blank=True)
204
204
205 # One post can be signed by many nodes that give their trust to it
205 # One post can be signed by many nodes that give their trust to it
206 signature = models.ManyToManyField('Signature', null=True, blank=True)
206 signature = models.ManyToManyField('Signature', null=True, blank=True)
207
207
208 def __str__(self):
208 def __str__(self):
209 return 'P#{}/{}'.format(self.id, self.title)
209 return 'P#{}/{}'.format(self.id, self.title)
210
210
211 def get_referenced_posts(self):
211 def get_referenced_posts(self):
212 threads = self.get_threads().all()
212 threads = self.get_threads().all()
213 return self.referenced_posts.filter(threads__in=threads)\
213 return self.referenced_posts.filter(threads__in=threads)\
214 .order_by('pub_time').distinct().all()
214 .order_by('pub_time').distinct().all()
215
215
216 def get_title(self) -> str:
216 def get_title(self) -> str:
217 """
217 """
218 Gets original post title or part of its text.
218 Gets original post title or part of its text.
219 """
219 """
220
220
221 title = self.title
221 title = self.title
222 if not title:
222 if not title:
223 title = self.get_text()
223 title = self.get_text()
224
224
225 return title
225 return title
226
226
227 def build_refmap(self) -> None:
227 def build_refmap(self) -> None:
228 """
228 """
229 Builds a replies map string from replies list. This is a cache to stop
229 Builds a replies map string from replies list. This is a cache to stop
230 the server from recalculating the map on every post show.
230 the server from recalculating the map on every post show.
231 """
231 """
232
232
233 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
233 post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
234 for refpost in self.referenced_posts.all()]
234 for refpost in self.referenced_posts.all()]
235
235
236 self.refmap = ', '.join(post_urls)
236 self.refmap = ', '.join(post_urls)
237
237
238 def is_referenced(self) -> bool:
238 def is_referenced(self) -> bool:
239 return self.refmap and len(self.refmap) > 0
239 return self.refmap and len(self.refmap) > 0
240
240
241 def is_opening(self) -> bool:
241 def is_opening(self) -> bool:
242 """
242 """
243 Checks if this is an opening post or just a reply.
243 Checks if this is an opening post or just a reply.
244 """
244 """
245
245
246 return self.get_thread().get_opening_post_id() == self.id
246 return self.get_thread().get_opening_post_id() == self.id
247
247
248 def get_absolute_url(self):
248 def get_absolute_url(self):
249 if self.url:
249 if self.url:
250 return self.url
250 return self.url
251 else:
251 else:
252 opening_id = self.get_thread().get_opening_post_id()
252 opening_id = self.get_thread().get_opening_post_id()
253 post_url = reverse('thread', kwargs={'post_id': opening_id})
253 post_url = reverse('thread', kwargs={'post_id': opening_id})
254 if self.id != opening_id:
254 if self.id != opening_id:
255 post_url += '#' + str(self.id)
255 post_url += '#' + str(self.id)
256 return post_url
256 return post_url
257
257
258 def get_thread(self):
258 def get_thread(self):
259 return self.thread
259 return self.thread
260
260
261 def get_threads(self) -> QuerySet:
261 def get_threads(self) -> QuerySet:
262 """
262 """
263 Gets post's thread.
263 Gets post's thread.
264 """
264 """
265
265
266 return self.threads
266 return self.threads
267
267
268 def get_view(self, *args, **kwargs) -> str:
268 def get_view(self, *args, **kwargs) -> str:
269 """
269 """
270 Renders post's HTML view. Some of the post params can be passed over
270 Renders post's HTML view. Some of the post params can be passed over
271 kwargs for the means of caching (if we view the thread, some params
271 kwargs for the means of caching (if we view the thread, some params
272 are same for every post and don't need to be computed over and over.
272 are same for every post and don't need to be computed over and over.
273 """
273 """
274
274
275 thread = self.get_thread()
275 thread = self.get_thread()
276 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
276 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
277
277
278 if is_opening:
278 if is_opening:
279 opening_post_id = self.id
279 opening_post_id = self.id
280 else:
280 else:
281 opening_post_id = thread.get_opening_post_id()
281 opening_post_id = thread.get_opening_post_id()
282
282
283 css_class = 'post'
283 css_class = 'post'
284 if thread.archived:
284 if thread.archived:
285 css_class += ' archive_post'
285 css_class += ' archive_post'
286 elif not thread.can_bump():
286 elif not thread.can_bump():
287 css_class += ' dead_post'
287 css_class += ' dead_post'
288
288
289 params = dict()
289 params = dict()
290 for param in POST_VIEW_PARAMS:
290 for param in POST_VIEW_PARAMS:
291 if param in kwargs:
291 if param in kwargs:
292 params[param] = kwargs[param]
292 params[param] = kwargs[param]
293
293
294 params.update({
294 params.update({
295 PARAMETER_POST: self,
295 PARAMETER_POST: self,
296 PARAMETER_IS_OPENING: is_opening,
296 PARAMETER_IS_OPENING: is_opening,
297 PARAMETER_THREAD: thread,
297 PARAMETER_THREAD: thread,
298 PARAMETER_CSS_CLASS: css_class,
298 PARAMETER_CSS_CLASS: css_class,
299 PARAMETER_OP_ID: opening_post_id,
299 PARAMETER_OP_ID: opening_post_id,
300 })
300 })
301
301
302 return render_to_string('boards/post.html', params)
302 return render_to_string('boards/post.html', params)
303
303
304 def get_search_view(self, *args, **kwargs):
304 def get_search_view(self, *args, **kwargs):
305 return self.get_view(need_op_data=True, *args, **kwargs)
305 return self.get_view(need_op_data=True, *args, **kwargs)
306
306
307 def get_first_image(self) -> PostImage:
307 def get_first_image(self) -> PostImage:
308 return self.images.earliest('id')
308 return self.images.earliest('id')
309
309
310 def delete(self, using=None):
310 def delete(self, using=None):
311 """
311 """
312 Deletes all post images and the post itself.
312 Deletes all post images and the post itself.
313 """
313 """
314
314
315 for image in self.images.all():
315 for image in self.images.all():
316 image_refs_count = Post.objects.filter(images__in=[image]).count()
316 image_refs_count = Post.objects.filter(images__in=[image]).count()
317 if image_refs_count == 1:
317 if image_refs_count == 1:
318 image.delete()
318 image.delete()
319
319
320 self.signature.all().delete()
320 self.signature.all().delete()
321 if self.global_id:
321 if self.global_id:
322 self.global_id.delete()
322 self.global_id.delete()
323
323
324 thread = self.get_thread()
324 thread = self.get_thread()
325 thread.last_edit_time = timezone.now()
325 thread.last_edit_time = timezone.now()
326 thread.save()
326 thread.save()
327
327
328 super(Post, self).delete(using)
328 super(Post, self).delete(using)
329
329
330 logging.getLogger('boards.post.delete').info(
330 logging.getLogger('boards.post.delete').info(
331 'Deleted post {}'.format(self))
331 'Deleted post {}'.format(self))
332
332
333 def set_global_id(self, key_pair=None):
333 def set_global_id(self, key_pair=None):
334 """
334 """
335 Sets global id based on the given key pair. If no key pair is given,
335 Sets global id based on the given key pair. If no key pair is given,
336 default one is used.
336 default one is used.
337 """
337 """
338
338
339 if key_pair:
339 if key_pair:
340 key = key_pair
340 key = key_pair
341 else:
341 else:
342 try:
342 try:
343 key = KeyPair.objects.get(primary=True)
343 key = KeyPair.objects.get(primary=True)
344 except KeyPair.DoesNotExist:
344 except KeyPair.DoesNotExist:
345 # Do not update the global id because there is no key defined
345 # Do not update the global id because there is no key defined
346 return
346 return
347 global_id = GlobalId(key_type=key.key_type,
347 global_id = GlobalId(key_type=key.key_type,
348 key=key.public_key,
348 key=key.public_key,
349 local_id = self.id)
349 local_id = self.id)
350 global_id.save()
350 global_id.save()
351
351
352 self.global_id = global_id
352 self.global_id = global_id
353
353
354 self.save(update_fields=['global_id'])
354 self.save(update_fields=['global_id'])
355
355
356 def get_pub_time_epoch(self):
356 def get_pub_time_epoch(self):
357 return utils.datetime_to_epoch(self.pub_time)
357 return utils.datetime_to_epoch(self.pub_time)
358
358
359 def get_replied_ids(self):
359 def get_replied_ids(self):
360 """
360 """
361 Gets ID list of the posts that this post replies.
361 Gets ID list of the posts that this post replies.
362 """
362 """
363
363
364 raw_text = self.get_raw_text()
364 raw_text = self.get_raw_text()
365
365
366 local_replied = REGEX_REPLY.findall(raw_text)
366 local_replied = REGEX_REPLY.findall(raw_text)
367 global_replied = []
367 global_replied = []
368 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
368 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
369 key_type = match[0]
369 key_type = match[0]
370 key = match[1]
370 key = match[1]
371 local_id = match[2]
371 local_id = match[2]
372
372
373 try:
373 try:
374 global_id = GlobalId.objects.get(key_type=key_type,
374 global_id = GlobalId.objects.get(key_type=key_type,
375 key=key, local_id=local_id)
375 key=key, local_id=local_id)
376 for post in Post.objects.filter(global_id=global_id).only('id'):
376 for post in Post.objects.filter(global_id=global_id).only('id'):
377 global_replied.append(post.id)
377 global_replied.append(post.id)
378 except GlobalId.DoesNotExist:
378 except GlobalId.DoesNotExist:
379 pass
379 pass
380 return local_replied + global_replied
380 return local_replied + global_replied
381
381
382 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
382 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
383 include_last_update=False) -> str:
383 include_last_update=False) -> str:
384 """
384 """
385 Gets post HTML or JSON data that can be rendered on a page or used by
385 Gets post HTML or JSON data that can be rendered on a page or used by
386 API.
386 API.
387 """
387 """
388
388
389 return get_exporter(format_type).export(self, request,
389 return get_exporter(format_type).export(self, request,
390 include_last_update)
390 include_last_update)
391
391
392 def notify_clients(self, recursive=True):
392 def notify_clients(self, recursive=True):
393 """
393 """
394 Sends post HTML data to the thread web socket.
394 Sends post HTML data to the thread web socket.
395 """
395 """
396
396
397 if not settings.get_bool('External', 'WebsocketsEnabled'):
397 if not settings.get_bool('External', 'WebsocketsEnabled'):
398 return
398 return
399
399
400 thread_ids = list()
400 thread_ids = list()
401 for thread in self.get_threads().all():
401 for thread in self.get_threads().all():
402 thread_ids.append(thread.id)
402 thread_ids.append(thread.id)
403
403
404 thread.notify_clients()
404 thread.notify_clients()
405
405
406 if recursive:
406 if recursive:
407 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
407 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
408 post_id = reply_number.group(1)
408 post_id = reply_number.group(1)
409
409
410 try:
410 try:
411 ref_post = Post.objects.get(id=post_id)
411 ref_post = Post.objects.get(id=post_id)
412
412
413 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
413 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
414 # If post is in this thread, its thread was already notified.
414 # If post is in this thread, its thread was already notified.
415 # Otherwise, notify its thread separately.
415 # Otherwise, notify its thread separately.
416 ref_post.notify_clients(recursive=False)
416 ref_post.notify_clients(recursive=False)
417 except ObjectDoesNotExist:
417 except ObjectDoesNotExist:
418 pass
418 pass
419
419
420 def build_url(self):
420 def build_url(self):
421 self.url = self.get_absolute_url()
421 self.url = self.get_absolute_url()
422 self.save(update_fields=['url'])
422 self.save(update_fields=['url'])
423
423
424 def save(self, force_insert=False, force_update=False, using=None,
424 def save(self, force_insert=False, force_update=False, using=None,
425 update_fields=None):
425 update_fields=None):
426 self._text_rendered = Parser().parse(self.get_raw_text())
426 self._text_rendered = Parser().parse(self.get_raw_text())
427
427
428 self.uid = str(uuid.uuid4())
428 self.uid = str(uuid.uuid4())
429 if update_fields is not None and 'uid' not in update_fields:
429 if update_fields is not None and 'uid' not in update_fields:
430 update_fields += ['uid']
430 update_fields += ['uid']
431
431
432 if self.id:
432 if self.id:
433 for thread in self.get_threads().all():
433 for thread in self.get_threads().all():
434 thread.last_edit_time = self.last_edit_time
434 thread.last_edit_time = self.last_edit_time
435
435
436 thread.save(update_fields=['last_edit_time'])
436 thread.save(update_fields=['last_edit_time'])
437
437
438 super().save(force_insert, force_update, using, update_fields)
438 super().save(force_insert, force_update, using, update_fields)
439
439
440 def get_text(self) -> str:
440 def get_text(self) -> str:
441 return self._text_rendered
441 return self._text_rendered
442
442
443 def get_raw_text(self) -> str:
443 def get_raw_text(self) -> str:
444 return self.text
444 return self.text
445
445
446 def get_sync_text(self) -> str:
447 """
448 Returns text applicable for sync. It has absolute post reflinks.
449 """
450
451 replacements = dict()
452 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
453 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
454 replacements[post_id] = absolute_post_id
455
456 text = self.get_raw_text()
457 for key in replacements:
458 text = text.replace('[post]{}[/post]'.format(key),
459 '[post]{}[/post]'.format(replacements[key]))
460
461 return text
462
446 def get_absolute_id(self) -> str:
463 def get_absolute_id(self) -> str:
447 """
464 """
448 If the post has many threads, shows its main thread OP id in the post
465 If the post has many threads, shows its main thread OP id in the post
449 ID.
466 ID.
450 """
467 """
451
468
452 if self.get_threads().count() > 1:
469 if self.get_threads().count() > 1:
453 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
470 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
454 else:
471 else:
455 return str(self.id)
472 return str(self.id)
456
473
457 def connect_notifications(self):
474 def connect_notifications(self):
458 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
475 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
459 user_name = reply_number.group(1).lower()
476 user_name = reply_number.group(1).lower()
460 Notification.objects.get_or_create(name=user_name, post=self)
477 Notification.objects.get_or_create(name=user_name, post=self)
461
478
462 def connect_replies(self):
479 def connect_replies(self):
463 """
480 """
464 Connects replies to a post to show them as a reflink map
481 Connects replies to a post to show them as a reflink map
465 """
482 """
466
483
467 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
484 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
468 post_id = reply_number.group(1)
485 post_id = reply_number.group(1)
469
486
470 try:
487 try:
471 referenced_post = Post.objects.get(id=post_id)
488 referenced_post = Post.objects.get(id=post_id)
472
489
473 referenced_post.referenced_posts.add(self)
490 referenced_post.referenced_posts.add(self)
474 referenced_post.last_edit_time = self.pub_time
491 referenced_post.last_edit_time = self.pub_time
475 referenced_post.build_refmap()
492 referenced_post.build_refmap()
476 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
493 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
477 except ObjectDoesNotExist:
494 except ObjectDoesNotExist:
478 pass
495 pass
479
496
480 def connect_threads(self, opening_posts):
497 def connect_threads(self, opening_posts):
481 for opening_post in opening_posts:
498 for opening_post in opening_posts:
482 threads = opening_post.get_threads().all()
499 threads = opening_post.get_threads().all()
483 for thread in threads:
500 for thread in threads:
484 if thread.can_bump():
501 if thread.can_bump():
485 thread.update_bump_status()
502 thread.update_bump_status()
486
503
487 thread.last_edit_time = self.last_edit_time
504 thread.last_edit_time = self.last_edit_time
488 thread.save(update_fields=['last_edit_time', 'bumpable'])
505 thread.save(update_fields=['last_edit_time', 'bumpable'])
489 self.threads.add(opening_post.get_thread())
506 self.threads.add(opening_post.get_thread())
@@ -1,117 +1,116 b''
1 import xml.etree.ElementTree as et
1 import xml.etree.ElementTree as et
2 from boards.models import KeyPair, GlobalId, Signature, Post
2 from boards.models import KeyPair, GlobalId, Signature, Post
3
3
4 ENCODING_UNICODE = 'unicode'
4 ENCODING_UNICODE = 'unicode'
5
5
6 TAG_MODEL = 'model'
6 TAG_MODEL = 'model'
7 TAG_REQUEST = 'request'
7 TAG_REQUEST = 'request'
8 TAG_RESPONSE = 'response'
8 TAG_RESPONSE = 'response'
9 TAG_ID = 'id'
9 TAG_ID = 'id'
10 TAG_STATUS = 'status'
10 TAG_STATUS = 'status'
11 TAG_MODELS = 'models'
11 TAG_MODELS = 'models'
12 TAG_TITLE = 'title'
12 TAG_TITLE = 'title'
13 TAG_TEXT = 'text'
13 TAG_TEXT = 'text'
14 TAG_THREAD = 'thread'
14 TAG_THREAD = 'thread'
15 TAG_PUB_TIME = 'pub-time'
15 TAG_PUB_TIME = 'pub-time'
16 TAG_SIGNATURES = 'signatures'
16 TAG_SIGNATURES = 'signatures'
17 TAG_SIGNATURE = 'signature'
17 TAG_SIGNATURE = 'signature'
18 TAG_CONTENT = 'content'
18 TAG_CONTENT = 'content'
19 TAG_ATTACHMENTS = 'attachments'
19 TAG_ATTACHMENTS = 'attachments'
20 TAG_ATTACHMENT = 'attachment'
20 TAG_ATTACHMENT = 'attachment'
21
21
22 TYPE_GET = 'get'
22 TYPE_GET = 'get'
23
23
24 ATTR_VERSION = 'version'
24 ATTR_VERSION = 'version'
25 ATTR_TYPE = 'type'
25 ATTR_TYPE = 'type'
26 ATTR_NAME = 'name'
26 ATTR_NAME = 'name'
27 ATTR_VALUE = 'value'
27 ATTR_VALUE = 'value'
28 ATTR_MIMETYPE = 'mimetype'
28 ATTR_MIMETYPE = 'mimetype'
29
29
30 STATUS_SUCCESS = 'success'
30 STATUS_SUCCESS = 'success'
31
31
32
32
33 # TODO Make this fully static
33 # TODO Make this fully static
34 class SyncManager:
34 class SyncManager:
35 def generate_response_get(self, model_list: list):
35 def generate_response_get(self, model_list: list):
36 response = et.Element(TAG_RESPONSE)
36 response = et.Element(TAG_RESPONSE)
37
37
38 status = et.SubElement(response, TAG_STATUS)
38 status = et.SubElement(response, TAG_STATUS)
39 status.text = STATUS_SUCCESS
39 status.text = STATUS_SUCCESS
40
40
41 models = et.SubElement(response, TAG_MODELS)
41 models = et.SubElement(response, TAG_MODELS)
42
42
43 for post in model_list:
43 for post in model_list:
44 model = et.SubElement(models, TAG_MODEL)
44 model = et.SubElement(models, TAG_MODEL)
45 model.set(ATTR_NAME, 'post')
45 model.set(ATTR_NAME, 'post')
46
46
47 content_tag = et.SubElement(model, TAG_CONTENT)
47 content_tag = et.SubElement(model, TAG_CONTENT)
48
48
49 tag_id = et.SubElement(content_tag, TAG_ID)
49 tag_id = et.SubElement(content_tag, TAG_ID)
50 post.global_id.to_xml_element(tag_id)
50 post.global_id.to_xml_element(tag_id)
51
51
52 title = et.SubElement(content_tag, TAG_TITLE)
52 title = et.SubElement(content_tag, TAG_TITLE)
53 title.text = post.title
53 title.text = post.title
54
54
55 text = et.SubElement(content_tag, TAG_TEXT)
55 text = et.SubElement(content_tag, TAG_TEXT)
56 # TODO Replace local links by global ones in the text
56 text.text = post.get_sync_text()
57 text.text = post.get_raw_text()
58
57
59 if not post.is_opening():
58 if not post.is_opening():
60 thread = et.SubElement(content_tag, TAG_THREAD)
59 thread = et.SubElement(content_tag, TAG_THREAD)
61 thread_id = et.SubElement(thread, TAG_ID)
60 thread_id = et.SubElement(thread, TAG_ID)
62 post.get_thread().get_opening_post().global_id.to_xml_element(thread_id)
61 post.get_thread().get_opening_post().global_id.to_xml_element(thread_id)
63 else:
62 else:
64 # TODO Output tags here
63 # TODO Output tags here
65 pass
64 pass
66
65
67 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
66 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
68 pub_time.text = str(post.get_pub_time_epoch())
67 pub_time.text = str(post.get_pub_time_epoch())
69
68
70 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
69 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
71 post_signatures = post.signature.all()
70 post_signatures = post.signature.all()
72 if post_signatures:
71 if post_signatures:
73 signatures = post.signatures
72 signatures = post.signatures
74 else:
73 else:
75 # TODO Maybe the signature can be computed only once after
74 # TODO Maybe the signature can be computed only once after
76 # the post is added? Need to add some on_save signal queue
75 # the post is added? Need to add some on_save signal queue
77 # and add this there.
76 # and add this there.
78 key = KeyPair.objects.get(public_key=post.global_id.key)
77 key = KeyPair.objects.get(public_key=post.global_id.key)
79 signatures = [Signature(
78 signatures = [Signature(
80 key_type=key.key_type,
79 key_type=key.key_type,
81 key=key.public_key,
80 key=key.public_key,
82 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
81 signature=key.sign(et.tostring(model, ENCODING_UNICODE)),
83 )]
82 )]
84 for signature in signatures:
83 for signature in signatures:
85 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
84 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
86 signature_tag.set(ATTR_TYPE, signature.key_type)
85 signature_tag.set(ATTR_TYPE, signature.key_type)
87 signature_tag.set(ATTR_VALUE, signature.signature)
86 signature_tag.set(ATTR_VALUE, signature.signature)
88
87
89 return et.tostring(response, ENCODING_UNICODE)
88 return et.tostring(response, ENCODING_UNICODE)
90
89
91 def parse_response_get(self, response_xml):
90 def parse_response_get(self, response_xml):
92 tag_root = et.fromstring(response_xml)
91 tag_root = et.fromstring(response_xml)
93 tag_status = tag_root.find(TAG_STATUS)
92 tag_status = tag_root.find(TAG_STATUS)
94 if STATUS_SUCCESS == tag_status.text:
93 if STATUS_SUCCESS == tag_status.text:
95 tag_models = tag_root.find(TAG_MODELS)
94 tag_models = tag_root.find(TAG_MODELS)
96 for tag_model in tag_models:
95 for tag_model in tag_models:
97 tag_content = tag_model.find(TAG_CONTENT)
96 tag_content = tag_model.find(TAG_CONTENT)
98 tag_id = tag_content.find(TAG_ID)
97 tag_id = tag_content.find(TAG_ID)
99 try:
98 try:
100 GlobalId.from_xml_element(tag_id, existing=True)
99 GlobalId.from_xml_element(tag_id, existing=True)
101 print('Post with same ID already exists')
100 print('Post with same ID already exists')
102 except GlobalId.DoesNotExist:
101 except GlobalId.DoesNotExist:
103 global_id = GlobalId.from_xml_element(tag_id)
102 global_id = GlobalId.from_xml_element(tag_id)
104
103
105 title = tag_content.find(TAG_TITLE).text
104 title = tag_content.find(TAG_TITLE).text
106 text = tag_content.find(TAG_TEXT).text
105 text = tag_content.find(TAG_TEXT).text
107 # TODO Check that the replied posts are already present
106 # TODO Check that the replied posts are already present
108 # before adding new ones
107 # before adding new ones
109
108
110 # TODO Pub time, thread, tags
109 # TODO Pub time, thread, tags
111
110
112 print(title)
111 print(title)
113 print(text)
112 print(text)
114 # post = Post.objects.create(title=title, text=text)
113 # post = Post.objects.create(title=title, text=text)
115 else:
114 else:
116 # TODO Throw an exception?
115 # TODO Throw an exception?
117 pass
116 pass
@@ -1,87 +1,87 b''
1 from base64 import b64encode
1 from base64 import b64encode
2 import logging
2 import logging
3
3
4 from django.test import TestCase
4 from django.test import TestCase
5 from boards.models import KeyPair, GlobalId, Post
5 from boards.models import KeyPair, GlobalId, Post
6 from boards.models.post.sync import SyncManager
6 from boards.models.post.sync import SyncManager
7
7
8 logger = logging.getLogger(__name__)
8 logger = logging.getLogger(__name__)
9
9
10
10
11 class KeyTest(TestCase):
11 class KeyTest(TestCase):
12 def test_create_key(self):
12 def test_create_key(self):
13 key = KeyPair.objects.generate_key('ecdsa')
13 key = KeyPair.objects.generate_key('ecdsa')
14
14
15 self.assertIsNotNone(key, 'The key was not created.')
15 self.assertIsNotNone(key, 'The key was not created.')
16
16
17 def test_validation(self):
17 def test_validation(self):
18 key = KeyPair.objects.generate_key(key_type='ecdsa')
18 key = KeyPair.objects.generate_key(key_type='ecdsa')
19 message = 'msg'
19 message = 'msg'
20 signature = key.sign(message)
20 signature = key.sign(message)
21 valid = KeyPair.objects.verify(key.public_key, message, signature,
21 valid = KeyPair.objects.verify(key.public_key, message, signature,
22 key_type='ecdsa')
22 key_type='ecdsa')
23
23
24 self.assertTrue(valid, 'Message verification failed.')
24 self.assertTrue(valid, 'Message verification failed.')
25
25
26 def test_primary_constraint(self):
26 def test_primary_constraint(self):
27 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
27 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
28
28
29 with self.assertRaises(Exception):
29 with self.assertRaises(Exception):
30 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
30 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
31
31
32 def test_model_id_save(self):
32 def test_model_id_save(self):
33 model_id = GlobalId(key_type='test', key='test key', local_id='1')
33 model_id = GlobalId(key_type='test', key='test key', local_id='1')
34 model_id.save()
34 model_id.save()
35
35
36 def test_request_get(self):
36 def test_request_get(self):
37 post = self._create_post_with_key()
37 post = self._create_post_with_key()
38
38
39 request = GlobalId.objects.generate_request_get([post.global_id])
39 request = GlobalId.objects.generate_request_get([post.global_id])
40 logger.debug(request)
40 logger.debug(request)
41
41
42 key = KeyPair.objects.get(primary=True)
42 key = KeyPair.objects.get(primary=True)
43 self.assertTrue('<request type="get" version="1.0">'
43 self.assertTrue('<request type="get" version="1.0">'
44 '<model name="post" version="1.0">'
44 '<model name="post" version="1.0">'
45 '<id key="%s" local-id="1" type="%s" />'
45 '<id key="%s" local-id="1" type="%s" />'
46 '</model>'
46 '</model>'
47 '</request>' % (
47 '</request>' % (
48 key.public_key,
48 key.public_key,
49 key.key_type,
49 key.key_type,
50 ) in request,
50 ) in request,
51 'Wrong XML generated for the GET request.')
51 'Wrong XML generated for the GET request.')
52
52
53 def test_response_get(self):
53 def test_response_get(self):
54 post = self._create_post_with_key()
54 post = self._create_post_with_key()
55 reply_post = Post.objects.create_post(title='test_title',
55 reply_post = Post.objects.create_post(title='test_title',
56 text='[post]%d[/post]' % post.id,
56 text='[post]%d[/post]' % post.id,
57 thread=post.get_thread())
57 thread=post.get_thread())
58
58
59 response = SyncManager().generate_response_get([reply_post])
59 response = SyncManager().generate_response_get([reply_post])
60 logger.debug(response)
60 logger.debug(response)
61
61
62 key = KeyPair.objects.get(primary=True)
62 key = KeyPair.objects.get(primary=True)
63 self.assertTrue('<status>success</status>'
63 self.assertTrue('<status>success</status>'
64 '<models>'
64 '<models>'
65 '<model name="post">'
65 '<model name="post">'
66 '<content>'
66 '<content>'
67 '<id key="%s" local-id="%d" type="%s" />'
67 '<id key="%s" local-id="%d" type="%s" />'
68 '<title>test_title</title>'
68 '<title>test_title</title>'
69 '<text>[post]%d[/post]</text>'
69 '<text>[post]%s[/post]</text>'
70 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
70 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
71 '<pub-time>%s</pub-time>'
71 '<pub-time>%s</pub-time>'
72 '</content>' % (
72 '</content>' % (
73 key.public_key,
73 key.public_key,
74 reply_post.id,
74 reply_post.id,
75 key.key_type,
75 key.key_type,
76 post.id,
76 str(post.global_id),
77 key.public_key,
77 key.public_key,
78 post.id,
78 post.id,
79 key.key_type,
79 key.key_type,
80 str(reply_post.get_pub_time_epoch()),
80 str(reply_post.get_pub_time_epoch()),
81 ) in response,
81 ) in response,
82 'Wrong XML generated for the GET response.')
82 'Wrong XML generated for the GET response.')
83
83
84 def _create_post_with_key(self):
84 def _create_post_with_key(self):
85 KeyPair.objects.generate_key(primary=True)
85 KeyPair.objects.generate_key(primary=True)
86
86
87 return Post.objects.create_post(title='test_title', text='test_text')
87 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