__init__.py
452 lines
| 14.2 KiB
| text/x-python
|
PythonLexer
neko259
|
r1156 | import logging | ||
import re | ||||
import uuid | ||||
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
|
r1385 | from django.template.defaultfilters import truncatewords, striptags | ||
neko259
|
r1156 | from django.template.loader import render_to_string | ||
from django.utils import timezone | ||||
from boards import settings | ||||
neko259
|
r1296 | from boards.abstracts.tripcode import Tripcode | ||
neko259
|
r1499 | from boards.mdx_neboard import get_parser | ||
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 | ||
from boards.models.user import Notification | ||||
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
|
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. | ||||
global_id = models.OneToOneField('GlobalId', null=True, blank=True) | ||||
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 | |||
neko259
|
r1180 | def get_referenced_posts(self): | ||
neko259
|
r1181 | threads = self.get_threads().all() | ||
return self.referenced_posts.filter(threads__in=threads)\ | ||||
neko259
|
r1358 | .order_by('pub_time').distinct().all() | ||
neko259
|
r1180 | |||
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
|
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') | ||||
def delete(self, using=None): | ||||
""" | ||||
Deletes all post images and the post itself. | ||||
""" | ||||
for image in self.images.all(): | ||||
neko259
|
r1281 | image_refs_count = image.post_images.count() | ||
neko259
|
r1156 | if image_refs_count == 1: | ||
image.delete() | ||||
neko259
|
r1278 | for attachment in self.attachments.all(): | ||
neko259
|
r1281 | attachment_refs_count = attachment.attachment_posts.count() | ||
neko259
|
r1278 | if attachment_refs_count == 1: | ||
attachment.delete() | ||||
neko259
|
r1157 | if self.global_id: | ||
self.global_id.delete() | ||||
neko259
|
r1156 | thread = self.get_thread() | ||
thread.last_edit_time = timezone.now() | ||||
thread.save() | ||||
super(Post, self).delete(using) | ||||
logging.getLogger('boards.post.delete').info( | ||||
'Deleted post {}'.format(self)) | ||||
neko259
|
r1157 | def set_global_id(self, key_pair=None): | ||
""" | ||||
Sets global id based on the given key pair. If no key pair is given, | ||||
default one is used. | ||||
""" | ||||
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
|
r1157 | |||
def get_replied_ids(self): | ||||
""" | ||||
Gets ID list of the posts that this post replies. | ||||
""" | ||||
raw_text = self.get_raw_text() | ||||
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] | ||||
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 | ||||
text = self.get_raw_text() | ||||
for key in replacements: | ||||
text = text.replace('[post]{}[/post]'.format(key), | ||||
'[post]{}[/post]'.format(replacements[key])) | ||||
return text | ||||
neko259
|
r1156 | def get_absolute_id(self) -> str: | ||
""" | ||||
If the post has many threads, shows its main thread OP id in the post | ||||
ID. | ||||
""" | ||||
if self.get_threads().count() > 1: | ||||
return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id) | ||||
else: | ||||
return str(self.id) | ||||
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 | ||||
neko259
|
r1498 | |||
# SIGNALS (Maybe move to other module?) | ||||
@receiver(post_save, sender=Post) | ||||
def connect_replies(instance, **kwargs): | ||||
for reply_number in re.finditer(REGEX_REPLY, instance.get_raw_text()): | ||||
post_id = reply_number.group(1) | ||||
try: | ||||
referenced_post = Post.objects.get(id=post_id) | ||||
referenced_post.referenced_posts.add(instance) | ||||
referenced_post.last_edit_time = instance.pub_time | ||||
referenced_post.build_refmap() | ||||
referenced_post.save(update_fields=['refmap', 'last_edit_time']) | ||||
except ObjectDoesNotExist: | ||||
pass | ||||
@receiver(post_save, sender=Post) | ||||
def connect_notifications(instance, **kwargs): | ||||
for reply_number in re.finditer(REGEX_NOTIFICATION, instance.get_raw_text()): | ||||
user_name = reply_number.group(1).lower() | ||||
Notification.objects.get_or_create(name=user_name, post=instance) | ||||
@receiver(pre_save, sender=Post) | ||||
def preparse_text(instance, **kwargs): | ||||
neko259
|
r1499 | instance._text_rendered = get_parser().parse(instance.get_raw_text()) | ||