__init__.py
456 lines
| 13.9 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 | |||
neko259
|
r1221 | from django.db.models import TextField, QuerySet | |
neko259
|
r1156 | from django.template.loader import render_to_string | |
from django.utils import timezone | |||
from boards import settings | |||
from boards.mdx_neboard import Parser | |||
neko259
|
r1273 | from boards.models import PostImage, Attachment | |
neko259
|
r1156 | 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' | |||
neko259
|
r1166 | PARAMETER_NEED_OP_DATA = 'need_op_data' | |
neko259
|
r1156 | ||
neko259
|
r1180 | POST_VIEW_PARAMS = ( | |
'need_op_data', | |||
'reply_link', | |||
'moderator', | |||
'need_open_link', | |||
'truncated', | |||
'mode_tree', | |||
) | |||
neko259
|
r1156 | REFMAP_STR = '<a href="{}">>>{}</a>' | |
neko259
|
r1273 | IMAGE_TYPES = ( | |
'jpeg', | |||
'jpg', | |||
'png', | |||
'bmp', | |||
neko259
|
r1277 | 'gif', | |
neko259
|
r1273 | ) | |
neko259
|
r1156 | ||
class PostManager(models.Manager): | |||
@transaction.atomic | |||
neko259
|
r1273 | def create_post(self, title: str, text: str, file=None, thread=None, | |
neko259
|
r1293 | ip=NO_IP, tags: list=None, opening_posts: list=None, tripcode=None): | |
neko259
|
r1156 | """ | |
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 = [] | |||
neko259
|
r1221 | if not opening_posts: | |
opening_posts = [] | |||
neko259
|
r1156 | ||
posting_time = timezone.now() | |||
neko259
|
r1240 | new_thread = False | |
neko259
|
r1156 | if not thread: | |
thread = boards.models.thread.Thread.objects.create( | |||
bump_time=posting_time, last_edit_time=posting_time) | |||
neko259
|
r1187 | list(map(thread.tags.add, tags)) | |
neko259
|
r1230 | boards.models.thread.Thread.objects.process_oldest_threads() | |
neko259
|
r1240 | new_thread = True | |
neko259
|
r1156 | ||
pre_text = Parser().preparse(text) | |||
post = self.create(title=title, | |||
text=pre_text, | |||
pub_time=posting_time, | |||
poster_ip=ip, | |||
thread=thread, | |||
neko259
|
r1293 | last_edit_time=posting_time, | |
tripcode=tripcode) | |||
neko259
|
r1156 | post.threads.add(thread) | |
logger = logging.getLogger('boards.post.create') | |||
logger.info('Created post {} by {}'.format(post, post.poster_ip)) | |||
neko259
|
r1273 | # TODO Move this to other place | |
if file: | |||
file_type = file.name.split('.')[-1].lower() | |||
if file_type in IMAGE_TYPES: | |||
post.images.add(PostImage.objects.create_with_hash(file)) | |||
else: | |||
post.attachments.add(Attachment.objects.create_with_hash(file)) | |||
neko259
|
r1156 | ||
neko259
|
r1192 | post.build_url() | |
neko259
|
r1156 | post.connect_replies() | |
neko259
|
r1221 | post.connect_threads(opening_posts) | |
neko259
|
r1156 | post.connect_notifications() | |
neko259
|
r1240 | # Thread needs to be bumped only when the post is already created | |
if not new_thread: | |||
thread.last_edit_time = posting_time | |||
thread.bump() | |||
thread.save() | |||
neko259
|
r1156 | 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, | |||
neko259
|
r1250 | related_name='post_images', db_index=True) | |
neko259
|
r1273 | attachments = models.ManyToManyField(Attachment, null=True, blank=True, | |
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) | |||
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) | |||
neko259
|
r1293 | tripcode = models.CharField(max_length=50, null=True) | |
neko259
|
r1156 | def __str__(self): | |
return 'P#{}/{}'.format(self.id, self.title) | |||
neko259
|
r1180 | def get_referenced_posts(self): | |
neko259
|
r1181 | threads = self.get_threads().all() | |
return self.referenced_posts.filter(threads__in=threads)\ | |||
.order_by('pub_time').distinct().all() | |||
neko259
|
r1180 | ||
neko259
|
r1156 | 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): | |||
neko259
|
r1192 | if self.url: | |
return self.url | |||
else: | |||
opening_id = self.get_thread().get_opening_post_id() | |||
post_url = reverse('thread', kwargs={'post_id': opening_id}) | |||
if self.id != opening_id: | |||
post_url += '#' + str(self.id) | |||
return post_url | |||
neko259
|
r1156 | ||
def get_thread(self): | |||
return self.thread | |||
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() | |||
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' | |||
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, | |
PARAMETER_IS_OPENING: is_opening, | |||
PARAMETER_THREAD: thread, | |||
PARAMETER_CSS_CLASS: css_class, | |||
PARAMETER_OP_ID: opening_post_id, | |||
}) | |||
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
|
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)) | |||
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): | |||
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(): | |||
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): | |||
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']) | |||
neko259
|
r1201 | self.threads.add(opening_post.get_thread()) | |
neko259
|
r1293 | ||
def get_tripcode_color(self): | |||
return self.tripcode[:6] | |||
neko259
|
r1294 | def get_tripcode_background(self): | |
code = self.get_tripcode_color() | |||
result = '' | |||
for i in range(0, len(code), 2): | |||
p = code[i:i+2] | |||
result += hex(255 - int(p, 16))[2:] | |||
return result | |||
neko259
|
r1293 | def get_short_tripcode(self): | |
return self.tripcode[:10] |