post.py
492 lines
| 14.4 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
|
r827 | import xml.etree.ElementTree as et | ||
neko259
|
r527 | |||
neko259
|
r410 | from django.core.cache import cache | ||
neko259
|
r589 | from django.core.urlresolvers import reverse | ||
neko259
|
r566 | from django.db import models, transaction | ||
neko259
|
r692 | from django.template.loader import render_to_string | ||
neko259
|
r384 | from django.utils import timezone | ||
neko259
|
r827 | |||
neko259
|
r384 | from markupfield.fields import MarkupField | ||
neko259
|
r723 | |||
neko259
|
r837 | from boards.models import PostImage, KeyPair, GlobalId, Signature | ||
neko259
|
r692 | from boards.models.base import Viewable | ||
neko259
|
r691 | from boards.models.thread import Thread | ||
neko259
|
r828 | from boards import utils | ||
neko259
|
r384 | |||
neko259
|
r622 | |||
neko259
|
r410 | APP_LABEL_BOARDS = 'boards' | ||
CACHE_KEY_PPD = 'ppd' | ||||
neko259
|
r589 | CACHE_KEY_POST_URL = 'post_url' | ||
neko259
|
r410 | |||
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
|
r736 | DEFAULT_MARKUP_TYPE = 'bbcode' | ||
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
|
r384 | |||
neko259
|
r827 | TAG_MODEL = 'model' | ||
TAG_REQUEST = 'request' | ||||
TAG_RESPONSE = 'response' | ||||
TAG_ID = 'id' | ||||
TAG_STATUS = 'status' | ||||
TAG_MODELS = 'models' | ||||
TAG_TITLE = 'title' | ||||
neko259
|
r838 | TAG_TEXT = 'text' | ||
neko259
|
r828 | TAG_THREAD = 'thread' | ||
TAG_PUB_TIME = 'pub-time' | ||||
TAG_EDIT_TIME = 'edit-time' | ||||
neko259
|
r829 | TAG_PREVIOUS = 'previous' | ||
TAG_NEXT = 'next' | ||||
neko259
|
r837 | TAG_SIGNATURES = 'signatures' | ||
TAG_SIGNATURE = 'signature' | ||||
neko259
|
r838 | TAG_CONTENT = 'content' | ||
neko259
|
r827 | |||
TYPE_GET = 'get' | ||||
ATTR_VERSION = 'version' | ||||
ATTR_TYPE = 'type' | ||||
ATTR_NAME = 'name' | ||||
neko259
|
r837 | ATTR_VALUE = 'value' | ||
neko259
|
r827 | |||
STATUS_SUCCESS = 'success' | ||||
neko259
|
r639 | logger = logging.getLogger(__name__) | ||
neko259
|
r384 | |||
class PostManager(models.Manager): | ||||
neko259
|
r728 | def create_post(self, title, text, image=None, thread=None, ip=NO_IP, | ||
tags=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: | ||
thread = Thread.objects.create(bump_time=posting_time, | ||||
last_edit_time=posting_time) | ||||
neko259
|
r615 | new_thread = True | ||
neko259
|
r398 | else: | ||
thread.bump() | ||||
thread.last_edit_time = posting_time | ||||
thread.save() | ||||
neko259
|
r615 | new_thread = False | ||
neko259
|
r384 | |||
post = self.create(title=title, | ||||
text=text, | ||||
pub_time=posting_time, | ||||
neko259
|
r398 | thread_new=thread, | ||
neko259
|
r384 | poster_ip=ip, | ||
neko259
|
r484 | poster_user_agent=UNKNOWN_UA, # TODO Get UA at | ||
# last! | ||||
neko259
|
r728 | last_edit_time=posting_time) | ||
neko259
|
r384 | |||
neko259
|
r827 | post.set_global_id() | ||
neko259
|
r693 | if image: | ||
post_image = PostImage.objects.create(image=image) | ||||
post.images.add(post_image) | ||||
logger.info('Created image #%d for post #%d' % (post_image.id, | ||||
post.id)) | ||||
neko259
|
r398 | thread.replies.add(post) | ||
neko259
|
r770 | list(map(thread.add_tag, tags)) | ||
neko259
|
r384 | |||
neko259
|
r615 | if new_thread: | ||
neko259
|
r716 | Thread.objects.process_oldest_threads() | ||
neko259
|
r384 | self.connect_replies(post) | ||
neko259
|
r835 | logger.info('Created post #%d with title %s' | ||
% (post.id, post.get_title())) | ||||
neko259
|
r639 | |||
neko259
|
r384 | return post | ||
def delete_post(self, post): | ||||
neko259
|
r418 | """ | ||
neko259
|
r622 | Deletes post and update or delete its thread | ||
neko259
|
r418 | """ | ||
neko259
|
r552 | |||
neko259
|
r639 | post_id = post.id | ||
neko259
|
r619 | thread = post.get_thread() | ||
neko259
|
r418 | |||
neko259
|
r552 | if post.is_opening(): | ||
neko259
|
r718 | thread.delete() | ||
neko259
|
r456 | else: | ||
thread.last_edit_time = timezone.now() | ||||
thread.save() | ||||
neko259
|
r384 | |||
neko259
|
r470 | post.delete() | ||
neko259
|
r384 | |||
neko259
|
r742 | logger.info('Deleted post #%d (%s)' % (post_id, post.get_title())) | ||
neko259
|
r639 | |||
neko259
|
r384 | 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: | ||
self.delete_post(post) | ||||
neko259
|
r384 | |||
def connect_replies(self, post): | ||||
neko259
|
r418 | """ | ||
neko259
|
r622 | Connects replies to a post to show them as a reflink map | ||
neko259
|
r418 | """ | ||
neko259
|
r384 | |||
neko259
|
r829 | for reply_number in post.get_replied_ids(): | ||
ref_post = self.filter(id=reply_number) | ||||
neko259
|
r384 | if ref_post.count() > 0: | ||
referenced_post = ref_post[0] | ||||
referenced_post.referenced_posts.add(post) | ||||
referenced_post.last_edit_time = post.pub_time | ||||
neko259
|
r674 | referenced_post.build_refmap() | ||
neko259
|
r715 | referenced_post.save(update_fields=['refmap', 'last_edit_time']) | ||
neko259
|
r384 | |||
neko259
|
r619 | referenced_thread = referenced_post.get_thread() | ||
neko259
|
r535 | referenced_thread.last_edit_time = post.pub_time | ||
neko259
|
r715 | referenced_thread.save(update_fields=['last_edit_time']) | ||
neko259
|
r535 | |||
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) | ||||
cache_key = CACHE_KEY_PPD + str(day_end) | ||||
ppd = cache.get(cache_key) | ||||
neko259
|
r410 | if ppd: | ||
return ppd | ||||
neko259
|
r745 | 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
|
r745 | cache.set(cache_key, ppd) | ||
neko259
|
r410 | return ppd | ||
neko259
|
r407 | |||
neko259
|
r827 | def generate_request_get(self, model_list: list): | ||
""" | ||||
Form a get request from a list of ModelId objects. | ||||
""" | ||||
request = et.Element(TAG_REQUEST) | ||||
request.set(ATTR_TYPE, TYPE_GET) | ||||
request.set(ATTR_VERSION, '1.0') | ||||
model = et.SubElement(request, TAG_MODEL) | ||||
model.set(ATTR_VERSION, '1.0') | ||||
model.set(ATTR_NAME, 'post') | ||||
for post in model_list: | ||||
tag_id = et.SubElement(model, TAG_ID) | ||||
post.global_id.to_xml_element(tag_id) | ||||
return et.tostring(request, 'unicode') | ||||
def generate_response_get(self, model_list: list): | ||||
response = et.Element(TAG_RESPONSE) | ||||
status = et.SubElement(response, TAG_STATUS) | ||||
status.text = STATUS_SUCCESS | ||||
models = et.SubElement(response, TAG_MODELS) | ||||
for post in model_list: | ||||
model = et.SubElement(models, TAG_MODEL) | ||||
model.set(ATTR_NAME, 'post') | ||||
neko259
|
r838 | content_tag = et.SubElement(model, TAG_CONTENT) | ||
tag_id = et.SubElement(content_tag, TAG_ID) | ||||
neko259
|
r827 | post.global_id.to_xml_element(tag_id) | ||
neko259
|
r838 | title = et.SubElement(content_tag, TAG_TITLE) | ||
neko259
|
r827 | title.text = post.title | ||
neko259
|
r838 | text = et.SubElement(content_tag, TAG_TEXT) | ||
neko259
|
r828 | text.text = post.text.raw | ||
if not post.is_opening(): | ||||
neko259
|
r838 | thread = et.SubElement(content_tag, TAG_THREAD) | ||
neko259
|
r829 | thread.text = str(post.get_thread().get_opening_post_id()) | ||
neko259
|
r828 | |||
neko259
|
r838 | pub_time = et.SubElement(content_tag, TAG_PUB_TIME) | ||
neko259
|
r828 | pub_time.text = str(post.get_pub_time_epoch()) | ||
neko259
|
r838 | signatures_tag = et.SubElement(model, TAG_SIGNATURES) | ||
neko259
|
r837 | post_signatures = post.signature.all() | ||
if post_signatures: | ||||
neko259
|
r838 | signatures = post.signatures | ||
neko259
|
r837 | else: | ||
# TODO Maybe the signature can be computed only once after | ||||
# the post is added? Need to add some on_save signal queue | ||||
# and add this there. | ||||
key = KeyPair.objects.get(public_key=post.global_id.key) | ||||
neko259
|
r838 | signatures = [Signature( | ||
neko259
|
r837 | key_type=key.key_type, | ||
key=key.public_key, | ||||
signature=key.sign(et.tostring(model, 'unicode')), | ||||
)] | ||||
for signature in signatures: | ||||
signature_tag = et.SubElement(signatures_tag, TAG_SIGNATURE) | ||||
signature_tag.set(ATTR_TYPE, signature.key_type) | ||||
signature_tag.set(ATTR_VALUE, signature.signature) | ||||
neko259
|
r827 | return et.tostring(response, 'unicode') | ||
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() | ||||
text = MarkupField(default_markup_type=DEFAULT_MARKUP_TYPE, | ||||
escape_html=False) | ||||
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() | ||||
neko259
|
r661 | thread_new = models.ForeignKey('Thread', null=True, default=None, | ||
neko259
|
r715 | db_index=True) | ||
neko259
|
r384 | last_edit_time = models.DateTimeField() | ||
neko259
|
r793 | # Replies to the post | ||
neko259
|
r384 | referenced_posts = models.ManyToManyField('Post', symmetrical=False, | ||
null=True, | ||||
neko259
|
r661 | blank=True, related_name='rfp+', | ||
db_index=True) | ||||
neko259
|
r793 | |||
# Replies map. This is built from the referenced posts list to speed up | ||||
# page loading (no need to get all the referenced posts from the database). | ||||
neko259
|
r674 | refmap = models.TextField(null=True, blank=True) | ||
neko259
|
r384 | |||
neko259
|
r797 | # Global ID with author key. If the message was downloaded from another | ||
# server, this indicates the server. | ||||
neko259
|
r820 | global_id = models.OneToOneField('GlobalId', null=True, blank=True) | ||
# One post can be signed by many nodes that give their trust to it | ||||
signature = models.ManyToManyField('Signature', null=True, blank=True) | ||||
neko259
|
r793 | |||
neko259
|
r384 | def __unicode__(self): | ||
return '#' + str(self.id) + ' ' + self.title + ' (' + \ | ||||
self.text.raw[:50] + ')' | ||||
def get_title(self): | ||||
neko259
|
r622 | """ | ||
Gets original post title or part of its text. | ||||
""" | ||||
neko259
|
r384 | title = self.title | ||
neko259
|
r618 | if not title: | ||
neko259
|
r521 | title = self.text.rendered | ||
neko259
|
r384 | |||
return title | ||||
neko259
|
r674 | def build_refmap(self): | ||
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 = '' | ||
first = True | ||||
for refpost in self.referenced_posts.all(): | ||||
if not first: | ||||
map_string += ', ' | ||||
neko259
|
r740 | map_string += '<a href="%s">>>%s</a>' % (refpost.get_url(), | ||
refpost.id) | ||||
neko259
|
r674 | first = False | ||
self.refmap = map_string | ||||
neko259
|
r398 | def get_sorted_referenced_posts(self): | ||
neko259
|
r674 | return self.refmap | ||
neko259
|
r398 | |||
def is_referenced(self): | ||||
neko259
|
r674 | return len(self.refmap) > 0 | ||
neko259
|
r398 | |||
neko259
|
r400 | def is_opening(self): | ||
neko259
|
r622 | """ | ||
Checks if this is an opening post or just a reply. | ||||
""" | ||||
neko259
|
r620 | 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
|
r619 | 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 | |||
@transaction.atomic | ||||
def remove_tag(self, tag): | ||||
edit_time = timezone.now() | ||||
neko259
|
r619 | thread = self.get_thread() | ||
neko259
|
r576 | thread.remove_tag(tag) | ||
neko259
|
r566 | 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
|
r625 | def get_url(self, thread=None): | ||
neko259
|
r589 | """ | ||
neko259
|
r622 | Gets full url to the post. | ||
neko259
|
r589 | """ | ||
cache_key = CACHE_KEY_POST_URL + str(self.id) | ||||
link = cache.get(cache_key) | ||||
if not link: | ||||
neko259
|
r625 | if not thread: | ||
thread = self.get_thread() | ||||
opening_id = thread.get_opening_post_id() | ||||
neko259
|
r614 | |||
if self.id != opening_id: | ||||
neko259
|
r608 | link = reverse('thread', kwargs={ | ||
neko259
|
r614 | 'post_id': opening_id}) + '#' + str(self.id) | ||
neko259
|
r589 | else: | ||
link = reverse('thread', kwargs={'post_id': self.id}) | ||||
cache.set(cache_key, link) | ||||
return link | ||||
neko259
|
r617 | def get_thread(self): | ||
neko259
|
r622 | """ | ||
Gets post's thread. | ||||
""" | ||||
neko259
|
r617 | return self.thread_new | ||
neko259
|
r660 | def get_referenced_posts(self): | ||
neko259
|
r692 | return self.referenced_posts.only('id', 'thread_new') | ||
def get_text(self): | ||||
return self.text | ||||
def get_view(self, moderator=False, need_open_link=False, | ||||
truncated=False, *args, **kwargs): | ||||
if 'is_opening' in kwargs: | ||||
is_opening = kwargs['is_opening'] | ||||
else: | ||||
is_opening = self.is_opening() | ||||
if 'thread' in kwargs: | ||||
thread = kwargs['thread'] | ||||
else: | ||||
thread = self.get_thread() | ||||
if 'can_bump' in kwargs: | ||||
can_bump = kwargs['can_bump'] | ||||
else: | ||||
can_bump = thread.can_bump() | ||||
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', { | ||||
'post': self, | ||||
'moderator': moderator, | ||||
'is_opening': is_opening, | ||||
'thread': thread, | ||||
'bumpable': can_bump, | ||||
'need_open_link': need_open_link, | ||||
'truncated': truncated, | ||||
'opening_post_id': opening_post_id, | ||||
}) | ||||
neko259
|
r693 | def get_first_image(self): | ||
return self.images.earliest('id') | ||||
neko259
|
r715 | def delete(self, using=None): | ||
""" | ||||
neko259
|
r740 | Deletes all post images and the post itself. | ||
neko259
|
r715 | """ | ||
self.images.all().delete() | ||||
neko259
|
r829 | self.signature.all().delete() | ||
if self.global_id: | ||||
self.global_id.delete() | ||||
neko259
|
r715 | |||
neko259
|
r736 | super(Post, self).delete(using) | ||
neko259
|
r827 | |||
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, | ||||
local_id = self.id) | ||||
neko259
|
r829 | global_id.save() | ||
neko259
|
r827 | |||
self.global_id = global_id | ||||
self.save(update_fields=['global_id']) | ||||
neko259
|
r828 | |||
def get_pub_time_epoch(self): | ||||
return utils.datetime_to_epoch(self.pub_time) | ||||
def get_edit_time_epoch(self): | ||||
return utils.datetime_to_epoch(self.last_edit_time) | ||||
neko259
|
r829 | |||
def get_replied_ids(self): | ||||
return re.findall(REGEX_REPLY, self.text.raw) | ||||