|
|
from datetime import datetime, timedelta, date
|
|
|
from datetime import time as dtime
|
|
|
import logging
|
|
|
import re
|
|
|
import uuid
|
|
|
import xml.etree.ElementTree as et
|
|
|
|
|
|
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.mdx_neboard import Parser
|
|
|
from boards.models import KeyPair, GlobalId, Signature
|
|
|
from boards import settings
|
|
|
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
|
|
|
|
|
|
|
|
|
ENCODING_UNICODE = 'unicode'
|
|
|
|
|
|
WS_NOTIFICATION_TYPE_NEW_POST = 'new_post'
|
|
|
WS_NOTIFICATION_TYPE = 'notification_type'
|
|
|
|
|
|
WS_CHANNEL_THREAD = "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_GLOBAL_REPLY = re.compile(r'\[post\](\w+)::([^:]+)::(\d+)\[/post\]')
|
|
|
REGEX_URL = re.compile(r'https?\://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(/\S*)?')
|
|
|
REGEX_NOTIFICATION = re.compile(r'\[user\](\w+)\[/user\]')
|
|
|
|
|
|
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_SIGNATURES = 'signatures'
|
|
|
TAG_SIGNATURE = 'signature'
|
|
|
TAG_CONTENT = 'content'
|
|
|
TAG_ATTACHMENTS = 'attachments'
|
|
|
TAG_ATTACHMENT = 'attachment'
|
|
|
|
|
|
TYPE_GET = 'get'
|
|
|
|
|
|
ATTR_VERSION = 'version'
|
|
|
ATTR_TYPE = 'type'
|
|
|
ATTR_NAME = 'name'
|
|
|
ATTR_VALUE = 'value'
|
|
|
ATTR_MIMETYPE = 'mimetype'
|
|
|
|
|
|
STATUS_SUCCESS = 'success'
|
|
|
|
|
|
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'
|
|
|
PARAMETER_NEED_OP_DATA = 'need_op_data'
|
|
|
|
|
|
DIFF_TYPE_HTML = 'html'
|
|
|
DIFF_TYPE_JSON = 'json'
|
|
|
|
|
|
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)
|
|
|
|
|
|
post.set_global_id()
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
# TODO Make a separate sync facade?
|
|
|
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')
|
|
|
|
|
|
content_tag = et.SubElement(model, TAG_CONTENT)
|
|
|
|
|
|
tag_id = et.SubElement(content_tag, TAG_ID)
|
|
|
post.global_id.to_xml_element(tag_id)
|
|
|
|
|
|
title = et.SubElement(content_tag, TAG_TITLE)
|
|
|
title.text = post.title
|
|
|
|
|
|
text = et.SubElement(content_tag, TAG_TEXT)
|
|
|
# TODO Replace local links by global ones in the text
|
|
|
text.text = post.get_raw_text()
|
|
|
|
|
|
if not post.is_opening():
|
|
|
thread = et.SubElement(content_tag, TAG_THREAD)
|
|
|
thread_id = et.SubElement(thread, TAG_ID)
|
|
|
post.get_thread().get_opening_post().global_id.to_xml_element(thread_id)
|
|
|
else:
|
|
|
# TODO Output tags here
|
|
|
pass
|
|
|
|
|
|
pub_time = et.SubElement(content_tag, TAG_PUB_TIME)
|
|
|
pub_time.text = str(post.get_pub_time_epoch())
|
|
|
|
|
|
signatures_tag = et.SubElement(model, TAG_SIGNATURES)
|
|
|
post_signatures = post.signature.all()
|
|
|
if post_signatures:
|
|
|
signatures = post.signatures
|
|
|
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)
|
|
|
signatures = [Signature(
|
|
|
key_type=key.key_type,
|
|
|
key=key.public_key,
|
|
|
signature=key.sign(et.tostring(model, ENCODING_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)
|
|
|
|
|
|
return et.tostring(response, ENCODING_UNICODE)
|
|
|
|
|
|
def parse_response_get(self, response_xml):
|
|
|
tag_root = et.fromstring(response_xml)
|
|
|
tag_status = tag_root[0]
|
|
|
if 'success' == tag_status.text:
|
|
|
tag_models = tag_root[1]
|
|
|
for tag_model in tag_models:
|
|
|
tag_content = tag_model[0]
|
|
|
tag_id = tag_content[1]
|
|
|
try:
|
|
|
GlobalId.from_xml_element(tag_id, existing=True)
|
|
|
# If this post already exists, just continue
|
|
|
# TODO Compare post content and update the post if necessary
|
|
|
pass
|
|
|
except GlobalId.DoesNotExist:
|
|
|
global_id = GlobalId.from_xml_element(tag_id)
|
|
|
|
|
|
title = tag_content.find(TAG_TITLE).text
|
|
|
text = tag_content.find(TAG_TEXT).text
|
|
|
# TODO Check that the replied posts are already present
|
|
|
# before adding new ones
|
|
|
|
|
|
# TODO Pub time, thread, tags
|
|
|
|
|
|
post = Post.objects.create(title=title, text=text)
|
|
|
else:
|
|
|
# TODO Throw an exception?
|
|
|
pass
|
|
|
|
|
|
# TODO Make a separate parser module and move preparser there
|
|
|
def _preparse_text(self, text: str) -> str:
|
|
|
"""
|
|
|
Preparses text to change patterns like '>>' to a proper bbcode
|
|
|
tags.
|
|
|
"""
|
|
|
|
|
|
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)
|
|
|
|
|
|
# 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 __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.
|
|
|
"""
|
|
|
|
|
|
post_urls = [REFMAP_STR.format(refpost.get_absolute_url(), refpost.id)
|
|
|
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,
|
|
|
PARAMETER_NEED_OP_DATA: kwargs.get(PARAMETER_NEED_OP_DATA)
|
|
|
})
|
|
|
|
|
|
def get_search_view(self, *args, **kwargs):
|
|
|
return self.get_view(need_op_data=True, *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()
|
|
|
|
|
|
self.signature.all().delete()
|
|
|
if self.global_id:
|
|
|
self.global_id.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))
|
|
|
|
|
|
# TODO Implement this with OOP, e.g. use the factory and HtmlPostData class
|
|
|
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_replied_ids(self):
|
|
|
"""
|
|
|
Gets ID list of the posts that this post replies.
|
|
|
"""
|
|
|
|
|
|
raw_text = self.get_raw_text()
|
|
|
|
|
|
local_replied = REGEX_REPLY.findall(raw_text)
|
|
|
global_replied = []
|
|
|
for match in REGEX_GLOBAL_REPLY.findall(raw_text):
|
|
|
key_type = match[0]
|
|
|
key = match[1]
|
|
|
local_id = match[2]
|
|
|
|
|
|
try:
|
|
|
global_id = GlobalId.objects.get(key_type=key_type,
|
|
|
key=key, local_id=local_id)
|
|
|
for post in Post.objects.filter(global_id=global_id).only('id'):
|
|
|
global_replied.append(post.id)
|
|
|
except GlobalId.DoesNotExist:
|
|
|
pass
|
|
|
return local_replied + global_replied
|
|
|
|
|
|
|
|
|
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)
|
|
|
|