|
|
from datetime import datetime, timedelta, date
|
|
|
from datetime import time as dtime
|
|
|
import logging
|
|
|
import re
|
|
|
import xml.etree.ElementTree as et
|
|
|
|
|
|
from django.core.cache import cache
|
|
|
from django.core.urlresolvers import reverse
|
|
|
from django.db import models, transaction
|
|
|
from django.template.loader import render_to_string
|
|
|
from django.utils import timezone
|
|
|
|
|
|
from markupfield.fields import MarkupField
|
|
|
|
|
|
from boards.models import PostImage, KeyPair, GlobalId
|
|
|
from boards.models.base import Viewable
|
|
|
from boards.models.thread import Thread
|
|
|
from boards import utils
|
|
|
|
|
|
|
|
|
APP_LABEL_BOARDS = 'boards'
|
|
|
|
|
|
CACHE_KEY_PPD = 'ppd'
|
|
|
CACHE_KEY_POST_URL = 'post_url'
|
|
|
|
|
|
POSTS_PER_DAY_RANGE = 7
|
|
|
|
|
|
BAN_REASON_AUTO = 'Auto'
|
|
|
|
|
|
IMAGE_THUMB_SIZE = (200, 150)
|
|
|
|
|
|
TITLE_MAX_LENGTH = 200
|
|
|
|
|
|
DEFAULT_MARKUP_TYPE = 'bbcode'
|
|
|
|
|
|
# TODO This should be removed
|
|
|
NO_IP = '0.0.0.0'
|
|
|
|
|
|
# TODO Real user agent should be saved instead of this
|
|
|
UNKNOWN_UA = ''
|
|
|
|
|
|
REGEX_REPLY = re.compile(r'\[post\](\d+)\[/post\]')
|
|
|
|
|
|
TAG_MODEL = 'model'
|
|
|
TAG_REQUEST = 'request'
|
|
|
TAG_RESPONSE = 'response'
|
|
|
TAG_ID = 'id'
|
|
|
TAG_STATUS = 'status'
|
|
|
TAG_MODELS = 'models'
|
|
|
TAG_TITLE = 'title'
|
|
|
TAG_TEXT = 'text'
|
|
|
TAG_THREAD = 'thread'
|
|
|
TAG_PUB_TIME = 'pub-time'
|
|
|
TAG_EDIT_TIME = 'edit-time'
|
|
|
TAG_PREVIOUS = 'previous'
|
|
|
TAG_NEXT = 'next'
|
|
|
|
|
|
TYPE_GET = 'get'
|
|
|
|
|
|
ATTR_VERSION = 'version'
|
|
|
ATTR_TYPE = 'type'
|
|
|
ATTR_NAME = 'name'
|
|
|
ATTR_REF_ID = 'ref-id'
|
|
|
|
|
|
STATUS_SUCCESS = 'success'
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
class PostManager(models.Manager):
|
|
|
def create_post(self, title, text, image=None, thread=None, ip=NO_IP,
|
|
|
tags=None):
|
|
|
"""
|
|
|
Creates new post
|
|
|
"""
|
|
|
|
|
|
if not tags:
|
|
|
tags = []
|
|
|
|
|
|
posting_time = timezone.now()
|
|
|
if not thread:
|
|
|
thread = Thread.objects.create(bump_time=posting_time,
|
|
|
last_edit_time=posting_time)
|
|
|
new_thread = True
|
|
|
else:
|
|
|
thread.bump()
|
|
|
thread.last_edit_time = posting_time
|
|
|
thread.save()
|
|
|
new_thread = False
|
|
|
|
|
|
post = self.create(title=title,
|
|
|
text=text,
|
|
|
pub_time=posting_time,
|
|
|
thread_new=thread,
|
|
|
poster_ip=ip,
|
|
|
poster_user_agent=UNKNOWN_UA, # TODO Get UA at
|
|
|
# last!
|
|
|
last_edit_time=posting_time)
|
|
|
|
|
|
post.set_global_id()
|
|
|
|
|
|
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))
|
|
|
|
|
|
thread.replies.add(post)
|
|
|
list(map(thread.add_tag, tags))
|
|
|
|
|
|
if new_thread:
|
|
|
Thread.objects.process_oldest_threads()
|
|
|
self.connect_replies(post)
|
|
|
|
|
|
logger.info('Created post #%d with title %s and key %s'
|
|
|
% (post.id, post.get_title(), post.global_id.key))
|
|
|
|
|
|
return post
|
|
|
|
|
|
def delete_post(self, post):
|
|
|
"""
|
|
|
Deletes post and update or delete its thread
|
|
|
"""
|
|
|
|
|
|
post_id = post.id
|
|
|
|
|
|
thread = post.get_thread()
|
|
|
|
|
|
if post.is_opening():
|
|
|
thread.delete()
|
|
|
else:
|
|
|
thread.last_edit_time = timezone.now()
|
|
|
thread.save()
|
|
|
|
|
|
post.delete()
|
|
|
|
|
|
logger.info('Deleted post #%d (%s)' % (post_id, post.get_title()))
|
|
|
|
|
|
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:
|
|
|
self.delete_post(post)
|
|
|
|
|
|
def connect_replies(self, post):
|
|
|
"""
|
|
|
Connects replies to a post to show them as a reflink map
|
|
|
"""
|
|
|
|
|
|
for reply_number in post.get_replied_ids():
|
|
|
ref_post = self.filter(id=reply_number)
|
|
|
if ref_post.count() > 0:
|
|
|
referenced_post = ref_post[0]
|
|
|
referenced_post.referenced_posts.add(post)
|
|
|
referenced_post.last_edit_time = post.pub_time
|
|
|
referenced_post.build_refmap()
|
|
|
referenced_post.save(update_fields=['refmap', 'last_edit_time'])
|
|
|
|
|
|
referenced_thread = referenced_post.get_thread()
|
|
|
referenced_thread.last_edit_time = post.pub_time
|
|
|
referenced_thread.save(update_fields=['last_edit_time'])
|
|
|
|
|
|
def get_posts_per_day(self):
|
|
|
"""
|
|
|
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)
|
|
|
|
|
|
cache_key = CACHE_KEY_PPD + str(day_end)
|
|
|
ppd = cache.get(cache_key)
|
|
|
if ppd:
|
|
|
return ppd
|
|
|
|
|
|
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
|
|
|
|
|
|
cache.set(cache_key, ppd)
|
|
|
return ppd
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
ref_id = 1
|
|
|
for post in model_list:
|
|
|
model = et.SubElement(models, TAG_MODEL)
|
|
|
model.set(ATTR_NAME, 'post')
|
|
|
model.set(ATTR_REF_ID, str(ref_id))
|
|
|
ref_id += 1
|
|
|
|
|
|
tag_id = et.SubElement(model, TAG_ID)
|
|
|
post.global_id.to_xml_element(tag_id)
|
|
|
|
|
|
title = et.SubElement(model, TAG_TITLE)
|
|
|
title.text = post.title
|
|
|
|
|
|
text = et.SubElement(model, TAG_TEXT)
|
|
|
text.text = post.text.raw
|
|
|
|
|
|
if not post.is_opening():
|
|
|
thread = et.SubElement(model, TAG_THREAD)
|
|
|
thread.text = str(post.get_thread().get_opening_post_id())
|
|
|
|
|
|
pub_time = et.SubElement(model, TAG_PUB_TIME)
|
|
|
pub_time.text = str(post.get_pub_time_epoch())
|
|
|
|
|
|
edit_time = et.SubElement(model, TAG_EDIT_TIME)
|
|
|
edit_time.text = str(post.get_edit_time_epoch())
|
|
|
|
|
|
previous_ids = post.get_replied_ids()
|
|
|
if len(previous_ids) > 0:
|
|
|
previous = et.SubElement(model, TAG_PREVIOUS)
|
|
|
for id in previous_ids:
|
|
|
prev_id = et.SubElement(previous, TAG_ID)
|
|
|
replied_post = Post.objects.get(id=id)
|
|
|
replied_post.global_id.to_xml_element(prev_id)
|
|
|
|
|
|
|
|
|
next_ids = post.referenced_posts.order_by('id').all()
|
|
|
if len(next_ids) > 0:
|
|
|
next_el = et.SubElement(model, TAG_NEXT)
|
|
|
for ref_post in next_ids:
|
|
|
next_id = et.SubElement(next_el, TAG_ID)
|
|
|
ref_post.global_id.to_xml_element(next_id)
|
|
|
|
|
|
return et.tostring(response, 'unicode')
|
|
|
|
|
|
|
|
|
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)
|
|
|
pub_time = models.DateTimeField()
|
|
|
text = MarkupField(default_markup_type=DEFAULT_MARKUP_TYPE,
|
|
|
escape_html=False)
|
|
|
|
|
|
images = models.ManyToManyField(PostImage, null=True, blank=True,
|
|
|
related_name='ip+', db_index=True)
|
|
|
|
|
|
poster_ip = models.GenericIPAddressField()
|
|
|
poster_user_agent = models.TextField()
|
|
|
|
|
|
thread_new = models.ForeignKey('Thread', null=True, default=None,
|
|
|
db_index=True)
|
|
|
last_edit_time = models.DateTimeField()
|
|
|
|
|
|
# Replies to the post
|
|
|
referenced_posts = models.ManyToManyField('Post', symmetrical=False,
|
|
|
null=True,
|
|
|
blank=True, related_name='rfp+',
|
|
|
db_index=True)
|
|
|
|
|
|
# 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).
|
|
|
refmap = models.TextField(null=True, blank=True)
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
# One post can be signed by many nodes that give their trust to it
|
|
|
signature = models.ManyToManyField('Signature', null=True, blank=True)
|
|
|
|
|
|
def __unicode__(self):
|
|
|
return '#' + str(self.id) + ' ' + self.title + ' (' + \
|
|
|
self.text.raw[:50] + ')'
|
|
|
|
|
|
def get_title(self):
|
|
|
"""
|
|
|
Gets original post title or part of its text.
|
|
|
"""
|
|
|
|
|
|
title = self.title
|
|
|
if not title:
|
|
|
title = self.text.rendered
|
|
|
|
|
|
return title
|
|
|
|
|
|
def build_refmap(self):
|
|
|
"""
|
|
|
Builds a replies map string from replies list. This is a cache to stop
|
|
|
the server from recalculating the map on every post show.
|
|
|
"""
|
|
|
map_string = ''
|
|
|
|
|
|
first = True
|
|
|
for refpost in self.referenced_posts.all():
|
|
|
if not first:
|
|
|
map_string += ', '
|
|
|
map_string += '<a href="%s">>>%s</a>' % (refpost.get_url(),
|
|
|
refpost.id)
|
|
|
first = False
|
|
|
|
|
|
self.refmap = map_string
|
|
|
|
|
|
def get_sorted_referenced_posts(self):
|
|
|
return self.refmap
|
|
|
|
|
|
def is_referenced(self):
|
|
|
return len(self.refmap) > 0
|
|
|
|
|
|
def is_opening(self):
|
|
|
"""
|
|
|
Checks if this is an opening post or just a reply.
|
|
|
"""
|
|
|
|
|
|
return self.get_thread().get_opening_post_id() == self.id
|
|
|
|
|
|
@transaction.atomic
|
|
|
def add_tag(self, tag):
|
|
|
edit_time = timezone.now()
|
|
|
|
|
|
thread = self.get_thread()
|
|
|
thread.add_tag(tag)
|
|
|
self.last_edit_time = edit_time
|
|
|
self.save(update_fields=['last_edit_time'])
|
|
|
|
|
|
thread.last_edit_time = edit_time
|
|
|
thread.save(update_fields=['last_edit_time'])
|
|
|
|
|
|
@transaction.atomic
|
|
|
def remove_tag(self, tag):
|
|
|
edit_time = timezone.now()
|
|
|
|
|
|
thread = self.get_thread()
|
|
|
thread.remove_tag(tag)
|
|
|
self.last_edit_time = edit_time
|
|
|
self.save(update_fields=['last_edit_time'])
|
|
|
|
|
|
thread.last_edit_time = edit_time
|
|
|
thread.save(update_fields=['last_edit_time'])
|
|
|
|
|
|
def get_url(self, thread=None):
|
|
|
"""
|
|
|
Gets full url to the post.
|
|
|
"""
|
|
|
|
|
|
cache_key = CACHE_KEY_POST_URL + str(self.id)
|
|
|
link = cache.get(cache_key)
|
|
|
|
|
|
if not link:
|
|
|
if not thread:
|
|
|
thread = self.get_thread()
|
|
|
|
|
|
opening_id = thread.get_opening_post_id()
|
|
|
|
|
|
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})
|
|
|
|
|
|
cache.set(cache_key, link)
|
|
|
|
|
|
return link
|
|
|
|
|
|
def get_thread(self):
|
|
|
"""
|
|
|
Gets post's thread.
|
|
|
"""
|
|
|
|
|
|
return self.thread_new
|
|
|
|
|
|
def get_referenced_posts(self):
|
|
|
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()
|
|
|
|
|
|
if is_opening:
|
|
|
opening_post_id = self.id
|
|
|
else:
|
|
|
opening_post_id = thread.get_opening_post_id()
|
|
|
|
|
|
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,
|
|
|
})
|
|
|
|
|
|
def get_first_image(self):
|
|
|
return self.images.earliest('id')
|
|
|
|
|
|
def delete(self, using=None):
|
|
|
"""
|
|
|
Deletes all post images and the post itself.
|
|
|
"""
|
|
|
|
|
|
self.images.all().delete()
|
|
|
self.signature.all().delete()
|
|
|
if self.global_id:
|
|
|
self.global_id.delete()
|
|
|
|
|
|
super(Post, self).delete(using)
|
|
|
|
|
|
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)
|
|
|
global_id.save()
|
|
|
|
|
|
self.global_id = global_id
|
|
|
|
|
|
self.save(update_fields=['global_id'])
|
|
|
|
|
|
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)
|
|
|
|
|
|
def get_replied_ids(self):
|
|
|
return re.findall(REGEX_REPLY, self.text.raw)
|
|
|
|