##// END OF EJS Templates
Allow to edit tag and post in the admin site
neko259 -
r1367:2712a5bd default
parent child Browse files
Show More
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations, models
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0031_post_hidden'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='post',
16 name='tripcode',
17 field=models.CharField(default='', blank=True, max_length=50),
18 ),
19 ]
@@ -0,0 +1,19 b''
1 # -*- coding: utf-8 -*-
2 from __future__ import unicode_literals
3
4 from django.db import migrations, models
5
6
7 class Migration(migrations.Migration):
8
9 dependencies = [
10 ('boards', '0032_auto_20151014_2222'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='tag',
16 name='parent',
17 field=models.ForeignKey(blank=True, related_name='children', to='boards.Tag', null=True),
18 ),
19 ]
@@ -1,371 +1,376 b''
1 1 import hashlib
2 2 import re
3 3 import time
4 4
5 5 import pytz
6 6 from django import forms
7 7 from django.core.files.uploadedfile import SimpleUploadedFile
8 8 from django.core.exceptions import ObjectDoesNotExist
9 9 from django.forms.util import ErrorList
10 10 from django.utils.translation import ugettext_lazy as _, ungettext_lazy
11 11
12 12 from boards.mdx_neboard import formatters
13 13 from boards.models.attachment.downloaders import Downloader
14 14 from boards.models.post import TITLE_MAX_LENGTH
15 15 from boards.models import Tag, Post
16 16 from boards.utils import validate_file_size
17 17 from neboard import settings
18 18 import boards.settings as board_settings
19 19 import neboard
20 20
21 21 REGEX_TAGS = re.compile(r'^[\w\s\d]+$', re.UNICODE)
22 22
23 23 VETERAN_POSTING_DELAY = 5
24 24
25 25 ATTRIBUTE_PLACEHOLDER = 'placeholder'
26 26 ATTRIBUTE_ROWS = 'rows'
27 27
28 28 LAST_POST_TIME = 'last_post_time'
29 29 LAST_LOGIN_TIME = 'last_login_time'
30 30 TEXT_PLACEHOLDER = _('Type message here. Use formatting panel for more advanced usage.')
31 31 TAGS_PLACEHOLDER = _('music images i_dont_like_tags')
32 32
33 33 LABEL_TITLE = _('Title')
34 34 LABEL_TEXT = _('Text')
35 35 LABEL_TAG = _('Tag')
36 36 LABEL_SEARCH = _('Search')
37 37
38 38 ERROR_SPEED = 'Please wait %(delay)d second before sending message'
39 39 ERROR_SPEED_PLURAL = 'Please wait %(delay)d seconds before sending message'
40 40
41 41 TAG_MAX_LENGTH = 20
42 42
43 43 TEXTAREA_ROWS = 4
44 44
45 TRIPCODE_DELIM = '#'
46
45 47
46 48 def get_timezones():
47 49 timezones = []
48 50 for tz in pytz.common_timezones:
49 51 timezones.append((tz, tz),)
50 52 return timezones
51 53
52 54
53 55 class FormatPanel(forms.Textarea):
54 56 """
55 57 Panel for text formatting. Consists of buttons to add different tags to the
56 58 form text area.
57 59 """
58 60
59 61 def render(self, name, value, attrs=None):
60 62 output = '<div id="mark-panel">'
61 63 for formatter in formatters:
62 64 output += '<span class="mark_btn"' + \
63 65 ' onClick="addMarkToMsg(\'' + formatter.format_left + \
64 66 '\', \'' + formatter.format_right + '\')">' + \
65 67 formatter.preview_left + formatter.name + \
66 68 formatter.preview_right + '</span>'
67 69
68 70 output += '</div>'
69 71 output += super(FormatPanel, self).render(name, value, attrs=None)
70 72
71 73 return output
72 74
73 75
74 76 class PlainErrorList(ErrorList):
75 77 def __unicode__(self):
76 78 return self.as_text()
77 79
78 80 def as_text(self):
79 81 return ''.join(['(!) %s ' % e for e in self])
80 82
81 83
82 84 class NeboardForm(forms.Form):
83 85 """
84 86 Form with neboard-specific formatting.
85 87 """
86 88
87 89 def as_div(self):
88 90 """
89 91 Returns this form rendered as HTML <as_div>s.
90 92 """
91 93
92 94 return self._html_output(
93 95 # TODO Do not show hidden rows in the list here
94 96 normal_row='<div class="form-row">'
95 97 '<div class="form-label">'
96 98 '%(label)s'
97 99 '</div>'
98 100 '<div class="form-input">'
99 101 '%(field)s'
100 102 '</div>'
101 103 '</div>'
102 104 '<div class="form-row">'
103 105 '%(help_text)s'
104 106 '</div>',
105 107 error_row='<div class="form-row">'
106 108 '<div class="form-label"></div>'
107 109 '<div class="form-errors">%s</div>'
108 110 '</div>',
109 111 row_ender='</div>',
110 112 help_text_html='%s',
111 113 errors_on_separate_row=True)
112 114
113 115 def as_json_errors(self):
114 116 errors = []
115 117
116 118 for name, field in list(self.fields.items()):
117 119 if self[name].errors:
118 120 errors.append({
119 121 'field': name,
120 122 'errors': self[name].errors.as_text(),
121 123 })
122 124
123 125 return errors
124 126
125 127
126 128 class PostForm(NeboardForm):
127 129
128 130 title = forms.CharField(max_length=TITLE_MAX_LENGTH, required=False,
129 131 label=LABEL_TITLE,
130 132 widget=forms.TextInput(
131 133 attrs={ATTRIBUTE_PLACEHOLDER:
132 134 'test#tripcode'}))
133 135 text = forms.CharField(
134 136 widget=FormatPanel(attrs={
135 137 ATTRIBUTE_PLACEHOLDER: TEXT_PLACEHOLDER,
136 138 ATTRIBUTE_ROWS: TEXTAREA_ROWS,
137 139 }),
138 140 required=False, label=LABEL_TEXT)
139 141 file = forms.FileField(required=False, label=_('File'),
140 142 widget=forms.ClearableFileInput(
141 143 attrs={'accept': 'file/*'}))
142 144 file_url = forms.CharField(required=False, label=_('File URL'),
143 145 widget=forms.TextInput(
144 146 attrs={ATTRIBUTE_PLACEHOLDER:
145 147 'http://example.com/image.png'}))
146 148
147 149 # This field is for spam prevention only
148 150 email = forms.CharField(max_length=100, required=False, label=_('e-mail'),
149 151 widget=forms.TextInput(attrs={
150 152 'class': 'form-email'}))
151 153 threads = forms.CharField(required=False, label=_('Additional threads'),
152 154 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER:
153 155 '123 456 789'}))
154 156
155 157 session = None
156 158 need_to_ban = False
157 159
158 160 def clean_title(self):
159 161 title = self.cleaned_data['title']
160 162 if title:
161 163 if len(title) > TITLE_MAX_LENGTH:
162 164 raise forms.ValidationError(_('Title must have less than %s '
163 165 'characters') %
164 166 str(TITLE_MAX_LENGTH))
165 167 return title
166 168
167 169 def clean_text(self):
168 170 text = self.cleaned_data['text'].strip()
169 171 if text:
170 172 max_length = board_settings.get_int('Forms', 'MaxTextLength')
171 173 if len(text) > max_length:
172 174 raise forms.ValidationError(_('Text must have less than %s '
173 175 'characters') % str(max_length))
174 176 return text
175 177
176 178 def clean_file(self):
177 179 file = self.cleaned_data['file']
178 180
179 181 if file:
180 182 validate_file_size(file.size)
181 183
182 184 return file
183 185
184 186 def clean_file_url(self):
185 187 url = self.cleaned_data['file_url']
186 188
187 189 file = None
188 190 if url:
189 191 file = self._get_file_from_url(url)
190 192
191 193 if not file:
192 194 raise forms.ValidationError(_('Invalid URL'))
193 195 else:
194 196 validate_file_size(file.size)
195 197
196 198 return file
197 199
198 200 def clean_threads(self):
199 201 threads_str = self.cleaned_data['threads']
200 202
201 203 if len(threads_str) > 0:
202 204 threads_id_list = threads_str.split(' ')
203 205
204 206 threads = list()
205 207
206 208 for thread_id in threads_id_list:
207 209 try:
208 210 thread = Post.objects.get(id=int(thread_id))
209 211 if not thread.is_opening() or thread.get_thread().archived:
210 212 raise ObjectDoesNotExist()
211 213 threads.append(thread)
212 214 except (ObjectDoesNotExist, ValueError):
213 215 raise forms.ValidationError(_('Invalid additional thread list'))
214 216
215 217 return threads
216 218
217 219 def clean(self):
218 220 cleaned_data = super(PostForm, self).clean()
219 221
220 222 if cleaned_data['email']:
221 223 self.need_to_ban = True
222 224 raise forms.ValidationError('A human cannot enter a hidden field')
223 225
224 226 if not self.errors:
225 227 self._clean_text_file()
226 228
227 229 if not self.errors and self.session:
228 230 self._validate_posting_speed()
229 231
230 232 return cleaned_data
231 233
232 234 def get_file(self):
233 235 """
234 236 Gets file from form or URL.
235 237 """
236 238
237 239 file = self.cleaned_data['file']
238 240 return file or self.cleaned_data['file_url']
239 241
240 242 def get_tripcode(self):
241 243 title = self.cleaned_data['title']
242 if title is not None and '#' in title:
243 code = title.split('#', maxsplit=1)[1] + neboard.settings.SECRET_KEY
244 return hashlib.md5(code.encode()).hexdigest()
244 if title is not None and TRIPCODE_DELIM in title:
245 code = title.split(TRIPCODE_DELIM, maxsplit=1)[1] + neboard.settings.SECRET_KEY
246 tripcode = hashlib.md5(code.encode()).hexdigest()
247 else:
248 tripcode = ''
249 return tripcode
245 250
246 251 def get_title(self):
247 252 title = self.cleaned_data['title']
248 if title is not None and '#' in title:
249 return title.split('#', maxsplit=1)[0]
253 if title is not None and TRIPCODE_DELIM in title:
254 return title.split(TRIPCODE_DELIM, maxsplit=1)[0]
250 255 else:
251 256 return title
252 257
253 258 def _clean_text_file(self):
254 259 text = self.cleaned_data.get('text')
255 260 file = self.get_file()
256 261
257 262 if (not text) and (not file):
258 263 error_message = _('Either text or file must be entered.')
259 264 self._errors['text'] = self.error_class([error_message])
260 265
261 266 def _validate_posting_speed(self):
262 267 can_post = True
263 268
264 269 posting_delay = settings.POSTING_DELAY
265 270
266 271 if board_settings.get_bool('Forms', 'LimitPostingSpeed'):
267 272 now = time.time()
268 273
269 274 current_delay = 0
270 275
271 276 if LAST_POST_TIME not in self.session:
272 277 self.session[LAST_POST_TIME] = now
273 278
274 279 need_delay = True
275 280 else:
276 281 last_post_time = self.session.get(LAST_POST_TIME)
277 282 current_delay = int(now - last_post_time)
278 283
279 284 need_delay = current_delay < posting_delay
280 285
281 286 if need_delay:
282 287 delay = posting_delay - current_delay
283 288 error_message = ungettext_lazy(ERROR_SPEED, ERROR_SPEED_PLURAL,
284 289 delay) % {'delay': delay}
285 290 self._errors['text'] = self.error_class([error_message])
286 291
287 292 can_post = False
288 293
289 294 if can_post:
290 295 self.session[LAST_POST_TIME] = now
291 296
292 297 def _get_file_from_url(self, url: str) -> SimpleUploadedFile:
293 298 """
294 299 Gets an file file from URL.
295 300 """
296 301
297 302 img_temp = None
298 303
299 304 try:
300 305 for downloader in Downloader.__subclasses__():
301 306 if downloader.handles(url):
302 307 return downloader.download(url)
303 308 # If nobody of the specific downloaders handles this, use generic
304 309 # one
305 310 return Downloader.download(url)
306 311 except forms.ValidationError as e:
307 312 raise e
308 313 except Exception as e:
309 314 # Just return no file
310 315 pass
311 316
312 317
313 318 class ThreadForm(PostForm):
314 319
315 320 tags = forms.CharField(
316 321 widget=forms.TextInput(attrs={ATTRIBUTE_PLACEHOLDER: TAGS_PLACEHOLDER}),
317 322 max_length=100, label=_('Tags'), required=True)
318 323
319 324 def clean_tags(self):
320 325 tags = self.cleaned_data['tags'].strip()
321 326
322 327 if not tags or not REGEX_TAGS.match(tags):
323 328 raise forms.ValidationError(
324 329 _('Inappropriate characters in tags.'))
325 330
326 331 required_tag_exists = False
327 332 tag_set = set()
328 333 for tag_string in tags.split():
329 334 tag, created = Tag.objects.get_or_create(name=tag_string.strip().lower())
330 335 tag_set.add(tag)
331 336
332 337 # If this is a new tag, don't check for its parents because nobody
333 338 # added them yet
334 339 if not created:
335 340 tag_set |= set(tag.get_all_parents())
336 341
337 342 for tag in tag_set:
338 343 if tag.required:
339 344 required_tag_exists = True
340 345 break
341 346
342 347 if not required_tag_exists:
343 348 raise forms.ValidationError(
344 349 _('Need at least one section.'))
345 350
346 351 return tag_set
347 352
348 353 def clean(self):
349 354 cleaned_data = super(ThreadForm, self).clean()
350 355
351 356 return cleaned_data
352 357
353 358
354 359 class SettingsForm(NeboardForm):
355 360
356 361 theme = forms.ChoiceField(choices=settings.THEMES, label=_('Theme'))
357 362 image_viewer = forms.ChoiceField(choices=settings.IMAGE_VIEWERS, label=_('Image view mode'))
358 363 username = forms.CharField(label=_('User name'), required=False)
359 364 timezone = forms.ChoiceField(choices=get_timezones(), label=_('Time zone'))
360 365
361 366 def clean_username(self):
362 367 username = self.cleaned_data['username']
363 368
364 369 if username and not REGEX_TAGS.match(username):
365 370 raise forms.ValidationError(_('Inappropriate characters.'))
366 371
367 372 return username
368 373
369 374
370 375 class SearchForm(NeboardForm):
371 376 query = forms.CharField(max_length=500, label=LABEL_SEARCH, required=False)
@@ -1,361 +1,361 b''
1 1 import logging
2 2 import re
3 3 import uuid
4 4
5 5 from django.core.exceptions import ObjectDoesNotExist
6 6 from django.core.urlresolvers import reverse
7 7 from django.db import models
8 8 from django.db.models import TextField, QuerySet
9 9 from django.template.defaultfilters import striptags, truncatewords
10 10 from django.template.loader import render_to_string
11 11 from django.utils import timezone
12 12
13 13 from boards import settings
14 14 from boards.abstracts.tripcode import Tripcode
15 15 from boards.mdx_neboard import Parser
16 16 from boards.models import PostImage, Attachment
17 17 from boards.models.base import Viewable
18 18 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
19 19 from boards.models.post.manager import PostManager
20 20 from boards.models.user import Notification
21 21
22 22 CSS_CLS_HIDDEN_POST = 'hidden_post'
23 23 CSS_CLS_DEAD_POST = 'dead_post'
24 24 CSS_CLS_ARCHIVE_POST = 'archive_post'
25 25 CSS_CLS_POST = 'post'
26 26
27 27 TITLE_MAX_WORDS = 10
28 28
29 29 APP_LABEL_BOARDS = 'boards'
30 30
31 31 BAN_REASON_AUTO = 'Auto'
32 32
33 33 IMAGE_THUMB_SIZE = (200, 150)
34 34
35 35 TITLE_MAX_LENGTH = 200
36 36
37 37 REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
38 38 REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
39 39
40 40 PARAMETER_TRUNCATED = 'truncated'
41 41 PARAMETER_TAG = 'tag'
42 42 PARAMETER_OFFSET = 'offset'
43 43 PARAMETER_DIFF_TYPE = 'type'
44 44 PARAMETER_CSS_CLASS = 'css_class'
45 45 PARAMETER_THREAD = 'thread'
46 46 PARAMETER_IS_OPENING = 'is_opening'
47 47 PARAMETER_MODERATOR = 'moderator'
48 48 PARAMETER_POST = 'post'
49 49 PARAMETER_OP_ID = 'opening_post_id'
50 50 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
51 51 PARAMETER_REPLY_LINK = 'reply_link'
52 52 PARAMETER_NEED_OP_DATA = 'need_op_data'
53 53
54 54 POST_VIEW_PARAMS = (
55 55 'need_op_data',
56 56 'reply_link',
57 57 'moderator',
58 58 'need_open_link',
59 59 'truncated',
60 60 'mode_tree',
61 61 )
62 62
63 63
64 64 class Post(models.Model, Viewable):
65 65 """A post is a message."""
66 66
67 67 objects = PostManager()
68 68
69 69 class Meta:
70 70 app_label = APP_LABEL_BOARDS
71 71 ordering = ('id',)
72 72
73 73 title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True)
74 74 pub_time = models.DateTimeField()
75 75 text = TextField(blank=True, null=True)
76 76 _text_rendered = TextField(blank=True, null=True, editable=False)
77 77
78 78 images = models.ManyToManyField(PostImage, null=True, blank=True,
79 79 related_name='post_images', db_index=True)
80 80 attachments = models.ManyToManyField(Attachment, null=True, blank=True,
81 81 related_name='attachment_posts')
82 82
83 83 poster_ip = models.GenericIPAddressField()
84 84
85 85 # TODO This field can be removed cause UID is used for update now
86 86 last_edit_time = models.DateTimeField()
87 87
88 88 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
89 89 null=True,
90 90 blank=True, related_name='refposts',
91 91 db_index=True)
92 92 refmap = models.TextField(null=True, blank=True)
93 93 threads = models.ManyToManyField('Thread', db_index=True,
94 94 related_name='multi_replies')
95 95 thread = models.ForeignKey('Thread', db_index=True, related_name='pt+')
96 96
97 97 url = models.TextField()
98 98 uid = models.TextField(db_index=True)
99 99
100 tripcode = models.CharField(max_length=50, null=True)
100 tripcode = models.CharField(max_length=50, blank=True, default='')
101 101 opening = models.BooleanField()
102 102 hidden = models.BooleanField(default=False)
103 103
104 104 def __str__(self):
105 105 return 'P#{}/{}'.format(self.id, self.get_title())
106 106
107 107 def get_referenced_posts(self):
108 108 threads = self.get_threads().all()
109 109 return self.referenced_posts.filter(threads__in=threads)\
110 110 .order_by('pub_time').distinct().all()
111 111
112 112 def get_title(self) -> str:
113 113 return self.title
114 114
115 115 def get_title_or_text(self):
116 116 title = self.get_title()
117 117 if not title:
118 118 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
119 119
120 120 return title
121 121
122 122 def build_refmap(self) -> None:
123 123 """
124 124 Builds a replies map string from replies list. This is a cache to stop
125 125 the server from recalculating the map on every post show.
126 126 """
127 127
128 128 post_urls = [refpost.get_link_view()
129 129 for refpost in self.referenced_posts.all()]
130 130
131 131 self.refmap = ', '.join(post_urls)
132 132
133 133 def is_referenced(self) -> bool:
134 134 return self.refmap and len(self.refmap) > 0
135 135
136 136 def is_opening(self) -> bool:
137 137 """
138 138 Checks if this is an opening post or just a reply.
139 139 """
140 140
141 141 return self.opening
142 142
143 143 def get_absolute_url(self, thread=None):
144 144 url = None
145 145
146 146 if thread is None:
147 147 thread = self.get_thread()
148 148
149 149 # Url is cached only for the "main" thread. When getting url
150 150 # for other threads, do it manually.
151 151 if self.url:
152 152 url = self.url
153 153
154 154 if url is None:
155 155 opening_id = thread.get_opening_post_id()
156 156 url = reverse('thread', kwargs={'post_id': opening_id})
157 157 if self.id != opening_id:
158 158 url += '#' + str(self.id)
159 159
160 160 return url
161 161
162 162 def get_thread(self):
163 163 return self.thread
164 164
165 165 def get_threads(self) -> QuerySet:
166 166 """
167 167 Gets post's thread.
168 168 """
169 169
170 170 return self.threads
171 171
172 172 def get_view(self, *args, **kwargs) -> str:
173 173 """
174 174 Renders post's HTML view. Some of the post params can be passed over
175 175 kwargs for the means of caching (if we view the thread, some params
176 176 are same for every post and don't need to be computed over and over.
177 177 """
178 178
179 179 thread = self.get_thread()
180 180
181 181 css_classes = [CSS_CLS_POST]
182 182 if thread.archived:
183 183 css_classes.append(CSS_CLS_ARCHIVE_POST)
184 184 elif not thread.can_bump():
185 185 css_classes.append(CSS_CLS_DEAD_POST)
186 186 if self.is_hidden():
187 187 css_classes.append(CSS_CLS_HIDDEN_POST)
188 188
189 189 params = dict()
190 190 for param in POST_VIEW_PARAMS:
191 191 if param in kwargs:
192 192 params[param] = kwargs[param]
193 193
194 194 params.update({
195 195 PARAMETER_POST: self,
196 196 PARAMETER_IS_OPENING: self.is_opening(),
197 197 PARAMETER_THREAD: thread,
198 198 PARAMETER_CSS_CLASS: ' '.join(css_classes),
199 199 })
200 200
201 201 return render_to_string('boards/post.html', params)
202 202
203 203 def get_search_view(self, *args, **kwargs):
204 204 return self.get_view(need_op_data=True, *args, **kwargs)
205 205
206 206 def get_first_image(self) -> PostImage:
207 207 return self.images.earliest('id')
208 208
209 209 def delete(self, using=None):
210 210 """
211 211 Deletes all post images and the post itself.
212 212 """
213 213
214 214 for image in self.images.all():
215 215 image_refs_count = image.post_images.count()
216 216 if image_refs_count == 1:
217 217 image.delete()
218 218
219 219 for attachment in self.attachments.all():
220 220 attachment_refs_count = attachment.attachment_posts.count()
221 221 if attachment_refs_count == 1:
222 222 attachment.delete()
223 223
224 224 thread = self.get_thread()
225 225 thread.last_edit_time = timezone.now()
226 226 thread.save()
227 227
228 228 super(Post, self).delete(using)
229 229
230 230 logging.getLogger('boards.post.delete').info(
231 231 'Deleted post {}'.format(self))
232 232
233 233 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
234 234 include_last_update=False) -> str:
235 235 """
236 236 Gets post HTML or JSON data that can be rendered on a page or used by
237 237 API.
238 238 """
239 239
240 240 return get_exporter(format_type).export(self, request,
241 241 include_last_update)
242 242
243 243 def notify_clients(self, recursive=True):
244 244 """
245 245 Sends post HTML data to the thread web socket.
246 246 """
247 247
248 248 if not settings.get_bool('External', 'WebsocketsEnabled'):
249 249 return
250 250
251 251 thread_ids = list()
252 252 for thread in self.get_threads().all():
253 253 thread_ids.append(thread.id)
254 254
255 255 thread.notify_clients()
256 256
257 257 if recursive:
258 258 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
259 259 post_id = reply_number.group(1)
260 260
261 261 try:
262 262 ref_post = Post.objects.get(id=post_id)
263 263
264 264 if ref_post.get_threads().exclude(id__in=thread_ids).exists():
265 265 # If post is in this thread, its thread was already notified.
266 266 # Otherwise, notify its thread separately.
267 267 ref_post.notify_clients(recursive=False)
268 268 except ObjectDoesNotExist:
269 269 pass
270 270
271 271 def build_url(self):
272 272 self.url = self.get_absolute_url()
273 273 self.save(update_fields=['url'])
274 274
275 275 def save(self, force_insert=False, force_update=False, using=None,
276 276 update_fields=None):
277 277 self._text_rendered = Parser().parse(self.get_raw_text())
278 278
279 279 self.uid = str(uuid.uuid4())
280 280 if update_fields is not None and 'uid' not in update_fields:
281 281 update_fields += ['uid']
282 282
283 283 if self.id:
284 284 for thread in self.get_threads().all():
285 285 thread.last_edit_time = self.last_edit_time
286 286
287 287 thread.save(update_fields=['last_edit_time', 'bumpable'])
288 288
289 289 super().save(force_insert, force_update, using, update_fields)
290 290
291 291 def get_text(self) -> str:
292 292 return self._text_rendered
293 293
294 294 def get_raw_text(self) -> str:
295 295 return self.text
296 296
297 297 def get_absolute_id(self) -> str:
298 298 """
299 299 If the post has many threads, shows its main thread OP id in the post
300 300 ID.
301 301 """
302 302
303 303 if self.get_threads().count() > 1:
304 304 return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id)
305 305 else:
306 306 return str(self.id)
307 307
308 308 def connect_notifications(self):
309 309 for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()):
310 310 user_name = reply_number.group(1).lower()
311 311 Notification.objects.get_or_create(name=user_name, post=self)
312 312
313 313 def connect_replies(self):
314 314 """
315 315 Connects replies to a post to show them as a reflink map
316 316 """
317 317
318 318 for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()):
319 319 post_id = reply_number.group(1)
320 320
321 321 try:
322 322 referenced_post = Post.objects.get(id=post_id)
323 323
324 324 referenced_post.referenced_posts.add(self)
325 325 referenced_post.last_edit_time = self.pub_time
326 326 referenced_post.build_refmap()
327 327 referenced_post.save(update_fields=['refmap', 'last_edit_time'])
328 328 except ObjectDoesNotExist:
329 329 pass
330 330
331 331 def connect_threads(self, opening_posts):
332 332 for opening_post in opening_posts:
333 333 threads = opening_post.get_threads().all()
334 334 for thread in threads:
335 335 if thread.can_bump():
336 336 thread.update_bump_status()
337 337
338 338 thread.last_edit_time = self.last_edit_time
339 339 thread.save(update_fields=['last_edit_time', 'bumpable'])
340 340 self.threads.add(opening_post.get_thread())
341 341
342 342 def get_tripcode(self):
343 343 if self.tripcode:
344 344 return Tripcode(self.tripcode)
345 345
346 346 def get_link_view(self):
347 347 """
348 348 Gets view of a reflink to the post.
349 349 """
350 350 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
351 351 self.id)
352 352 if self.is_opening():
353 353 result = '<b>{}</b>'.format(result)
354 354
355 355 return result
356 356
357 357 def is_hidden(self) -> bool:
358 358 return self.hidden
359 359
360 360 def set_hidden(self, hidden):
361 361 self.hidden = hidden
@@ -1,126 +1,127 b''
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 django.db import models, transaction
7 7 from django.utils import timezone
8 8
9 9 import boards
10 10
11 11 from boards.models.user import Ban
12 12 from boards.mdx_neboard import Parser
13 13 from boards.models import PostImage, Attachment
14 14 from boards import utils
15 15
16 16 __author__ = 'neko259'
17 17
18 18 IMAGE_TYPES = (
19 19 'jpeg',
20 20 'jpg',
21 21 'png',
22 22 'bmp',
23 23 'gif',
24 24 )
25 25
26 26 POSTS_PER_DAY_RANGE = 7
27 27 NO_IP = '0.0.0.0'
28 28
29 29
30 30 class PostManager(models.Manager):
31 31 @transaction.atomic
32 32 def create_post(self, title: str, text: str, file=None, thread=None,
33 ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None):
33 ip=NO_IP, tags: list=None, opening_posts: list=None,
34 tripcode=''):
34 35 """
35 36 Creates new post
36 37 """
37 38
38 39 if not utils.is_anonymous_mode():
39 40 is_banned = Ban.objects.filter(ip=ip).exists()
40 41
41 42 # TODO Raise specific exception and catch it in the views
42 43 if is_banned:
43 44 raise Exception("This user is banned")
44 45
45 46 if not tags:
46 47 tags = []
47 48 if not opening_posts:
48 49 opening_posts = []
49 50
50 51 posting_time = timezone.now()
51 52 new_thread = False
52 53 if not thread:
53 54 thread = boards.models.thread.Thread.objects.create(
54 55 bump_time=posting_time, last_edit_time=posting_time)
55 56 list(map(thread.tags.add, tags))
56 57 boards.models.thread.Thread.objects.process_oldest_threads()
57 58 new_thread = True
58 59
59 60 pre_text = Parser().preparse(text)
60 61
61 62 post = self.create(title=title,
62 63 text=pre_text,
63 64 pub_time=posting_time,
64 65 poster_ip=ip,
65 66 thread=thread,
66 67 last_edit_time=posting_time,
67 68 tripcode=tripcode,
68 69 opening=new_thread)
69 70 post.threads.add(thread)
70 71
71 72 logger = logging.getLogger('boards.post.create')
72 73
73 74 logger.info('Created post {} by {}'.format(post, post.poster_ip))
74 75
75 76 # TODO Move this to other place
76 77 if file:
77 78 file_type = file.name.split('.')[-1].lower()
78 79 if file_type in IMAGE_TYPES:
79 80 post.images.add(PostImage.objects.create_with_hash(file))
80 81 else:
81 82 post.attachments.add(Attachment.objects.create_with_hash(file))
82 83
83 84 post.build_url()
84 85 post.connect_replies()
85 86 post.connect_threads(opening_posts)
86 87 post.connect_notifications()
87 88
88 89 # Thread needs to be bumped only when the post is already created
89 90 if not new_thread:
90 91 thread.last_edit_time = posting_time
91 92 thread.bump()
92 93 thread.save()
93 94
94 95 return post
95 96
96 97 def delete_posts_by_ip(self, ip):
97 98 """
98 99 Deletes all posts of the author with same IP
99 100 """
100 101
101 102 posts = self.filter(poster_ip=ip)
102 103 for post in posts:
103 104 post.delete()
104 105
105 106 @utils.cached_result()
106 107 def get_posts_per_day(self) -> float:
107 108 """
108 109 Gets average count of posts per day for the last 7 days
109 110 """
110 111
111 112 day_end = date.today()
112 113 day_start = day_end - timedelta(POSTS_PER_DAY_RANGE)
113 114
114 115 day_time_start = timezone.make_aware(datetime.combine(
115 116 day_start, dtime()), timezone.get_current_timezone())
116 117 day_time_end = timezone.make_aware(datetime.combine(
117 118 day_end, dtime()), timezone.get_current_timezone())
118 119
119 120 posts_per_period = float(self.filter(
120 121 pub_time__lte=day_time_end,
121 122 pub_time__gte=day_time_start).count())
122 123
123 124 ppd = posts_per_period / POSTS_PER_DAY_RANGE
124 125
125 126 return ppd
126 127
@@ -1,137 +1,138 b''
1 1 import hashlib
2 2 from django.template.loader import render_to_string
3 3 from django.db import models
4 4 from django.db.models import Count
5 5 from django.core.urlresolvers import reverse
6 6
7 7 from boards.models.base import Viewable
8 8 from boards.utils import cached_result
9 9 import boards
10 10
11 11 __author__ = 'neko259'
12 12
13 13
14 14 RELATED_TAGS_COUNT = 5
15 15
16 16
17 17 class TagManager(models.Manager):
18 18
19 19 def get_not_empty_tags(self):
20 20 """
21 21 Gets tags that have non-archived threads.
22 22 """
23 23
24 24 return self.annotate(num_threads=Count('thread_tags')).filter(num_threads__gt=0)\
25 25 .order_by('-required', 'name')
26 26
27 27 def get_tag_url_list(self, tags: list) -> str:
28 28 """
29 29 Gets a comma-separated list of tag links.
30 30 """
31 31
32 32 return ', '.join([tag.get_view() for tag in tags])
33 33
34 34
35 35 class Tag(models.Model, Viewable):
36 36 """
37 37 A tag is a text node assigned to the thread. The tag serves as a board
38 38 section. There can be multiple tags for each thread
39 39 """
40 40
41 41 objects = TagManager()
42 42
43 43 class Meta:
44 44 app_label = 'boards'
45 45 ordering = ('name',)
46 46
47 47 name = models.CharField(max_length=100, db_index=True, unique=True)
48 48 required = models.BooleanField(default=False, db_index=True)
49 49 description = models.TextField(blank=True)
50 50
51 parent = models.ForeignKey('Tag', null=True, related_name='children')
51 parent = models.ForeignKey('Tag', null=True, blank=True,
52 related_name='children')
52 53
53 54 def __str__(self):
54 55 return self.name
55 56
56 57 def is_empty(self) -> bool:
57 58 """
58 59 Checks if the tag has some threads.
59 60 """
60 61
61 62 return self.get_thread_count() == 0
62 63
63 64 def get_thread_count(self, archived=None) -> int:
64 65 threads = self.get_threads()
65 66 if archived is not None:
66 67 threads = threads.filter(archived=archived)
67 68 return threads.count()
68 69
69 70 def get_active_thread_count(self) -> int:
70 71 return self.get_thread_count(archived=False)
71 72
72 73 def get_archived_thread_count(self) -> int:
73 74 return self.get_thread_count(archived=True)
74 75
75 76 def get_absolute_url(self):
76 77 return reverse('tag', kwargs={'tag_name': self.name})
77 78
78 79 def get_threads(self):
79 80 return self.thread_tags.order_by('-bump_time')
80 81
81 82 def is_required(self):
82 83 return self.required
83 84
84 85 def get_view(self):
85 86 link = '<a class="tag" href="{}">{}</a>'.format(
86 87 self.get_absolute_url(), self.name)
87 88 if self.is_required():
88 89 link = '<b>{}</b>'.format(link)
89 90 return link
90 91
91 92 def get_search_view(self, *args, **kwargs):
92 93 return render_to_string('boards/tag.html', {
93 94 'tag': self,
94 95 })
95 96
96 97 @cached_result()
97 98 def get_post_count(self):
98 99 return self.get_threads().aggregate(num_posts=Count('multi_replies'))['num_posts']
99 100
100 101 def get_description(self):
101 102 return self.description
102 103
103 104 def get_random_image_post(self, archived=False):
104 105 posts = boards.models.Post.objects.annotate(images_count=Count(
105 106 'images')).filter(images_count__gt=0, threads__tags__in=[self])
106 107 if archived is not None:
107 108 posts = posts.filter(thread__archived=archived)
108 109 return posts.order_by('?').first()
109 110
110 111 def get_first_letter(self):
111 112 return self.name and self.name[0] or ''
112 113
113 114 def get_related_tags(self):
114 115 return set(Tag.objects.filter(thread_tags__in=self.get_threads()).exclude(
115 116 id=self.id).order_by('?')[:RELATED_TAGS_COUNT])
116 117
117 118 @cached_result()
118 119 def get_color(self):
119 120 """
120 121 Gets color hashed from the tag name.
121 122 """
122 123 return hashlib.md5(self.name.encode()).hexdigest()[:6]
123 124
124 125 def get_parent(self):
125 126 return self.parent
126 127
127 128 def get_all_parents(self):
128 129 parents = list()
129 130 parent = self.get_parent()
130 131 if parent and parent not in parents:
131 132 parents.insert(0, parent)
132 133 parents = parent.get_all_parents() + parents
133 134
134 135 return parents
135 136
136 137 def get_children(self):
137 138 return self.children
General Comments 0
You need to be logged in to leave comments. Login now