##// END OF EJS Templates
More abstract OP extensions (sticker pack, monochrome etc that are shown in the OP)
neko259 -
r2131:218bf349 default
parent child Browse files
Show More
@@ -1,367 +1,374 b''
1 1 import uuid
2 2
3 3 import hashlib
4 4 import re
5 5 from django.db import models
6 6 from django.db.models import TextField
7 7 from django.template.defaultfilters import truncatewords, striptags
8 8 from django.template.loader import render_to_string
9 9 from django.urls import reverse
10 10
11 11 from boards.abstracts.constants import REGEX_REPLY
12 12 from boards.abstracts.tripcode import Tripcode
13 13 from boards.models import Attachment, KeyPair, GlobalId
14 14 from boards.models.attachment import FILE_TYPES_IMAGE
15 15 from boards.models.base import Viewable
16 16 from boards.models.post.export import get_exporter, DIFF_TYPE_JSON
17 17 from boards.models.post.manager import PostManager, NO_IP
18 18 from boards.utils import datetime_to_epoch
19 19
20 20 CSS_CLS_HIDDEN_POST = 'hidden_post'
21 21 CSS_CLS_DEAD_POST = 'dead_post'
22 22 CSS_CLS_ARCHIVE_POST = 'archive_post'
23 23 CSS_CLS_POST = 'post'
24 24 CSS_CLS_MONOCHROME = 'monochrome'
25 25
26 26 CSS_CLASS_DELIMITER = ' '
27 27
28 28 TITLE_MAX_WORDS = 10
29 29
30 30 APP_LABEL_BOARDS = 'boards'
31 31
32 32 BAN_REASON_AUTO = 'Auto'
33 33
34 34 TITLE_MAX_LENGTH = 200
35 35
36 36 REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
37 37 REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
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_POST = 'post'
48 48 PARAMETER_OP_ID = 'opening_post_id'
49 49 PARAMETER_NEED_OPEN_LINK = 'need_open_link'
50 50 PARAMETER_REPLY_LINK = 'reply_link'
51 51 PARAMETER_NEED_OP_DATA = 'need_op_data'
52 PARAMETER_OP_EXTENSIONS = 'op_extensions'
52 53
53 54 POST_VIEW_PARAMS = (
54 55 'need_op_data',
55 56 'reply_link',
56 57 'need_open_link',
57 58 'truncated',
58 59 'mode_tree',
59 60 'perms',
60 61 'tree_depth',
61 62 )
62 63
63 64 TEMPLATE_POST = 'boards/post.html'
64 65
65 66
66 67 class Post(models.Model, Viewable):
67 68 """A post is a message."""
68 69
69 70 objects = PostManager()
70 71
71 72 class Meta:
72 73 app_label = APP_LABEL_BOARDS
73 74 ordering = ('id',)
74 75
75 76 title = models.CharField(max_length=TITLE_MAX_LENGTH, blank=True, default='')
76 77 pub_time = models.DateTimeField(db_index=True)
77 78 text = TextField(blank=True, default='')
78 79 _text_rendered = TextField(blank=True, null=True, editable=False)
79 80
80 81 attachments = models.ManyToManyField(Attachment, blank=True,
81 82 related_name='attachment_posts')
82 83
83 84 poster_ip = models.GenericIPAddressField()
84 85
85 86 # Used for cache and threads updating
86 87 last_edit_time = models.DateTimeField()
87 88
88 89 referenced_posts = models.ManyToManyField('Post', symmetrical=False,
89 90 blank=True, related_name='refposts',
90 91 db_index=True)
91 92 refmap = models.TextField(null=True, blank=True)
92 93 thread = models.ForeignKey('Thread', on_delete=models.CASCADE,
93 94 db_index=True, related_name='replies')
94 95
95 96 url = models.TextField()
96 97 uid = models.TextField()
97 98
98 99 # Global ID with author key. If the message was downloaded from another
99 100 # server, this indicates the server.
100 101 global_id = models.OneToOneField(GlobalId, null=True, blank=True,
101 102 on_delete=models.CASCADE)
102 103
103 104 tripcode = models.CharField(max_length=50, blank=True, default='')
104 105 opening = models.BooleanField(db_index=True)
105 106 hidden = models.BooleanField(default=False)
106 107
107 108 def __str__(self):
108 109 return 'P#{}/{}'.format(self.id, self.get_title())
109 110
110 111 def get_title(self) -> str:
111 112 return self.title
112 113
113 114 def get_title_or_text(self):
114 115 title = self.get_title()
115 116 if not title:
116 117 title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS)
117 118
118 119 return title
119 120
120 121 def build_refmap(self, excluded_ids=None) -> None:
121 122 """
122 123 Builds a replies map string from replies list. This is a cache to stop
123 124 the server from recalculating the map on every post show.
124 125 """
125 126
126 127 replies = self.referenced_posts
127 128 if excluded_ids is not None:
128 129 replies = replies.exclude(id__in=excluded_ids)
129 130 else:
130 131 replies = replies.all()
131 132
132 133 post_urls = [refpost.get_link_view() for refpost in replies]
133 134
134 135 self.refmap = ', '.join(post_urls)
135 136
136 137 def is_referenced(self) -> bool:
137 138 return self.refmap and len(self.refmap) > 0
138 139
139 140 def is_opening(self) -> bool:
140 141 """
141 142 Checks if this is an opening post or just a reply.
142 143 """
143 144
144 145 return self.opening
145 146
146 147 def get_absolute_url(self, thread=None):
147 148 # Url is cached only for the "main" thread. When getting url
148 149 # for other threads, do it manually.
149 150 return self.url or self._build_url()
150 151
151 152 def get_thread(self):
152 153 return self.thread
153 154
154 155 def get_thread_id(self):
155 156 return self.thread_id
156 157
157 158 def _get_cache_key(self):
158 159 return [datetime_to_epoch(self.last_edit_time)]
159 160
160 161 def get_view_params(self, *args, **kwargs):
161 162 """
162 163 Gets the parameters required for viewing the post based on the arguments
163 164 given and the post itself.
164 165 """
165 166 thread = kwargs.get('thread') or self.get_thread()
166 167
168 op_extensions = dict()
169
167 170 css_classes = [CSS_CLS_POST]
168 171 if thread.is_archived():
169 172 css_classes.append(CSS_CLS_ARCHIVE_POST)
170 173 elif not thread.can_bump():
171 174 css_classes.append(CSS_CLS_DEAD_POST)
172 175 if self.is_hidden():
173 176 css_classes.append(CSS_CLS_HIDDEN_POST)
174 177 if thread.is_monochrome():
175 178 css_classes.append(CSS_CLS_MONOCHROME)
179 op_extensions['Monochrome'] = '☯'
180 if thread.is_stickerpack():
181 op_extensions['Sticker Pack'] = '📷'
176 182
177 183 params = dict()
178 184 for param in POST_VIEW_PARAMS:
179 185 if param in kwargs:
180 186 params[param] = kwargs[param]
181 187
182 188 params.update({
183 189 PARAMETER_POST: self,
184 190 PARAMETER_IS_OPENING: self.is_opening(),
185 191 PARAMETER_THREAD: thread,
186 192 PARAMETER_CSS_CLASS: CSS_CLASS_DELIMITER.join(css_classes),
193 PARAMETER_OP_EXTENSIONS: op_extensions,
187 194 })
188 195
189 196 return params
190 197
191 198 def get_view(self, *args, **kwargs) -> str:
192 199 """
193 200 Renders post's HTML view. Some of the post params can be passed over
194 201 kwargs for the means of caching (if we view the thread, some params
195 202 are same for every post and don't need to be computed over and over.
196 203 """
197 204 params = self.get_view_params(*args, **kwargs)
198 205
199 206 return render_to_string(TEMPLATE_POST, params)
200 207
201 208 def get_images(self) -> Attachment:
202 209 return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE)
203 210
204 211 def get_first_image(self) -> Attachment:
205 212 try:
206 213 return self.get_images().earliest('-id')
207 214 except Attachment.DoesNotExist:
208 215 return None
209 216
210 217 def set_global_id(self, key_pair=None):
211 218 """
212 219 Sets global id based on the given key pair. If no key pair is given,
213 220 default one is used.
214 221 """
215 222
216 223 if key_pair:
217 224 key = key_pair
218 225 else:
219 226 try:
220 227 key = KeyPair.objects.get(primary=True)
221 228 except KeyPair.DoesNotExist:
222 229 # Do not update the global id because there is no key defined
223 230 return
224 231 global_id = GlobalId(key_type=key.key_type,
225 232 key=key.public_key,
226 233 local_id=self.id)
227 234 global_id.save()
228 235
229 236 self.global_id = global_id
230 237
231 238 self.save(update_fields=['global_id'])
232 239
233 240 def get_pub_time_str(self):
234 241 return str(self.pub_time)
235 242
236 243 def get_replied_ids(self):
237 244 """
238 245 Gets ID list of the posts that this post replies.
239 246 """
240 247
241 248 raw_text = self.get_raw_text()
242 249
243 250 local_replied = REGEX_REPLY.findall(raw_text)
244 251 global_replied = []
245 252 for match in REGEX_GLOBAL_REPLY.findall(raw_text):
246 253 key_type = match[0]
247 254 key = match[1]
248 255 local_id = match[2]
249 256
250 257 try:
251 258 global_id = GlobalId.objects.get(key_type=key_type,
252 259 key=key, local_id=local_id)
253 260 for post in Post.objects.filter(global_id=global_id).only('id'):
254 261 global_replied.append(post.id)
255 262 except GlobalId.DoesNotExist:
256 263 pass
257 264 return local_replied + global_replied
258 265
259 266 def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None,
260 267 include_last_update=False) -> str:
261 268 """
262 269 Gets post HTML or JSON data that can be rendered on a page or used by
263 270 API.
264 271 """
265 272
266 273 return get_exporter(format_type).export(self, request,
267 274 include_last_update)
268 275
269 276 def _build_url(self):
270 277 if self.id:
271 278 opening = self.is_opening()
272 279 opening_id = self.id if opening else self.get_thread().get_opening_post_id()
273 280 url = reverse('thread', kwargs={'post_id': opening_id})
274 281 if not opening:
275 282 url += '#' + str(self.id)
276 283
277 284 return url
278 285
279 286 def save(self, force_insert=False, force_update=False, using=None,
280 287 update_fields=None):
281 288 new_post = self.id is None
282 289
283 290 self.uid = str(uuid.uuid4())
284 291 if update_fields is not None and 'uid' not in update_fields:
285 292 update_fields += ['uid']
286 293
287 294 if not new_post:
288 295 thread = self.get_thread()
289 296 if thread:
290 297 thread.last_edit_time = self.last_edit_time
291 298 thread.save(update_fields=['last_edit_time', 'status'])
292 299
293 300 super().save(force_insert, force_update, using, update_fields)
294 301
295 302 if new_post:
296 303 if not self.url:
297 304 self.url = self._build_url()
298 305 super().save(update_fields=['url'])
299 306
300 307 def get_text(self) -> str:
301 308 return self._text_rendered
302 309
303 310 def get_raw_text(self) -> str:
304 311 return self.text
305 312
306 313 def get_sync_text(self) -> str:
307 314 """
308 315 Returns text applicable for sync. It has absolute post reflinks.
309 316 """
310 317
311 318 replacements = dict()
312 319 for post_id in REGEX_REPLY.findall(self.get_raw_text()):
313 320 try:
314 321 absolute_post_id = str(Post.objects.get(id=post_id).global_id)
315 322 replacements[post_id] = absolute_post_id
316 323 except Post.DoesNotExist:
317 324 pass
318 325
319 326 text = self.get_raw_text() or ''
320 327 for key in replacements:
321 328 text = text.replace('[post]{}[/post]'.format(key),
322 329 '[post]{}[/post]'.format(replacements[key]))
323 330 text = text.replace('\r\n', '\n').replace('\r', '\n')
324 331
325 332 return text
326 333
327 334 def get_tripcode(self):
328 335 if self.tripcode:
329 336 return Tripcode(self.tripcode)
330 337
331 338 def get_link_view(self):
332 339 """
333 340 Gets view of a reflink to the post.
334 341 """
335 342 result = '<a href="{}">&gt;&gt;{}</a>'.format(self.get_absolute_url(),
336 343 self.id)
337 344 if self.is_opening():
338 345 result = '<b>{}</b>'.format(result)
339 346
340 347 return result
341 348
342 349 def is_hidden(self) -> bool:
343 350 return self.hidden
344 351
345 352 def set_hidden(self, hidden):
346 353 self.hidden = hidden
347 354
348 355 def clear_cache(self):
349 356 """
350 357 Clears sync data (content cache, signatures etc).
351 358 """
352 359 global_id = self.global_id
353 360 if global_id is not None and global_id.is_local()\
354 361 and global_id.content is not None:
355 362 global_id.clear_cache()
356 363
357 364 def get_tags(self):
358 365 return self.get_thread().get_tags()
359 366
360 367 def get_ip_color(self):
361 368 return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6]
362 369
363 370 def has_ip(self):
364 371 return self.poster_ip != NO_IP
365 372
366 373 def has_global_id(self):
367 374 return self.global_id_id is not None
@@ -1,88 +1,86 b''
1 1 {% load i18n %}
2 2 {% load board %}
3 3
4 4 {% get_current_language as LANGUAGE_CODE %}
5 5
6 6 {% autoescape off %}
7 7 <div class="{{ css_class }}" id="{{ post.id }}"
8 8 data-uid="{{ post.uid }}"
9 9 data-has-global-id="{{ post.has_global_id }}"
10 10 data-thread-id="{{ thread.id }}"
11 11 data-opening="{{ is_opening }}"
12 12 {% if tree_depth %}style="margin-left: {{ tree_depth }}em;"{% endif %}>
13 13 <div class="post-info">
14 14 <a class="post_id" href="{{ post.get_absolute_url }}">#{{ post.id }}</a>
15 15 {% if is_opening %}
16 {% if thread.is_stickerpack %}
17 <span title="{% trans "Sticker Pack" %}">&#128247;</span>
18 {% elif thread.is_monochrome %}
19 <span title="{% trans "Monochrome" %}">&#9775;</span>
20 {% endif %}
16 {% for name, character in op_extensions.items %}
17 <span title="{% trans name %}">{{ character }}</span>
18 {% endfor %}
21 19 {% endif %}
22 20 <span class="title">{{ post.title|escape }}</span>
23 21 <span class="pub_time" {% if perms.boards.change_post and post.has_ip %}style="border-bottom: solid 2px #{{ post.get_ip_color }};" title="{{ post.poster_ip }}"{% endif %}>
24 22 <time datetime="{{ post.pub_time|date:'c' }}">{{ post.pub_time }}</time></span>
25 23 {% if post.tripcode %}
26 24 / {{ post.get_tripcode.get_view|safe }}
27 25 {% endif %}
28 26 {% comment %}
29 27 Thread death time needs to be shown only if the thread is alredy archived
30 28 and this is an opening post (thread death time) or a post for popup
31 29 (we don't see OP here so we show the death time in the post itself).
32 30 {% endcomment %}
33 31 {% if is_opening %}
34 32 {% if thread.is_archived %}
35 33 <time datetime="{{ thread.bump_time|date:'c' }}">{{ thread.bump_time }}</time>
36 34 {% endif %}
37 35 {% if need_open_link %}
38 36 <a class="link" href="{% url 'thread' post.id %}{% if not thread.is_archived %}#form{% endif %}">{% trans "Open" %}</a>
39 37 {% endif %}
40 38 {% else %}
41 39 {% if need_op_data %}
42 40 {% with thread.get_opening_post as op %}
43 41 {% trans " in " %}{{ op.get_link_view|safe }} <span class="title">{{ op.get_title_or_text }}</span>
44 42 {% endwith %}
45 43 {% endif %}
46 44 {% endif %}
47 45 {% if reply_link and not thread.is_archived %}
48 46 <a href="#form" onclick="addQuickReply('{{ post.id }}'); return false;">{% trans 'Reply' %}</a>
49 47 {% endif %}
50 48
51 49 <a class="post-menu" href="#">&#8942;</a>
52 50 </div>
53 51 {% for file in post.attachments.all %}
54 52 {{ file.get_view|safe }}
55 53 {% endfor %}
56 54 {% comment %}
57 55 Post message (text)
58 56 {% endcomment %}
59 57 <div class="message">
60 58 {% if truncated %}
61 59 {{ post.get_text|truncatewords_html:50|truncatenewlines_html:3|safe }}
62 60 {% else %}
63 61 {{ post.get_text|safe }}
64 62 {% endif %}
65 63 </div>
66 64 {% if post.is_referenced and not mode_tree %}
67 65 <div class="refmap">
68 66 {% trans "Replies" %}: {{ post.refmap|safe }}
69 67 </div>
70 68 {% endif %}
71 69 {% comment %}
72 70 Thread metadata: counters, tags etc
73 71 {% endcomment %}
74 72 {% if is_opening %}
75 73 <div class="metadata">
76 74 {% if need_open_link %}
77 75 ♥ {{ thread.get_reply_count }}
78 76 ❄ {{ thread.get_attachment_count }}
79 77 <a href="{% url 'thread_gallery' post.id %}">G</a>
80 78 <a href="{% url 'thread_tree' post.id %}">T</a>
81 79 {% endif %}
82 80 <span class="tags">
83 81 {{ thread.get_tag_url_list|safe }}
84 82 </span>
85 83 </div>
86 84 {% endif %}
87 85 </div>
88 86 {% endautoescape %}
General Comments 0
You need to be logged in to leave comments. Login now