##// END OF EJS Templates
Image deduplication (BB-53). When an image with the same hash is uploaded, it...
neko259 -
r944:6ed17cb6 default
parent child Browse files
Show More
@@ -1,246 +1,240 b''
1 1 import re
2 2 import time
3 3
4 4 from django import forms
5 5 from django.forms.util import ErrorList
6 6 from django.utils.translation import ugettext_lazy as _
7 7
8 8 from boards.mdx_neboard import formatters
9 9 from boards.models.post import TITLE_MAX_LENGTH
10 10 from boards.models import PostImage, Tag
11 11 from neboard import settings
12 12 import boards.settings as board_settings
13 13
14 14 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
15 15
16 16 VETERAN_POSTING_DELAY = 5
17 17
18 18 ATTRIBUTE_PLACEHOLDER = 'placeholder'
19 19
20 20 LAST_POST_TIME = 'last_post_time'
21 21 LAST_LOGIN_TIME = 'last_login_time'
22 22 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
23 23 TAGS_PLACEHOLDER = _('tag1 several_words_tag')
24 24
25 ERROR_IMAGE_DUPLICATE = _('Such image was already posted')
26
27 25 LABEL_TITLE = _('Title')
28 26 LABEL_TEXT = _('Text')
29 27 LABEL_TAG = _('Tag')
30 28 LABEL_SEARCH = _('Search')
31 29
32 30 TAG_MAX_LENGTH = 20
33 31
34 32
35 33 class FormatPanel(forms.Textarea):
36 34 """
37 35 Panel for text formatting. Consists of buttons to add different tags to the
38 36 form text area.
39 37 """
40 38
41 39 def render(self, name, value, attrs=None):
42 40 output = '<div id="mark-panel">'
43 41 for formatter in formatters:
44 42 output += '<span class="mark_btn"' + \
45 43 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
46 44 '\', \'' + formatter.format_right + '\')">' + \
47 45 formatter.preview_left + formatter.name + \
48 46 formatter.preview_right + '</span>'
49 47
50 48 output += '</div>'
51 49 output += super(FormatPanel, self).render(name, value, attrs=None)
52 50
53 51 return output
54 52
55 53
56 54 class PlainErrorList(ErrorList):
57 55 def __unicode__(self):
58 56 return self.as_text()
59 57
60 58 def as_text(self):
61 59 return ''.join(['(!) %s ' % e for e in self])
62 60
63 61
64 62 class NeboardForm(forms.Form):
65 63 """
66 64 Form with neboard-specific formatting.
67 65 """
68 66
69 67 def as_div(self):
70 68 """
71 69 Returns this form rendered as HTML <as_div>s.
72 70 """
73 71
74 72 return self._html_output(
75 73 # TODO Do not show hidden rows in the list here
76 74 normal_row='<div class="form-row"><div class="form-label">'
77 75 '%(label)s'
78 76 '</div></div>'
79 77 '<div class="form-row"><div class="form-input">'
80 78 '%(field)s'
81 79 '</div></div>'
82 80 '<div class="form-row">'
83 81 '%(help_text)s'
84 82 '</div>',
85 83 error_row='<div class="form-row">'
86 84 '<div class="form-label"></div>'
87 85 '<div class="form-errors">%s</div>'
88 86 '</div>',
89 87 row_ender='</div>',
90 88 help_text_html='%s',
91 89 errors_on_separate_row=True)
92 90
93 91 def as_json_errors(self):
94 92 errors = []
95 93
96 94 for name, field in list(self.fields.items()):
97 95 if self[name].errors:
98 96 errors.append({
99 97 'field': name,
100 98 'errors': self[name].errors.as_text(),
101 99 })
102 100
103 101 return errors
104 102
105 103
106 104 class PostForm(NeboardForm):
107 105
108 106 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
109 107 label=LABEL_TITLE)
110 108 text = forms.CharField(
111 109 widget=FormatPanel(attrs={ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER}),
112 110 required=False, label=LABEL_TEXT)
113 111 image = forms.ImageField(required=False, label=_('Image'),
114 112 widget=forms.ClearableFileInput(
115 113 attrs={'accept': 'image/*'}))
116 114
117 115 # This field is for spam prevention only
118 116 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
119 117 widget=forms.TextInput(attrs={
120 118 'class': 'form-email'}))
121 119
122 120 session = None
123 121 need_to_ban = False
124 122
125 123 def clean_title(self):
126 124 title = self.cleaned_data['title']
127 125 if title:
128 126 if len(title) > TITLE_MAX_LENGTH:
129 127 raise forms.ValidationError(_('Title must have less than %s '
130 128 'characters') %
131 129 str(TITLE_MAX_LENGTH))
132 130 return title
133 131
134 132 def clean_text(self):
135 133 text = self.cleaned_data['text'].strip()
136 134 if text:
137 135 if len(text) > board_settings.MAX_TEXT_LENGTH:
138 136 raise forms.ValidationError(_('Text must have less than %s '
139 137 'characters') %
140 138 str(board_settings
141 139 .MAX_TEXT_LENGTH))
142 140 return text
143 141
144 142 def clean_image(self):
145 143 image = self.cleaned_data['image']
146 144 if image:
147 145 if image.size > board_settings.MAX_IMAGE_SIZE:
148 146 raise forms.ValidationError(
149 147 _('Image must be less than %s bytes')
150 148 % str(board_settings.MAX_IMAGE_SIZE))
151 149
152 image_hash = PostImage.get_hash(image)
153 if PostImage.objects.filter(hash=image_hash).exists():
154 raise forms.ValidationError(ERROR_IMAGE_DUPLICATE)
155
156 150 return image
157 151
158 152 def clean(self):
159 153 cleaned_data = super(PostForm, self).clean()
160 154
161 155 if not self.session:
162 156 raise forms.ValidationError('Humans have sessions')
163 157
164 158 if cleaned_data['email']:
165 159 self.need_to_ban = True
166 160 raise forms.ValidationError('A human cannot enter a hidden field')
167 161
168 162 if not self.errors:
169 163 self._clean_text_image()
170 164
171 165 if not self.errors and self.session:
172 166 self._validate_posting_speed()
173 167
174 168 return cleaned_data
175 169
176 170 def _clean_text_image(self):
177 171 text = self.cleaned_data.get('text')
178 172 image = self.cleaned_data.get('image')
179 173
180 174 if (not text) and (not image):
181 175 error_message = _('Either text or image must be entered.')
182 176 self._errors['text'] = self.error_class([error_message])
183 177
184 178 def _validate_posting_speed(self):
185 179 can_post = True
186 180
187 181 posting_delay = settings.POSTING_DELAY
188 182
189 183 if board_settings.LIMIT_POSTING_SPEED and LAST_POST_TIME in \
190 184 self.session:
191 185 now = time.time()
192 186 last_post_time = self.session[LAST_POST_TIME]
193 187
194 188 current_delay = int(now - last_post_time)
195 189
196 190 if current_delay < posting_delay:
197 191 error_message = _('Wait %s seconds after last posting') % str(
198 192 posting_delay - current_delay)
199 193 self._errors['text'] = self.error_class([error_message])
200 194
201 195 can_post = False
202 196
203 197 if can_post:
204 198 self.session[LAST_POST_TIME] = time.time()
205 199
206 200
207 201 class ThreadForm(PostForm):
208 202
209 203 tags = forms.CharField(
210 204 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
211 205 max_length=100, label=_('Tags'), required=True)
212 206
213 207 def clean_tags(self):
214 208 tags = self.cleaned_data['tags'].strip()
215 209
216 210 if not tags or not REGEX_TAGS.match(tags):
217 211 raise forms.ValidationError(
218 212 _('Inappropriate characters in tags.'))
219 213
220 214 required_tag_exists = False
221 215 for tag in tags.split():
222 216 tag_model = Tag.objects.filter(name=tag.strip().lower(),
223 217 required=True)
224 218 if tag_model.exists():
225 219 required_tag_exists = True
226 220 break
227 221
228 222 if not required_tag_exists:
229 223 raise forms.ValidationError(_('Need at least 1 required tag.'))
230 224
231 225 return tags
232 226
233 227 def clean(self):
234 228 cleaned_data = super(ThreadForm, self).clean()
235 229
236 230 return cleaned_data
237 231
238 232
239 233 class SettingsForm(NeboardForm):
240 234
241 235 theme = forms.ChoiceField(choices=settings.THEMES,
242 236 label=_('Theme'))
243 237
244 238
245 239 class SearchForm(NeboardForm):
246 240 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,441 +1,451 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 6 from adjacent import Client
7 7 from django.core.cache import cache
8 8 from django.core.urlresolvers import reverse
9 9 from django.db import models, transaction
10 10 from django.db.models import TextField
11 11 from django.template.loader import render_to_string
12 12 from django.utils import timezone
13 13
14 14 from boards import settings
15 15 from boards.mdx_neboard import bbcode_extended
16 16 from boards.models import PostImage
17 17 from boards.models.base import Viewable
18 18 from boards.models.thread import Thread
19 19 from boards.utils import datetime_to_epoch
20 20
21 21
22 22 WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
23 23 WS_NOTIFICATION_TYPE = 'notification_type'
24 24
25 25 WS_CHANNEL_THREAD = "thread:"
26 26
27 27 APP_LABEL_BOARDS = 'boards'
28 28
29 29 CACHE_KEY_PPD = 'ppd'
30 30 CACHE_KEY_POST_URL = 'post_url'
31 31
32 32 POSTS_PER_DAY_RANGE = 7
33 33
34 34 BAN_REASON_AUTO = 'Auto'
35 35
36 36 IMAGE_THUMB_SIZE = (200, 150)
37 37
38 38 TITLE_MAX_LENGTH = 200
39 39
40 40 # TODO This should be removed
41 41 NO_IP = '0.0.0.0'
42 42
43 43 # TODO Real user agent should be saved instead of this
44 44 UNKNOWN_UA = ''
45 45
46 46 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
47 47
48 48 PARAMETER_TRUNCATED = 'truncated'
49 49 PARAMETER_TAG = 'tag'
50 50 PARAMETER_OFFSET = 'offset'
51 51 PARAMETER_DIFF_TYPE = 'type'
52 52 PARAMETER_BUMPABLE = 'bumpable'
53 53 PARAMETER_THREAD = 'thread'
54 54 PARAMETER_IS_OPENING = 'is_opening'
55 55 PARAMETER_MODERATOR = 'moderator'
56 56 PARAMETER_POST = 'post'
57 57 PARAMETER_OP_ID = 'opening_post_id'
58 58 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
59 59
60 60 DIFF_TYPE_HTML = 'html'
61 61 DIFF_TYPE_JSON = 'json'
62 62
63 63 PREPARSE_PATTERNS = {
64 64 r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123"
65 65 r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text"
66 66 r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text"
67 67 }
68 68
69 69
70 70 class PostManager(models.Manager):
71 71 @transaction.atomic
72 72 def create_post(self, title: str, text: str, image=None, thread=None,
73 73 ip=NO_IP, tags: list=None):
74 74 """
75 75 Creates new post
76 76 """
77 77
78 78 if not tags:
79 79 tags = []
80 80
81 81 posting_time = timezone.now()
82 82 if not thread:
83 83 thread = Thread.objects.create(bump_time=posting_time,
84 84 last_edit_time=posting_time)
85 85 new_thread = True
86 86 else:
87 87 new_thread = False
88 88
89 89 pre_text = self._preparse_text(text)
90 90
91 91 post = self.create(title=title,
92 92 text=pre_text,
93 93 pub_time=posting_time,
94 94 thread_new=thread,
95 95 poster_ip=ip,
96 96 poster_user_agent=UNKNOWN_UA, # TODO Get UA at
97 97 # last!
98 98 last_edit_time=posting_time)
99 99
100 100 logger = logging.getLogger('boards.post.create')
101 101
102 102 logger.info('Created post {} by {}'.format(
103 103 post, post.poster_ip))
104 104
105 105 if image:
106 # Try to find existing image. If it exists, assign it to the post
107 # instead of createing the new one
108 image_hash = PostImage.get_hash(image)
109 existing = PostImage.objects.filter(hash=image_hash)
110 if len(existing) > 0:
111 post_image = existing[0]
112 else:
106 113 post_image = PostImage.objects.create(image=image)
114 logger.info('Created new image #{} for post #{}'.format(
115 post_image.id, post.id))
107 116 post.images.add(post_image)
108 logger.info('Created image #{} for post #{}'.format(
109 post_image.id, post.id))
110 117
111 118 thread.replies.add(post)
112 119 list(map(thread.add_tag, tags))
113 120
114 121 if new_thread:
115 122 Thread.objects.process_oldest_threads()
116 123 else:
117 124 thread.bump()
118 125 thread.last_edit_time = posting_time
119 126 thread.save()
120 127
121 128 self.connect_replies(post)
122 129
123 130 return post
124 131
125 132 def delete_posts_by_ip(self, ip):
126 133 """
127 134 Deletes all posts of the author with same IP
128 135 """
129 136
130 137 posts = self.filter(poster_ip=ip)
131 138 for post in posts:
132 139 post.delete()
133 140
134 141 def connect_replies(self, post):
135 142 """
136 143 Connects replies to a post to show them as a reflink map
137 144 """
138 145
139 146 for reply_number in re.finditer(REGEX_REPLY, post.get_raw_text()):
140 147 post_id = reply_number.group(1)
141 148 ref_post = self.filter(id=post_id)
142 149 if ref_post.count() > 0:
143 150 referenced_post = ref_post[0]
144 151 referenced_post.referenced_posts.add(post)
145 152 referenced_post.last_edit_time = post.pub_time
146 153 referenced_post.build_refmap()
147 154 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
148 155
149 156 referenced_thread = referenced_post.get_thread()
150 157 referenced_thread.last_edit_time = post.pub_time
151 158 referenced_thread.save(update_fields=['last_edit_time'])
152 159
153 160 def get_posts_per_day(self):
154 161 """
155 162 Gets average count of posts per day for the last 7 days
156 163 """
157 164
158 165 day_end = date.today()
159 166 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
160 167
161 168 cache_key = CACHE_KEY_PPD + str(day_end)
162 169 ppd = cache.get(cache_key)
163 170 if ppd:
164 171 return ppd
165 172
166 173 day_time_start = timezone.make_aware(datetime.combine(
167 174 day_start, dtime()), timezone.get_current_timezone())
168 175 day_time_end = timezone.make_aware(datetime.combine(
169 176 day_end, dtime()), timezone.get_current_timezone())
170 177
171 178 posts_per_period = float(self.filter(
172 179 pub_time__lte=day_time_end,
173 180 pub_time__gte=day_time_start).count())
174 181
175 182 ppd = posts_per_period / POSTS_PER_DAY_RANGE
176 183
177 184 cache.set(cache_key, ppd)
178 185 return ppd
179 186
180 187 def _preparse_text(self, text):
181 188 """
182 189 Preparses text to change patterns like '>>' to a proper bbcode
183 190 tags.
184 191 """
185 192
186 193 for key, value in PREPARSE_PATTERNS.items():
187 194 text = re.sub(key, value, text, flags=re.MULTILINE)
188 195
189 196 return text
190 197
191 198
192 199 class Post(models.Model, Viewable):
193 200 """A post is a message."""
194 201
195 202 objects = PostManager()
196 203
197 204 class Meta:
198 205 app_label = APP_LABEL_BOARDS
199 206 ordering = ('id',)
200 207
201 208 title = models.CharField(max_length=TITLE_MAX_LENGTH)
202 209 pub_time = models.DateTimeField()
203 210 text = TextField(blank=True, null=True)
204 211 _text_rendered = TextField(blank=True, null=True, editable=False)
205 212
206 213 images = models.ManyToManyField(PostImage, null=True, blank=True,
207 214 related_name='ip+', db_index=True)
208 215
209 216 poster_ip = models.GenericIPAddressField()
210 217 poster_user_agent = models.TextField()
211 218
212 219 thread_new = models.ForeignKey('Thread', null=True, default=None,
213 220 db_index=True)
214 221 last_edit_time = models.DateTimeField()
215 222
216 223 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
217 224 null=True,
218 225 blank=True, related_name='rfp+',
219 226 db_index=True)
220 227 refmap = models.TextField(null=True, blank=True)
221 228
222 229 def __str__(self):
223 230 return 'P#{}/{}'.format(self.id, self.title)
224 231
225 232 def get_title(self) -> str:
226 233 """
227 234 Gets original post title or part of its text.
228 235 """
229 236
230 237 title = self.title
231 238 if not title:
232 239 title = self.get_text()
233 240
234 241 return title
235 242
236 243 def build_refmap(self) -> None:
237 244 """
238 245 Builds a replies map string from replies list. This is a cache to stop
239 246 the server from recalculating the map on every post show.
240 247 """
241 248 map_string = ''
242 249
243 250 first = True
244 251 for refpost in self.referenced_posts.all():
245 252 if not first:
246 253 map_string += ', '
247 254 map_string += '<a href="%s">&gt;&gt;%s</a>' % (refpost.get_url(),
248 255 refpost.id)
249 256 first = False
250 257
251 258 self.refmap = map_string
252 259
253 260 def get_sorted_referenced_posts(self):
254 261 return self.refmap
255 262
256 263 def is_referenced(self) -> bool:
257 264 if not self.refmap:
258 265 return False
259 266 else:
260 267 return len(self.refmap) > 0
261 268
262 269 def is_opening(self) -> bool:
263 270 """
264 271 Checks if this is an opening post or just a reply.
265 272 """
266 273
267 274 return self.get_thread().get_opening_post_id() == self.id
268 275
269 276 @transaction.atomic
270 277 def add_tag(self, tag):
271 278 edit_time = timezone.now()
272 279
273 280 thread = self.get_thread()
274 281 thread.add_tag(tag)
275 282 self.last_edit_time = edit_time
276 283 self.save(update_fields=['last_edit_time'])
277 284
278 285 thread.last_edit_time = edit_time
279 286 thread.save(update_fields=['last_edit_time'])
280 287
281 288 def get_url(self, thread=None):
282 289 """
283 290 Gets full url to the post.
284 291 """
285 292
286 293 cache_key = CACHE_KEY_POST_URL + str(self.id)
287 294 link = cache.get(cache_key)
288 295
289 296 if not link:
290 297 if not thread:
291 298 thread = self.get_thread()
292 299
293 300 opening_id = thread.get_opening_post_id()
294 301
295 302 if self.id != opening_id:
296 303 link = reverse('thread', kwargs={
297 304 'post_id': opening_id}) + '#' + str(self.id)
298 305 else:
299 306 link = reverse('thread', kwargs={'post_id': self.id})
300 307
301 308 cache.set(cache_key, link)
302 309
303 310 return link
304 311
305 312 def get_thread(self) -> Thread:
306 313 """
307 314 Gets post's thread.
308 315 """
309 316
310 317 return self.thread_new
311 318
312 319 def get_referenced_posts(self):
313 320 return self.referenced_posts.only('id', 'thread_new')
314 321
315 322 def get_view(self, moderator=False, need_open_link=False,
316 323 truncated=False, *args, **kwargs):
317 324 """
318 325 Renders post's HTML view. Some of the post params can be passed over
319 326 kwargs for the means of caching (if we view the thread, some params
320 327 are same for every post and don't need to be computed over and over.
321 328 """
322 329
323 330 is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening())
324 331 thread = kwargs.get(PARAMETER_THREAD, self.get_thread())
325 332 can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump())
326 333
327 334 if is_opening:
328 335 opening_post_id = self.id
329 336 else:
330 337 opening_post_id = thread.get_opening_post_id()
331 338
332 339 return render_to_string('boards/post.html', {
333 340 PARAMETER_POST: self,
334 341 PARAMETER_MODERATOR: moderator,
335 342 PARAMETER_IS_OPENING: is_opening,
336 343 PARAMETER_THREAD: thread,
337 344 PARAMETER_BUMPABLE: can_bump,
338 345 PARAMETER_NEED_OPEN_LINK: need_open_link,
339 346 PARAMETER_TRUNCATED: truncated,
340 347 PARAMETER_OP_ID: opening_post_id,
341 348 })
342 349
343 350 def get_search_view(self, *args, **kwargs):
344 351 return self.get_view(args, kwargs)
345 352
346 353 def get_first_image(self) -> PostImage:
347 354 return self.images.earliest('id')
348 355
349 356 def delete(self, using=None):
350 357 """
351 358 Deletes all post images and the post itself. If the post is opening,
352 359 thread with all posts is deleted.
353 360 """
354 361
355 self.images.all().delete()
362 for image in self.images.all():
363 image_refs_count = Post.objects.filter(images__in=[image]).count()
364 if image_refs_count == 1:
365 image.delete()
356 366
357 367 if self.is_opening():
358 368 self.get_thread().delete()
359 369 else:
360 370 thread = self.get_thread()
361 371 thread.last_edit_time = timezone.now()
362 372 thread.save()
363 373
364 374 super(Post, self).delete(using)
365 375
366 376 logging.getLogger('boards.post.delete').info(
367 377 'Deleted post {}'.format(self))
368 378
369 379 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
370 380 include_last_update=False):
371 381 """
372 382 Gets post HTML or JSON data that can be rendered on a page or used by
373 383 API.
374 384 """
375 385
376 386 if format_type == DIFF_TYPE_HTML:
377 387 params = dict()
378 388 params['post'] = self
379 389 if PARAMETER_TRUNCATED in request.GET:
380 390 params[PARAMETER_TRUNCATED] = True
381 391
382 392 return render_to_string('boards/api_post.html', params)
383 393 elif format_type == DIFF_TYPE_JSON:
384 394 post_json = {
385 395 'id': self.id,
386 396 'title': self.title,
387 397 'text': self._text_rendered,
388 398 }
389 399 if self.images.exists():
390 400 post_image = self.get_first_image()
391 401 post_json['image'] = post_image.image.url
392 402 post_json['image_preview'] = post_image.image.url_200x150
393 403 if include_last_update:
394 404 post_json['bump_time'] = datetime_to_epoch(
395 405 self.thread_new.bump_time)
396 406 return post_json
397 407
398 408 def send_to_websocket(self, request, recursive=True):
399 409 """
400 410 Sends post HTML data to the thread web socket.
401 411 """
402 412
403 413 if not settings.WEBSOCKETS_ENABLED:
404 414 return
405 415
406 416 client = Client()
407 417
408 418 thread = self.get_thread()
409 419 thread_id = thread.id
410 420 channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id())
411 421 client.publish(channel_name, {
412 422 WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST,
413 423 })
414 424 client.send()
415 425
416 426 logger = logging.getLogger('boards.post.websocket')
417 427
418 428 logger.info('Sent notification from post #{} to channel {}'.format(
419 429 self.id, channel_name))
420 430
421 431 if recursive:
422 432 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
423 433 post_id = reply_number.group(1)
424 434 ref_post = Post.objects.filter(id=post_id)[0]
425 435
426 436 # If post is in this thread, its thread was already notified.
427 437 # Otherwise, notify its thread separately.
428 438 if ref_post.thread_new_id != thread_id:
429 439 ref_post.send_to_websocket(request, recursive=False)
430 440
431 441 def save(self, force_insert=False, force_update=False, using=None,
432 442 update_fields=None):
433 443 self._text_rendered = bbcode_extended(self.get_raw_text())
434 444
435 445 super().save(force_insert, force_update, using, update_fields)
436 446
437 447 def get_text(self) -> str:
438 448 return self._text_rendered
439 449
440 450 def get_raw_text(self) -> str:
441 451 return self.text
General Comments 0
You need to be logged in to leave comments. Login now