__init__.py
367 lines
| 11.3 KiB
| text/x-python
|
PythonLexer
neko259
|
r1156 | import uuid | ||
neko259
|
r1986 | |||
neko259
|
r1638 | import hashlib | ||
import re | ||||
neko259
|
r1986 | from django.db import models | ||
from django.db.models import TextField | ||||
from django.template.defaultfilters import truncatewords, striptags | ||||
from django.template.loader import render_to_string | ||||
from django.urls import reverse | ||||
neko259
|
r1156 | |||
neko259
|
r1997 | from boards.abstracts.constants import REGEX_REPLY | ||
neko259
|
r1296 | from boards.abstracts.tripcode import Tripcode | ||
neko259
|
r1590 | from boards.models import Attachment, KeyPair, GlobalId | ||
from boards.models.attachment import FILE_TYPES_IMAGE | ||||
neko259
|
r1156 | from boards.models.base import Viewable | ||
from boards.models.post.export import get_exporter, DIFF_TYPE_JSON | ||||
neko259
|
r1678 | from boards.models.post.manager import PostManager, NO_IP | ||
neko259
|
r1533 | from boards.utils import datetime_to_epoch | ||
neko259
|
r1156 | |||
neko259
|
r1366 | CSS_CLS_HIDDEN_POST = 'hidden_post' | ||
CSS_CLS_DEAD_POST = 'dead_post' | ||||
CSS_CLS_ARCHIVE_POST = 'archive_post' | ||||
CSS_CLS_POST = 'post' | ||||
neko259
|
r1434 | CSS_CLS_MONOCHROME = 'monochrome' | ||
neko259
|
r1366 | |||
neko259
|
r2029 | CSS_CLASS_DELIMITER = ' ' | ||
neko259
|
r1358 | TITLE_MAX_WORDS = 10 | ||
neko259
|
r1156 | APP_LABEL_BOARDS = 'boards' | ||
BAN_REASON_AUTO = 'Auto' | ||||
TITLE_MAX_LENGTH = 200 | ||||
neko259
|
r1157 | REGEX_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]') | ||
REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?') | ||||
neko259
|
r1156 | REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]') | ||
PARAMETER_TRUNCATED = 'truncated' | ||||
PARAMETER_TAG = 'tag' | ||||
PARAMETER_OFFSET = 'offset' | ||||
PARAMETER_DIFF_TYPE = 'type' | ||||
PARAMETER_CSS_CLASS = 'css_class' | ||||
PARAMETER_THREAD = 'thread' | ||||
PARAMETER_IS_OPENING = 'is_opening' | ||||
PARAMETER_POST = 'post' | ||||
PARAMETER_OP_ID = 'opening_post_id' | ||||
PARAMETER_NEED_OPEN_LINK = 'need_open_link' | ||||
PARAMETER_REPLY_LINK = 'reply_link' | ||||
neko259
|
r1166 | PARAMETER_NEED_OP_DATA = 'need_op_data' | ||
neko259
|
r1156 | |||
neko259
|
r1180 | POST_VIEW_PARAMS = ( | ||
'need_op_data', | ||||
'reply_link', | ||||
'need_open_link', | ||||
'truncated', | ||||
'mode_tree', | ||||
neko259
|
r1388 | 'perms', | ||
neko259
|
r1473 | 'tree_depth', | ||
neko259
|
r1180 | ) | ||
neko259
|
r2029 | TEMPLATE_POST = 'boards/post.html' | ||
neko259
|
r1358 | |||
neko259
|
r1156 | class Post(models.Model, Viewable): | ||
"""A post is a message.""" | ||||
objects = PostManager() | ||||
class Meta: | ||||
app_label = APP_LABEL_BOARDS | ||||
ordering = ('id',) | ||||
neko259
|
r1751 | title = models.CharField(max_length=TITLE_MAX_LENGTH, blank=True, default='') | ||
neko259
|
r1667 | pub_time = models.DateTimeField(db_index=True) | ||
neko259
|
r1751 | text = TextField(blank=True, default='') | ||
neko259
|
r1156 | _text_rendered = TextField(blank=True, null=True, editable=False) | ||
neko259
|
r2067 | attachments = models.ManyToManyField(Attachment, blank=True, | ||
neko259
|
r1358 | related_name='attachment_posts') | ||
neko259
|
r1156 | |||
poster_ip = models.GenericIPAddressField() | ||||
neko259
|
r1601 | # Used for cache and threads updating | ||
neko259
|
r1156 | last_edit_time = models.DateTimeField() | ||
referenced_posts = models.ManyToManyField('Post', symmetrical=False, | ||||
neko259
|
r1180 | blank=True, related_name='refposts', | ||
neko259
|
r1156 | db_index=True) | ||
refmap = models.TextField(null=True, blank=True) | ||||
neko259
|
r1986 | thread = models.ForeignKey('Thread', on_delete=models.CASCADE, | ||
db_index=True, related_name='replies') | ||||
neko259
|
r1156 | |||
url = models.TextField() | ||||
neko259
|
r1835 | uid = models.TextField() | ||
neko259
|
r1156 | |||
neko259
|
r1157 | # Global ID with author key. If the message was downloaded from another | ||
# server, this indicates the server. | ||||
neko259
|
r1511 | global_id = models.OneToOneField(GlobalId, null=True, blank=True, | ||
on_delete=models.CASCADE) | ||||
neko259
|
r1157 | |||
neko259
|
r1367 | tripcode = models.CharField(max_length=50, blank=True, default='') | ||
neko259
|
r1381 | opening = models.BooleanField(db_index=True) | ||
neko259
|
r1366 | hidden = models.BooleanField(default=False) | ||
neko259
|
r1293 | |||
neko259
|
r1156 | def __str__(self): | ||
neko259
|
r1358 | return 'P#{}/{}'.format(self.id, self.get_title()) | ||
neko259
|
r1156 | |||
def get_title(self) -> str: | ||||
neko259
|
r1358 | return self.title | ||
neko259
|
r1156 | |||
neko259
|
r1358 | def get_title_or_text(self): | ||
title = self.get_title() | ||||
neko259
|
r1156 | if not title: | ||
neko259
|
r1358 | title = truncatewords(striptags(self.get_text()), TITLE_MAX_WORDS) | ||
neko259
|
r1156 | |||
return title | ||||
neko259
|
r1619 | def build_refmap(self, excluded_ids=None) -> None: | ||
neko259
|
r1156 | """ | ||
Builds a replies map string from replies list. This is a cache to stop | ||||
the server from recalculating the map on every post show. | ||||
""" | ||||
neko259
|
r1619 | replies = self.referenced_posts | ||
if excluded_ids is not None: | ||||
replies = replies.exclude(id__in=excluded_ids) | ||||
else: | ||||
replies = replies.all() | ||||
post_urls = [refpost.get_link_view() for refpost in replies] | ||||
neko259
|
r1156 | |||
self.refmap = ', '.join(post_urls) | ||||
def is_referenced(self) -> bool: | ||||
return self.refmap and len(self.refmap) > 0 | ||||
def is_opening(self) -> bool: | ||||
""" | ||||
Checks if this is an opening post or just a reply. | ||||
""" | ||||
neko259
|
r1337 | return self.opening | ||
neko259
|
r1156 | |||
neko259
|
r1365 | def get_absolute_url(self, thread=None): | ||
neko259
|
r1668 | # Url is cached only for the "main" thread. When getting url | ||
# for other threads, do it manually. | ||||
neko259
|
r2047 | return self.url or self._build_url() | ||
neko259
|
r1192 | |||
neko259
|
r1156 | def get_thread(self): | ||
return self.thread | ||||
neko259
|
r1455 | def get_thread_id(self): | ||
return self.thread_id | ||||
neko259
|
r1531 | def _get_cache_key(self): | ||
return [datetime_to_epoch(self.last_edit_time)] | ||||
neko259
|
r1648 | def get_view_params(self, *args, **kwargs): | ||
neko259
|
r1651 | """ | ||
Gets the parameters required for viewing the post based on the arguments | ||||
given and the post itself. | ||||
""" | ||||
neko259
|
r1670 | thread = kwargs.get('thread') or self.get_thread() | ||
neko259
|
r1156 | |||
neko259
|
r1366 | css_classes = [CSS_CLS_POST] | ||
neko259
|
r1414 | if thread.is_archived(): | ||
neko259
|
r1366 | css_classes.append(CSS_CLS_ARCHIVE_POST) | ||
neko259
|
r1156 | elif not thread.can_bump(): | ||
neko259
|
r1366 | css_classes.append(CSS_CLS_DEAD_POST) | ||
if self.is_hidden(): | ||||
css_classes.append(CSS_CLS_HIDDEN_POST) | ||||
neko259
|
r1434 | if thread.is_monochrome(): | ||
css_classes.append(CSS_CLS_MONOCHROME) | ||||
neko259
|
r1156 | |||
neko259
|
r1180 | params = dict() | ||
for param in POST_VIEW_PARAMS: | ||||
if param in kwargs: | ||||
params[param] = kwargs[param] | ||||
params.update({ | ||||
neko259
|
r1156 | PARAMETER_POST: self, | ||
neko259
|
r1337 | PARAMETER_IS_OPENING: self.is_opening(), | ||
neko259
|
r1156 | PARAMETER_THREAD: thread, | ||
neko259
|
r2029 | PARAMETER_CSS_CLASS: CSS_CLASS_DELIMITER.join(css_classes), | ||
neko259
|
r1156 | }) | ||
neko259
|
r1648 | return params | ||
def get_view(self, *args, **kwargs) -> str: | ||||
""" | ||||
Renders post's HTML view. Some of the post params can be passed over | ||||
kwargs for the means of caching (if we view the thread, some params | ||||
are same for every post and don't need to be computed over and over. | ||||
""" | ||||
neko259
|
r1650 | params = self.get_view_params(*args, **kwargs) | ||
neko259
|
r1648 | |||
neko259
|
r2029 | return render_to_string(TEMPLATE_POST, params) | ||
neko259
|
r1180 | |||
neko259
|
r1755 | def get_images(self) -> Attachment: | ||
return self.attachments.filter(mimetype__in=FILE_TYPES_IMAGE) | ||||
neko259
|
r1590 | def get_first_image(self) -> Attachment: | ||
neko259
|
r1725 | try: | ||
neko259
|
r1755 | return self.get_images().earliest('-id') | ||
neko259
|
r1725 | except Attachment.DoesNotExist: | ||
neko259
|
r1755 | return None | ||
neko259
|
r1156 | |||
neko259
|
r1157 | def set_global_id(self, key_pair=None): | ||
neko259
|
r1156 | """ | ||
neko259
|
r1157 | Sets global id based on the given key pair. If no key pair is given, | ||
default one is used. | ||||
neko259
|
r1156 | """ | ||
neko259
|
r1157 | if key_pair: | ||
key = key_pair | ||||
else: | ||||
try: | ||||
key = KeyPair.objects.get(primary=True) | ||||
except KeyPair.DoesNotExist: | ||||
# Do not update the global id because there is no key defined | ||||
return | ||||
global_id = GlobalId(key_type=key.key_type, | ||||
key=key.public_key, | ||||
neko259
|
r1229 | local_id=self.id) | ||
neko259
|
r1157 | global_id.save() | ||
self.global_id = global_id | ||||
self.save(update_fields=['global_id']) | ||||
neko259
|
r1229 | def get_pub_time_str(self): | ||
return str(self.pub_time) | ||||
neko259
|
r1156 | |||
neko259
|
r1157 | def get_replied_ids(self): | ||
""" | ||||
Gets ID list of the posts that this post replies. | ||||
""" | ||||
raw_text = self.get_raw_text() | ||||
neko259
|
r1278 | |||
neko259
|
r1157 | local_replied = REGEX_REPLY.findall(raw_text) | ||
global_replied = [] | ||||
for match in REGEX_GLOBAL_REPLY.findall(raw_text): | ||||
key_type = match[0] | ||||
key = match[1] | ||||
local_id = match[2] | ||||
neko259
|
r1156 | |||
neko259
|
r1157 | try: | ||
global_id = GlobalId.objects.get(key_type=key_type, | ||||
neko259
|
r1241 | key=key, local_id=local_id) | ||
neko259
|
r1157 | for post in Post.objects.filter(global_id=global_id).only('id'): | ||
global_replied.append(post.id) | ||||
except GlobalId.DoesNotExist: | ||||
pass | ||||
return local_replied + global_replied | ||||
neko259
|
r1156 | |||
def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None, | ||||
include_last_update=False) -> str: | ||||
""" | ||||
Gets post HTML or JSON data that can be rendered on a page or used by | ||||
API. | ||||
""" | ||||
return get_exporter(format_type).export(self, request, | ||||
include_last_update) | ||||
neko259
|
r1669 | def _build_url(self): | ||
neko259
|
r2047 | if self.id: | ||
opening = self.is_opening() | ||||
opening_id = self.id if opening else self.get_thread().get_opening_post_id() | ||||
url = reverse('thread', kwargs={'post_id': opening_id}) | ||||
if not opening: | ||||
url += '#' + str(self.id) | ||||
neko259
|
r1669 | |||
neko259
|
r2047 | return url | ||
neko259
|
r1156 | |||
def save(self, force_insert=False, force_update=False, using=None, | ||||
update_fields=None): | ||||
neko259
|
r1440 | new_post = self.id is None | ||
neko259
|
r1156 | self.uid = str(uuid.uuid4()) | ||
if update_fields is not None and 'uid' not in update_fields: | ||||
update_fields += ['uid'] | ||||
neko259
|
r1440 | if not new_post: | ||
neko259
|
r1704 | thread = self.get_thread() | ||
if thread: | ||||
neko259
|
r1156 | thread.last_edit_time = self.last_edit_time | ||
neko259
|
r1414 | thread.save(update_fields=['last_edit_time', 'status']) | ||
neko259
|
r1156 | |||
super().save(force_insert, force_update, using, update_fields) | ||||
neko259
|
r1669 | if new_post: | ||
neko259
|
r2047 | if not self.url: | ||
self.url = self._build_url() | ||||
neko259
|
r1669 | super().save(update_fields=['url']) | ||
neko259
|
r1440 | |||
neko259
|
r1156 | def get_text(self) -> str: | ||
return self._text_rendered | ||||
def get_raw_text(self) -> str: | ||||
return self.text | ||||
neko259
|
r1228 | def get_sync_text(self) -> str: | ||
""" | ||||
Returns text applicable for sync. It has absolute post reflinks. | ||||
""" | ||||
replacements = dict() | ||||
for post_id in REGEX_REPLY.findall(self.get_raw_text()): | ||||
neko259
|
r1575 | try: | ||
absolute_post_id = str(Post.objects.get(id=post_id).global_id) | ||||
replacements[post_id] = absolute_post_id | ||||
except Post.DoesNotExist: | ||||
pass | ||||
neko259
|
r1228 | |||
neko259
|
r1511 | text = self.get_raw_text() or '' | ||
neko259
|
r1228 | for key in replacements: | ||
text = text.replace('[post]{}[/post]'.format(key), | ||||
'[post]{}[/post]'.format(replacements[key])) | ||||
neko259
|
r1504 | text = text.replace('\r\n', '\n').replace('\r', '\n') | ||
neko259
|
r1228 | |||
return text | ||||
neko259
|
r1296 | def get_tripcode(self): | ||
if self.tripcode: | ||||
return Tripcode(self.tripcode) | ||||
neko259
|
r1309 | |||
def get_link_view(self): | ||||
""" | ||||
Gets view of a reflink to the post. | ||||
""" | ||||
result = '<a href="{}">>>{}</a>'.format(self.get_absolute_url(), | ||||
neko259
|
r1323 | self.id) | ||
neko259
|
r1309 | if self.is_opening(): | ||
result = '<b>{}</b>'.format(result) | ||||
return result | ||||
neko259
|
r1366 | |||
def is_hidden(self) -> bool: | ||||
return self.hidden | ||||
def set_hidden(self, hidden): | ||||
self.hidden = hidden | ||||
neko259
|
r1569 | |||
def clear_cache(self): | ||||
neko259
|
r1586 | """ | ||
Clears sync data (content cache, signatures etc). | ||||
""" | ||||
neko259
|
r1569 | global_id = self.global_id | ||
if global_id is not None and global_id.is_local()\ | ||||
and global_id.content is not None: | ||||
neko259
|
r1586 | global_id.clear_cache() | ||
neko259
|
r1620 | |||
def get_tags(self): | ||||
return self.get_thread().get_tags() | ||||
neko259
|
r1638 | |||
def get_ip_color(self): | ||||
return hashlib.md5(self.poster_ip.encode()).hexdigest()[:6] | ||||
neko259
|
r1678 | |||
def has_ip(self): | ||||
return self.poster_ip != NO_IP | ||||
neko259
|
r2039 | def has_global_id(self): | ||
return self.global_id_id is not None | ||||