##// END OF EJS Templates
Use update-time of a post instead of version
neko259 -
r1928:f3702378 default
parent child Browse files
Show More
@@ -0,0 +1,19
1 # -*- coding: utf-8 -*-
2 # Generated by Django 1.11 on 2017-09-27 12:38
3 from __future__ import unicode_literals
4
5 from django.db import migrations
6
7
8 class Migration(migrations.Migration):
9
10 dependencies = [
11 ('boards', '0063_auto_20170301_1058'),
12 ]
13
14 operations = [
15 migrations.RemoveField(
16 model_name='post',
17 name='version',
18 ),
19 ]
@@ -1,181 +1,178
1 1 from boards.models.attachment import FILE_TYPES_IMAGE
2 2 from django.contrib import admin
3 3 from django.utils.translation import ugettext_lazy as _
4 4 from django.core.urlresolvers import reverse
5 5 from boards.models import Post, Tag, Ban, Thread, Banner, Attachment, \
6 6 KeyPair, GlobalId, TagAlias
7 7
8 8
9 9 @admin.register(Post)
10 10 class PostAdmin(admin.ModelAdmin):
11 11
12 12 list_display = ('id', 'title', 'text', 'poster_ip', 'linked_images',
13 13 'foreign', 'tags')
14 14 list_filter = ('pub_time',)
15 15 search_fields = ('id', 'title', 'text', 'poster_ip')
16 16 exclude = ('referenced_posts', 'refmap', 'images', 'global_id')
17 17 readonly_fields = ('poster_ip', 'thread', 'linked_images',
18 18 'attachments', 'uid', 'url', 'pub_time', 'opening', 'linked_global_id',
19 'version', 'foreign', 'tags')
19 'foreign', 'tags')
20 20
21 21 def ban_poster(self, request, queryset):
22 22 bans = 0
23 23 for post in queryset:
24 24 poster_ip = post.poster_ip
25 25 ban, created = Ban.objects.get_or_create(ip=poster_ip)
26 26 if created:
27 27 bans += 1
28 28 self.message_user(request, _('{} posters were banned').format(bans))
29 29
30 30 def ban_latter_with_delete(self, request, queryset):
31 31 bans = 0
32 32 hidden = 0
33 33 for post in queryset:
34 34 poster_ip = post.poster_ip
35 35 ban, created = Ban.objects.get_or_create(ip=poster_ip)
36 36 if created:
37 37 bans += 1
38 38 posts = Post.objects.filter(poster_ip=poster_ip, id__gte=post.id)
39 39 hidden += posts.count()
40 40 posts.delete()
41 41 self.message_user(request, _('{} posters were banned, {} messages were removed.').format(bans, hidden))
42 42 ban_latter_with_delete.short_description = _('Ban user and delete posts starting from this one and later')
43 43
44 44 def linked_images(self, obj: Post):
45 45 images = obj.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
46 46 image_urls = ['<a href="{}"><img src="{}" /></a>'.format(
47 47 reverse('admin:%s_%s_change' % (image._meta.app_label,
48 48 image._meta.model_name),
49 49 args=[image.id]), image.get_thumb_url()) for image in images]
50 50 return ', '.join(image_urls)
51 51 linked_images.allow_tags = True
52 52
53 53 def linked_global_id(self, obj: Post):
54 54 global_id = obj.global_id
55 55 if global_id is not None:
56 56 return '<a href="{}">{}</a>'.format(
57 57 reverse('admin:%s_%s_change' % (global_id._meta.app_label,
58 58 global_id._meta.model_name),
59 59 args=[global_id.id]), str(global_id))
60 60 linked_global_id.allow_tags = True
61 61
62 62 def tags(self, obj: Post):
63 63 return ', '.join([tag.get_name() for tag in obj.get_tags()])
64 64
65 65 def save_model(self, request, obj, form, change):
66 obj.increment_version()
67 66 obj.save()
68 67 obj.clear_cache()
69 68
70 69 def foreign(self, obj: Post):
71 70 return obj is not None and obj.global_id is not None and\
72 71 not obj.global_id.is_local()
73 72
74 73 actions = ['ban_poster', 'ban_latter_with_delete']
75 74
76 75
77 76 @admin.register(Tag)
78 77 class TagAdmin(admin.ModelAdmin):
79 78 def thread_count(self, obj: Tag) -> int:
80 79 return obj.get_thread_count()
81 80
82 81 def display_children(self, obj: Tag):
83 82 return ', '.join([str(child) for child in obj.get_children().all()])
84 83
85 84 def name(self, obj: Tag):
86 85 return obj.get_name()
87 86
88 87 def save_model(self, request, obj, form, change):
89 88 super().save_model(request, obj, form, change)
90 89 for thread in obj.get_threads().all():
91 90 thread.refresh_tags()
92 91
93 92 list_display = ('name', 'thread_count', 'display_children')
94 93 search_fields = ('id',)
95 94 readonly_fields = ('name',)
96 95
97 96
98 97 @admin.register(TagAlias)
99 98 class TagAliasAdmin(admin.ModelAdmin):
100 99 list_display = ('locale', 'name', 'parent')
101 100 list_filter = ('locale',)
102 101 search_fields = ('name',)
103 102
104 103
105 104 @admin.register(Thread)
106 105 class ThreadAdmin(admin.ModelAdmin):
107 106
108 107 def title(self, obj: Thread) -> str:
109 108 return obj.get_opening_post().get_title()
110 109
111 110 def reply_count(self, obj: Thread) -> int:
112 111 return obj.get_reply_count()
113 112
114 113 def ip(self, obj: Thread):
115 114 return obj.get_opening_post().poster_ip
116 115
117 116 def display_tags(self, obj: Thread):
118 117 return ', '.join([str(tag) for tag in obj.get_tags().all()])
119 118
120 119 def op(self, obj: Thread):
121 120 return obj.get_opening_post_id()
122 121
123 122 # Save parent tags when editing tags
124 123 def save_related(self, request, form, formsets, change):
125 124 super().save_related(request, form, formsets, change)
126 125 form.instance.refresh_tags()
127 126
128 127 def save_model(self, request, obj, form, change):
129 128 op = obj.get_opening_post()
130 op.increment_version()
131 op.save(update_fields=['version'])
132 129 obj.save()
133 130 op.clear_cache()
134 131
135 132 list_display = ('id', 'op', 'title', 'reply_count', 'status', 'ip',
136 133 'display_tags')
137 134 list_filter = ('bump_time', 'status')
138 135 search_fields = ('id', 'title')
139 136 filter_horizontal = ('tags',)
140 137
141 138
142 139 @admin.register(KeyPair)
143 140 class KeyPairAdmin(admin.ModelAdmin):
144 141 list_display = ('public_key', 'primary')
145 142 list_filter = ('primary',)
146 143 search_fields = ('public_key',)
147 144
148 145
149 146 @admin.register(Ban)
150 147 class BanAdmin(admin.ModelAdmin):
151 148 list_display = ('ip', 'can_read')
152 149 list_filter = ('can_read',)
153 150 search_fields = ('ip',)
154 151
155 152
156 153 @admin.register(Banner)
157 154 class BannerAdmin(admin.ModelAdmin):
158 155 list_display = ('title', 'text')
159 156
160 157
161 158 @admin.register(Attachment)
162 159 class AttachmentAdmin(admin.ModelAdmin):
163 160 list_display = ('__str__', 'mimetype', 'file', 'url', 'alias')
164 161 search_fields = ('alias',)
165 162
166 163 def delete_alias(self, request, queryset):
167 164 for attachment in queryset:
168 165 attachment.alias = None
169 166 attachment.save(update_fields=['alias'])
170 167 self.message_user(request, _('Aliases removed'))
171 168
172 169 actions = ['delete_alias']
173 170
174 171
175 172 @admin.register(GlobalId)
176 173 class GlobalIdAdmin(admin.ModelAdmin):
177 174 def is_linked(self, obj):
178 175 return Post.objects.filter(global_id=obj).exists()
179 176
180 177 list_display = ('__str__', 'is_linked',)
181 178 readonly_fields = ('content',)
@@ -1,115 +1,116
1 1 import re
2 2 import logging
3 3 import xml.etree.ElementTree as ET
4 4
5 5 import httplib2
6 6 from django.core.management import BaseCommand
7 from django.utils.dateparse import parse_datetime
7 8
8 9 from boards.models import GlobalId
9 from boards.models.post.sync import SyncManager, TAG_ID, TAG_VERSION
10 from boards.models.post.sync import SyncManager, TAG_ID, TAG_UPDATE_TIME
10 11
11 12 __author__ = 'neko259'
12 13
13 14
14 15 REGEX_GLOBAL_ID = re.compile(r'(\w+)::([\w\+/]+)::(\d+)')
15 16
16 17
17 18 class Command(BaseCommand):
18 19 help = 'Send a sync or get request to the server.'
19 20
20 21 def add_arguments(self, parser):
21 22 parser.add_argument('url', type=str, help='Server root url')
22 23 parser.add_argument('--global-id', type=str, default='',
23 24 help='Post global ID')
24 25 parser.add_argument('--split-query', type=int, default=1,
25 26 help='Split GET query into separate by the given'
26 27 ' number of posts in one')
27 28 parser.add_argument('--thread', type=int,
28 29 help='Get posts of one specific thread')
29 30 parser.add_argument('--tags', type=str,
30 31 help='Get posts of the tags, comma-separated')
31 32 parser.add_argument('--time-from', type=str,
32 33 help='Get posts from the given timestamp')
33 34
34 35 def handle(self, *args, **options):
35 36 logger = logging.getLogger('boards.sync')
36 37
37 38 url = options.get('url')
38 39
39 40 list_url = url + 'api/sync/list/'
40 41 get_url = url + 'api/sync/get/'
41 42 file_url = url[:-1]
42 43
43 44 global_id_str = options.get('global_id')
44 45 if global_id_str:
45 46 match = REGEX_GLOBAL_ID.match(global_id_str)
46 47 if match:
47 48 key_type = match.group(1)
48 49 key = match.group(2)
49 50 local_id = match.group(3)
50 51
51 52 global_id = GlobalId(key_type=key_type, key=key,
52 53 local_id=local_id)
53 54
54 55 xml = SyncManager.generate_request_get([global_id])
55 56 h = httplib2.Http()
56 57 response, content = h.request(get_url, method="POST", body=xml)
57 58
58 59 SyncManager.parse_response_get(content, file_url)
59 60 else:
60 61 raise Exception('Invalid global ID')
61 62 else:
62 63 logger.info('Running LIST request...')
63 64 h = httplib2.Http()
64 65
65 66 tags = []
66 67 tags_str = options.get('tags')
67 68 if tags_str:
68 69 tags = tags_str.split(',')
69 70
70 71 xml = SyncManager.generate_request_list(
71 72 opening_post=options.get('thread'), tags=tags,
72 73 timestamp_from=options.get('time_from')).encode()
73 74 response, content = h.request(list_url, method="POST", body=xml)
74 75 if response.status != 200:
75 76 raise Exception('Server returned error {}'.format(response.status))
76 77
77 78 logger.info('Processing response...')
78 79
79 80 root = ET.fromstring(content)
80 81 status = root.findall('status')[0].text
81 82 if status == 'success':
82 83 ids_to_sync = list()
83 84
84 85 models = root.findall('models')[0]
85 86 for model in models:
86 87 tag_id = model.find(TAG_ID)
87 88 global_id, exists = GlobalId.from_xml_element(tag_id)
88 tag_version = model.find(TAG_VERSION)
89 if tag_version is not None:
90 version = int(tag_version.text) or 1
89 tag_update_time = model.find(TAG_UPDATE_TIME)
90 if tag_update_time:
91 update_time = tag_update_time.text
91 92 else:
92 version = 1
93 if not exists or global_id.post.version < version:
93 update_time = None
94 if not exists or update_time is None or global_id.post.last_edit_time < parse_datetime(update_time):
94 95 logger.debug('Processed (+) post {}'.format(global_id))
95 96 ids_to_sync.append(global_id)
96 97 else:
97 98 logger.debug('* Processed (-) post {}'.format(global_id))
98 99 logger.info('Starting sync...')
99 100
100 101 if len(ids_to_sync) > 0:
101 102 limit = options.get('split_query', len(ids_to_sync))
102 103 for offset in range(0, len(ids_to_sync), limit):
103 104 xml = SyncManager.generate_request_get(ids_to_sync[offset:offset + limit])
104 105 h = httplib2.Http()
105 106 logger.info('Running GET request...')
106 107 response, content = h.request(get_url, method="POST", body=xml)
107 108 logger.info('Processing response...')
108 109
109 110 SyncManager.parse_response_get(content, file_url)
110 111
111 112 logger.info('Sync completed successfully')
112 113 else:
113 114 logger.info('Nothing to get, everything synced')
114 115 else:
115 116 raise Exception('Invalid response status')
@@ -1,364 +1,360
1 1 import uuid
2 2 import hashlib
3 3 import re
4 4
5 5 from boards import settings
6 6 from boards.abstracts.tripcode import Tripcode
7 7 from boards.models import Attachment, KeyPair, GlobalId
8 8 from boards.models.attachment import FILE_TYPES_IMAGE
9 9 from boards.models.base import Viewable
10 10 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
11 11 from boards.models.post.manager import PostManager, NO_IP
12 12 from boards.utils import datetime_to_epoch
13 13 from django.core.exceptions import ObjectDoesNotExist
14 14 from django.core.urlresolvers import reverse
15 15 from django.db import models
16 16 from django.db.models import TextField, QuerySet, F
17 17 from django.template.defaultfilters import truncatewords, striptags
18 18 from django.template.loader import render_to_string
19 19
20 20 CSS_CLS_HIDDEN_POST = 'hidden_post'
21 21 CSS_CLS_DEAD_POST = 'dead_post'
22 22 CSS_CLS_ARCHIVE_POST = 'archive_post'
23 23 CSS_CLS_POST = 'post'
24 24 CSS_CLS_MONOCHROME = 'monochrome'
25 25
26 26 TITLE_MAX_WORDS = 10
27 27
28 28 APP_LABEL_BOARDS = 'boards'
29 29
30 30 BAN_REASON_AUTO = 'Auto'
31 31
32 32 TITLE_MAX_LENGTH = 200
33 33
34 34 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
35 35 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
36 36 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
37 37 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
38 38
39 39 PARAMETER_TRUNCATED = 'truncated'
40 40 PARAMETER_TAG = 'tag'
41 41 PARAMETER_OFFSET = 'offset'
42 42 PARAMETER_DIFF_TYPE = 'type'
43 43 PARAMETER_CSS_CLASS = 'css_class'
44 44 PARAMETER_THREAD = 'thread'
45 45 PARAMETER_IS_OPENING = 'is_opening'
46 46 PARAMETER_POST = 'post'
47 47 PARAMETER_OP_ID = 'opening_post_id'
48 48 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
49 49 PARAMETER_REPLY_LINK = 'reply_link'
50 50 PARAMETER_NEED_OP_DATA = 'need_op_data'
51 51
52 52 POST_VIEW_PARAMS = (
53 53 'need_op_data',
54 54 'reply_link',
55 55 'need_open_link',
56 56 'truncated',
57 57 'mode_tree',
58 58 'perms',
59 59 'tree_depth',
60 60 )
61 61
62 62
63 63 class Post(models.Model, Viewable):
64 64 """A post is a message."""
65 65
66 66 objects = PostManager()
67 67
68 68 class Meta:
69 69 app_label = APP_LABEL_BOARDS
70 70 ordering = ('id',)
71 71
72 72 title = models.CharField(max_length=TITLE_MAX_LENGTH, blank=True, default='')
73 73 pub_time = models.DateTimeField(db_index=True)
74 74 text = TextField(blank=True, default='')
75 75 _text_rendered = TextField(blank=True, null=True, editable=False)
76 76
77 77 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
78 78 related_name='attachment_posts')
79 79
80 80 poster_ip = models.GenericIPAddressField()
81 81
82 82 # Used for cache and threads updating
83 83 last_edit_time = models.DateTimeField()
84 84
85 85 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
86 86 null=True,
87 87 blank=True, related_name='refposts',
88 88 db_index=True)
89 89 refmap = models.TextField(null=True, blank=True)
90 90 thread = models.ForeignKey('Thread', db_index=True, related_name='replies')
91 91
92 92 url = models.TextField()
93 93 uid = models.TextField()
94 94
95 95 # Global ID with author key. If the message was downloaded from another
96 96 # server, this indicates the server.
97 97 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
98 98 on_delete=models.CASCADE)
99 99
100 100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 101 opening = models.BooleanField(db_index=True)
102 102 hidden = models.BooleanField(default=False)
103 version = models.IntegerField(default=1)
104 103
105 104 def __str__(self):
106 105 return 'P#{}/{}'.format(self.id, self.get_title())
107 106
108 107 def get_title(self) -> str:
109 108 return self.title
110 109
111 110 def get_title_or_text(self):
112 111 title = self.get_title()
113 112 if not title:
114 113 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
115 114
116 115 return title
117 116
118 117 def build_refmap(self, excluded_ids=None) -> None:
119 118 """
120 119 Builds a replies map string from replies list. This is a cache to stop
121 120 the server from recalculating the map on every post show.
122 121 """
123 122
124 123 replies = self.referenced_posts
125 124 if excluded_ids is not None:
126 125 replies = replies.exclude(id__in=excluded_ids)
127 126 else:
128 127 replies = replies.all()
129 128
130 129 post_urls = [refpost.get_link_view() for refpost in replies]
131 130
132 131 self.refmap = ', '.join(post_urls)
133 132
134 133 def is_referenced(self) -> bool:
135 134 return self.refmap and len(self.refmap) > 0
136 135
137 136 def is_opening(self) -> bool:
138 137 """
139 138 Checks if this is an opening post or just a reply.
140 139 """
141 140
142 141 return self.opening
143 142
144 143 def get_absolute_url(self, thread=None):
145 144 # Url is cached only for the "main" thread. When getting url
146 145 # for other threads, do it manually.
147 146 return self.url
148 147
149 148 def get_thread(self):
150 149 return self.thread
151 150
152 151 def get_thread_id(self):
153 152 return self.thread_id
154 153
155 154 def _get_cache_key(self):
156 155 return [datetime_to_epoch(self.last_edit_time)]
157 156
158 157 def get_view_params(self, *args, **kwargs):
159 158 """
160 159 Gets the parameters required for viewing the post based on the arguments
161 160 given and the post itself.
162 161 """
163 162 thread = kwargs.get('thread') or self.get_thread()
164 163
165 164 css_classes = [CSS_CLS_POST]
166 165 if thread.is_archived():
167 166 css_classes.append(CSS_CLS_ARCHIVE_POST)
168 167 elif not thread.can_bump():
169 168 css_classes.append(CSS_CLS_DEAD_POST)
170 169 if self.is_hidden():
171 170 css_classes.append(CSS_CLS_HIDDEN_POST)
172 171 if thread.is_monochrome():
173 172 css_classes.append(CSS_CLS_MONOCHROME)
174 173
175 174 params = dict()
176 175 for param in POST_VIEW_PARAMS:
177 176 if param in kwargs:
178 177 params[param] = kwargs[param]
179 178
180 179 params.update({
181 180 PARAMETER_POST: self,
182 181 PARAMETER_IS_OPENING: self.is_opening(),
183 182 PARAMETER_THREAD: thread,
184 183 PARAMETER_CSS_CLASS: ' '.join(css_classes),
185 184 })
186 185
187 186 return params
188 187
189 188 def get_view(self, *args, **kwargs) -> str:
190 189 """
191 190 Renders post's HTML view. Some of the post params can be passed over
192 191 kwargs for the means of caching (if we view the thread, some params
193 192 are same for every post and don't need to be computed over and over.
194 193 """
195 194 params = self.get_view_params(*args, **kwargs)
196 195
197 196 return render_to_string('boards/post.html', params)
198 197
199 198 def get_images(self) -> Attachment:
200 199 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
201 200
202 201 def get_first_image(self) -> Attachment:
203 202 try:
204 203 return self.get_images().earliest('-id')
205 204 except Attachment.DoesNotExist:
206 205 return None
207 206
208 207 def set_global_id(self, key_pair=None):
209 208 """
210 209 Sets global id based on the given key pair. If no key pair is given,
211 210 default one is used.
212 211 """
213 212
214 213 if key_pair:
215 214 key = key_pair
216 215 else:
217 216 try:
218 217 key = KeyPair.objects.get(primary=True)
219 218 except KeyPair.DoesNotExist:
220 219 # Do not update the global id because there is no key defined
221 220 return
222 221 global_id = GlobalId(key_type=key.key_type,
223 222 key=key.public_key,
224 223 local_id=self.id)
225 224 global_id.save()
226 225
227 226 self.global_id = global_id
228 227
229 228 self.save(update_fields=['global_id'])
230 229
231 230 def get_pub_time_str(self):
232 231 return str(self.pub_time)
233 232
234 233 def get_replied_ids(self):
235 234 """
236 235 Gets ID list of the posts that this post replies.
237 236 """
238 237
239 238 raw_text = self.get_raw_text()
240 239
241 240 local_replied = REGEX_REPLY.findall(raw_text)
242 241 global_replied = []
243 242 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
244 243 key_type = match[0]
245 244 key = match[1]
246 245 local_id = match[2]
247 246
248 247 try:
249 248 global_id = GlobalId.objects.get(key_type=key_type,
250 249 key=key, local_id=local_id)
251 250 for post in Post.objects.filter(global_id=global_id).only('id'):
252 251 global_replied.append(post.id)
253 252 except GlobalId.DoesNotExist:
254 253 pass
255 254 return local_replied + global_replied
256 255
257 256 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
258 257 include_last_update=False) -> str:
259 258 """
260 259 Gets post HTML or JSON data that can be rendered on a page or used by
261 260 API.
262 261 """
263 262
264 263 return get_exporter(format_type).export(self, request,
265 264 include_last_update)
266 265
267 266 def _build_url(self):
268 267 opening = self.is_opening()
269 268 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
270 269 url = reverse('thread', kwargs={'post_id': opening_id})
271 270 if not opening:
272 271 url += '#' + str(self.id)
273 272
274 273 return url
275 274
276 275 def save(self, force_insert=False, force_update=False, using=None,
277 276 update_fields=None):
278 277 new_post = self.id is None
279 278
280 279 self.uid = str(uuid.uuid4())
281 280 if update_fields is not None and 'uid' not in update_fields:
282 281 update_fields += ['uid']
283 282
284 283 if not new_post:
285 284 thread = self.get_thread()
286 285 if thread:
287 286 thread.last_edit_time = self.last_edit_time
288 287 thread.save(update_fields=['last_edit_time', 'status'])
289 288
290 289 super().save(force_insert, force_update, using, update_fields)
291 290
292 291 if new_post:
293 292 self.url = self._build_url()
294 293 super().save(update_fields=['url'])
295 294
296 295 def get_text(self) -> str:
297 296 return self._text_rendered
298 297
299 298 def get_raw_text(self) -> str:
300 299 return self.text
301 300
302 301 def get_sync_text(self) -> str:
303 302 """
304 303 Returns text applicable for sync. It has absolute post reflinks.
305 304 """
306 305
307 306 replacements = dict()
308 307 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
309 308 try:
310 309 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
311 310 replacements[post_id] = absolute_post_id
312 311 except Post.DoesNotExist:
313 312 pass
314 313
315 314 text = self.get_raw_text() or ''
316 315 for key in replacements:
317 316 text = text.replace('[post]{}[/post]'.format(key),
318 317 '[post]{}[/post]'.format(replacements[key]))
319 318 text = text.replace('\r\n', '\n').replace('\r', '\n')
320 319
321 320 return text
322 321
323 322 def get_tripcode(self):
324 323 if self.tripcode:
325 324 return Tripcode(self.tripcode)
326 325
327 326 def get_link_view(self):
328 327 """
329 328 Gets view of a reflink to the post.
330 329 """
331 330 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
332 331 self.id)
333 332 if self.is_opening():
334 333 result = '<b>{}</b>'.format(result)
335 334
336 335 return result
337 336
338 337 def is_hidden(self) -> bool:
339 338 return self.hidden
340 339
341 340 def set_hidden(self, hidden):
342 341 self.hidden = hidden
343 342
344 def increment_version(self):
345 self.version = F('version') + 1
346
347 343 def clear_cache(self):
348 344 """
349 345 Clears sync data (content cache, signatures etc).
350 346 """
351 347 global_id = self.global_id
352 348 if global_id is not None and global_id.is_local()\
353 349 and global_id.content is not None:
354 350 global_id.clear_cache()
355 351
356 352 def get_tags(self):
357 353 return self.get_thread().get_tags()
358 354
359 355 def get_ip_color(self):
360 356 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
361 357
362 358 def has_ip(self):
363 359 return self.poster_ip != NO_IP
364 360
@@ -1,194 +1,192
1 1 import logging
2 2
3 3 from datetime import datetime, timedelta, date
4 4 from datetime import time as dtime
5 5
6 6 from boards.abstracts.exceptions import BannedException, ArchiveException
7 7 from django.db import models, transaction
8 8 from django.utils import timezone
9 9 from django.dispatch import Signal
10 10 from django.core.exceptions import PermissionDenied
11 11
12 12 import boards
13 13
14 14 from boards.models.user import Ban
15 15 from boards.mdx_neboard import Parser
16 16 from boards.models import Attachment
17 17 from boards import utils
18 18
19 19 __author__ = 'neko259'
20 20
21 21 POSTS_PER_DAY_RANGE = 7
22 22 NO_IP = '0.0.0.0'
23 23
24 24
25 25 post_import_deps = Signal()
26 26
27 27
28 28 class PostManager(models.Manager):
29 29 @transaction.atomic
30 30 def create_post(self, title: str, text: str, files=[], thread=None,
31 31 ip=NO_IP, tags: list=None,
32 32 tripcode='', monochrome=False, images=[],
33 33 file_urls=[]):
34 34 """
35 35 Creates new post
36 36 """
37 37
38 38 if thread is not None and thread.is_archived():
39 39 raise ArchiveException('Cannot post into an archived thread')
40 40
41 41 if not utils.is_anonymous_mode():
42 42 is_banned = Ban.objects.filter(ip=ip).exists()
43 43 else:
44 44 is_banned = False
45 45
46 46 if is_banned:
47 47 raise PermissionDenied()
48 48
49 49 if not tags:
50 50 tags = []
51 51
52 52 posting_time = timezone.now()
53 53 new_thread = False
54 54 if not thread:
55 55 thread = boards.models.thread.Thread.objects.create(
56 56 bump_time=posting_time, last_edit_time=posting_time,
57 57 monochrome=monochrome)
58 58 list(map(thread.tags.add, tags))
59 59 new_thread = True
60 60
61 61 pre_text = Parser().preparse(text)
62 62
63 63 post = self.create(title=title,
64 64 text=pre_text,
65 65 pub_time=posting_time,
66 66 poster_ip=ip,
67 67 thread=thread,
68 68 last_edit_time=posting_time,
69 69 tripcode=tripcode,
70 70 opening=new_thread)
71 71
72 72 logger = logging.getLogger('boards.post.create')
73 73
74 74 logger.info('Created post [{}] with text [{}] by {}'.format(post,
75 75 post.get_text(),post.poster_ip))
76 76
77 77 for file in files:
78 78 self._add_file_to_post(file, post)
79 79 for image in images:
80 80 post.attachments.add(image)
81 81 for file_url in file_urls:
82 82 post.attachments.add(Attachment.objects.create_from_url(file_url))
83 83
84 84 post.set_global_id()
85 85
86 86 # Thread needs to be bumped only when the post is already created
87 87 if not new_thread:
88 88 thread.last_edit_time = posting_time
89 89 thread.bump()
90 90 thread.save()
91 91
92 92 return post
93 93
94 94 def delete_posts_by_ip(self, ip):
95 95 """
96 96 Deletes all posts of the author with same IP
97 97 """
98 98
99 99 posts = self.filter(poster_ip=ip)
100 100 for post in posts:
101 101 post.delete()
102 102
103 103 @utils.cached_result()
104 104 def get_posts_per_day(self) -> float:
105 105 """
106 106 Gets average count of posts per day for the last 7 days
107 107 """
108 108
109 109 day_end = date.today()
110 110 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
111 111
112 112 day_time_start = timezone.make_aware(datetime.combine(
113 113 day_start, dtime()), timezone.get_current_timezone())
114 114 day_time_end = timezone.make_aware(datetime.combine(
115 115 day_end, dtime()), timezone.get_current_timezone())
116 116
117 117 posts_per_period = float(self.filter(
118 118 pub_time__lte=day_time_end,
119 119 pub_time__gte=day_time_start).count())
120 120
121 121 ppd = posts_per_period / POSTS_PER_DAY_RANGE
122 122
123 123 return ppd
124 124
125 125 def get_post_per_days(self, days) -> int:
126 126 day_end = date.today() + timedelta(1)
127 127 day_start = day_end - timedelta(days)
128 128
129 129 day_time_start = timezone.make_aware(datetime.combine(
130 130 day_start, dtime()), timezone.get_current_timezone())
131 131 day_time_end = timezone.make_aware(datetime.combine(
132 132 day_end, dtime()), timezone.get_current_timezone())
133 133
134 134 return self.filter(
135 135 pub_time__lte=day_time_end,
136 136 pub_time__gte=day_time_start).count()
137 137
138 138 @transaction.atomic
139 139 def import_post(self, title: str, text: str, pub_time: str, global_id,
140 140 opening_post=None, tags=list(), files=list(),
141 file_urls=list(), tripcode=None, version=1):
141 file_urls=list(), tripcode=None, last_edit_time=None):
142 142 is_opening = opening_post is None
143 143 if is_opening:
144 144 thread = boards.models.thread.Thread.objects.create(
145 145 bump_time=pub_time, last_edit_time=pub_time)
146 146 list(map(thread.tags.add, tags))
147 147 else:
148 148 thread = opening_post.get_thread()
149 149
150 150 post = self.create(title=title,
151 151 text=text,
152 152 pub_time=pub_time,
153 153 poster_ip=NO_IP,
154 last_edit_time=pub_time,
154 last_edit_time=last_edit_time or pub_time,
155 155 global_id=global_id,
156 156 opening=is_opening,
157 157 thread=thread,
158 tripcode=tripcode,
159 version=version)
158 tripcode=tripcode)
160 159
161 160 for file in files:
162 161 self._add_file_to_post(file, post)
163 162 for file_url in file_urls:
164 163 post.attachments.add(Attachment.objects.create_from_url(file_url))
165 164
166 165 url_to_post = '[post]{}[/post]'.format(str(global_id))
167 166 replies = self.filter(text__contains=url_to_post)
168 167 for reply in replies:
169 168 post_import_deps.send(reply)
170 169
171 170 @transaction.atomic
172 171 def update_post(self, post, title: str, text: str, pub_time: str,
173 tags=list(), files=list(), file_urls=list(), tripcode=None, version=1):
172 tags=list(), files=list(), file_urls=list(), tripcode=None):
174 173 post.title = title
175 174 post.text = text
176 175 post.pub_time = pub_time
177 176 post.tripcode = tripcode
178 post.version = version
179 177 post.save()
180 178
181 179 post.clear_cache()
182 180
183 181 post.attachments.clear()
184 182 for file in files:
185 183 self._add_file_to_post(file, post)
186 184 for file_url in file_urls:
187 185 post.attachments.add(Attachment.objects.create_from_url(file_url))
188 186
189 187 thread = post.get_thread()
190 188 thread.tags.clear()
191 189 list(map(thread.tags.add, tags))
192 190
193 191 def _add_file_to_post(self, file, post):
194 192 post.attachments.add(Attachment.objects.create_with_hash(file))
@@ -1,385 +1,389
1 import logging
1 2 import xml.etree.ElementTree as et
2 import logging
3 from xml.etree import ElementTree
3
4 from django.db import transaction
5 from django.utils.dateparse import parse_datetime
4 6
5 7 from boards.abstracts.exceptions import SyncException
6 from boards.abstracts.sync_filters import ThreadFilter, TagsFilter,\
7 TimestampFromFilter
8 from boards.abstracts.sync_filters import ThreadFilter, TagsFilter, \
9 TimestampFromFilter
8 10 from boards.models import KeyPair, GlobalId, Signature, Post, Tag
9 11 from boards.models.attachment.downloaders import download
10 12 from boards.models.signature import TAG_REQUEST, ATTR_TYPE, TYPE_GET, \
11 13 ATTR_VERSION, TAG_MODEL, ATTR_NAME, TAG_ID, TYPE_LIST
12 14 from boards.utils import get_file_mimetype, get_file_hash
13 from django.db import transaction
14 15
15 16 EXCEPTION_NODE = 'Sync node returned an error: {}.'
16 17 EXCEPTION_DOWNLOAD = 'File was not downloaded.'
17 18 EXCEPTION_HASH = 'File hash does not match attachment hash.'
18 19 EXCEPTION_SIGNATURE = 'Invalid model signature for {}.'
19 20 EXCEPTION_AUTHOR_SIGNATURE = 'Model {} has no author signature.'
20 21 EXCEPTION_THREAD = 'No thread exists for post {}'
21 22 ENCODING_UNICODE = 'unicode'
22 23
23 24 TAG_MODEL = 'model'
24 25 TAG_REQUEST = 'request'
25 26 TAG_RESPONSE = 'response'
26 27 TAG_ID = 'id'
27 28 TAG_STATUS = 'status'
28 29 TAG_MODELS = 'models'
29 30 TAG_TITLE = 'title'
30 31 TAG_TEXT = 'text'
31 32 TAG_THREAD = 'thread'
32 33 TAG_PUB_TIME = 'pub-time'
34 TAG_UPDATE_TIME = 'update-time'
33 35 TAG_SIGNATURES = 'signatures'
34 36 TAG_SIGNATURE = 'signature'
35 37 TAG_CONTENT = 'content'
36 38 TAG_ATTACHMENTS = 'attachments'
37 39 TAG_ATTACHMENT = 'attachment'
38 40 TAG_TAGS = 'tags'
39 41 TAG_TAG = 'tag'
40 42 TAG_ATTACHMENT_REFS = 'attachment-refs'
41 43 TAG_ATTACHMENT_REF = 'attachment-ref'
42 44 TAG_TRIPCODE = 'tripcode'
43 45 TAG_VERSION = 'version'
44 46
45 47 TYPE_GET = 'get'
46 48
47 49 ATTR_VERSION = 'version'
48 50 ATTR_TYPE = 'type'
49 51 ATTR_NAME = 'name'
50 52 ATTR_VALUE = 'value'
51 53 ATTR_MIMETYPE = 'mimetype'
52 54 ATTR_KEY = 'key'
53 55 ATTR_REF = 'ref'
54 56 ATTR_URL = 'url'
55 57 ATTR_ID_TYPE = 'id-type'
56 58
57 59 ID_TYPE_MD5 = 'md5'
58 60 ID_TYPE_URL = 'url'
59 61
60 62 STATUS_SUCCESS = 'success'
61 63
64 CURRENT_MODEL_VERSION = '1.1'
65
62 66
63 67 logger = logging.getLogger('boards.sync')
64 68
65 69
66 70 class SyncManager:
67 71 @staticmethod
68 72 def generate_response_get(model_list: list):
69 73 response = et.Element(TAG_RESPONSE)
70 74
71 75 status = et.SubElement(response, TAG_STATUS)
72 76 status.text = STATUS_SUCCESS
73 77
74 78 models = et.SubElement(response, TAG_MODELS)
75 79
76 80 for post in model_list:
77 81 model = et.SubElement(models, TAG_MODEL)
78 82 model.set(ATTR_NAME, 'post')
79 83
80 84 global_id = post.global_id
81 85
82 86 attachments = post.attachments.all()
83 87 if global_id.content:
84 88 model.append(et.fromstring(global_id.content))
85 89 if len(attachments) > 0:
86 90 internal_attachments = False
87 91 for attachment in attachments:
88 92 if attachment.is_internal():
89 93 internal_attachments = True
90 94 break
91 95
92 96 if internal_attachments:
93 97 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
94 98 for file in attachments:
95 99 SyncManager._attachment_to_xml(
96 100 None, attachment_refs, file)
97 101 else:
98 102 content_tag = et.SubElement(model, TAG_CONTENT)
99 103
100 104 tag_id = et.SubElement(content_tag, TAG_ID)
101 105 global_id.to_xml_element(tag_id)
102 106
103 107 title = et.SubElement(content_tag, TAG_TITLE)
104 108 title.text = post.title
105 109
106 110 text = et.SubElement(content_tag, TAG_TEXT)
107 111 text.text = post.get_sync_text()
108 112
109 113 thread = post.get_thread()
110 114 if post.is_opening():
111 115 tag_tags = et.SubElement(content_tag, TAG_TAGS)
112 116 for tag in thread.get_tags():
113 117 tag_tag = et.SubElement(tag_tags, TAG_TAG)
114 118 tag_tag.text = tag.get_name()
115 119 else:
116 120 tag_thread = et.SubElement(content_tag, TAG_THREAD)
117 121 thread_id = et.SubElement(tag_thread, TAG_ID)
118 122 thread.get_opening_post().global_id.to_xml_element(thread_id)
119 123
120 124 pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
121 125 pub_time.text = str(post.get_pub_time_str())
122 126
127 update_time = et.SubElement(content_tag, TAG_UPDATE_TIME)
128 update_time.text = str(post.last_edit_time)
129
123 130 if post.tripcode:
124 131 tripcode = et.SubElement(content_tag, TAG_TRIPCODE)
125 132 tripcode.text = post.tripcode
126 133
127 134 if len(attachments) > 0:
128 135 attachments_tag = et.SubElement(content_tag, TAG_ATTACHMENTS)
129 136
130 137 internal_attachments = False
131 138 for attachment in attachments:
132 139 if attachment.is_internal():
133 140 internal_attachments = True
134 141 break
135 142
136 143 if internal_attachments:
137 144 attachment_refs = et.SubElement(model, TAG_ATTACHMENT_REFS)
138 145 else:
139 146 attachment_refs = None
140 147
141 148 for file in attachments:
142 149 SyncManager._attachment_to_xml(
143 150 attachments_tag, attachment_refs, file)
144 version_tag = et.SubElement(content_tag, TAG_VERSION)
145 version_tag.text = str(post.version)
146 151
147 152 global_id.content = et.tostring(content_tag, ENCODING_UNICODE)
148 153 global_id.save()
149 154
150 155 signatures_tag = et.SubElement(model, TAG_SIGNATURES)
151 156 post_signatures = global_id.signature_set.all()
152 157 if post_signatures:
153 158 signatures = post_signatures
154 159 else:
155 160 key = KeyPair.objects.get(public_key=global_id.key)
156 161 signature = Signature(
157 162 key_type=key.key_type,
158 163 key=key.public_key,
159 164 signature=key.sign(global_id.content),
160 165 global_id=global_id,
161 166 )
162 167 signature.save()
163 168 signatures = [signature]
164 169 for signature in signatures:
165 170 signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE)
166 171 signature_tag.set(ATTR_TYPE, signature.key_type)
167 172 signature_tag.set(ATTR_VALUE, signature.signature)
168 173 signature_tag.set(ATTR_KEY, signature.key)
169 174
170 175 return et.tostring(response, ENCODING_UNICODE)
171 176
172 177 @staticmethod
173 178 def parse_response_get(response_xml, hostname):
174 179 tag_root = et.fromstring(response_xml)
175 180 tag_status = tag_root.find(TAG_STATUS)
176 181 if STATUS_SUCCESS == tag_status.text:
177 182 tag_models = tag_root.find(TAG_MODELS)
178 183 for tag_model in tag_models:
179 184 SyncManager.parse_post(tag_model, hostname)
180 185 else:
181 186 raise SyncException(EXCEPTION_NODE.format(tag_status.text))
182 187
183 188 @staticmethod
184 189 @transaction.atomic
185 190 def parse_post(tag_model, hostname):
186 191 tag_content = tag_model.find(TAG_CONTENT)
187 192
188 193 content_str = et.tostring(tag_content, ENCODING_UNICODE)
189 194
190 195 tag_id = tag_content.find(TAG_ID)
191 196 global_id, exists = GlobalId.from_xml_element(tag_id)
192 197 signatures = SyncManager._verify_model(global_id, content_str, tag_model)
193 198
194 version = int(tag_content.find(TAG_VERSION).text)
195 is_old = exists and global_id.post.version < version
199 update_time = tag_content.find(TAG_UPDATE_TIME).text
200 is_old = exists and global_id.post.last_edit_time < parse_datetime(update_time)
196 201 if exists and not is_old:
197 202 logger.debug('Post {} exists and is up to date.'.format(global_id))
198 203 else:
199 204 global_id.content = content_str
200 205 global_id.save()
201 206 for signature in signatures:
202 207 signature.global_id = global_id
203 208 signature.save()
204 209
205 210 title = tag_content.find(TAG_TITLE).text or ''
206 211 text = tag_content.find(TAG_TEXT).text or ''
207 pub_time = tag_content.find(TAG_PUB_TIME).text
208 212 tripcode_tag = tag_content.find(TAG_TRIPCODE)
209 213 if tripcode_tag is not None:
210 214 tripcode = tripcode_tag.text or ''
211 215 else:
212 216 tripcode = ''
213 217
218 pub_time = tag_content.find(TAG_PUB_TIME).text
214 219 thread = tag_content.find(TAG_THREAD)
215 220 tags = []
216 221 if thread:
217 222 thread_id = thread.find(TAG_ID)
218 223 op_global_id, exists = GlobalId.from_xml_element(thread_id)
219 224 if exists:
220 225 opening_post = Post.objects.get(global_id=op_global_id)
221 226 else:
222 227 raise Exception(EXCEPTION_THREAD.format(global_id))
223 228 else:
224 229 opening_post = None
225 230 tag_tags = tag_content.find(TAG_TAGS)
226 231 for tag_tag in tag_tags:
227 232 tag, created = Tag.objects.get_or_create(
228 233 aliases__name=tag_tag.text)
229 234 tags.append(tag)
230 235
231 236 # TODO Check that the replied posts are already present
232 237 # before adding new ones
233 238
234 239 files = []
235 240 urls = []
236 241 tag_attachments = tag_content.find(TAG_ATTACHMENTS) or list()
237 242 tag_refs = tag_model.find(TAG_ATTACHMENT_REFS)
238 243 for attachment in tag_attachments:
239 244 if attachment.get(ATTR_ID_TYPE) == ID_TYPE_URL:
240 245 urls.append(attachment.text)
241 246 else:
242 247 tag_ref = tag_refs.find("{}[@ref='{}']".format(
243 248 TAG_ATTACHMENT_REF, attachment.text))
244 249 url = tag_ref.get(ATTR_URL)
245 250 attached_file = download(hostname + url, validate=False)
246 251 if attached_file is None:
247 252 raise SyncException(EXCEPTION_DOWNLOAD)
248 253
249 254 hash = get_file_hash(attached_file)
250 255 if hash != attachment.text:
251 256 raise SyncException(EXCEPTION_HASH)
252 257
253 258 files.append(attached_file)
254 259
255 260 if is_old:
256 261 post = global_id.post
257 262 Post.objects.update_post(
258 263 post, title=title, text=text, pub_time=pub_time,
259 264 tags=tags, files=files, file_urls=urls,
260 tripcode=tripcode, version=version)
265 tripcode=tripcode, version=version, last_edit_time=update_time)
261 266 logger.debug('Parsed updated post {}'.format(global_id))
262 267 else:
263 268 Post.objects.import_post(
264 269 title=title, text=text, pub_time=pub_time,
265 270 opening_post=opening_post, tags=tags,
266 271 global_id=global_id, files=files,
267 file_urls=urls, tripcode=tripcode,
268 version=version)
272 file_urls=urls, tripcode=tripcode, last_edit_time=update_time)
269 273 logger.debug('Parsed new post {}'.format(global_id))
270 274
271 275 @staticmethod
272 276 def generate_response_list(filters):
273 277 response = et.Element(TAG_RESPONSE)
274 278
275 279 status = et.SubElement(response, TAG_STATUS)
276 280 status.text = STATUS_SUCCESS
277 281
278 282 models = et.SubElement(response, TAG_MODELS)
279 283
280 284 posts = Post.objects.prefetch_related('global_id')
281 285 for post_filter in filters:
282 286 posts = post_filter.filter(posts)
283 287
284 288 for post in posts:
285 289 tag_model = et.SubElement(models, TAG_MODEL)
286 290 tag_id = et.SubElement(tag_model, TAG_ID)
287 291 post.global_id.to_xml_element(tag_id)
288 tag_version = et.SubElement(tag_model, TAG_VERSION)
289 tag_version.text = str(post.version)
292 update_time = et.SubElement(tag_model, TAG_UPDATE_TIME)
293 update_time.text = str(post.last_edit_time)
290 294
291 295 return et.tostring(response, ENCODING_UNICODE)
292 296
293 297 @staticmethod
294 298 def _verify_model(global_id, content_str, tag_model):
295 299 """
296 300 Verifies all signatures for a single model.
297 301 """
298 302
299 303 signatures = []
300 304
301 305 tag_signatures = tag_model.find(TAG_SIGNATURES)
302 306 has_author_signature = False
303 307 for tag_signature in tag_signatures:
304 308 signature_type = tag_signature.get(ATTR_TYPE)
305 309 signature_value = tag_signature.get(ATTR_VALUE)
306 310 signature_key = tag_signature.get(ATTR_KEY)
307 311
308 312 if global_id.key_type == signature_type and\
309 313 global_id.key == signature_key:
310 314 has_author_signature = True
311 315
312 316 signature = Signature(key_type=signature_type,
313 317 key=signature_key,
314 318 signature=signature_value)
315 319
316 320 if not KeyPair.objects.verify(signature, content_str):
317 321 raise SyncException(EXCEPTION_SIGNATURE.format(content_str))
318 322
319 323 signatures.append(signature)
320 324 if not has_author_signature:
321 325 raise SyncException(EXCEPTION_AUTHOR_SIGNATURE.format(content_str))
322 326
323 327 return signatures
324 328
325 329 @staticmethod
326 330 def _attachment_to_xml(tag_attachments, tag_refs, attachment):
327 331 if tag_attachments is not None:
328 332 attachment_tag = et.SubElement(tag_attachments, TAG_ATTACHMENT)
329 333 if attachment.is_internal():
330 334 mimetype = get_file_mimetype(attachment.file.file)
331 335 attachment_tag.set(ATTR_MIMETYPE, mimetype)
332 336 attachment_tag.set(ATTR_ID_TYPE, ID_TYPE_MD5)
333 337 attachment_tag.text = attachment.hash
334 338 else:
335 339 attachment_tag.set(ATTR_ID_TYPE, ID_TYPE_URL)
336 340 attachment_tag.text = attachment.url
337 341
338 342 if tag_refs is not None and attachment.is_internal():
339 343 attachment_ref = et.SubElement(tag_refs, TAG_ATTACHMENT_REF)
340 344 attachment_ref.set(ATTR_REF, attachment.hash)
341 345 attachment_ref.set(ATTR_URL, attachment.file.url)
342 346
343 347 @staticmethod
344 348 def generate_request_get(global_id_list: list):
345 349 """
346 350 Form a get request from a list of ModelId objects.
347 351 """
348 352
349 353 request = et.Element(TAG_REQUEST)
350 354 request.set(ATTR_TYPE, TYPE_GET)
351 355 request.set(ATTR_VERSION, '1.0')
352 356
353 357 model = et.SubElement(request, TAG_MODEL)
354 358 model.set(ATTR_VERSION, '1.0')
355 359 model.set(ATTR_NAME, 'post')
356 360
357 361 for global_id in global_id_list:
358 362 tag_id = et.SubElement(model, TAG_ID)
359 363 global_id.to_xml_element(tag_id)
360 364
361 365 return et.tostring(request, 'unicode')
362 366
363 367 @staticmethod
364 368 def generate_request_list(opening_post=None, tags=list(),
365 369 timestamp_from=None):
366 370 """
367 371 Form a pull request from a list of ModelId objects.
368 372 """
369 373
370 374 request = et.Element(TAG_REQUEST)
371 375 request.set(ATTR_TYPE, TYPE_LIST)
372 376 request.set(ATTR_VERSION, '1.0')
373 377
374 378 model = et.SubElement(request, TAG_MODEL)
375 model.set(ATTR_VERSION, '1.0')
379 model.set(ATTR_VERSION, CURRENT_MODEL_VERSION)
376 380 model.set(ATTR_NAME, 'post')
377 381
378 382 if opening_post:
379 383 ThreadFilter().add_filter(model, opening_post)
380 384 if tags:
381 385 TagsFilter().add_filter(model, tags)
382 386 if timestamp_from:
383 387 TimestampFromFilter().add_filter(model, timestamp_from)
384 388
385 389 return et.tostring(request, 'unicode')
@@ -1,313 +1,313
1 1 import logging
2 2 from datetime import timedelta
3 3
4 4 from django.db import models, transaction
5 5 from django.db.models import Count, Sum, QuerySet, Q
6 6 from django.utils import timezone
7 7
8 8 import boards
9 9 from boards import settings
10 10 from boards.models import STATUS_BUMPLIMIT, STATUS_ACTIVE, STATUS_ARCHIVE
11 11 from boards.models.attachment import FILE_TYPES_IMAGE
12 12 from boards.models.post import Post
13 13 from boards.models.tag import Tag, DEFAULT_LOCALE, TagAlias
14 14 from boards.utils import cached_result, datetime_to_epoch
15 15
16 16 FAV_THREAD_NO_UPDATES = -1
17 17
18 18
19 19 __author__ = 'neko259'
20 20
21 21
22 22 logger = logging.getLogger(__name__)
23 23
24 24
25 25 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 26 WS_NOTIFICATION_TYPE = 'notification_type'
27 27
28 28 WS_CHANNEL_THREAD = "thread:"
29 29
30 30 STATUS_CHOICES = (
31 31 (STATUS_ACTIVE, STATUS_ACTIVE),
32 32 (STATUS_BUMPLIMIT, STATUS_BUMPLIMIT),
33 33 (STATUS_ARCHIVE, STATUS_ARCHIVE),
34 34 )
35 35
36 36
37 37 class ThreadManager(models.Manager):
38 38 def process_old_threads(self):
39 39 """
40 40 Preserves maximum thread count. If there are too many threads,
41 41 archive or delete the old ones.
42 42 """
43 43 old_time_delta = settings.get_int('Messages', 'ThreadArchiveDays')
44 44 old_time = timezone.now() - timedelta(days=old_time_delta)
45 45 old_ops = Post.objects.filter(opening=True, pub_time__lte=old_time).exclude(thread__status=STATUS_ARCHIVE)
46 46
47 47 for op in old_ops:
48 48 thread = op.get_thread()
49 49 if settings.get_bool('Storage', 'ArchiveThreads'):
50 50 self._archive_thread(thread)
51 51 else:
52 52 thread.delete()
53 53 logger.info('Processed old thread {}'.format(thread))
54 54
55 55
56 56 def _archive_thread(self, thread):
57 57 thread.status = STATUS_ARCHIVE
58 58 thread.last_edit_time = timezone.now()
59 59 thread.update_posts_time()
60 60 thread.save(update_fields=['last_edit_time', 'status'])
61 61
62 62 def get_new_posts(self, datas):
63 63 query = None
64 64 # TODO Use classes instead of dicts
65 65 for data in datas:
66 66 if data['last_id'] != FAV_THREAD_NO_UPDATES:
67 67 q = (Q(id=data['op'].get_thread_id())
68 68 & Q(replies__id__gt=data['last_id']))
69 69 if query is None:
70 70 query = q
71 71 else:
72 72 query = query | q
73 73 if query is not None:
74 74 return self.filter(query).annotate(
75 75 new_post_count=Count('replies'))
76 76
77 77 def get_new_post_count(self, datas):
78 78 new_posts = self.get_new_posts(datas)
79 79 return new_posts.aggregate(total_count=Count('replies'))\
80 80 ['total_count'] if new_posts else 0
81 81
82 82
83 83 def get_thread_max_posts():
84 84 return settings.get_int('Messages', 'MaxPostsPerThread')
85 85
86 86
87 87 class Thread(models.Model):
88 88 objects = ThreadManager()
89 89
90 90 class Meta:
91 91 app_label = 'boards'
92 92
93 93 tags = models.ManyToManyField('Tag', related_name='thread_tags')
94 94 bump_time = models.DateTimeField(db_index=True)
95 95 last_edit_time = models.DateTimeField()
96 96 max_posts = models.IntegerField(default=get_thread_max_posts)
97 97 status = models.CharField(max_length=50, default=STATUS_ACTIVE,
98 98 choices=STATUS_CHOICES, db_index=True)
99 99 monochrome = models.BooleanField(default=False)
100 100
101 101 def get_tags(self) -> QuerySet:
102 102 """
103 103 Gets a sorted tag list.
104 104 """
105 105
106 106 return self.tags.filter(aliases__in=TagAlias.objects.filter_localized(parent__thread_tags=self)).order_by('aliases__name')
107 107
108 108 def bump(self):
109 109 """
110 110 Bumps (moves to up) thread if possible.
111 111 """
112 112
113 113 if self.can_bump():
114 114 self.bump_time = self.last_edit_time
115 115
116 116 self.update_bump_status()
117 117
118 118 logger.info('Bumped thread %d' % self.id)
119 119
120 120 def has_post_limit(self) -> bool:
121 121 return self.max_posts > 0
122 122
123 123 def update_bump_status(self, exclude_posts=None):
124 124 if self.has_post_limit() and self.get_reply_count() >= self.max_posts:
125 125 self.status = STATUS_BUMPLIMIT
126 126 self.update_posts_time(exclude_posts=exclude_posts)
127 127
128 128 def _get_cache_key(self):
129 129 return [datetime_to_epoch(self.last_edit_time)]
130 130
131 131 @cached_result(key_method=_get_cache_key)
132 132 def get_reply_count(self) -> int:
133 133 return self.get_replies().count()
134 134
135 135 @cached_result(key_method=_get_cache_key)
136 136 def get_images_count(self) -> int:
137 137 return self.get_replies().filter(
138 138 attachments__mimetype__in=FILE_TYPES_IMAGE)\
139 139 .annotate(images_count=Count(
140 140 'attachments')).aggregate(Sum('images_count'))['images_count__sum'] or 0
141 141
142 142 def can_bump(self) -> bool:
143 143 """
144 144 Checks if the thread can be bumped by replying to it.
145 145 """
146 146
147 147 return self.get_status() == STATUS_ACTIVE
148 148
149 149 def get_last_replies(self) -> QuerySet:
150 150 """
151 151 Gets several last replies, not including opening post
152 152 """
153 153
154 154 last_replies_count = settings.get_int('View', 'LastRepliesCount')
155 155
156 156 if last_replies_count > 0:
157 157 reply_count = self.get_reply_count()
158 158
159 159 if reply_count > 0:
160 160 reply_count_to_show = min(last_replies_count,
161 161 reply_count - 1)
162 162 replies = self.get_replies()
163 163 last_replies = replies[reply_count - reply_count_to_show:]
164 164
165 165 return last_replies
166 166
167 167 def get_skipped_replies_count(self) -> int:
168 168 """
169 169 Gets number of posts between opening post and last replies.
170 170 """
171 171 reply_count = self.get_reply_count()
172 172 last_replies_count = min(settings.get_int('View', 'LastRepliesCount'),
173 173 reply_count - 1)
174 174 return reply_count - last_replies_count - 1
175 175
176 176 # TODO Remove argument, it is not used
177 177 def get_replies(self, view_fields_only=True) -> QuerySet:
178 178 """
179 179 Gets sorted thread posts
180 180 """
181 181 query = self.replies.order_by('pub_time').prefetch_related(
182 182 'attachments')
183 183 return query
184 184
185 185 def get_viewable_replies(self) -> QuerySet:
186 186 """
187 187 Gets replies with only fields that are used for viewing.
188 188 """
189 return self.get_replies().defer('text', 'last_edit_time', 'version')
189 return self.get_replies().defer('text', 'last_edit_time')
190 190
191 191 def get_top_level_replies(self) -> QuerySet:
192 192 return self.get_replies().exclude(refposts__threads__in=[self])
193 193
194 194 def get_replies_with_images(self, view_fields_only=False) -> QuerySet:
195 195 """
196 196 Gets replies that have at least one image attached
197 197 """
198 198 return self.get_replies(view_fields_only).filter(
199 199 attachments__mimetype__in=FILE_TYPES_IMAGE).annotate(images_count=Count(
200 200 'attachments')).filter(images_count__gt=0)
201 201
202 202 def get_opening_post(self, only_id=False) -> Post:
203 203 """
204 204 Gets the first post of the thread
205 205 """
206 206
207 207 query = self.get_replies().filter(opening=True)
208 208 if only_id:
209 209 query = query.only('id')
210 210 opening_post = query.first()
211 211
212 212 return opening_post
213 213
214 214 @cached_result()
215 215 def get_opening_post_id(self) -> int:
216 216 """
217 217 Gets ID of the first thread post.
218 218 """
219 219
220 220 return self.get_opening_post(only_id=True).id
221 221
222 222 def get_pub_time(self):
223 223 """
224 224 Gets opening post's pub time because thread does not have its own one.
225 225 """
226 226
227 227 return self.get_opening_post().pub_time
228 228
229 229 def __str__(self):
230 230 return 'T#{}'.format(self.id)
231 231
232 232 def get_tag_url_list(self) -> list:
233 233 return boards.models.Tag.objects.get_tag_url_list(self.get_tags().all())
234 234
235 235 def update_posts_time(self, exclude_posts=None):
236 236 last_edit_time = self.last_edit_time
237 237
238 238 for post in self.replies.all():
239 239 if exclude_posts is None or post not in exclude_posts:
240 240 # Manual update is required because uids are generated on save
241 241 post.last_edit_time = last_edit_time
242 242 post.save(update_fields=['last_edit_time'])
243 243
244 244 def get_absolute_url(self):
245 245 return self.get_opening_post().get_absolute_url()
246 246
247 247 def get_required_tags(self):
248 248 return self.get_tags().filter(required=True)
249 249
250 250 def get_sections_str(self):
251 251 return Tag.objects.get_tag_url_list(self.get_required_tags())
252 252
253 253 def get_replies_newer(self, post_id):
254 254 return self.get_replies().filter(id__gt=post_id)
255 255
256 256 def is_archived(self):
257 257 return self.get_status() == STATUS_ARCHIVE
258 258
259 259 def get_status(self):
260 260 return self.status
261 261
262 262 def is_monochrome(self):
263 263 return self.monochrome
264 264
265 265 # If tags have parent, add them to the tag list
266 266 @transaction.atomic
267 267 def refresh_tags(self):
268 268 for tag in self.get_tags().all():
269 269 parents = tag.get_all_parents()
270 270 if len(parents) > 0:
271 271 self.tags.add(*parents)
272 272
273 273 def get_reply_tree(self):
274 274 replies = self.get_replies().prefetch_related('refposts')
275 275 tree = []
276 276 for reply in replies:
277 277 parents = reply.refposts.all()
278 278
279 279 found_parent = False
280 280 searching_for_index = False
281 281
282 282 if len(parents) > 0:
283 283 index = 0
284 284 parent_depth = 0
285 285
286 286 indexes_to_insert = []
287 287
288 288 for depth, element in tree:
289 289 index += 1
290 290
291 291 # If this element is next after parent on the same level,
292 292 # insert child before it
293 293 if searching_for_index and depth <= parent_depth:
294 294 indexes_to_insert.append((index - 1, parent_depth))
295 295 searching_for_index = False
296 296
297 297 if element in parents:
298 298 found_parent = True
299 299 searching_for_index = True
300 300 parent_depth = depth
301 301
302 302 if not found_parent:
303 303 tree.append((0, reply))
304 304 else:
305 305 if searching_for_index:
306 306 tree.append((parent_depth + 1, reply))
307 307
308 308 offset = 0
309 309 for last_index, parent_depth in indexes_to_insert:
310 310 tree.insert(last_index + offset, (parent_depth + 1, reply))
311 311 offset += 1
312 312
313 313 return tree
@@ -1,208 +1,210
1 1 {% extends "boards/paginated.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load board %}
5 5 {% load static %}
6 6 {% load tz %}
7 7
8 8 {% block head %}
9 9 <meta name="robots" content="noindex">
10 10
11 11 {% if tag %}
12 12 <title>{{ tag.get_name }} - {{ site_name }}</title>
13 13 {% else %}
14 14 <title>{{ site_name }}</title>
15 15 {% endif %}
16 16
17 17 {% if prev_page_link %}
18 18 <link rel="prev" href="{{ prev_page_link }}" />
19 19 {% endif %}
20 20 {% if next_page_link %}
21 21 <link rel="next" href="{{ next_page_link }}" />
22 22 {% endif %}
23 23
24 24 {% endblock %}
25 25
26 26 {% block content %}
27 27
28 28 {% get_current_language as LANGUAGE_CODE %}
29 29 {% get_current_timezone as TIME_ZONE %}
30 30
31 31 {% for banner in banners %}
32 32 <div class="post">
33 33 <div class="title">{{ banner.title }}</div>
34 34 <div>{{ banner.get_text|safe }}</div>
35 35 <div>{% trans 'Details' %}: <a href="{{ banner.post.get_absolute_url }}">>>{{ banner.post.id }}</a></div>
36 36 </div>
37 37 {% endfor %}
38 38
39 39 {% if tag %}
40 40 <div class="tag_info" style="border-bottom: solid .5ex #{{ tag.get_color }}">
41 41 {% if random_image_post %}
42 42 <div class="tag-image">
43 43 {% with image=random_image_post.get_first_image %}
44 44 <a href="{{ random_image_post.get_absolute_url }}"><img
45 45 src="{{ image.get_thumb_url }}"
46 46 width="{{ image.get_preview_size.0 }}"
47 47 height="{{ image.get_preview_size.1 }}"
48 48 alt="{{ random_image_post.id }}"/></a>
49 49 {% endwith %}
50 50 </div>
51 51 {% endif %}
52 52 <div class="tag-text-data">
53 53 <h2>
54 54 /{{ tag.get_view|safe }}/
55 55 </h2>
56 56 {% if perms.change_tag %}
57 57 <div class="moderator_info"><a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a></div>
58 58 {% endif %}
59 59 <p>
60 60 <form action="{% url 'tag' tag.get_name %}" method="post" class="post-button-form">
61 61 {% if is_favorite %}
62 62 <button name="method" value="unsubscribe" class="fav">★ {% trans "Remove from favorites" %}</button>
63 63 {% else %}
64 64 <button name="method" value="subscribe" class="not_fav">★ {% trans "Add to favorites" %}</button>
65 65 {% endif %}
66 66 </form>
67 &bull;
67 68 <form action="{% url 'tag' tag.get_name %}" method="post" class="post-button-form">
68 69 {% if is_hidden %}
69 70 <button name="method" value="unhide" class="fav">{% trans "Show" %}</button>
70 71 {% else %}
71 72 <button name="method" value="hide" class="not_fav">{% trans "Hide" %}</button>
72 73 {% endif %}
73 74 </form>
75 &bull;
74 76 <a href="{% url 'tag_gallery' tag.get_name %}">{% trans 'Gallery' %}</a>
75 77 </p>
76 78 {% if tag.get_description %}
77 79 <p>{{ tag.get_description|safe }}</p>
78 80 {% endif %}
79 81 <p>
80 82 {% with active_count=tag.get_active_thread_count bumplimit_count=tag.get_bumplimit_thread_count archived_count=tag.get_archived_thread_count %}
81 83 {% if active_count %}
82 84 ● {{ active_count }}&ensp;
83 85 {% endif %}
84 86 {% if bumplimit_count %}
85 87 ◍ {{ bumplimit_count }}&ensp;
86 88 {% endif %}
87 89 {% if archived_count %}
88 90 ○ {{ archived_count }}&ensp;
89 91 {% endif %}
90 92 {% endwith %}
91 93 ♥ {{ tag.get_post_count }}
92 94 </p>
93 95 {% if tag.get_all_parents %}
94 96 <p>
95 97 {% for parent in tag.get_all_parents %}
96 98 {{ parent.get_view|safe }} &gt;
97 99 {% endfor %}
98 100 {{ tag.get_view|safe }}
99 101 </p>
100 102 {% endif %}
101 103 {% if tag.get_children.all %}
102 104 <p>
103 105 {% trans "Subsections: " %}
104 106 {% for child in tag.get_children.all %}
105 107 {{ child.get_view|safe }}{% if not forloop.last%}, {% endif %}
106 108 {% endfor %}
107 109 </p>
108 110 {% endif %}
109 111 </div>
110 112 </div>
111 113 {% endif %}
112 114
113 115 {% if threads %}
114 116 {% if prev_page_link %}
115 117 <div class="page_link">
116 118 <a href="{{ prev_page_link }}">&lt;&lt; {% trans "Previous page" %} &lt;&lt;</a>
117 119 </div>
118 120 {% endif %}
119 121
120 122 {% for thread in threads %}
121 123 <div class="thread">
122 124 {% post_view thread.get_opening_post thread=thread truncated=True need_open_link=True %}
123 125 {% if not thread.archived %}
124 126 {% with last_replies=thread.get_last_replies %}
125 127 {% if last_replies %}
126 128 {% with skipped_replies_count=thread.get_skipped_replies_count %}
127 129 {% if skipped_replies_count %}
128 130 <div class="skipped_replies">
129 131 <a href="{% url 'thread' thread.get_opening_post_id %}">
130 132 {% blocktrans count count=skipped_replies_count %}Skipped {{ count }} reply. Open thread to see all replies.{% plural %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
131 133 </a>
132 134 </div>
133 135 {% endif %}
134 136 {% endwith %}
135 137 <div class="last-replies">
136 138 {% for post in last_replies %}
137 139 {% post_view post truncated=True %}
138 140 {% endfor %}
139 141 </div>
140 142 {% endif %}
141 143 {% endwith %}
142 144 {% endif %}
143 145 </div>
144 146 {% endfor %}
145 147
146 148 {% if next_page_link %}
147 149 <div class="page_link">
148 150 <a href="{{ next_page_link }}">&gt;&gt; {% trans "Next page" %} &gt;&gt;</a>
149 151 </div>
150 152 {% endif %}
151 153 {% else %}
152 154 <div class="post">
153 155 {% trans 'No threads exist. Create the first one!' %}</div>
154 156 {% endif %}
155 157
156 158 <div class="post-form-w">
157 159 <script src="{% static 'js/panel.js' %}"></script>
158 160 <div class="post-form" data-hasher="{% static 'js/3party/sha256.js' %}"
159 161 data-pow-script="{% static 'js/proof_of_work.js' %}">
160 162 <div class="form-title">{% trans "Create new thread" %}</div>
161 163 <div class="swappable-form-full">
162 164 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
163 165 {{ form.as_div }}
164 166 <div class="form-submit">
165 167 <input type="submit" value="{% trans "Post" %}"/>
166 168 <button id="preview-button" type="button" onclick="return false;">{% trans 'Preview' %}</button>
167 169 </div>
168 170 </form>
169 171 </div>
170 172 <div>
171 173 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
172 174 {% with size=max_file_size|filesizeformat %}
173 175 {% blocktrans %}Max file size is {{ size }}.{% endblocktrans %}
174 176 {% endwith %}
175 177 {% blocktrans %}Max file number is {{ max_files }}.{% endblocktrans %}
176 178 </div>
177 179 <div id="preview-text"></div>
178 180 <div><a href="{% url "staticpage" name="help" %}">{% trans 'Text syntax' %}</a></div>
179 181 </div>
180 182 </div>
181 183
182 184 <script src="{% static 'js/form.js' %}"></script>
183 185 <script src="{% static 'js/3party/jquery.blockUI.js' %}"></script>
184 186 <script src="{% static 'js/thread_create.js' %}"></script>
185 187
186 188 {% endblock %}
187 189
188 190 {% block metapanel %}
189 191
190 192 <span class="metapanel">
191 193 {% trans "Pages:" %}
192 194 [
193 195 {% with dividers=paginator.get_dividers %}
194 196 {% for page in paginator.get_divided_range %}
195 197 {% if page in dividers %}
196 198 …,
197 199 {% endif %}
198 200 <a
199 201 {% ifequal page current_page.number %}
200 202 class="current_page"
201 203 {% endifequal %}
202 204 href="{% page_url paginator page %}">{{ page }}</a>{% if not forloop.last %},{% endif %}
203 205 {% endfor %}
204 206 {% endwith %}
205 207 ]
206 208 </span>
207 209
208 210 {% endblock %}
@@ -1,90 +1,90
1 1 import logging
2 2
3 3 from django.test import TestCase
4 4 from boards.models import KeyPair, GlobalId, Post, Signature
5 5 from boards.models.post.sync import SyncManager
6 6
7 7 logger = logging.getLogger(__name__)
8 8
9 9
10 10 class KeyTest(TestCase):
11 11 def test_create_key(self):
12 12 key = KeyPair.objects.generate_key('ecdsa')
13 13
14 14 self.assertIsNotNone(key, 'The key was not created.')
15 15
16 16 def test_validation(self):
17 17 key = KeyPair.objects.generate_key(key_type='ecdsa')
18 18 message = 'msg'
19 19 signature_value = key.sign(message)
20 20
21 21 signature = Signature(key_type='ecdsa', key=key.public_key,
22 22 signature=signature_value)
23 23 valid = KeyPair.objects.verify(signature, message)
24 24
25 25 self.assertTrue(valid, 'Message verification failed.')
26 26
27 27 def test_primary_constraint(self):
28 28 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
29 29
30 30 with self.assertRaises(Exception):
31 31 KeyPair.objects.generate_key(key_type='ecdsa', primary=True)
32 32
33 33 def test_model_id_save(self):
34 34 model_id = GlobalId(key_type='test', key='test key', local_id='1')
35 35 model_id.save()
36 36
37 37 def test_request_get(self):
38 38 post = self._create_post_with_key()
39 39
40 40 request = SyncManager.generate_request_get([post.global_id])
41 41 logger.debug(request)
42 42
43 43 key = KeyPair.objects.get(primary=True)
44 44 self.assertTrue('<request type="get" version="1.0">'
45 45 '<model name="post" version="1.0">'
46 46 '<id key="%s" local-id="1" type="%s" />'
47 47 '</model>'
48 48 '</request>' % (
49 49 key.public_key,
50 50 key.key_type,
51 51 ) in request,
52 52 'Wrong XML generated for the GET request.')
53 53
54 54 def test_response_get(self):
55 55 post = self._create_post_with_key()
56 56 reply_post = Post.objects.create_post(title='test_title',
57 57 text='[post]%d[/post]' % post.id,
58 58 thread=post.get_thread())
59 59
60 60 response = SyncManager.generate_response_get([reply_post])
61 61 logger.debug(response)
62 62
63 63 key = KeyPair.objects.get(primary=True)
64 64 self.assertTrue('<status>success</status>'
65 65 '<models>'
66 66 '<model name="post">'
67 67 '<content>'
68 '<id key="%s" local-id="%d" type="%s" />'
68 '<id key="{}" local-id="{}" type="{}" />'
69 69 '<title>test_title</title>'
70 '<text>[post]%s[/post]</text>'
71 '<thread><id key="%s" local-id="%d" type="%s" /></thread>'
72 '<pub-time>%s</pub-time>'
73 '<version>%s</version>'
74 '</content>' % (
70 '<text>[post]{}[/post]</text>'
71 '<thread><id key="{}" local-id="{}" type="{}" /></thread>'
72 '<pub-time>{}</pub-time>'
73 '<update-time>{}</update-time>'
74 '</content>'.format(
75 75 key.public_key,
76 76 reply_post.id,
77 77 key.key_type,
78 str(post.global_id),
78 post.global_id,
79 79 key.public_key,
80 80 post.id,
81 81 key.key_type,
82 str(reply_post.get_pub_time_str()),
83 post.version,
82 reply_post.get_pub_time_str(),
83 reply_post.last_edit_time,
84 84 ) in response,
85 85 'Wrong XML generated for the GET response.')
86 86
87 87 def _create_post_with_key(self):
88 88 KeyPair.objects.generate_key(primary=True)
89 89
90 90 return Post.objects.create_post(title='test_title', text='test_text')
@@ -1,253 +1,252
1 1 from django.test import TestCase
2 2
3 3 from boards.models import KeyPair, Post, Tag
4 4 from boards.models.post.sync import SyncManager
5 5 from boards.tests.mocks import MockRequest
6 6 from boards.views.sync import response_get, response_list
7 7
8 8 __author__ = 'neko259'
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 key = KeyPair.objects.generate_key(primary=True)
18 18 tag, created = Tag.objects.get_or_create_with_alias(name='tag1')
19 19 post = Post.objects.create_post(title='test_title',
20 20 text='test_text\rline two',
21 21 tags=[tag])
22 22
23 23 request = MockRequest()
24 24 request.body = (
25 25 '<request type="get" version="1.0">'
26 26 '<model name="post" version="1.0">'
27 27 '<id key="%s" local-id="%d" type="%s" />'
28 28 '</model>'
29 29 '</request>' % (post.global_id.key,
30 30 post.id,
31 31 post.global_id.key_type)
32 32 )
33 33
34 34 response = response_get(request).content.decode()
35 35 self.assertTrue(
36 36 '<status>success</status>'
37 37 '<models>'
38 38 '<model name="post">'
39 39 '<content>'
40 '<id key="%s" local-id="%d" type="%s" />'
41 '<title>%s</title>'
42 '<text>%s</text>'
43 '<tags><tag>%s</tag></tags>'
44 '<pub-time>%s</pub-time>'
45 '<version>%s</version>'
46 '</content>' % (
40 '<id key="{}" local-id="{}" type="{}" />'
41 '<title>{}</title>'
42 '<text>{}</text>'
43 '<tags><tag>{}</tag></tags>'
44 '<pub-time>{}</pub-time>'
45 '<update-time>{}</update-time>'
46 '</content>'.format(
47 47 post.global_id.key,
48 48 post.global_id.local_id,
49 49 post.global_id.key_type,
50 50 post.title,
51 51 post.get_sync_text(),
52 52 post.get_thread().get_tags().first().get_name(),
53 53 post.get_pub_time_str(),
54 post.version,
54 post.last_edit_time,
55 55 ) in response,
56 56 'Wrong response generated for the GET request.')
57 57
58 58 post.delete()
59 59 key.delete()
60 60
61 61 KeyPair.objects.generate_key(primary=True)
62 62
63 63 SyncManager.parse_response_get(response, None)
64 64 self.assertEqual(1, Post.objects.count(),
65 65 'Post was not created from XML response.')
66 66
67 67 parsed_post = Post.objects.first()
68 68 self.assertEqual('tag1',
69 69 parsed_post.get_thread().get_tags().first().get_name(),
70 70 'Invalid tag was parsed.')
71 71
72 72 SyncManager.parse_response_get(response, None)
73 73 self.assertEqual(1, Post.objects.count(),
74 74 'The same post was imported twice.')
75 75
76 76 self.assertEqual(1, parsed_post.global_id.signature_set.count(),
77 77 'Signature was not saved.')
78 78
79 79 post = parsed_post
80 80
81 81 # Trying to sync the same once more
82 82 response = response_get(request).content.decode()
83 83
84 84 self.assertTrue(
85 85 '<status>success</status>'
86 86 '<models>'
87 87 '<model name="post">'
88 88 '<content>'
89 '<id key="%s" local-id="%d" type="%s" />'
90 '<title>%s</title>'
91 '<text>%s</text>'
92 '<tags><tag>%s</tag></tags>'
93 '<pub-time>%s</pub-time>'
94 '<version>%s</version>'
95 '</content>' % (
89 '<id key="{}" local-id="{}" type="{}" />'
90 '<title>{}</title>'
91 '<text>{}</text>'
92 '<tags><tag>{}</tag></tags>'
93 '<pub-time>{}</pub-time>'
94 '<update-time>{}</update-time>'
95 '</content>'.format(
96 96 post.global_id.key,
97 97 post.global_id.local_id,
98 98 post.global_id.key_type,
99 99 post.title,
100 100 post.get_sync_text(),
101 101 post.get_thread().get_tags().first().get_name(),
102 102 post.get_pub_time_str(),
103 post.version,
104 103 ) in response,
105 104 'Wrong response generated for the GET request.')
106 105
107 106 def test_list_all(self):
108 107 key = KeyPair.objects.generate_key(primary=True)
109 108 tag, created = Tag.objects.get_or_create_with_alias(name='tag1')
110 109 post = Post.objects.create_post(title='test_title',
111 110 text='test_text\rline two',
112 111 tags=[tag])
113 112 post2 = Post.objects.create_post(title='test title 2',
114 113 text='test text 2',
115 114 tags=[tag])
116 115
117 116 request_all = MockRequest()
118 117 request_all.body = (
119 118 '<request type="list" version="1.0">'
120 119 '<model name="post" version="1.0">'
121 120 '</model>'
122 121 '</request>'
123 122 )
124 123
125 124 response_all = response_list(request_all).content.decode()
126 125 self.assertTrue(
127 126 '<status>success</status>'
128 127 '<models>'
129 128 '<model>'
130 129 '<id key="{}" local-id="{}" type="{}" />'
131 '<version>{}</version>'
130 '<update-time>{}</update-time>'
132 131 '</model>'
133 132 '<model>'
134 133 '<id key="{}" local-id="{}" type="{}" />'
135 '<version>{}</version>'
134 '<update-time>{}</update-time>'
136 135 '</model>'
137 136 '</models>'.format(
138 137 post.global_id.key,
139 138 post.global_id.local_id,
140 139 post.global_id.key_type,
141 post.version,
140 post.last_edit_time,
142 141 post2.global_id.key,
143 142 post2.global_id.local_id,
144 143 post2.global_id.key_type,
145 post2.version,
144 post2.last_edit_time,
146 145 ) in response_all,
147 146 'Wrong response generated for the LIST request for all posts.')
148 147
149 148 def test_list_existing_thread(self):
150 149 key = KeyPair.objects.generate_key(primary=True)
151 150 tag, created = Tag.objects.get_or_create_with_alias(name='tag1')
152 151 post = Post.objects.create_post(title='test_title',
153 152 text='test_text\rline two',
154 153 tags=[tag])
155 154 post2 = Post.objects.create_post(title='test title 2',
156 155 text='test text 2',
157 156 tags=[tag])
158 157
159 158 request_thread = MockRequest()
160 159 request_thread.body = (
161 160 '<request type="list" version="1.0">'
162 161 '<model name="post" version="1.0">'
163 162 '<thread>{}</thread>'
164 163 '</model>'
165 164 '</request>'.format(
166 165 post.id,
167 166 )
168 167 )
169 168
170 169 response_thread = response_list(request_thread).content.decode()
171 170 self.assertTrue(
172 171 '<status>success</status>'
173 172 '<models>'
174 173 '<model>'
175 174 '<id key="{}" local-id="{}" type="{}" />'
176 '<version>{}</version>'
175 '<update-time>{}</update-time>'
177 176 '</model>'
178 177 '</models>'.format(
179 178 post.global_id.key,
180 179 post.global_id.local_id,
181 180 post.global_id.key_type,
182 post.version,
181 post.last_edit_time,
183 182 ) in response_thread,
184 183 'Wrong response generated for the LIST request for posts of '
185 184 'existing thread.')
186 185
187 186 def test_list_non_existing_thread(self):
188 187 key = KeyPair.objects.generate_key(primary=True)
189 188 tag, created = Tag.objects.get_or_create_with_alias(name='tag1')
190 189 post = Post.objects.create_post(title='test_title',
191 190 text='test_text\rline two',
192 191 tags=[tag])
193 192 post2 = Post.objects.create_post(title='test title 2',
194 193 text='test text 2',
195 194 tags=[tag])
196 195
197 196 request_thread = MockRequest()
198 197 request_thread.body = (
199 198 '<request type="list" version="1.0">'
200 199 '<model name="post" version="1.0">'
201 200 '<thread>{}</thread>'
202 201 '</model>'
203 202 '</request>'.format(
204 203 0,
205 204 )
206 205 )
207 206
208 207 response_thread = response_list(request_thread).content.decode()
209 208 self.assertTrue(
210 209 '<status>success</status>'
211 210 '<models />'
212 211 in response_thread,
213 212 'Wrong response generated for the LIST request for posts of '
214 213 'non-existing thread.')
215 214
216 215 def test_list_pub_time(self):
217 216 key = KeyPair.objects.generate_key(primary=True)
218 217 tag, created = Tag.objects.get_or_create_with_alias(name='tag1')
219 218 post = Post.objects.create_post(title='test_title',
220 219 text='test_text\rline two',
221 220 tags=[tag])
222 221 post2 = Post.objects.create_post(title='test title 2',
223 222 text='test text 2',
224 223 tags=[tag])
225 224
226 225 request_thread = MockRequest()
227 226 request_thread.body = (
228 227 '<request type="list" version="1.0">'
229 228 '<model name="post" version="1.0">'
230 229 '<timestamp_from>{}</timestamp_from>'
231 230 '</model>'
232 231 '</request>'.format(
233 232 post.pub_time,
234 233 )
235 234 )
236 235
237 236 response_thread = response_list(request_thread).content.decode()
238 237 self.assertTrue(
239 238 '<status>success</status>'
240 239 '<models>'
241 240 '<model>'
242 241 '<id key="{}" local-id="{}" type="{}" />'
243 '<version>{}</version>'
242 '<update-time>{}</update_time>'
244 243 '</model>'
245 244 '</models>'.format(
246 245 post2.global_id.key,
247 246 post2.global_id.local_id,
248 247 post2.global_id.key_type,
249 post2.version,
248 post2.last_edit_time,
250 249 ) in response_thread,
251 250 'Wrong response generated for the LIST request for posts of '
252 251 'existing thread.')
253 252
@@ -1,211 +1,211
1 1 # 0 Title #
2 2
3 3 DIP-1 Common protocol description
4 4
5 5 # 1 Intro #
6 6
7 7 This document describes the Data Interchange Protocol (DIP), designed to
8 8 exchange filtered data that can be stored as a graph structure between
9 9 network nodes.
10 10
11 11 # 2 Purpose #
12 12
13 13 This protocol will be used to share the models (originally imageboard posts)
14 14 across multiple servers. The main differnce of this protocol is that the node
15 15 can specify what models it wants to get and from whom. The nodes can get
16 16 models from a specific server, or from all except some specific servers. Also
17 17 the models can be filtered by timestamps or tags.
18 18
19 19 # 3 Protocol description #
20 20
21 21 The node requests other node's changes list since some time (since epoch if
22 22 this is the start). The other node sends a list of post ids or posts in the
23 23 XML format.
24 24
25 25 Protocol version is the version of the sync api. Model version is the version
26 26 of data models. If at least one of them is different, the sync cannot be
27 27 performed.
28 28
29 29 The node signs the data with its keys. The receiving node saves the key at the
30 30 first sync and checks it every time. If the key has changed, the info won't be
31 31 saved from the node (or the node id must be changed). A model can be signed
32 32 with several keys but at least one of them must be the same as in the global
33 33 ID to verify the sender.
34 34
35 35 Each node can have several keys. Nodes can have shared keys to serve as a pool
36 36 (several nodes with the same key).
37 37
38 38 Each post has an ID in the unique format: key-type::key::local-id
39 39
40 40 All requests pass a request type, protocol and model versions, and a list of
41 41 optional arguments used for filtering.
42 42
43 43 Each request has its own version. Version consists of 2 numbers: first is
44 44 incompatible version (1.3 and 2.0 are not compatible and must not be in sync)
45 45 and the second one is minor and compatible (for example, new optional field
46 46 is added which will be igroned by those who don't support it yet).
47 47
48 48 Post edits and reflinks are not saved to the sync model. The replied post ID
49 49 can be got from the post text, and reflinks can be computed when loading
50 50 posts. The edit time is not saved because a foreign post can be 'edited' (new
51 51 replies are added) but the signature must not change (so we can't update the
52 52 content). The inner posts can be edited, and the signature will change then
53 53 but the local-id won't, so the other node can detect that and replace the post
54 54 instead of adding a new one.
55 55
56 56 ## 3.1 Requests ##
57 57
58 58 There is no constraint on how the server should calculate the request. The
59 59 server can return any information by any filter and the requesting node is
60 60 responsible for validating it.
61 61
62 62 The server is required to return the status of request. See 3.2 for details.
63 63
64 64 ### 3.1.1 list ###
65 65
66 66 "list" request gets the desired model id list by the given filter (e.g. thread, tags,
67 67 author)
68 68
69 69 Sample request is as follows:
70 70
71 71 <?xml version="1.1" encoding="UTF-8" ?>
72 72 <request version="1.0" type="list">
73 73 <model version="1.0" name="post">
74 74 <timestamp_from>0</timestamp_from>
75 75 <timestamp_to>0</timestamp_to>
76 76 <tags>
77 77 <tag>tag1</tag>
78 78 </tags>
79 79 <sender>
80 80 <allow>
81 81 <key>abcehy3h9t</key>
82 82 <key>ehoehyoe</key>
83 83 </allow>
84 84 <!-- There can be only allow block (all other are denied) or deny block (all other are allowed) -->
85 85 </sender>
86 86 </model>
87 87 </request>
88 88
89 89 Under the <model> tag there are filters. Filters for the "post" model can
90 90 be found in DIP-2.
91 91
92 92 Sample response:
93 93
94 94 <?xml version="1.1" encoding="UTF-8" ?>
95 95 <response>
96 96 <status>success</status>
97 97 <models>
98 98 <model>
99 99 <id key="id1" type="ecdsa" local-id="1">
100 <version>1</version>
100 <update-time>2017-01-01 00:00:00</update-time>
101 101 </model>
102 102 <model>
103 103 <id key="id1" type="ecdsa" local-id="2" />
104 104 </model>
105 105 <model>
106 106 <id key="id2" type="ecdsa" local-id="1" />
107 107 <some-valuable-attr>value</some-valuable-attr>
108 108 </model>
109 109 </models>
110 110 </response>
111 111
112 112 ### 3.1.2 get ###
113 113
114 114 "get" gets models by id list.
115 115
116 116 Sample request:
117 117
118 118 <?xml version="1.1" encoding="UTF-8" ?>
119 119 <request version="1.0" type="get">
120 120 <model version="1.0" name="post">
121 121 <id key="id1" type="ecdsa" local-id="1" />
122 122 <id key="id1" type="ecdsa" local-id="2" />
123 123 </model>
124 124 </request>
125 125
126 126 Id consists of a key, key type and local id. This key is used for signing and
127 127 validating of data in the model content.
128 128
129 129 Sample response:
130 130
131 131 <?xml version="1.1" encoding="UTF-8" ?>
132 132 <response>
133 133 <!--
134 134 Valid statuses are 'success' and 'error'.
135 135 -->
136 136 <status>success</status>
137 137 <models>
138 138 <model name="post">
139 139 <!--
140 140 Content tag is the data that is signed by signatures and must
141 141 not be changed for the post from other node.
142 142 -->
143 143 <content>
144 144 <id key="id1" type="ecdsa" local-id="1" />
145 145 <title>13</title>
146 146 <text>Thirteen</text>
147 147 <thread><id key="id1" type="ecdsa" local-id="2" /></thread>
148 148 <pub-time>12</pub-time>
149 149 <!--
150 150 Images are saved as attachments and included in the
151 151 signature.
152 152 -->
153 153 <attachments>
154 154 <attachment mimetype="image/png" id-type="md5">TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5I</attachment>
155 155 <attachment id-type="url">http://example.com/</attachment>
156 156 </attachments>
157 157 </content>
158 158 <!--
159 159 There can be several signatures for one model. At least one
160 160 signature must be made with the key used in global ID.
161 161 -->
162 162 <signatures>
163 163 <signature key="id1" type="ecdsa" value="dhefhtreh" />
164 164 <signature key="id45" type="ecdsa" value="dsgfgdhefhtreh" />
165 165 </signatures>
166 166 <attachment-refs>
167 167 <attachment-ref ref="TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0"
168 168 url="/media/images/12345.png" />
169 169 </attachment-refs>
170 170 </model>
171 171 <model name="post">
172 172 <content>
173 173 <id key="id1" type="ecdsa" local-id="id2" />
174 174 <title>13</title>
175 175 <text>Thirteen</text>
176 176 <pub-time>12</pub-time>
177 177 <edit-time>13</edit-time>
178 178 <tags>
179 179 <tag>tag1</tag>
180 180 </tags>
181 181 </content>
182 182 <signatures>
183 183 <signature key="id2" type="ecdsa" value="dehdfh" />
184 184 </signatures>
185 185 </model>
186 186 </models>
187 187 </response>
188 188
189 189 ### 3.1.3 put ###
190 190
191 191 "put" gives a model to the given node (you have no guarantee the node takes
192 192 it, consider you are just advising the node to take your post). This request
193 193 type is useful in pool where all the nodes try to duplicate all of their data
194 194 across the pool.
195 195
196 196 ## 3.2 Responses ##
197 197
198 198 ### 3.2.1 "not supported" ###
199 199
200 200 If the request if completely not supported, a "not supported" status will be
201 201 returned.
202 202
203 203 ### 3.2.2 "success" ###
204 204
205 205 "success" status means the request was processed and the result is returned.
206 206
207 207 ### 3.2.3 "error" ###
208 208
209 209 If the server knows for sure that the operation cannot be processed, it sends
210 210 the "error" status. Additional tags describing the error may be <description>
211 211 and <stack>.
@@ -1,31 +1,31
1 1 # 0 Title #
2 2
3 "post" model reference
3 "post" model reference of version 1.1
4 4
5 5 # 1 Description #
6 6
7 7 "post" is a model that defines an imageboard message, or post.
8 8
9 9 # 2 Fields #
10 10
11 11 # 2.1 Mandatory fields #
12 12
13 13 * title -- text field.
14 14 * text -- text field.
15 15 * pub-time -- timestamp (TBD: Define format).
16 * version -- when post content changes, version should be incremented.
16 * update -- when post content changes, the update time should be incremented.
17 17
18 18 # 2.2 Optional fields #
19 19
20 20 * attachments -- defines attachments such as images.
21 21 * attachment -- contains and attachment or link to attachment to be downloaded
22 22 manually. Required attributes are mimetype and name.
23 23
24 24 This field is used for the opening post (thread):
25 25
26 26 * tags -- text tag name list.
27 27
28 28 This field is used for non-opening post:
29 29
30 30 * thread -- ID of a post model that this post is related to.
31 31 * tripcode
General Comments 0
You need to be logged in to leave comments. Login now