__init__.py
409 lines
| 12.6 KiB
| text/x-python
|
PythonLexer
neko259
|
r1156 | from datetime import datetime, timedelta, date | ||
from datetime import time as dtime | ||||
import logging | ||||
import re | ||||
import uuid | ||||
from django.core.exceptions import ObjectDoesNotExist | ||||
from django.core.urlresolvers import reverse | ||||
from django.db import models, transaction | ||||
from django.db.models import TextField | ||||
from django.template.loader import render_to_string | ||||
from django.utils import timezone | ||||
from boards import settings | ||||
from boards.mdx_neboard import Parser | ||||
from boards.models import PostImage | ||||
from boards.models.base import Viewable | ||||
from boards import utils | ||||
from boards.models.post.export import get_exporter, DIFF_TYPE_JSON | ||||
from boards.models.user import Notification, Ban | ||||
import boards.models.thread | ||||
APP_LABEL_BOARDS = 'boards' | ||||
POSTS_PER_DAY_RANGE = 7 | ||||
BAN_REASON_AUTO = 'Auto' | ||||
IMAGE_THUMB_SIZE = (200, 150) | ||||
TITLE_MAX_LENGTH = 200 | ||||
# TODO This should be removed | ||||
NO_IP = '0.0.0.0' | ||||
REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]') | ||||
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_MODERATOR = 'moderator' | ||||
PARAMETER_POST = 'post' | ||||
PARAMETER_OP_ID = 'opening_post_id' | ||||
PARAMETER_NEED_OPEN_LINK = 'need_open_link' | ||||
PARAMETER_REPLY_LINK = 'reply_link' | ||||
REFMAP_STR = '<a href="{}">>>{}</a>' | ||||
class PostManager(models.Manager): | ||||
@transaction.atomic | ||||
def create_post(self, title: str, text: str, image=None, thread=None, | ||||
ip=NO_IP, tags: list=None, threads: list=None): | ||||
""" | ||||
Creates new post | ||||
""" | ||||
is_banned = Ban.objects.filter(ip=ip).exists() | ||||
# TODO Raise specific exception and catch it in the views | ||||
if is_banned: | ||||
raise Exception("This user is banned") | ||||
if not tags: | ||||
tags = [] | ||||
if not threads: | ||||
threads = [] | ||||
posting_time = timezone.now() | ||||
if not thread: | ||||
thread = boards.models.thread.Thread.objects.create( | ||||
bump_time=posting_time, last_edit_time=posting_time) | ||||
new_thread = True | ||||
else: | ||||
new_thread = False | ||||
pre_text = Parser().preparse(text) | ||||
post = self.create(title=title, | ||||
text=pre_text, | ||||
pub_time=posting_time, | ||||
poster_ip=ip, | ||||
thread=thread, | ||||
last_edit_time=posting_time) | ||||
post.threads.add(thread) | ||||
logger = logging.getLogger('boards.post.create') | ||||
logger.info('Created post {} by {}'.format(post, post.poster_ip)) | ||||
if image: | ||||
post.images.add(PostImage.objects.create_with_hash(image)) | ||||
list(map(thread.add_tag, tags)) | ||||
if new_thread: | ||||
boards.models.thread.Thread.objects.process_oldest_threads() | ||||
else: | ||||
thread.last_edit_time = posting_time | ||||
thread.bump() | ||||
thread.save() | ||||
post.connect_replies() | ||||
post.connect_threads(threads) | ||||
post.connect_notifications() | ||||
post.build_url() | ||||
return post | ||||
def delete_posts_by_ip(self, ip): | ||||
""" | ||||
Deletes all posts of the author with same IP | ||||
""" | ||||
posts = self.filter(poster_ip=ip) | ||||
for post in posts: | ||||
post.delete() | ||||
@utils.cached_result() | ||||
def get_posts_per_day(self) -> float: | ||||
""" | ||||
Gets average count of posts per day for the last 7 days | ||||
""" | ||||
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()) | ||||
posts_per_period = float(self.filter( | ||||
pub_time__lte=day_time_end, | ||||
pub_time__gte=day_time_start).count()) | ||||
ppd = posts_per_period / POSTS_PER_DAY_RANGE | ||||
return ppd | ||||
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, | ||||
related_name='ip+', db_index=True) | ||||
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, | ||||
blank=True, related_name='rfp+', | ||||
db_index=True) | ||||
refmap = models.TextField(null=True, blank=True) | ||||
threads = models.ManyToManyField('Thread', db_index=True) | ||||
thread = models.ForeignKey('Thread', db_index=True, related_name='pt+') | ||||
url = models.TextField() | ||||
uid = models.TextField(db_index=True) | ||||
def __str__(self): | ||||
return 'P#{}/{}'.format(self.id, self.title) | ||||
def get_title(self) -> str: | ||||
""" | ||||
Gets original post title or part of its text. | ||||
""" | ||||
title = self.title | ||||
if not title: | ||||
title = self.get_text() | ||||
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
|
r1160 | post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id) | ||
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. | ||||
""" | ||||
return self.get_thread().get_opening_post_id() == self.id | ||||
def get_absolute_url(self): | ||||
return self.url | ||||
def get_thread(self): | ||||
return self.thread | ||||
def get_threads(self) -> list: | ||||
""" | ||||
Gets post's thread. | ||||
""" | ||||
return self.threads | ||||
def get_view(self, moderator=False, need_open_link=False, | ||||
truncated=False, reply_link=False, *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. | ||||
""" | ||||
thread = self.get_thread() | ||||
is_opening = kwargs.get(PARAMETER_IS_OPENING, self.is_opening()) | ||||
if is_opening: | ||||
opening_post_id = self.id | ||||
else: | ||||
opening_post_id = thread.get_opening_post_id() | ||||
css_class = 'post' | ||||
if thread.archived: | ||||
css_class += ' archive_post' | ||||
elif not thread.can_bump(): | ||||
css_class += ' dead_post' | ||||
return render_to_string('boards/post.html', { | ||||
PARAMETER_POST: self, | ||||
PARAMETER_MODERATOR: moderator, | ||||
PARAMETER_IS_OPENING: is_opening, | ||||
PARAMETER_THREAD: thread, | ||||
PARAMETER_CSS_CLASS: css_class, | ||||
PARAMETER_NEED_OPEN_LINK: need_open_link, | ||||
PARAMETER_TRUNCATED: truncated, | ||||
PARAMETER_OP_ID: opening_post_id, | ||||
PARAMETER_REPLY_LINK: reply_link, | ||||
}) | ||||
def get_search_view(self, *args, **kwargs): | ||||
return self.get_view(args, kwargs) | ||||
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(): | ||||
image_refs_count = Post.objects.filter(images__in=[image]).count() | ||||
if image_refs_count == 1: | ||||
image.delete() | ||||
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)) | ||||
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): | ||||
thread = self.get_thread() | ||||
opening_id = thread.get_opening_post_id() | ||||
post_url = reverse('thread', kwargs={'post_id': opening_id}) | ||||
if self.id != opening_id: | ||||
post_url += '#' + str(self.id) | ||||
self.url = post_url | ||||
self.save(update_fields=['url']) | ||||
def save(self, force_insert=False, force_update=False, using=None, | ||||
update_fields=None): | ||||
self._text_rendered = Parser().parse(self.get_raw_text()) | ||||
self.uid = str(uuid.uuid4()) | ||||
if update_fields is not None and 'uid' not in update_fields: | ||||
update_fields += ['uid'] | ||||
if self.id: | ||||
for thread in self.get_threads().all(): | ||||
if thread.can_bump(): | ||||
thread.update_bump_status(exclude_posts=[self]) | ||||
thread.last_edit_time = self.last_edit_time | ||||
thread.save(update_fields=['last_edit_time', 'bumpable']) | ||||
super().save(force_insert, force_update, using, update_fields) | ||||
def get_text(self) -> str: | ||||
return self._text_rendered | ||||
def get_raw_text(self) -> str: | ||||
return self.text | ||||
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_notifications(self): | ||||
for reply_number in re.finditer(REGEX_NOTIFICATION, self.get_raw_text()): | ||||
user_name = reply_number.group(1).lower() | ||||
Notification.objects.get_or_create(name=user_name, post=self) | ||||
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) | ||||
try: | ||||
referenced_post = Post.objects.get(id=post_id) | ||||
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']) | ||||
except ObjectDoesNotExist: | ||||
pass | ||||
def connect_threads(self, opening_posts): | ||||
""" | ||||
If the referenced post is an OP in another thread, | ||||
make this post multi-thread. | ||||
""" | ||||
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 | ||||
thread.save(update_fields=['last_edit_time', 'bumpable']) | ||||
self.threads.add(thread) | ||||