##// END OF EJS Templates
Moved text parser and preparser to a separate module (BB-64)
neko259 -
r1066:dc65b709 default
parent child Browse files
Show More
@@ -1,210 +1,233 b''
1 1 # coding=utf-8
2 2
3 3 import re
4 4 import bbcode
5
6 from urllib.parse import unquote
7
5 8 from django.core.exceptions import ObjectDoesNotExist
6 9 from django.core.urlresolvers import reverse
7 10
8 11 import boards
9 12
10 13
11 14 __author__ = 'neko259'
12 15
13 16
14 17 REFLINK_PATTERN = re.compile(r'^\d+$')
15 18 MULTI_NEWLINES_PATTERN = re.compile(r'(\r?\n){2,}')
16 19 ONE_NEWLINE = '\n'
20 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
17 21
18 22
19 23 class TextFormatter():
20 24 """
21 25 An interface for formatter that can be used in the text format panel
22 26 """
23 27
24 28 def __init__(self):
25 29 pass
26 30
27 31 name = ''
28 32
29 33 # Left and right tags for the button preview
30 34 preview_left = ''
31 35 preview_right = ''
32 36
33 37 # Left and right characters for the textarea input
34 38 format_left = ''
35 39 format_right = ''
36 40
37 41
38 42 class AutolinkPattern():
39 43 def handleMatch(self, m):
40 44 link_element = etree.Element('a')
41 45 href = m.group(2)
42 46 link_element.set('href', href)
43 47 link_element.text = href
44 48
45 49 return link_element
46 50
47 51
48 52 class QuotePattern(TextFormatter):
49 53 name = 'q'
50 54 preview_left = '<span class="multiquote">'
51 55 preview_right = '</span>'
52 56
53 57 format_left = '[quote]'
54 58 format_right = '[/quote]'
55 59
56 60
57 61 class SpoilerPattern(TextFormatter):
58 62 name = 'spoiler'
59 63 preview_left = '<span class="spoiler">'
60 64 preview_right = '</span>'
61 65
62 66 format_left = '[spoiler]'
63 67 format_right = '[/spoiler]'
64 68
65 69 def handleMatch(self, m):
66 70 quote_element = etree.Element('span')
67 71 quote_element.set('class', 'spoiler')
68 72 quote_element.text = m.group(2)
69 73
70 74 return quote_element
71 75
72 76
73 77 class CommentPattern(TextFormatter):
74 78 name = ''
75 79 preview_left = '<span class="comment">// '
76 80 preview_right = '</span>'
77 81
78 82 format_left = '[comment]'
79 83 format_right = '[/comment]'
80 84
81 85
82 86 # TODO Use <s> tag here
83 87 class StrikeThroughPattern(TextFormatter):
84 88 name = 's'
85 89 preview_left = '<span class="strikethrough">'
86 90 preview_right = '</span>'
87 91
88 92 format_left = '[s]'
89 93 format_right = '[/s]'
90 94
91 95
92 96 class ItalicPattern(TextFormatter):
93 97 name = 'i'
94 98 preview_left = '<i>'
95 99 preview_right = '</i>'
96 100
97 101 format_left = '[i]'
98 102 format_right = '[/i]'
99 103
100 104
101 105 class BoldPattern(TextFormatter):
102 106 name = 'b'
103 107 preview_left = '<b>'
104 108 preview_right = '</b>'
105 109
106 110 format_left = '[b]'
107 111 format_right = '[/b]'
108 112
109 113
110 114 class CodePattern(TextFormatter):
111 115 name = 'code'
112 116 preview_left = '<code>'
113 117 preview_right = '</code>'
114 118
115 119 format_left = '[code]'
116 120 format_right = '[/code]'
117 121
118 122
119 123 def render_reflink(tag_name, value, options, parent, context):
120 124 result = '>>%s' % value
121 125
122 126 if REFLINK_PATTERN.match(value):
123 127 post_id = int(value)
124 128
125 129 try:
126 130 post = boards.models.Post.objects.get(id=post_id)
127 131
128 132 result = '<a href="%s">&gt;&gt;%s</a>' % (post.get_url(), post_id)
129 133 except ObjectDoesNotExist:
130 134 pass
131 135
132 136 return result
133 137
134 138
135 139 def render_multithread(tag_name, value, options, parent, context):
136 140 result = '>>>%s' % value
137 141
138 142 if REFLINK_PATTERN.match(value):
139 143 post_id = int(value)
140 144
141 145 try:
142 146 post = boards.models.Post.objects.get(id=post_id)
143 147
144 148 if post.is_opening():
145 149 result = '<a href="%s">&gt;&gt;&gt;%s</a>' % (post.get_url(), post_id)
146 150 except ObjectDoesNotExist:
147 151 pass
148 152
149 153 return result
150 154
151 155
152 156 def render_quote(tag_name, value, options, parent, context):
153 157 source = ''
154 158 if 'source' in options:
155 159 source = options['source']
156 160
157 161 if source:
158 162 result = '<div class="multiquote"><div class="quote-header">%s</div><div class="quote-text">%s</div></div>' % (source, value)
159 163 else:
160 164 result = '<div class="multiquote"><div class="quote-text">%s</div></div>' % value
161 165
162 166 return result
163 167
164 168
165 169 def render_notification(tag_name, value, options, parent, content):
166 170 username = value.lower()
167 171
168 172 return '<a href="{}" class="user-cast">@{}</a>'.format(
169 173 reverse('notifications', kwargs={'username': username}), username)
170 174
171 175
172 def preparse_text(text):
173 """
174 Performs manual parsing before the bbcode parser is used.
175 """
176
177 return MULTI_NEWLINES_PATTERN.sub(ONE_NEWLINE, text)
178
179
180 def bbcode_extended(markup):
181 # The newline hack is added because br's margin does not work in all
182 # browsers except firefox, when the div's does.
183 parser = bbcode.Parser(newline='<div class="br"></div>')
184 parser.add_formatter('post', render_reflink, strip=True)
185 parser.add_formatter('thread', render_multithread, strip=True)
186 parser.add_formatter('quote', render_quote, strip=True)
187 parser.add_formatter('user', render_notification, strip=True)
188 parser.add_simple_formatter('comment',
189 '<span class="comment">//%(value)s</span>')
190 parser.add_simple_formatter('spoiler',
191 '<span class="spoiler">%(value)s</span>')
192 parser.add_simple_formatter('s',
193 '<span class="strikethrough">%(value)s</span>')
194 # TODO Why not use built-in tag?
195 parser.add_simple_formatter('code',
196 '<pre><code>%(value)s</pre></code>',
197 render_embedded=False)
198
199 text = preparse_text(markup)
200 return parser.format(text)
201
202 176 formatters = [
203 177 QuotePattern,
204 178 SpoilerPattern,
205 179 ItalicPattern,
206 180 BoldPattern,
207 181 CommentPattern,
208 182 StrikeThroughPattern,
209 183 CodePattern,
210 184 ]
185
186
187 PREPARSE_PATTERNS = {
188 r'>>>(\d+)': r'[thread]\1[/thread]', # Multi-thread post ">>>123"
189 r'(?<!>)>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
190 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
191 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
192 r'\B@(\w+)': r'[user]\1[/user]', # User notification "@user"
193 }
194
195
196 class Parser:
197 def __init__(self):
198 # The newline hack is added because br's margin does not work in all
199 # browsers except firefox, when the div's does.
200 self.parser = bbcode.Parser(newline='<div class="br"></div>')
201
202 self.parser.add_formatter('post', render_reflink, strip=True)
203 self.parser.add_formatter('thread', render_multithread, strip=True)
204 self.parser.add_formatter('quote', render_quote, strip=True)
205 self.parser.add_formatter('user', render_notification, strip=True)
206 self.parser.add_simple_formatter(
207 'comment', '<span class="comment">//%(value)s</span>')
208 self.parser.add_simple_formatter(
209 'spoiler', '<span class="spoiler">%(value)s</span>')
210 self.parser.add_simple_formatter(
211 's', '<span class="strikethrough">%(value)s</span>')
212 # TODO Why not use built-in tag?
213 self.parser.add_simple_formatter('code',
214 '<pre><code>%(value)s</pre></code>',
215 render_embedded=False)
216
217 def preparse(self, text):
218 """
219 Performs manual parsing before the bbcode parser is used.
220 Preparsed text is saved as raw and the text before preparsing is lost.
221 """
222 new_text = MULTI_NEWLINES_PATTERN.sub(ONE_NEWLINE, text)
223
224 for key, value in PREPARSE_PATTERNS.items():
225 new_text = re.sub(key, value, new_text, flags=re.MULTILINE)
226
227 for link in REGEX_URL.findall(text):
228 new_text = new_text.replace(link, unquote(link))
229
230 return new_text
231
232 def parse(self, text):
233 return self.parser.format(text) No newline at end of file
@@ -1,476 +1,450 b''
1 1 from datetime import datetime, timedelta, date
2 2 from datetime import time as dtime
3 3 import logging
4 4 import re
5 5
6 from urllib.parse import unquote
7
8 6 from adjacent import Client
9 7 from django.core.exceptions import ObjectDoesNotExist
10 8 from django.core.urlresolvers import reverse
11 9 from django.db import models, transaction
12 10 from django.db.models import TextField
13 11 from django.template.loader import render_to_string
14 12 from django.utils import timezone
15 13
16 14 from boards import settings
17 from boards.mdx_neboard import bbcode_extended
15 from boards.mdx_neboard import Parser
18 16 from boards.models import PostImage
19 17 from boards.models.base import Viewable
20 18 from boards.utils import datetime_to_epoch, cached_result
21 19 from boards.models.user import Notification
22 20 import boards.models.thread
23 21
24 22
25 23 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
26 24 WS_NOTIFICATION_TYPE = 'notification_type'
27 25
28 26 WS_CHANNEL_THREAD = "thread:"
29 27
30 28 APP_LABEL_BOARDS = 'boards'
31 29
32 30 POSTS_PER_DAY_RANGE = 7
33 31
34 32 BAN_REASON_AUTO = 'Auto'
35 33
36 34 IMAGE_THUMB_SIZE = (200, 150)
37 35
38 36 TITLE_MAX_LENGTH = 200
39 37
40 38 # TODO This should be removed
41 39 NO_IP = '0.0.0.0'
42 40
43 41 # TODO Real user agent should be saved instead of this
44 42 UNKNOWN_UA = ''
45 43
46 44 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
47 45 REGEX_MULTI_THREAD = re.compile(r'\[thread\](\d+)\[/thread\]')
48 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
49 46 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
50 47
51 48 PARAMETER_TRUNCATED = 'truncated'
52 49 PARAMETER_TAG = 'tag'
53 50 PARAMETER_OFFSET = 'offset'
54 51 PARAMETER_DIFF_TYPE = 'type'
55 52 PARAMETER_BUMPABLE = 'bumpable'
56 53 PARAMETER_THREAD = 'thread'
57 54 PARAMETER_IS_OPENING = 'is_opening'
58 55 PARAMETER_MODERATOR = 'moderator'
59 56 PARAMETER_POST = 'post'
60 57 PARAMETER_OP_ID = 'opening_post_id'
61 58 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
62 59 PARAMETER_REPLY_LINK = 'reply_link'
63 60
64 61 DIFF_TYPE_HTML = 'html'
65 62 DIFF_TYPE_JSON = 'json'
66 63
67 PREPARSE_PATTERNS = {
68 r'>>>(\d+)': r'[thread]\1[/thread]', # Multi-thread post ">>>123"
69 r'(?<!>)>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
70 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
71 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
72 r'\B@(\w+)': r'[user]\1[/user]', # User notification "@user"
73 }
74
75 64
76 65 class PostManager(models.Manager):
77 66 @transaction.atomic
78 67 def create_post(self, title: str, text: str, image=None, thread=None,
79 68 ip=NO_IP, tags: list=None):
80 69 """
81 70 Creates new post
82 71 """
83 72
84 73 if not tags:
85 74 tags = []
86 75
87 76 posting_time = timezone.now()
88 77 if not thread:
89 78 thread = boards.models.thread.Thread.objects.create(
90 79 bump_time=posting_time, last_edit_time=posting_time)
91 80 new_thread = True
92 81 else:
93 82 new_thread = False
94 83
95 pre_text = self._preparse_text(text)
84 pre_text = Parser().preparse(text)
96 85
97 86 post = self.create(title=title,
98 87 text=pre_text,
99 88 pub_time=posting_time,
100 89 poster_ip=ip,
101 90 thread=thread,
102 91 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
103 92 # last!
104 93 last_edit_time=posting_time)
105 94 post.threads.add(thread)
106 95
107 96 logger = logging.getLogger('boards.post.create')
108 97
109 98 logger.info('Created post {} by {}'.format(
110 99 post, post.poster_ip))
111 100
112 101 if image:
113 102 post.images.add(PostImage.objects.create_with_hash(image))
114 103
115 104 list(map(thread.add_tag, tags))
116 105
117 106 if new_thread:
118 107 boards.models.thread.Thread.objects.process_oldest_threads()
119 108 else:
120 109 thread.last_edit_time = posting_time
121 110 thread.bump()
122 111 thread.save()
123 112
124 113 post.connect_replies()
125 114 post.connect_threads()
126 115 post.connect_notifications()
127 116
128 117 return post
129 118
130 119 def delete_posts_by_ip(self, ip):
131 120 """
132 121 Deletes all posts of the author with same IP
133 122 """
134 123
135 124 posts = self.filter(poster_ip=ip)
136 125 for post in posts:
137 126 post.delete()
138 127
139 128 @cached_result
140 129 def get_posts_per_day(self):
141 130 """
142 131 Gets average count of posts per day for the last 7 days
143 132 """
144 133
145 134 day_end = date.today()
146 135 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
147 136
148 137 day_time_start = timezone.make_aware(datetime.combine(
149 138 day_start, dtime()), timezone.get_current_timezone())
150 139 day_time_end = timezone.make_aware(datetime.combine(
151 140 day_end, dtime()), timezone.get_current_timezone())
152 141
153 142 posts_per_period = float(self.filter(
154 143 pub_time__lte=day_time_end,
155 144 pub_time__gte=day_time_start).count())
156 145
157 146 ppd = posts_per_period / POSTS_PER_DAY_RANGE
158 147
159 148 return ppd
160 149
161 # TODO Make a separate parser module and move preparser there
162 def _preparse_text(self, text: str) -> str:
163 """
164 Preparses text to change patterns like '>>' to a proper bbcode
165 tags.
166 """
167
168 for key, value in PREPARSE_PATTERNS.items():
169 text = re.sub(key, value, text, flags=re.MULTILINE)
170
171 for link in REGEX_URL.findall(text):
172 text = text.replace(link, unquote(link))
173
174 return text
175
176 150
177 151 class Post(models.Model, Viewable):
178 152 """A post is a message."""
179 153
180 154 objects = PostManager()
181 155
182 156 class Meta:
183 157 app_label = APP_LABEL_BOARDS
184 158 ordering = ('id',)
185 159
186 160 title = models.CharField(max_length=TITLE_MAX_LENGTH)
187 161 pub_time = models.DateTimeField()
188 162 text = TextField(blank=True, null=True)
189 163 _text_rendered = TextField(blank=True, null=True, editable=False)
190 164
191 165 images = models.ManyToManyField(PostImage, null=True, blank=True,
192 166 related_name='ip+', db_index=True)
193 167
194 168 poster_ip = models.GenericIPAddressField()
195 169 poster_user_agent = models.TextField()
196 170
197 171 last_edit_time = models.DateTimeField()
198 172
199 173 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
200 174 null=True,
201 175 blank=True, related_name='rfp+',
202 176 db_index=True)
203 177 refmap = models.TextField(null=True, blank=True)
204 178 threads = models.ManyToManyField('Thread', db_index=True)
205 179 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
206 180
207 181 def __str__(self):
208 182 return 'P#{}/{}'.format(self.id, self.title)
209 183
210 184 def get_title(self) -> str:
211 185 """
212 186 Gets original post title or part of its text.
213 187 """
214 188
215 189 title = self.title
216 190 if not title:
217 191 title = self.get_text()
218 192
219 193 return title
220 194
221 195 def build_refmap(self) -> None:
222 196 """
223 197 Builds a replies map string from replies list. This is a cache to stop
224 198 the server from recalculating the map on every post show.
225 199 """
226 200
227 201 post_urls = ['<a href="{}">&gt;&gt;{}</a>'.format(
228 202 refpost.get_url(), refpost.id) for refpost in self.referenced_posts.all()]
229 203
230 204 self.refmap = ', '.join(post_urls)
231 205
232 206 def get_sorted_referenced_posts(self):
233 207 return self.refmap
234 208
235 209 def is_referenced(self) -> bool:
236 210 return self.refmap and len(self.refmap) > 0
237 211
238 212 def is_opening(self) -> bool:
239 213 """
240 214 Checks if this is an opening post or just a reply.
241 215 """
242 216
243 217 return self.get_thread().get_opening_post_id() == self.id
244 218
245 219 @cached_result
246 220 def get_url(self):
247 221 """
248 222 Gets full url to the post.
249 223 """
250 224
251 225 thread = self.get_thread()
252 226
253 227 opening_id = thread.get_opening_post_id()
254 228
255 229 if self.id != opening_id:
256 230 link = reverse('thread', kwargs={
257 231 'post_id': opening_id}) + '#' + str(self.id)
258 232 else:
259 233 link = reverse('thread', kwargs={'post_id': self.id})
260 234
261 235 return link
262 236
263 237 def get_thread(self):
264 238 return self.thread
265 239
266 240 def get_threads(self):
267 241 """
268 242 Gets post's thread.
269 243 """
270 244
271 245 return self.threads
272 246
273 247 def get_referenced_posts(self):
274 248 return self.referenced_posts.only('id', 'threads')
275 249
276 250 def get_view(self, moderator=False, need_open_link=False,
277 251 truncated=False, *args, **kwargs):
278 252 """
279 253 Renders post's HTML view. Some of the post params can be passed over
280 254 kwargs for the means of caching (if we view the thread, some params
281 255 are same for every post and don't need to be computed over and over.
282 256 """
283 257
284 258 thread = self.get_thread()
285 259 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
286 260 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
287 261
288 262 if is_opening:
289 263 opening_post_id = self.id
290 264 else:
291 265 opening_post_id = thread.get_opening_post_id()
292 266
293 267 return render_to_string('boards/post.html', {
294 268 PARAMETER_POST: self,
295 269 PARAMETER_MODERATOR: moderator,
296 270 PARAMETER_IS_OPENING: is_opening,
297 271 PARAMETER_THREAD: thread,
298 272 PARAMETER_BUMPABLE: can_bump,
299 273 PARAMETER_NEED_OPEN_LINK: need_open_link,
300 274 PARAMETER_TRUNCATED: truncated,
301 275 PARAMETER_OP_ID: opening_post_id,
302 276 })
303 277
304 278 def get_search_view(self, *args, **kwargs):
305 279 return self.get_view(args, kwargs)
306 280
307 281 def get_first_image(self) -> PostImage:
308 282 return self.images.earliest('id')
309 283
310 284 def delete(self, using=None):
311 285 """
312 286 Deletes all post images and the post itself.
313 287 """
314 288
315 289 for image in self.images.all():
316 290 image_refs_count = Post.objects.filter(images__in=[image]).count()
317 291 if image_refs_count == 1:
318 292 image.delete()
319 293
320 294 thread = self.get_thread()
321 295 thread.last_edit_time = timezone.now()
322 296 thread.save()
323 297
324 298 super(Post, self).delete(using)
325 299
326 300 logging.getLogger('boards.post.delete').info(
327 301 'Deleted post {}'.format(self))
328 302
329 303 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
330 304 include_last_update=False):
331 305 """
332 306 Gets post HTML or JSON data that can be rendered on a page or used by
333 307 API.
334 308 """
335 309
336 310 if format_type == DIFF_TYPE_HTML:
337 311 params = dict()
338 312 params['post'] = self
339 313 if PARAMETER_TRUNCATED in request.GET:
340 314 params[PARAMETER_TRUNCATED] = True
341 315 else:
342 316 params[PARAMETER_REPLY_LINK] = True
343 317
344 318 return render_to_string('boards/api_post.html', params)
345 319 elif format_type == DIFF_TYPE_JSON:
346 320 post_json = {
347 321 'id': self.id,
348 322 'title': self.title,
349 323 'text': self._text_rendered,
350 324 }
351 325 if self.images.exists():
352 326 post_image = self.get_first_image()
353 327 post_json['image'] = post_image.image.url
354 328 post_json['image_preview'] = post_image.image.url_200x150
355 329 if include_last_update:
356 330 post_json['bump_time'] = datetime_to_epoch(
357 331 self.get_thread().bump_time)
358 332 return post_json
359 333
360 334 def send_to_websocket(self, request, recursive=True):
361 335 """
362 336 Sends post HTML data to the thread web socket.
363 337 """
364 338
365 339 if not settings.WEBSOCKETS_ENABLED:
366 340 return
367 341
368 342 client = Client()
369 343
370 344 logger = logging.getLogger('boards.post.websocket')
371 345
372 346 thread_ids = list()
373 347 for thread in self.get_threads().all():
374 348 thread_ids.append(thread.id)
375 349
376 350 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
377 351 client.publish(channel_name, {
378 352 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
379 353 })
380 354 client.send()
381 355
382 356 logger.info('Sent notification from post #{} to channel {}'.format(
383 357 self.id, channel_name))
384 358
385 359 if recursive:
386 360 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
387 361 post_id = reply_number.group(1)
388 362
389 363 try:
390 364 ref_post = Post.objects.get(id=post_id)
391 365
392 366 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
393 367 # If post is in this thread, its thread was already notified.
394 368 # Otherwise, notify its thread separately.
395 369 ref_post.send_to_websocket(request, recursive=False)
396 370 except ObjectDoesNotExist:
397 371 pass
398 372
399 373 def save(self, force_insert=False, force_update=False, using=None,
400 374 update_fields=None):
401 self._text_rendered = bbcode_extended(self.get_raw_text())
375 self._text_rendered = Parser().parse(self.get_raw_text())
402 376
403 377 super().save(force_insert, force_update, using, update_fields)
404 378
405 379 def get_text(self) -> str:
406 380 return self._text_rendered
407 381
408 382 def get_raw_text(self) -> str:
409 383 return self.text
410 384
411 385 def get_absolute_id(self) -> str:
412 386 """
413 387 If the post has many threads, shows its main thread OP id in the post
414 388 ID.
415 389 """
416 390
417 391 if self.get_threads().count() > 1:
418 392 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
419 393 else:
420 394 return str(self.id)
421 395
422 396 def connect_notifications(self):
423 397 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
424 398 user_name = reply_number.group(1).lower()
425 399 Notification.objects.get_or_create(name=user_name, post=self)
426 400
427 401 def connect_replies(self):
428 402 """
429 403 Connects replies to a post to show them as a reflink map
430 404 """
431 405
432 406 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
433 407 post_id = reply_number.group(1)
434 408
435 409 try:
436 410 referenced_post = Post.objects.get(id=post_id)
437 411
438 412 referenced_post.referenced_posts.add(self)
439 413 referenced_post.last_edit_time = self.pub_time
440 414 referenced_post.build_refmap()
441 415 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
442 416
443 417 referenced_threads = referenced_post.get_threads().all()
444 418 for thread in referenced_threads:
445 419 if thread.can_bump():
446 420 thread.update_bump_status()
447 421
448 422 thread.last_edit_time = self.pub_time
449 423 thread.save(update_fields=['last_edit_time', 'bumpable'])
450 424 except ObjectDoesNotExist:
451 425 pass
452 426
453 427 def connect_threads(self):
454 428 """
455 429 If the referenced post is an OP in another thread,
456 430 make this post multi-thread.
457 431 """
458 432
459 433 for reply_number in re.finditer(REGEX_MULTI_THREAD, self.get_raw_text()):
460 434 post_id = reply_number.group(1)
461 435
462 436 try:
463 437 referenced_post = Post.objects.get(id=post_id)
464 438
465 439 if referenced_post.is_opening():
466 440 referenced_threads = referenced_post.get_threads().all()
467 441 for thread in referenced_threads:
468 442 if thread.can_bump():
469 443 thread.update_bump_status()
470 444
471 445 thread.last_edit_time = self.pub_time
472 446 thread.save(update_fields=['last_edit_time', 'bumpable'])
473 447
474 448 self.threads.add(thread)
475 449 except ObjectDoesNotExist:
476 450 pass
@@ -1,192 +1,191 b''
1 1 {% extends "boards/base.html" %}
2 2
3 3 {% load i18n %}
4 4 {% load cache %}
5 5 {% load board %}
6 6 {% load static %}
7 7 {% load tz %}
8 8
9 9 {% block head %}
10 10 <meta name="robots" content="noindex">
11 11
12 12 {% if tag %}
13 13 <title>{{ tag.name }} - {{ site_name }}</title>
14 14 {% else %}
15 15 <title>{{ site_name }}</title>
16 16 {% endif %}
17 17
18 18 {% if current_page.has_previous %}
19 19 <link rel="prev" href="
20 20 {% if tag %}
21 21 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
22 22 {% else %}
23 23 {% url "index" page=current_page.previous_page_number %}
24 24 {% endif %}
25 25 " />
26 26 {% endif %}
27 27 {% if current_page.has_next %}
28 28 <link rel="next" href="
29 29 {% if tag %}
30 30 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
31 31 {% else %}
32 32 {% url "index" page=current_page.next_page_number %}
33 33 {% endif %}
34 34 " />
35 35 {% endif %}
36 36
37 37 {% endblock %}
38 38
39 39 {% block content %}
40 40
41 41 {% get_current_language as LANGUAGE_CODE %}
42 42 {% get_current_timezone as TIME_ZONE %}
43 43
44 44 {% if tag %}
45 45 <div class="tag_info">
46 46 <h2>
47 47 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
48 48 {% if is_favorite %}
49 49 <button name="method" value="unsubscribe" class="fav">β˜…</button>
50 50 {% else %}
51 51 <button name="method" value="subscribe" class="not_fav">β˜…</button>
52 52 {% endif %}
53 53 </form>
54 54 <form action="{% url 'tag' tag.name %}" method="post" class="post-button-form">
55 55 {% if is_hidden %}
56 56 <button name="method" value="unhide" class="fav">H</button>
57 57 {% else %}
58 58 <button name="method" value="hide" class="not_fav">H</button>
59 59 {% endif %}
60 60 </form>
61 61 {% autoescape off %}
62 62 {{ tag.get_view }}
63 63 {% endautoescape %}
64 64 {% if moderator %}
65 65 <span class="moderator_info">[<a href="{% url 'admin:boards_tag_change' tag.id %}">{% trans 'Edit tag' %}</a>]</span>
66 66 {% endif %}
67 67 </h2>
68 68 <p>{% blocktrans with thread_count=tag.get_thread_count post_count=tag.get_post_count %}This tag has {{ thread_count }} threads and {{ post_count }} posts.{% endblocktrans %}</p>
69 69 </div>
70 70 {% endif %}
71 71
72 72 {% if threads %}
73 73 {% if current_page.has_previous %}
74 74 <div class="page_link">
75 75 <a href="
76 76 {% if tag %}
77 77 {% url "tag" tag_name=tag.name page=current_page.previous_page_number %}
78 78 {% else %}
79 79 {% url "index" page=current_page.previous_page_number %}
80 80 {% endif %}
81 81 ">{% trans "Previous page" %}</a>
82 82 </div>
83 83 {% endif %}
84 84
85 85 {% for thread in threads %}
86 86 {% cache 600 thread_short thread.id thread.last_edit_time moderator LANGUAGE_CODE TIME_ZONE %}
87 87 <div class="thread">
88 88 {% post_view thread.get_opening_post moderator is_opening=True thread=thread truncated=True need_open_link=True %}
89 89 {% if not thread.archived %}
90 90 {% with last_replies=thread.get_last_replies %}
91 91 {% if last_replies %}
92 92 {% with skipped_replies_count=thread.get_skipped_replies_count %}
93 93 {% if skipped_replies_count %}
94 94 <div class="skipped_replies">
95 95 <a href="{% url 'thread' thread.get_opening_post_id %}">
96 96 {% blocktrans with count=skipped_replies_count %}Skipped {{ count }} replies. Open thread to see all replies.{% endblocktrans %}
97 97 </a>
98 98 </div>
99 99 {% endif %}
100 100 {% endwith %}
101 101 <div class="last-replies">
102 102 {% for post in last_replies %}
103 103 {% post_view post is_opening=False moderator=moderator truncated=True %}
104 104 {% endfor %}
105 105 </div>
106 106 {% endif %}
107 107 {% endwith %}
108 108 {% endif %}
109 109 </div>
110 110 {% endcache %}
111 111 {% endfor %}
112 112
113 113 {% if current_page.has_next %}
114 114 <div class="page_link">
115 115 <a href="
116 116 {% if tag %}
117 117 {% url "tag" tag_name=tag.name page=current_page.next_page_number %}
118 118 {% else %}
119 119 {% url "index" page=current_page.next_page_number %}
120 120 {% endif %}
121 121 ">{% trans "Next page" %}</a>
122 122 </div>
123 123 {% endif %}
124 124 {% else %}
125 125 <div class="post">
126 126 {% trans 'No threads exist. Create the first one!' %}</div>
127 127 {% endif %}
128 128
129 129 <div class="post-form-w">
130 130 <script src="{% static 'js/panel.js' %}"></script>
131 131 <div class="post-form">
132 132 <div class="form-title">{% trans "Create new thread" %}</div>
133 133 <div class="swappable-form-full">
134 134 <form enctype="multipart/form-data" method="post" id="form">{% csrf_token %}
135 135 {{ form.as_div }}
136 136 <div class="form-submit">
137 137 <input type="submit" value="{% trans "Post" %}"/>
138 138 </div>
139 (ctrl-enter)
140 139 </form>
141 140 </div>
142 141 <div>
143 142 {% trans 'Tags must be delimited by spaces. Text or image is required.' %}
144 143 </div>
145 144 <div><a href="{% url "staticpage" name="help" %}">
146 145 {% trans 'Text syntax' %}</a></div>
147 146 </div>
148 147 </div>
149 148
150 149 <script src="{% static 'js/form.js' %}"></script>
151 150
152 151 {% endblock %}
153 152
154 153 {% block metapanel %}
155 154
156 155 <span class="metapanel">
157 156 <b><a href="{% url "authors" %}">{{ site_name }}</a> {{ version }}</b>
158 157 {% trans "Pages:" %}
159 158 <a href="
160 159 {% if tag %}
161 160 {% url "tag" tag_name=tag.name page=paginator.page_range|first %}
162 161 {% else %}
163 162 {% url "index" page=paginator.page_range|first %}
164 163 {% endif %}
165 164 ">&lt;&lt;</a>
166 165 [
167 166 {% for page in paginator.center_range %}
168 167 <a
169 168 {% ifequal page current_page.number %}
170 169 class="current_page"
171 170 {% endifequal %}
172 171 href="
173 172 {% if tag %}
174 173 {% url "tag" tag_name=tag.name page=page %}
175 174 {% else %}
176 175 {% url "index" page=page %}
177 176 {% endif %}
178 177 ">{{ page }}</a>
179 178 {% if not forloop.last %},{% endif %}
180 179 {% endfor %}
181 180 ]
182 181 <a href="
183 182 {% if tag %}
184 183 {% url "tag" tag_name=tag.name page=paginator.page_range|last %}
185 184 {% else %}
186 185 {% url "index" page=paginator.page_range|last %}
187 186 {% endif %}
188 187 ">&gt;&gt;</a>
189 188 [<a href="rss/">RSS</a>]
190 189 </span>
191 190
192 191 {% endblock %}
@@ -1,34 +1,35 b''
1 1 from django.test import TestCase
2 from boards.mdx_neboard import Parser
2 3 from boards.models import Post
3 4
4 5
5 6 class ParserTest(TestCase):
6 7 def test_preparse_quote(self):
7 8 raw_text = '>quote\nQuote in >line\nLine\n>Quote'
8 preparsed_text = Post.objects._preparse_text(raw_text)
9 preparsed_text = Parser().preparse(raw_text)
9 10
10 11 self.assertEqual(
11 12 '[quote]quote[/quote]\nQuote in >line\nLine\n[quote]Quote[/quote]',
12 13 preparsed_text, 'Quote not preparsed.')
13 14
14 15 def test_preparse_comment(self):
15 16 raw_text = '//comment'
16 preparsed_text = Post.objects._preparse_text(raw_text)
17 preparsed_text = Parser().preparse(raw_text)
17 18
18 19 self.assertEqual('[comment]comment[/comment]', preparsed_text,
19 20 'Comment not preparsed.')
20 21
21 22 def test_preparse_reflink(self):
22 23 raw_text = '>>12\nText'
23 preparsed_text = Post.objects._preparse_text(raw_text)
24 preparsed_text = Parser().preparse(raw_text)
24 25
25 26 self.assertEqual('[post]12[/post]\nText',
26 27 preparsed_text, 'Reflink not preparsed.')
27 28
28 29 def preparse_user(self):
29 30 raw_text = '@user\nuser@example.com\n@user\nuser @user'
30 preparsed_text = Post.objects._preparse_text(raw_text)
31 preparsed_text = Parser().preparse(raw_text)
31 32
32 33 self.assertEqual('[user]user[/user]\nuser@example.com\n[user]user[/user]\nuser [user]user[/user]',
33 34 preparsed_text, 'User link not preparsed.')
34 35
@@ -1,37 +1,39 b''
1 1 from django.shortcuts import render
2 2 from django.template import RequestContext
3 3 from django.views.generic import View
4 4
5 from boards.mdx_neboard import bbcode_extended
5 from boards.mdx_neboard import Parser
6
6 7
7 8 FORM_QUERY = 'query'
8 9
9 10 CONTEXT_RESULT = 'result'
10 11 CONTEXT_QUERY = 'query'
11 12
12 13 __author__ = 'neko259'
13 14
14 15 TEMPLATE = 'boards/preview.html'
15 16
16 17
17 18 class PostPreviewView(View):
18 19 def get(self, request):
19 20 context = RequestContext(request)
20 21
21 22 # TODO Use dict here
22 23 return render(request, TEMPLATE, context_instance=context)
23 24
24 25 def post(self, request):
25 26 context = RequestContext(request)
26 27
27 28 if FORM_QUERY in request.POST:
28 29 raw_text = request.POST[FORM_QUERY]
29 30
30 31 if len(raw_text) >= 0:
31 rendered_text = bbcode_extended(raw_text)
32 parser = Parser()
33 rendered_text = parser.parse(parser.preparse(raw_text))
32 34
33 35 context[CONTEXT_RESULT] = rendered_text
34 36 context[CONTEXT_QUERY] = raw_text
35 37
36 38 # TODO Use dict here
37 39 return render(request, TEMPLATE, context_instance=context)
@@ -1,234 +1,233 b''
1 1 # Django settings for neboard project.
2 2 import os
3 from boards.mdx_neboard import bbcode_extended
4 3
5 4 DEBUG = True
6 5 TEMPLATE_DEBUG = DEBUG
7 6
8 7 ADMINS = (
9 8 # ('Your Name', 'your_email@example.com'),
10 9 ('admin', 'admin@example.com')
11 10 )
12 11
13 12 MANAGERS = ADMINS
14 13
15 14 DATABASES = {
16 15 'default': {
17 16 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'.
18 17 'NAME': 'database.db', # Or path to database file if using sqlite3.
19 18 'USER': '', # Not used with sqlite3.
20 19 'PASSWORD': '', # Not used with sqlite3.
21 20 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
22 21 'PORT': '', # Set to empty string for default. Not used with sqlite3.
23 22 'CONN_MAX_AGE': None,
24 23 }
25 24 }
26 25
27 26 # Local time zone for this installation. Choices can be found here:
28 27 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
29 28 # although not all choices may be available on all operating systems.
30 29 # In a Windows environment this must be set to your system time zone.
31 30 TIME_ZONE = 'Europe/Kiev'
32 31
33 32 # Language code for this installation. All choices can be found here:
34 33 # http://www.i18nguy.com/unicode/language-identifiers.html
35 34 LANGUAGE_CODE = 'en'
36 35
37 36 SITE_ID = 1
38 37
39 38 # If you set this to False, Django will make some optimizations so as not
40 39 # to load the internationalization machinery.
41 40 USE_I18N = True
42 41
43 42 # If you set this to False, Django will not format dates, numbers and
44 43 # calendars according to the current locale.
45 44 USE_L10N = True
46 45
47 46 # If you set this to False, Django will not use timezone-aware datetimes.
48 47 USE_TZ = True
49 48
50 49 # Absolute filesystem path to the directory that will hold user-uploaded files.
51 50 # Example: "/home/media/media.lawrence.com/media/"
52 51 MEDIA_ROOT = './media/'
53 52
54 53 # URL that handles the media served from MEDIA_ROOT. Make sure to use a
55 54 # trailing slash.
56 55 # Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
57 56 MEDIA_URL = '/media/'
58 57
59 58 # Absolute path to the directory static files should be collected to.
60 59 # Don't put anything in this directory yourself; store your static files
61 60 # in apps' "static/" subdirectories and in STATICFILES_DIRS.
62 61 # Example: "/home/media/media.lawrence.com/static/"
63 62 STATIC_ROOT = ''
64 63
65 64 # URL prefix for static files.
66 65 # Example: "http://media.lawrence.com/static/"
67 66 STATIC_URL = '/static/'
68 67
69 68 # Additional locations of static files
70 69 # It is really a hack, put real paths, not related
71 70 STATICFILES_DIRS = (
72 71 os.path.dirname(__file__) + '/boards/static',
73 72
74 73 # '/d/work/python/django/neboard/neboard/boards/static',
75 74 # Put strings here, like "/home/html/static" or "C:/www/django/static".
76 75 # Always use forward slashes, even on Windows.
77 76 # Don't forget to use absolute paths, not relative paths.
78 77 )
79 78
80 79 # List of finder classes that know how to find static files in
81 80 # various locations.
82 81 STATICFILES_FINDERS = (
83 82 'django.contrib.staticfiles.finders.FileSystemFinder',
84 83 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
85 84 'compressor.finders.CompressorFinder',
86 85 )
87 86
88 87 if DEBUG:
89 88 STATICFILES_STORAGE = \
90 89 'django.contrib.staticfiles.storage.StaticFilesStorage'
91 90 else:
92 91 STATICFILES_STORAGE = \
93 92 'django.contrib.staticfiles.storage.CachedStaticFilesStorage'
94 93
95 94 # Make this unique, and don't share it with anybody.
96 95 SECRET_KEY = '@1rc$o(7=tt#kd+4s$u6wchm**z^)4x90)7f6z(i&amp;55@o11*8o'
97 96
98 97 # List of callables that know how to import templates from various sources.
99 98 TEMPLATE_LOADERS = (
100 99 'django.template.loaders.filesystem.Loader',
101 100 'django.template.loaders.app_directories.Loader',
102 101 )
103 102
104 103 TEMPLATE_CONTEXT_PROCESSORS = (
105 104 'django.core.context_processors.media',
106 105 'django.core.context_processors.static',
107 106 'django.core.context_processors.request',
108 107 'django.contrib.auth.context_processors.auth',
109 108 'boards.context_processors.user_and_ui_processor',
110 109 )
111 110
112 111 MIDDLEWARE_CLASSES = (
113 112 'django.contrib.sessions.middleware.SessionMiddleware',
114 113 'django.middleware.locale.LocaleMiddleware',
115 114 'django.middleware.common.CommonMiddleware',
116 115 'django.contrib.auth.middleware.AuthenticationMiddleware',
117 116 'django.contrib.messages.middleware.MessageMiddleware',
118 117 'boards.middlewares.BanMiddleware',
119 118 'boards.middlewares.TimezoneMiddleware',
120 119 )
121 120
122 121 ROOT_URLCONF = 'neboard.urls'
123 122
124 123 # Python dotted path to the WSGI application used by Django's runserver.
125 124 WSGI_APPLICATION = 'neboard.wsgi.application'
126 125
127 126 TEMPLATE_DIRS = (
128 127 # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
129 128 # Always use forward slashes, even on Windows.
130 129 # Don't forget to use absolute paths, not relative paths.
131 130 'templates',
132 131 )
133 132
134 133 INSTALLED_APPS = (
135 134 'django.contrib.auth',
136 135 'django.contrib.contenttypes',
137 136 'django.contrib.sessions',
138 137 # 'django.contrib.sites',
139 138 'django.contrib.messages',
140 139 'django.contrib.staticfiles',
141 140 # Uncomment the next line to enable the admin:
142 141 'django.contrib.admin',
143 142 # Uncomment the next line to enable admin documentation:
144 143 # 'django.contrib.admindocs',
145 144 'django.contrib.humanize',
146 145 'django_cleanup',
147 146
148 147 'debug_toolbar',
149 148
150 149 # Search
151 150 'haystack',
152 151
153 152 'boards',
154 153 )
155 154
156 155 # A sample logging configuration. The only tangible logging
157 156 # performed by this configuration is to send an email to
158 157 # the site admins on every HTTP 500 error when DEBUG=False.
159 158 # See http://docs.djangoproject.com/en/dev/topics/logging for
160 159 # more details on how to customize your logging configuration.
161 160 LOGGING = {
162 161 'version': 1,
163 162 'disable_existing_loggers': False,
164 163 'formatters': {
165 164 'verbose': {
166 165 'format': '%(levelname)s %(asctime)s %(name)s %(process)d %(thread)d %(message)s'
167 166 },
168 167 'simple': {
169 168 'format': '%(levelname)s %(asctime)s [%(name)s] %(message)s'
170 169 },
171 170 },
172 171 'filters': {
173 172 'require_debug_false': {
174 173 '()': 'django.utils.log.RequireDebugFalse'
175 174 }
176 175 },
177 176 'handlers': {
178 177 'console': {
179 178 'level': 'DEBUG',
180 179 'class': 'logging.StreamHandler',
181 180 'formatter': 'simple'
182 181 },
183 182 },
184 183 'loggers': {
185 184 'boards': {
186 185 'handlers': ['console'],
187 186 'level': 'DEBUG',
188 187 }
189 188 },
190 189 }
191 190
192 191 HAYSTACK_CONNECTIONS = {
193 192 'default': {
194 193 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
195 194 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'),
196 195 },
197 196 }
198 197
199 198 THEMES = [
200 199 ('md', 'Mystic Dark'),
201 200 ('md_centered', 'Mystic Dark (centered)'),
202 201 ('sw', 'Snow White'),
203 202 ('pg', 'Photon Gray'),
204 203 ]
205 204
206 205 POSTING_DELAY = 20 # seconds
207 206
208 207 # Websocket settins
209 208 CENTRIFUGE_HOST = 'localhost'
210 209 CENTRIFUGE_PORT = '9090'
211 210
212 211 CENTRIFUGE_ADDRESS = 'http://{}:{}'.format(CENTRIFUGE_HOST, CENTRIFUGE_PORT)
213 212 CENTRIFUGE_PROJECT_ID = '<project id here>'
214 213 CENTRIFUGE_PROJECT_SECRET = '<project secret here>'
215 214 CENTRIFUGE_TIMEOUT = 5
216 215
217 216 # Debug mode middlewares
218 217 if DEBUG:
219 218 MIDDLEWARE_CLASSES += (
220 219 'debug_toolbar.middleware.DebugToolbarMiddleware',
221 220 )
222 221
223 222 def custom_show_toolbar(request):
224 223 return True
225 224
226 225 DEBUG_TOOLBAR_CONFIG = {
227 226 'ENABLE_STACKTRACES': True,
228 227 'SHOW_TOOLBAR_CALLBACK': 'neboard.settings.custom_show_toolbar',
229 228 }
230 229
231 230 # FIXME Uncommenting this fails somehow. Need to investigate this
232 231 #DEBUG_TOOLBAR_PANELS += (
233 232 # 'debug_toolbar.panels.profiling.ProfilingDebugPanel',
234 233 #)
General Comments 0
You need to be logged in to leave comments. Login now