__init__.py
381 lines
| 12.0 KiB
| text/x-python
|
PythonLexer
neko259
|
r1156 | import uuid | ||
neko259
|
r1511 | import re | ||
neko259
|
r1156 | from boards import settings | ||
neko259
|
r1296 | from boards.abstracts.tripcode import Tripcode | ||
neko259
|
r1360 | from boards.models import PostImage, Attachment, KeyPair, GlobalId | ||
neko259
|
r1156 | from boards.models.base import Viewable | ||
from boards.models.post.export import get_exporter, DIFF_TYPE_JSON | ||||
neko259
|
r1359 | from boards.models.post.manager import PostManager | ||
neko259
|
r1533 | from boards.utils import datetime_to_epoch | ||
neko259
|
r1156 | from django.core.exceptions import ObjectDoesNotExist | ||
from django.core.urlresolvers import reverse | ||||
neko259
|
r1359 | from django.db import models | ||
neko259
|
r1221 | from django.db.models import TextField, QuerySet | ||
neko259
|
r1511 | from django.template.defaultfilters import truncatewords, striptags | ||
neko259
|
r1156 | from django.template.loader import render_to_string | ||
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
|
r1358 | TITLE_MAX_WORDS = 10 | ||
neko259
|
r1156 | APP_LABEL_BOARDS = 'boards' | ||
BAN_REASON_AUTO = 'Auto' | ||||
IMAGE_THUMB_SIZE = (200, 150) | ||||
TITLE_MAX_LENGTH = 200 | ||||
REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]') | ||||
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
|
r1358 | |||
neko259
|
r1156 | class Post(models.Model, Viewable): | ||
"""A post is a message.""" | ||||
objects = PostManager() | ||||
class Meta: | ||||
app_label = APP_LABEL_BOARDS | ||||
ordering = ('id',) | ||||
title = models.CharField(max_length=TITLE_MAX_LENGTH, null=True, blank=True) | ||||
pub_time = models.DateTimeField() | ||||
text = TextField(blank=True, null=True) | ||||
_text_rendered = TextField(blank=True, null=True, editable=False) | ||||
images = models.ManyToManyField(PostImage, null=True, blank=True, | ||||
neko259
|
r1250 | related_name='post_images', db_index=True) | ||
neko259
|
r1273 | attachments = models.ManyToManyField(Attachment, null=True, blank=True, | ||
neko259
|
r1358 | related_name='attachment_posts') | ||
neko259
|
r1156 | |||
poster_ip = models.GenericIPAddressField() | ||||
# TODO This field can be removed cause UID is used for update now | ||||
last_edit_time = models.DateTimeField() | ||||
referenced_posts = models.ManyToManyField('Post', symmetrical=False, | ||||
null=True, | ||||
neko259
|
r1180 | blank=True, related_name='refposts', | ||
neko259
|
r1156 | db_index=True) | ||
refmap = models.TextField(null=True, blank=True) | ||||
neko259
|
r1345 | threads = models.ManyToManyField('Thread', db_index=True, | ||
related_name='multi_replies') | ||||
neko259
|
r1156 | thread = models.ForeignKey('Thread', db_index=True, related_name='pt+') | ||
url = models.TextField() | ||||
uid = models.TextField(db_index=True) | ||||
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 | ||||
def build_refmap(self) -> None: | ||||
""" | ||||
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
|
r1309 | post_urls = [refpost.get_link_view() | ||
neko259
|
r1156 | for refpost in self.referenced_posts.all()] | ||
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): | ||
url = None | ||||
if thread is None: | ||||
thread = self.get_thread() | ||||
# Url is cached only for the "main" thread. When getting url | ||||
# for other threads, do it manually. | ||||
if self.url: | ||||
url = self.url | ||||
if url is None: | ||||
neko259
|
r1443 | opening = self.is_opening() | ||
opening_id = self.id if opening else thread.get_opening_post_id() | ||||
neko259
|
r1365 | url = reverse('thread', kwargs={'post_id': opening_id}) | ||
neko259
|
r1443 | if not opening: | ||
neko259
|
r1365 | url += '#' + str(self.id) | ||
return url | ||||
neko259
|
r1192 | |||
neko259
|
r1156 | def get_thread(self): | ||
return self.thread | ||||
neko259
|
r1455 | def get_thread_id(self): | ||
return self.thread_id | ||||
neko259
|
r1221 | def get_threads(self) -> QuerySet: | ||
neko259
|
r1156 | """ | ||
Gets post's thread. | ||||
""" | ||||
return self.threads | ||||
neko259
|
r1531 | def _get_cache_key(self): | ||
return [datetime_to_epoch(self.last_edit_time)] | ||||
neko259
|
r1180 | def get_view(self, *args, **kwargs) -> str: | ||
neko259
|
r1156 | """ | ||
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. | ||||
""" | ||||
thread = self.get_thread() | ||||
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
|
r1366 | PARAMETER_CSS_CLASS: ' '.join(css_classes), | ||
neko259
|
r1156 | }) | ||
neko259
|
r1180 | return render_to_string('boards/post.html', params) | ||
neko259
|
r1156 | def get_search_view(self, *args, **kwargs): | ||
neko259
|
r1172 | return self.get_view(need_op_data=True, *args, **kwargs) | ||
neko259
|
r1156 | |||
def get_first_image(self) -> PostImage: | ||||
return self.images.earliest('id') | ||||
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) | ||||
def notify_clients(self, recursive=True): | ||||
""" | ||||
Sends post HTML data to the thread web socket. | ||||
""" | ||||
if not settings.get_bool('External', 'WebsocketsEnabled'): | ||||
return | ||||
thread_ids = list() | ||||
for thread in self.get_threads().all(): | ||||
thread_ids.append(thread.id) | ||||
thread.notify_clients() | ||||
if recursive: | ||||
for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()): | ||||
post_id = reply_number.group(1) | ||||
try: | ||||
ref_post = Post.objects.get(id=post_id) | ||||
if ref_post.get_threads().exclude(id__in=thread_ids).exists(): | ||||
# If post is in this thread, its thread was already notified. | ||||
# Otherwise, notify its thread separately. | ||||
ref_post.notify_clients(recursive=False) | ||||
except ObjectDoesNotExist: | ||||
pass | ||||
def build_url(self): | ||||
neko259
|
r1192 | self.url = self.get_absolute_url() | ||
neko259
|
r1156 | self.save(update_fields=['url']) | ||
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
|
r1156 | for thread in self.get_threads().all(): | ||
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
|
r1443 | if self.url is None: | ||
neko259
|
r1440 | self.build_url() | ||
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()): | ||||
absolute_post_id = str(Post.objects.get(id=post_id).global_id) | ||||
replacements[post_id] = absolute_post_id | ||||
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
|
r1156 | def connect_threads(self, opening_posts): | ||
for opening_post in opening_posts: | ||||
threads = opening_post.get_threads().all() | ||||
for thread in threads: | ||||
if thread.can_bump(): | ||||
thread.update_bump_status() | ||||
thread.last_edit_time = self.last_edit_time | ||||
neko259
|
r1414 | thread.save(update_fields=['last_edit_time', 'status']) | ||
neko259
|
r1201 | self.threads.add(opening_post.get_thread()) | ||
neko259
|
r1293 | |||
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 | ||||