post.py
457 lines
| 14.2 KiB
| text/x-python
|
PythonLexer
neko259
|
r586 | from datetime import datetime, timedelta, date | ||
neko259
|
r407 | from datetime import time as dtime | ||
neko259
|
r639 | import logging | ||
neko259
|
r386 | import re | ||
neko259
|
r527 | |||
neko259
|
r961 | from urllib.parse import unquote | ||
neko259
|
r881 | from adjacent import Client | ||
neko259
|
r589 | from django.core.urlresolvers import reverse | ||
neko259
|
r566 | from django.db import models, transaction | ||
neko259
|
r881 | from django.db.models import TextField | ||
neko259
|
r692 | from django.template.loader import render_to_string | ||
neko259
|
r384 | from django.utils import timezone | ||
neko259
|
r881 | |||
neko259
|
r853 | from boards import settings | ||
neko259
|
r881 | from boards.mdx_neboard import bbcode_extended | ||
neko259
|
r693 | from boards.models import PostImage | ||
neko259
|
r692 | from boards.models.base import Viewable | ||
neko259
|
r994 | from boards.utils import datetime_to_epoch, cached_result | ||
neko259
|
r990 | from boards.models.user import Notification | ||
neko259
|
r959 | import boards.models.thread | ||
neko259
|
r384 | |||
neko259
|
r917 | |||
neko259
|
r915 | WS_NOTIFICATION_TYPE_NEW_POST = 'new_post' | ||
WS_NOTIFICATION_TYPE = 'notification_type' | ||||
neko259
|
r881 | |||
neko259
|
r853 | WS_CHANNEL_THREAD = "thread:" | ||
neko259
|
r622 | |||
neko259
|
r410 | APP_LABEL_BOARDS = 'boards' | ||
neko259
|
r745 | POSTS_PER_DAY_RANGE = 7 | ||
neko259
|
r408 | |||
neko259
|
r384 | BAN_REASON_AUTO = 'Auto' | ||
IMAGE_THUMB_SIZE = (200, 150) | ||||
neko259
|
r612 | TITLE_MAX_LENGTH = 200 | ||
neko259
|
r384 | |||
neko259
|
r702 | # TODO This should be removed | ||
neko259
|
r384 | NO_IP = '0.0.0.0' | ||
neko259
|
r702 | |||
# TODO Real user agent should be saved instead of this | ||||
neko259
|
r384 | UNKNOWN_UA = '' | ||
neko259
|
r702 | |||
neko259
|
r765 | REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]') | ||
neko259
|
r961 | REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?') | ||
neko259
|
r990 | REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]') | ||
neko259
|
r384 | |||
neko259
|
r853 | PARAMETER_TRUNCATED = 'truncated' | ||
PARAMETER_TAG = 'tag' | ||||
PARAMETER_OFFSET = 'offset' | ||||
PARAMETER_DIFF_TYPE = 'type' | ||||
neko259
|
r917 | PARAMETER_BUMPABLE = 'bumpable' | ||
PARAMETER_THREAD = 'thread' | ||||
PARAMETER_IS_OPENING = 'is_opening' | ||||
PARAMETER_MODERATOR = 'moderator' | ||||
PARAMETER_POST = 'post' | ||||
PARAMETER_OP_ID = 'opening_post_id' | ||||
PARAMETER_NEED_OPEN_LINK = 'need_open_link' | ||||
neko259
|
r853 | |||
DIFF_TYPE_HTML = 'html' | ||||
DIFF_TYPE_JSON = 'json' | ||||
neko259
|
r865 | PREPARSE_PATTERNS = { | ||
neko259
|
r886 | r'>>(\d+)': r'[post]\1[/post]', # Reflink ">>123" | ||
neko259
|
r926 | r'^>([^>].+)': r'[quote]\1[/quote]', # Quote ">text" | ||
neko259
|
r888 | r'^//(.+)': r'[comment]\1[/comment]', # Comment "//text" | ||
neko259
|
r1002 | r'\B@(\w+)': r'[user]\1[/user]', # User notification "@user" | ||
neko259
|
r865 | } | ||
neko259
|
r384 | |||
class PostManager(models.Manager): | ||||
neko259
|
r915 | @transaction.atomic | ||
def create_post(self, title: str, text: str, image=None, thread=None, | ||||
ip=NO_IP, tags: list=None): | ||||
neko259
|
r418 | """ | ||
neko259
|
r622 | Creates new post | ||
neko259
|
r418 | """ | ||
neko259
|
r766 | if not tags: | ||
tags = [] | ||||
neko259
|
r384 | posting_time = timezone.now() | ||
neko259
|
r398 | if not thread: | ||
neko259
|
r959 | thread = boards.models.thread.Thread.objects.create( | ||
bump_time=posting_time, last_edit_time=posting_time) | ||||
neko259
|
r615 | new_thread = True | ||
neko259
|
r398 | else: | ||
neko259
|
r615 | new_thread = False | ||
neko259
|
r384 | |||
neko259
|
r865 | pre_text = self._preparse_text(text) | ||
neko259
|
r384 | post = self.create(title=title, | ||
neko259
|
r865 | text=pre_text, | ||
neko259
|
r384 | pub_time=posting_time, | ||
poster_ip=ip, | ||||
neko259
|
r980 | thread=thread, | ||
neko259
|
r484 | poster_user_agent=UNKNOWN_UA, # TODO Get UA at | ||
# last! | ||||
neko259
|
r728 | last_edit_time=posting_time) | ||
neko259
|
r979 | post.threads.add(thread) | ||
neko259
|
r384 | |||
neko259
|
r868 | logger = logging.getLogger('boards.post.create') | ||
neko259
|
r866 | |||
neko259
|
r888 | logger.info('Created post {} by {}'.format( | ||
post, post.poster_ip)) | ||||
neko259
|
r850 | |||
neko259
|
r693 | if image: | ||
neko259
|
r944 | # Try to find existing image. If it exists, assign it to the post | ||
# instead of createing the new one | ||||
image_hash = PostImage.get_hash(image) | ||||
existing = PostImage.objects.filter(hash=image_hash) | ||||
if len(existing) > 0: | ||||
post_image = existing[0] | ||||
else: | ||||
post_image = PostImage.objects.create(image=image) | ||||
logger.info('Created new image #{} for post #{}'.format( | ||||
post_image.id, post.id)) | ||||
neko259
|
r693 | post.images.add(post_image) | ||
neko259
|
r770 | list(map(thread.add_tag, tags)) | ||
neko259
|
r384 | |||
neko259
|
r615 | if new_thread: | ||
neko259
|
r959 | boards.models.thread.Thread.objects.process_oldest_threads() | ||
neko259
|
r885 | else: | ||
thread.bump() | ||||
thread.last_edit_time = posting_time | ||||
thread.save() | ||||
neko259
|
r1008 | post.connect_replies() | ||
neko259
|
r990 | post.connect_notifications() | ||
neko259
|
r384 | |||
return post | ||||
def delete_posts_by_ip(self, ip): | ||||
neko259
|
r418 | """ | ||
neko259
|
r622 | Deletes all posts of the author with same IP | ||
neko259
|
r418 | """ | ||
neko259
|
r384 | posts = self.filter(poster_ip=ip) | ||
neko259
|
r766 | for post in posts: | ||
neko259
|
r880 | post.delete() | ||
neko259
|
r384 | |||
neko259
|
r957 | @cached_result | ||
neko259
|
r407 | def get_posts_per_day(self): | ||
neko259
|
r418 | """ | ||
neko259
|
r622 | Gets average count of posts per day for the last 7 days | ||
neko259
|
r418 | """ | ||
neko259
|
r407 | |||
neko259
|
r745 | day_end = date.today() | ||
day_start = day_end - timedelta(POSTS_PER_DAY_RANGE) | ||||
day_time_start = timezone.make_aware(datetime.combine( | ||||
day_start, dtime()), timezone.get_current_timezone()) | ||||
day_time_end = timezone.make_aware(datetime.combine( | ||||
day_end, dtime()), timezone.get_current_timezone()) | ||||
neko259
|
r413 | |||
neko259
|
r745 | posts_per_period = float(self.filter( | ||
pub_time__lte=day_time_end, | ||||
pub_time__gte=day_time_start).count()) | ||||
neko259
|
r408 | |||
neko259
|
r745 | ppd = posts_per_period / POSTS_PER_DAY_RANGE | ||
neko259
|
r408 | |||
neko259
|
r410 | return ppd | ||
neko259
|
r1008 | |||
# TODO Make a separate parser module and move preparser there | ||||
neko259
|
r961 | def _preparse_text(self, text: str) -> str: | ||
neko259
|
r865 | """ | ||
Preparses text to change patterns like '>>' to a proper bbcode | ||||
tags. | ||||
""" | ||||
for key, value in PREPARSE_PATTERNS.items(): | ||||
neko259
|
r887 | text = re.sub(key, value, text, flags=re.MULTILINE) | ||
neko259
|
r865 | |||
neko259
|
r961 | for link in REGEX_URL.findall(text): | ||
text = text.replace(link, unquote(link)) | ||||
neko259
|
r865 | return text | ||
neko259
|
r384 | |||
neko259
|
r692 | class Post(models.Model, Viewable): | ||
neko259
|
r384 | """A post is a message.""" | ||
objects = PostManager() | ||||
class Meta: | ||||
neko259
|
r410 | app_label = APP_LABEL_BOARDS | ||
neko259
|
r649 | ordering = ('id',) | ||
neko259
|
r384 | |||
title = models.CharField(max_length=TITLE_MAX_LENGTH) | ||||
pub_time = models.DateTimeField() | ||||
neko259
|
r881 | text = TextField(blank=True, null=True) | ||
_text_rendered = TextField(blank=True, null=True, editable=False) | ||||
neko259
|
r384 | |||
neko259
|
r693 | images = models.ManyToManyField(PostImage, null=True, blank=True, | ||
related_name='ip+', db_index=True) | ||||
neko259
|
r384 | |||
poster_ip = models.GenericIPAddressField() | ||||
poster_user_agent = models.TextField() | ||||
last_edit_time = models.DateTimeField() | ||||
referenced_posts = models.ManyToManyField('Post', symmetrical=False, | ||||
null=True, | ||||
neko259
|
r661 | blank=True, related_name='rfp+', | ||
db_index=True) | ||||
neko259
|
r674 | refmap = models.TextField(null=True, blank=True) | ||
neko259
|
r979 | threads = models.ManyToManyField('Thread', db_index=True) | ||
neko259
|
r980 | thread = models.ForeignKey('Thread', db_index=True, related_name='pt+') | ||
neko259
|
r384 | |||
neko259
|
r875 | def __str__(self): | ||
neko259
|
r878 | return 'P#{}/{}'.format(self.id, self.title) | ||
neko259
|
r384 | |||
neko259
|
r911 | def get_title(self) -> str: | ||
neko259
|
r622 | """ | ||
Gets original post title or part of its text. | ||||
""" | ||||
neko259
|
r384 | title = self.title | ||
neko259
|
r618 | if not title: | ||
neko259
|
r881 | title = self.get_text() | ||
neko259
|
r384 | |||
return title | ||||
neko259
|
r911 | def build_refmap(self) -> None: | ||
neko259
|
r740 | """ | ||
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
|
r674 | map_string = '' | ||
neko259
|
r1008 | post_urls = ['<a href="{}">>>{}</a>'.format( | ||
refpost.get_url(), refpost.id) for refpost in self.referenced_posts.all()] | ||||
neko259
|
r674 | |||
neko259
|
r1008 | self.refmap = ', '.join(post_urls) | ||
neko259
|
r674 | |||
neko259
|
r398 | def get_sorted_referenced_posts(self): | ||
neko259
|
r674 | return self.refmap | ||
neko259
|
r398 | |||
neko259
|
r911 | def is_referenced(self) -> bool: | ||
neko259
|
r1008 | return self.refmap and len(self.refmap) > 0 | ||
neko259
|
r398 | |||
neko259
|
r911 | def is_opening(self) -> bool: | ||
neko259
|
r622 | """ | ||
Checks if this is an opening post or just a reply. | ||||
""" | ||||
neko259
|
r949 | return self.get_thread().get_opening_post_id() == self.id | ||
neko259
|
r400 | |||
neko259
|
r566 | @transaction.atomic | ||
def add_tag(self, tag): | ||||
edit_time = timezone.now() | ||||
neko259
|
r949 | thread = self.get_thread() | ||
neko259
|
r566 | thread.add_tag(tag) | ||
self.last_edit_time = edit_time | ||||
neko259
|
r740 | self.save(update_fields=['last_edit_time']) | ||
neko259
|
r566 | |||
thread.last_edit_time = edit_time | ||||
neko259
|
r740 | thread.save(update_fields=['last_edit_time']) | ||
neko259
|
r566 | |||
neko259
|
r957 | @cached_result | ||
neko259
|
r984 | def get_url(self): | ||
neko259
|
r589 | """ | ||
neko259
|
r622 | Gets full url to the post. | ||
neko259
|
r589 | """ | ||
neko259
|
r984 | thread = self.get_thread() | ||
neko259
|
r589 | |||
neko259
|
r957 | opening_id = thread.get_opening_post_id() | ||
neko259
|
r625 | |||
neko259
|
r957 | if self.id != opening_id: | ||
link = reverse('thread', kwargs={ | ||||
'post_id': opening_id}) + '#' + str(self.id) | ||||
else: | ||||
link = reverse('thread', kwargs={'post_id': self.id}) | ||||
neko259
|
r589 | |||
return link | ||||
neko259
|
r958 | def get_thread(self): | ||
neko259
|
r980 | return self.thread | ||
neko259
|
r979 | |||
def get_threads(self): | ||||
neko259
|
r622 | """ | ||
Gets post's thread. | ||||
""" | ||||
neko259
|
r979 | return self.threads | ||
neko259
|
r617 | |||
neko259
|
r660 | def get_referenced_posts(self): | ||
neko259
|
r979 | return self.referenced_posts.only('id', 'threads') | ||
neko259
|
r692 | |||
def get_view(self, moderator=False, need_open_link=False, | ||||
truncated=False, *args, **kwargs): | ||||
neko259
|
r917 | """ | ||
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
|
r692 | |||
neko259
|
r988 | thread = self.get_thread() | ||
neko259
|
r917 | is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening()) | ||
can_bump = kwargs.get(PARAMETER_BUMPABLE, thread.can_bump()) | ||||
neko259
|
r692 | |||
neko259
|
r724 | if is_opening: | ||
opening_post_id = self.id | ||||
else: | ||||
opening_post_id = thread.get_opening_post_id() | ||||
neko259
|
r692 | |||
return render_to_string('boards/post.html', { | ||||
neko259
|
r917 | PARAMETER_POST: self, | ||
PARAMETER_MODERATOR: moderator, | ||||
PARAMETER_IS_OPENING: is_opening, | ||||
PARAMETER_THREAD: thread, | ||||
PARAMETER_BUMPABLE: can_bump, | ||||
PARAMETER_NEED_OPEN_LINK: need_open_link, | ||||
PARAMETER_TRUNCATED: truncated, | ||||
PARAMETER_OP_ID: opening_post_id, | ||||
neko259
|
r692 | }) | ||
neko259
|
r922 | def get_search_view(self, *args, **kwargs): | ||
return self.get_view(args, kwargs) | ||||
neko259
|
r911 | def get_first_image(self) -> PostImage: | ||
neko259
|
r693 | return self.images.earliest('id') | ||
neko259
|
r715 | def delete(self, using=None): | ||
""" | ||||
neko259
|
r950 | Deletes all post images and the post itself. | ||
neko259
|
r715 | """ | ||
neko259
|
r944 | for image in self.images.all(): | ||
image_refs_count = Post.objects.filter(images__in=[image]).count() | ||||
if image_refs_count == 1: | ||||
image.delete() | ||||
neko259
|
r715 | |||
neko259
|
r950 | thread = self.get_thread() | ||
thread.last_edit_time = timezone.now() | ||||
thread.save() | ||||
neko259
|
r880 | |||
neko259
|
r884 | super(Post, self).delete(using) | ||
neko259
|
r880 | |||
logging.getLogger('boards.post.delete').info( | ||||
neko259
|
r888 | 'Deleted post {}'.format(self)) | ||
neko259
|
r853 | |||
def get_post_data(self, format_type=DIFF_TYPE_JSON, request=None, | ||||
include_last_update=False): | ||||
""" | ||||
Gets post HTML or JSON data that can be rendered on a page or used by | ||||
API. | ||||
""" | ||||
if format_type == DIFF_TYPE_HTML: | ||||
neko259
|
r917 | params = dict() | ||
params['post'] = self | ||||
neko259
|
r853 | if PARAMETER_TRUNCATED in request.GET: | ||
neko259
|
r917 | params[PARAMETER_TRUNCATED] = True | ||
neko259
|
r853 | |||
neko259
|
r917 | return render_to_string('boards/api_post.html', params) | ||
neko259
|
r853 | elif format_type == DIFF_TYPE_JSON: | ||
post_json = { | ||||
'id': self.id, | ||||
'title': self.title, | ||||
neko259
|
r886 | 'text': self._text_rendered, | ||
neko259
|
r853 | } | ||
if self.images.exists(): | ||||
post_image = self.get_first_image() | ||||
post_json['image'] = post_image.image.url | ||||
post_json['image_preview'] = post_image.image.url_200x150 | ||||
if include_last_update: | ||||
post_json['bump_time'] = datetime_to_epoch( | ||||
neko259
|
r979 | self.get_thread().bump_time) | ||
neko259
|
r853 | return post_json | ||
def send_to_websocket(self, request, recursive=True): | ||||
""" | ||||
Sends post HTML data to the thread web socket. | ||||
""" | ||||
if not settings.WEBSOCKETS_ENABLED: | ||||
return | ||||
client = Client() | ||||
neko259
|
r949 | thread = self.get_thread() | ||
neko259
|
r915 | thread_id = thread.id | ||
channel_name = WS_CHANNEL_THREAD + str(thread.get_opening_post_id()) | ||||
neko259
|
r853 | client.publish(channel_name, { | ||
neko259
|
r915 | WS_NOTIFICATION_TYPE: WS_NOTIFICATION_TYPE_NEW_POST, | ||
neko259
|
r853 | }) | ||
client.send() | ||||
neko259
|
r868 | logger = logging.getLogger('boards.post.websocket') | ||
neko259
|
r866 | |||
neko259
|
r915 | logger.info('Sent notification from post #{} to channel {}'.format( | ||
self.id, channel_name)) | ||||
neko259
|
r853 | |||
if recursive: | ||||
neko259
|
r881 | for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()): | ||
neko259
|
r853 | post_id = reply_number.group(1) | ||
ref_post = Post.objects.filter(id=post_id)[0] | ||||
neko259
|
r915 | # If post is in this thread, its thread was already notified. | ||
# Otherwise, notify its thread separately. | ||||
neko259
|
r979 | if ref_post.get_thread().id != thread_id: | ||
neko259
|
r915 | ref_post.send_to_websocket(request, recursive=False) | ||
neko259
|
r881 | |||
def save(self, force_insert=False, force_update=False, using=None, | ||||
update_fields=None): | ||||
self._text_rendered = bbcode_extended(self.get_raw_text()) | ||||
super().save(force_insert, force_update, using, update_fields) | ||||
neko259
|
r911 | def get_text(self) -> str: | ||
neko259
|
r881 | return self._text_rendered | ||
neko259
|
r911 | def get_raw_text(self) -> str: | ||
neko259
|
r881 | return self.text | ||
neko259
|
r981 | |||
def get_absolute_id(self) -> str: | ||||
neko259
|
r984 | """ | ||
If the post has many threads, shows its main thread OP id in the post | ||||
ID. | ||||
""" | ||||
neko259
|
r981 | if self.get_threads().count() > 1: | ||
return '{}/{}'.format(self.get_thread().get_opening_post_id(), self.id) | ||||
else: | ||||
return str(self.id) | ||||
neko259
|
r990 | |||
def connect_notifications(self): | ||||
for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()): | ||||
neko259
|
r1008 | user_name = reply_number.group(1).lower() | ||
neko259
|
r990 | Notification.objects.get_or_create(name=user_name, post=self) | ||
neko259
|
r1008 | |||
def connect_replies(self): | ||||
""" | ||||
Connects replies to a post to show them as a reflink map | ||||
""" | ||||
for reply_number in re.finditer(REGEX_REPLY, self.get_raw_text()): | ||||
post_id = reply_number.group(1) | ||||
ref_post = Post.objects.filter(id=post_id) | ||||
if ref_post.count() > 0: | ||||
referenced_post = ref_post[0] | ||||
referenced_post.referenced_posts.add(self) | ||||
referenced_post.last_edit_time = self.pub_time | ||||
referenced_post.build_refmap() | ||||
referenced_post.save(update_fields=['refmap', 'last_edit_time']) | ||||
referenced_threads = referenced_post.get_threads().all() | ||||
for thread in referenced_threads: | ||||
thread.last_edit_time = self.pub_time | ||||
thread.save(update_fields=['last_edit_time']) | ||||
self.threads.add(thread) | ||||