annotate gpp/forums/views/main.py @ 318:c550933ff5b6

Fix a bug where you'd get an error when trying to delete a forum thread (topic does not exist). Apparently when you call topic.delete() the posts would get deleted, but the signal handler for each one would run, and it would try to update the topic's post count or something, but the topic was gone? Reworked the code a bit and explicitly delete the posts first. I also added a sync() call on the parent forum since post counts were not getting adjusted.
author Brian Neal <bgneal@gmail.com>
date Sat, 05 Feb 2011 21:46:52 +0000
parents 4f032a6e21f8
children 3f9b9fd54b01
rev   line source
bgneal@232 1 """
bgneal@232 2 Views for the forums application.
bgneal@232 3 """
bgneal@232 4 import datetime
bgneal@232 5
bgneal@232 6 from django.contrib.auth.decorators import login_required
bgneal@232 7 from django.contrib.auth.models import User
bgneal@232 8 from django.http import Http404
bgneal@232 9 from django.http import HttpResponse
bgneal@232 10 from django.http import HttpResponseBadRequest
bgneal@232 11 from django.http import HttpResponseForbidden
bgneal@232 12 from django.http import HttpResponseRedirect
bgneal@232 13 from django.core.urlresolvers import reverse
bgneal@232 14 from django.core.paginator import InvalidPage
bgneal@232 15 from django.shortcuts import get_object_or_404
bgneal@232 16 from django.shortcuts import render_to_response
bgneal@232 17 from django.template.loader import render_to_string
bgneal@232 18 from django.template import RequestContext
bgneal@232 19 from django.views.decorators.http import require_POST
bgneal@232 20 from django.utils.text import wrap
bgneal@232 21 from django.db.models import F
bgneal@232 22
bgneal@232 23 from core.paginator import DiggPaginator
bgneal@232 24 from core.functions import email_admins
bgneal@232 25 from forums.models import Forum, Topic, Post, FlaggedPost, TopicLastVisit, \
bgneal@285 26 ForumLastVisit, Attachment
bgneal@232 27 from forums.forms import NewTopicForm, NewPostForm, PostForm, MoveTopicForm, \
bgneal@232 28 SplitTopicForm
bgneal@232 29 from forums.unread import get_forum_unread_status, get_topic_unread_status, \
bgneal@232 30 get_post_unread_status, get_unread_topics
bgneal@232 31
bgneal@232 32 from bio.models import UserProfile
bgneal@232 33 import antispam
bgneal@232 34 import antispam.utils
bgneal@285 35 from forums.attachments import AttachmentProcessor
bgneal@232 36
bgneal@232 37 #######################################################################
bgneal@232 38
bgneal@232 39 TOPICS_PER_PAGE = 50
bgneal@232 40 POSTS_PER_PAGE = 20
bgneal@263 41 FEED_BASE = '/feeds/forums/'
bgneal@263 42 FORUM_FEED = FEED_BASE + '%s/'
bgneal@232 43
bgneal@232 44
bgneal@232 45 def get_page_num(request):
bgneal@232 46 """Returns the value of the 'page' variable in GET if it exists, or 1
bgneal@232 47 if it does not."""
bgneal@232 48
bgneal@232 49 try:
bgneal@232 50 page_num = int(request.GET.get('page', 1))
bgneal@232 51 except ValueError:
bgneal@232 52 page_num = 1
bgneal@232 53
bgneal@232 54 return page_num
bgneal@232 55
bgneal@232 56
bgneal@232 57 def create_topic_paginator(topics):
bgneal@232 58 return DiggPaginator(topics, TOPICS_PER_PAGE, body=5, tail=2, margin=3, padding=2)
bgneal@232 59
bgneal@232 60 def create_post_paginator(posts):
bgneal@232 61 return DiggPaginator(posts, POSTS_PER_PAGE, body=5, tail=2, margin=3, padding=2)
bgneal@232 62
bgneal@232 63
bgneal@232 64 def attach_topic_page_ranges(topics):
bgneal@232 65 """Attaches a page_range attribute to each topic in the supplied list.
bgneal@232 66 This attribute will be None if it is a single page topic. This is used
bgneal@232 67 by the templates to generate "goto page x" links.
bgneal@232 68 """
bgneal@232 69 for topic in topics:
bgneal@232 70 if topic.post_count > POSTS_PER_PAGE:
bgneal@286 71 pp = DiggPaginator(range(topic.post_count), POSTS_PER_PAGE,
bgneal@232 72 body=2, tail=3, margin=1)
bgneal@232 73 topic.page_range = pp.page(1).page_range
bgneal@232 74 else:
bgneal@232 75 topic.page_range = None
bgneal@232 76
bgneal@232 77 #######################################################################
bgneal@232 78
bgneal@232 79 def index(request):
bgneal@232 80 """
bgneal@232 81 This view displays all the forums available, ordered in each category.
bgneal@232 82 """
bgneal@232 83 public_forums = Forum.objects.public_forums()
bgneal@263 84 feeds = [{'name': 'All Forums', 'feed': FEED_BASE}]
bgneal@232 85
bgneal@232 86 forums = Forum.objects.forums_for_user(request.user)
bgneal@232 87 get_forum_unread_status(forums, request.user)
bgneal@232 88 cats = {}
bgneal@232 89 for forum in forums:
bgneal@232 90 forum.has_feed = forum in public_forums
bgneal@232 91 if forum.has_feed:
bgneal@232 92 feeds.append({
bgneal@232 93 'name': '%s Forum' % forum.name,
bgneal@263 94 'feed': FORUM_FEED % forum.slug,
bgneal@232 95 })
bgneal@232 96
bgneal@232 97 cat = cats.setdefault(forum.category.id, {
bgneal@232 98 'cat': forum.category,
bgneal@232 99 'forums': [],
bgneal@232 100 })
bgneal@232 101 cat['forums'].append(forum)
bgneal@232 102
bgneal@232 103 cmpdef = lambda a, b: cmp(a['cat'].position, b['cat'].position)
bgneal@232 104 cats = sorted(cats.values(), cmpdef)
bgneal@232 105
bgneal@232 106 return render_to_response('forums/index.html', {
bgneal@232 107 'cats': cats,
bgneal@232 108 'feeds': feeds,
bgneal@232 109 },
bgneal@232 110 context_instance=RequestContext(request))
bgneal@232 111
bgneal@232 112
bgneal@232 113 def forum_index(request, slug):
bgneal@232 114 """
bgneal@232 115 Displays all the topics in a forum.
bgneal@232 116 """
bgneal@232 117 forum = get_object_or_404(Forum.objects.select_related(), slug=slug)
bgneal@232 118
bgneal@232 119 if not forum.category.can_access(request.user):
bgneal@232 120 return HttpResponseForbidden()
bgneal@232 121
bgneal@263 122 feed = None
bgneal@263 123 if not forum.category.groups.all():
bgneal@263 124 feed = {
bgneal@263 125 'name': '%s Forum' % forum.name,
bgneal@263 126 'feed': FORUM_FEED % forum.slug,
bgneal@263 127 }
bgneal@263 128
bgneal@232 129 topics = forum.topics.select_related('user', 'last_post', 'last_post__user')
bgneal@232 130 get_topic_unread_status(forum, topics, request.user)
bgneal@232 131
bgneal@232 132 paginator = create_topic_paginator(topics)
bgneal@232 133 page_num = get_page_num(request)
bgneal@232 134 try:
bgneal@232 135 page = paginator.page(page_num)
bgneal@232 136 except InvalidPage:
bgneal@232 137 raise Http404
bgneal@232 138
bgneal@232 139 attach_topic_page_ranges(page.object_list)
bgneal@232 140
bgneal@232 141 # we do this for the template since it is rendered twice
bgneal@232 142 page_nav = render_to_string('forums/pagination.html', {'page': page})
bgneal@232 143
bgneal@232 144 can_moderate = _can_moderate(forum, request.user)
bgneal@286 145
bgneal@232 146 return render_to_response('forums/forum_index.html', {
bgneal@232 147 'forum': forum,
bgneal@263 148 'feed': feed,
bgneal@232 149 'page': page,
bgneal@232 150 'page_nav': page_nav,
bgneal@232 151 'can_moderate': can_moderate,
bgneal@232 152 },
bgneal@232 153 context_instance=RequestContext(request))
bgneal@232 154
bgneal@232 155
bgneal@232 156 def topic_index(request, id):
bgneal@232 157 """
bgneal@232 158 Displays all the posts in a topic.
bgneal@232 159 """
bgneal@232 160 topic = get_object_or_404(Topic.objects.select_related(
bgneal@232 161 'forum', 'forum__category', 'last_post'), pk=id)
bgneal@232 162
bgneal@232 163 if not topic.forum.category.can_access(request.user):
bgneal@232 164 return HttpResponseForbidden()
bgneal@232 165
bgneal@232 166 topic.view_count = F('view_count') + 1
bgneal@232 167 topic.save(force_update=True)
bgneal@232 168
bgneal@232 169 posts = topic.posts.select_related()
bgneal@232 170
bgneal@232 171 paginator = create_post_paginator(posts)
bgneal@232 172 page_num = get_page_num(request)
bgneal@232 173 try:
bgneal@232 174 page = paginator.page(page_num)
bgneal@232 175 except InvalidPage:
bgneal@232 176 raise Http404
bgneal@232 177 get_post_unread_status(topic, page.object_list, request.user)
bgneal@232 178
bgneal@232 179 # Attach user profiles to each post to avoid using get_user_profile() in
bgneal@232 180 # the template.
bgneal@232 181 users = set(post.user.id for post in page.object_list)
bgneal@232 182
bgneal@232 183 profiles = UserProfile.objects.filter(user__id__in=users).select_related()
bgneal@232 184 user_profiles = dict((profile.user.id, profile) for profile in profiles)
bgneal@232 185
bgneal@232 186 for post in page.object_list:
bgneal@232 187 post.user_profile = user_profiles[post.user.id]
bgneal@285 188 post.attach_list = []
bgneal@285 189
bgneal@285 190 # Attach any attachments
bgneal@285 191 post_ids = [post.pk for post in page.object_list]
bgneal@285 192 attachments = Attachment.objects.filter(post__in=post_ids).select_related(
bgneal@285 193 'embed').order_by('order')
bgneal@285 194
bgneal@285 195 post_dict = dict((post.pk, post) for post in page.object_list)
bgneal@285 196 for item in attachments:
bgneal@285 197 post_dict[item.post.id].attach_list.append(item.embed)
bgneal@232 198
bgneal@232 199 last_page = page_num == paginator.num_pages
bgneal@232 200
bgneal@232 201 if request.user.is_authenticated() and last_page:
bgneal@232 202 _update_last_visit(request.user, topic)
bgneal@232 203
bgneal@232 204 # we do this for the template since it is rendered twice
bgneal@232 205 page_nav = render_to_string('forums/pagination.html', {'page': page})
bgneal@232 206
bgneal@232 207 can_moderate = _can_moderate(topic.forum, request.user)
bgneal@232 208
bgneal@232 209 can_reply = request.user.is_authenticated() and (
bgneal@232 210 not topic.locked or can_moderate)
bgneal@232 211
bgneal@232 212 is_favorite = request.user.is_authenticated() and (
bgneal@232 213 topic in request.user.favorite_topics.all())
bgneal@232 214
bgneal@232 215 is_subscribed = request.user.is_authenticated() and (
bgneal@232 216 topic in request.user.subscriptions.all())
bgneal@232 217
bgneal@232 218 return render_to_response('forums/topic.html', {
bgneal@232 219 'forum': topic.forum,
bgneal@232 220 'topic': topic,
bgneal@232 221 'page': page,
bgneal@232 222 'page_nav': page_nav,
bgneal@232 223 'last_page': last_page,
bgneal@232 224 'can_moderate': can_moderate,
bgneal@232 225 'can_reply': can_reply,
bgneal@232 226 'form': NewPostForm(initial={'topic_id': topic.id}),
bgneal@232 227 'is_favorite': is_favorite,
bgneal@232 228 'is_subscribed': is_subscribed,
bgneal@232 229 },
bgneal@232 230 context_instance=RequestContext(request))
bgneal@232 231
bgneal@232 232
bgneal@232 233 @login_required
bgneal@232 234 def new_topic(request, slug):
bgneal@232 235 """
bgneal@232 236 This view handles the creation of new topics.
bgneal@232 237 """
bgneal@232 238 forum = get_object_or_404(Forum.objects.select_related(), slug=slug)
bgneal@232 239
bgneal@232 240 if not forum.category.can_access(request.user):
bgneal@232 241 return HttpResponseForbidden()
bgneal@232 242
bgneal@232 243 if request.method == 'POST':
bgneal@232 244 form = NewTopicForm(request.user, forum, request.POST)
bgneal@232 245 if form.is_valid():
bgneal@232 246 if antispam.utils.spam_check(request, form.cleaned_data['body']):
bgneal@232 247 return HttpResponseRedirect(reverse('antispam-suspended'))
bgneal@232 248
bgneal@232 249 topic = form.save(request.META.get("REMOTE_ADDR"))
bgneal@232 250 _bump_post_count(request.user)
bgneal@232 251 return HttpResponseRedirect(reverse('forums-new_topic_thanks',
bgneal@232 252 kwargs={'tid': topic.pk}))
bgneal@232 253 else:
bgneal@232 254 form = NewTopicForm(request.user, forum)
bgneal@286 255
bgneal@232 256 return render_to_response('forums/new_topic.html', {
bgneal@232 257 'forum': forum,
bgneal@232 258 'form': form,
bgneal@232 259 },
bgneal@232 260 context_instance=RequestContext(request))
bgneal@232 261
bgneal@232 262
bgneal@232 263 @login_required
bgneal@232 264 def new_topic_thanks(request, tid):
bgneal@232 265 """
bgneal@232 266 This view displays the success page for a newly created topic.
bgneal@232 267 """
bgneal@232 268 topic = get_object_or_404(Topic.objects.select_related(), pk=tid)
bgneal@232 269 return render_to_response('forums/new_topic_thanks.html', {
bgneal@232 270 'forum': topic.forum,
bgneal@232 271 'topic': topic,
bgneal@232 272 },
bgneal@232 273 context_instance=RequestContext(request))
bgneal@232 274
bgneal@232 275
bgneal@232 276 @require_POST
bgneal@232 277 def quick_reply_ajax(request):
bgneal@232 278 """
bgneal@232 279 This function handles the quick reply to a thread function. This
bgneal@232 280 function is meant to be the target of an AJAX post, and returns
bgneal@232 281 the HTML for the new post, which the client-side script appends
bgneal@232 282 to the document.
bgneal@232 283 """
bgneal@232 284 if not request.user.is_authenticated():
bgneal@232 285 return HttpResponseForbidden('Please login or register to post.')
bgneal@232 286
bgneal@232 287 form = NewPostForm(request.POST)
bgneal@232 288 if form.is_valid():
bgneal@232 289 if not _can_post_in_topic(form.topic, request.user):
bgneal@232 290 return HttpResponseForbidden("You don't have permission to post in this topic.")
bgneal@232 291 if antispam.utils.spam_check(request, form.cleaned_data['body']):
bgneal@232 292 return HttpResponseForbidden(antispam.BUSTED_MESSAGE)
bgneal@232 293
bgneal@232 294 post = form.save(request.user, request.META.get("REMOTE_ADDR", ""))
bgneal@232 295 post.unread = True
bgneal@232 296 post.user_profile = request.user.get_profile()
bgneal@285 297 post.attach_list = post.attachments.all()
bgneal@232 298 _bump_post_count(request.user)
bgneal@232 299 _update_last_visit(request.user, form.topic)
bgneal@285 300
bgneal@232 301 return render_to_response('forums/display_post.html', {
bgneal@232 302 'post': post,
bgneal@232 303 'can_moderate': _can_moderate(form.topic.forum, request.user),
bgneal@232 304 'can_reply': True,
bgneal@232 305 },
bgneal@232 306 context_instance=RequestContext(request))
bgneal@232 307
bgneal@286 308 return HttpResponseBadRequest("Oops, did you forget some text?");
bgneal@232 309
bgneal@232 310
bgneal@232 311 def goto_post(request, post_id):
bgneal@232 312 """
bgneal@232 313 This function calculates what page a given post is on, then redirects
bgneal@232 314 to that URL. This function is the target of get_absolute_url() for
bgneal@232 315 Post objects.
bgneal@232 316 """
bgneal@232 317 post = get_object_or_404(Post.objects.select_related(), pk=post_id)
bgneal@232 318 count = post.topic.posts.filter(creation_date__lt=post.creation_date).count()
bgneal@232 319 page = count / POSTS_PER_PAGE + 1
bgneal@232 320 url = reverse('forums-topic_index', kwargs={'id': post.topic.id}) + \
bgneal@232 321 '?page=%s#p%s' % (page, post.id)
bgneal@232 322 return HttpResponseRedirect(url)
bgneal@232 323
bgneal@232 324
bgneal@232 325 @require_POST
bgneal@232 326 def flag_post(request):
bgneal@232 327 """
bgneal@232 328 This function handles the flagging of posts by users. This function should
bgneal@232 329 be the target of an AJAX post.
bgneal@232 330 """
bgneal@232 331 if not request.user.is_authenticated():
bgneal@232 332 return HttpResponseForbidden('Please login or register to flag a post.')
bgneal@232 333
bgneal@232 334 id = request.POST.get('id')
bgneal@232 335 if id is None:
bgneal@232 336 return HttpResponseBadRequest('No post id')
bgneal@232 337
bgneal@232 338 try:
bgneal@232 339 post = Post.objects.get(pk=id)
bgneal@232 340 except Post.DoesNotExist:
bgneal@232 341 return HttpResponseBadRequest('No post with id %s' % id)
bgneal@232 342
bgneal@232 343 flag = FlaggedPost(user=request.user, post=post)
bgneal@232 344 flag.save()
bgneal@232 345 email_admins('A Post Has Been Flagged', """Hello,
bgneal@232 346
bgneal@232 347 A user has flagged a forum post for review.
bgneal@232 348 """)
bgneal@232 349 return HttpResponse('The post was flagged. A moderator will review the post shortly. ' \
bgneal@232 350 'Thanks for helping to improve the discussions on this site.')
bgneal@232 351
bgneal@232 352
bgneal@232 353 @login_required
bgneal@232 354 def edit_post(request, id):
bgneal@232 355 """
bgneal@232 356 This view function allows authorized users to edit posts.
bgneal@232 357 The superuser, forum moderators, and original author can edit posts.
bgneal@232 358 """
bgneal@232 359 post = get_object_or_404(Post.objects.select_related(), pk=id)
bgneal@232 360
bgneal@232 361 can_moderate = _can_moderate(post.topic.forum, request.user)
bgneal@232 362 can_edit = can_moderate or request.user == post.user
bgneal@232 363
bgneal@232 364 if not can_edit:
bgneal@232 365 return HttpResponseForbidden("You don't have permission to edit that post.")
bgneal@232 366
bgneal@295 367 topic_name = None
bgneal@295 368 first_post = Post.objects.filter(topic=post.topic).order_by('creation_date')[0]
bgneal@295 369 if first_post.id == post.id:
bgneal@295 370 topic_name = post.topic.name
bgneal@295 371
bgneal@232 372 if request.method == "POST":
bgneal@295 373 form = PostForm(request.POST, instance=post, topic_name=topic_name)
bgneal@232 374 if form.is_valid():
bgneal@232 375 if antispam.utils.spam_check(request, form.cleaned_data['body']):
bgneal@232 376 return HttpResponseRedirect(reverse('antispam-suspended'))
bgneal@232 377 post = form.save(commit=False)
bgneal@232 378 post.touch()
bgneal@232 379 post.save()
bgneal@285 380
bgneal@295 381 # if we are editing a first post, save the parent topic as well
bgneal@295 382 if topic_name:
bgneal@295 383 post.topic.save()
bgneal@295 384
bgneal@285 385 # Save any attachments
bgneal@286 386 form.attach_proc.save_attachments(post)
bgneal@285 387
bgneal@232 388 return HttpResponseRedirect(post.get_absolute_url())
bgneal@232 389 else:
bgneal@295 390 form = PostForm(instance=post, topic_name=topic_name)
bgneal@232 391
bgneal@298 392 post.user_profile = post.user.get_profile()
bgneal@232 393
bgneal@232 394 return render_to_response('forums/edit_post.html', {
bgneal@232 395 'forum': post.topic.forum,
bgneal@232 396 'topic': post.topic,
bgneal@232 397 'post': post,
bgneal@232 398 'form': form,
bgneal@232 399 'can_moderate': can_moderate,
bgneal@232 400 },
bgneal@232 401 context_instance=RequestContext(request))
bgneal@232 402
bgneal@232 403
bgneal@232 404 @require_POST
bgneal@232 405 def delete_post(request):
bgneal@232 406 """
bgneal@232 407 This view function allows superusers and forum moderators to delete posts.
bgneal@232 408 This function is the target of AJAX calls from the client.
bgneal@232 409 """
bgneal@232 410 if not request.user.is_authenticated():
bgneal@232 411 return HttpResponseForbidden('Please login to delete a post.')
bgneal@232 412
bgneal@232 413 id = request.POST.get('id')
bgneal@232 414 if id is None:
bgneal@232 415 return HttpResponseBadRequest('No post id')
bgneal@232 416
bgneal@232 417 post = get_object_or_404(Post.objects.select_related(), pk=id)
bgneal@232 418
bgneal@232 419 can_delete = request.user.is_superuser or \
bgneal@232 420 request.user in post.topic.forum.moderators.all()
bgneal@232 421
bgneal@232 422 if not can_delete:
bgneal@232 423 return HttpResponseForbidden("You don't have permission to delete that post.")
bgneal@232 424
bgneal@232 425 delete_single_post(post)
bgneal@232 426 return HttpResponse("The post has been deleted.")
bgneal@232 427
bgneal@232 428
bgneal@232 429 def delete_single_post(post):
bgneal@232 430 """
bgneal@232 431 This function deletes a single post. It handles the case of where
bgneal@232 432 a post is the sole post in a topic by deleting the topic also. It
bgneal@232 433 adjusts any foreign keys in Topic or Forum objects that might be pointing
bgneal@232 434 to this post before deleting the post to avoid a cascading delete.
bgneal@232 435 """
bgneal@232 436 if post.topic.post_count == 1 and post == post.topic.last_post:
bgneal@232 437 _delete_topic(post.topic)
bgneal@232 438 else:
bgneal@232 439 _delete_post(post)
bgneal@232 440
bgneal@232 441
bgneal@232 442 def _delete_post(post):
bgneal@232 443 """
bgneal@232 444 Internal function to delete a single post object.
bgneal@232 445 Decrements the post author's post count.
bgneal@232 446 Adjusts the parent topic and forum's last_post as needed.
bgneal@232 447 """
bgneal@232 448 # Adjust post creator's post count
bgneal@232 449 profile = post.user.get_profile()
bgneal@232 450 if profile.forum_post_count > 0:
bgneal@232 451 profile.forum_post_count -= 1
bgneal@232 452 profile.save()
bgneal@232 453
bgneal@232 454 # If this post is the last_post in a topic, we need to update
bgneal@232 455 # both the topic and parent forum's last post fields. If we don't
bgneal@232 456 # the cascading delete will delete them also!
bgneal@232 457
bgneal@232 458 topic = post.topic
bgneal@232 459 if topic.last_post == post:
bgneal@232 460 topic.last_post_pre_delete()
bgneal@232 461 topic.save()
bgneal@232 462
bgneal@232 463 forum = topic.forum
bgneal@232 464 if forum.last_post == post:
bgneal@232 465 forum.last_post_pre_delete()
bgneal@232 466 forum.save()
bgneal@232 467
bgneal@285 468 # delete any attachments
bgneal@285 469 post.attachments.clear()
bgneal@285 470
bgneal@232 471 # Should be safe to delete the post now:
bgneal@232 472 post.delete()
bgneal@232 473
bgneal@232 474
bgneal@232 475 def _delete_topic(topic):
bgneal@232 476 """
bgneal@232 477 Internal function to delete an entire topic.
bgneal@232 478 Deletes the topic and all posts contained within.
bgneal@232 479 Adjusts the parent forum's last_post as needed.
bgneal@232 480 Note that we don't bother adjusting all the users'
bgneal@232 481 post counts as that doesn't seem to be worth the effort.
bgneal@232 482 """
bgneal@318 483 parent_forum = topic.forum
bgneal@318 484 if parent_forum.last_post and parent_forum.last_post.topic == topic:
bgneal@318 485 parent_forum.last_post_pre_delete(deleting_topic=True)
bgneal@318 486 parent_forum.save()
bgneal@232 487
bgneal@232 488 # delete subscriptions to this topic
bgneal@232 489 topic.subscribers.clear()
bgneal@232 490 topic.bookmarkers.clear()
bgneal@232 491
bgneal@285 492 # delete all attachments
bgneal@285 493 posts = Post.objects.filter(topic=topic)
bgneal@285 494 for post in posts:
bgneal@285 495 post.attachments.clear()
bgneal@285 496
bgneal@318 497 # Null out the topic's last post so we don't have a foreign key pointing
bgneal@318 498 # to a post when we delete posts.
bgneal@318 499 topic.last_post = None
bgneal@318 500 topic.save()
bgneal@318 501
bgneal@318 502 # delete all posts in bulk
bgneal@318 503 posts.delete()
bgneal@318 504
bgneal@318 505 # It should be safe to just delete the topic now.
bgneal@232 506 topic.delete()
bgneal@232 507
bgneal@318 508 # Resync parent forum's post and topic counts
bgneal@318 509 parent_forum.sync()
bgneal@318 510 parent_forum.save()
bgneal@318 511
bgneal@232 512
bgneal@232 513 @login_required
bgneal@232 514 def new_post(request, topic_id):
bgneal@232 515 """
bgneal@232 516 This function is the view for creating a normal, non-quick reply
bgneal@232 517 to a topic.
bgneal@232 518 """
bgneal@232 519 topic = get_object_or_404(Topic.objects.select_related(), pk=topic_id)
bgneal@232 520 can_post = _can_post_in_topic(topic, request.user)
bgneal@232 521
bgneal@232 522 if can_post:
bgneal@232 523 if request.method == 'POST':
bgneal@232 524 form = PostForm(request.POST)
bgneal@232 525 if form.is_valid():
bgneal@232 526 if antispam.utils.spam_check(request, form.cleaned_data['body']):
bgneal@232 527 return HttpResponseRedirect(reverse('antispam-suspended'))
bgneal@232 528 post = form.save(commit=False)
bgneal@232 529 post.topic = topic
bgneal@232 530 post.user = request.user
bgneal@232 531 post.user_ip = request.META.get("REMOTE_ADDR", "")
bgneal@232 532 post.save()
bgneal@285 533
bgneal@285 534 # Save any attachments
bgneal@286 535 form.attach_proc.save_attachments(post)
bgneal@285 536
bgneal@232 537 _bump_post_count(request.user)
bgneal@232 538 _update_last_visit(request.user, topic)
bgneal@232 539 return HttpResponseRedirect(post.get_absolute_url())
bgneal@232 540 else:
bgneal@232 541 quote_id = request.GET.get('quote')
bgneal@232 542 if quote_id:
bgneal@232 543 quote_post = get_object_or_404(Post.objects.select_related(),
bgneal@232 544 pk=quote_id)
bgneal@232 545 form = PostForm(initial={'body': _quote_message(quote_post.user.username,
bgneal@232 546 quote_post.body)})
bgneal@232 547 else:
bgneal@232 548 form = PostForm()
bgneal@232 549 else:
bgneal@232 550 form = None
bgneal@232 551
bgneal@232 552 return render_to_response('forums/new_post.html', {
bgneal@232 553 'forum': topic.forum,
bgneal@232 554 'topic': topic,
bgneal@232 555 'form': form,
bgneal@232 556 'can_post': can_post,
bgneal@232 557 },
bgneal@232 558 context_instance=RequestContext(request))
bgneal@232 559
bgneal@232 560
bgneal@232 561 @login_required
bgneal@232 562 def mod_topic_stick(request, id):
bgneal@232 563 """
bgneal@232 564 This view function is for moderators to toggle the sticky status of a topic.
bgneal@232 565 """
bgneal@232 566 topic = get_object_or_404(Topic.objects.select_related(), pk=id)
bgneal@232 567 if _can_moderate(topic.forum, request.user):
bgneal@232 568 topic.sticky = not topic.sticky
bgneal@232 569 topic.save()
bgneal@232 570 return HttpResponseRedirect(topic.get_absolute_url())
bgneal@232 571
bgneal@232 572 return HttpResponseForbidden()
bgneal@232 573
bgneal@232 574
bgneal@232 575 @login_required
bgneal@232 576 def mod_topic_lock(request, id):
bgneal@232 577 """
bgneal@232 578 This view function is for moderators to toggle the locked status of a topic.
bgneal@232 579 """
bgneal@232 580 topic = get_object_or_404(Topic.objects.select_related(), pk=id)
bgneal@232 581 if _can_moderate(topic.forum, request.user):
bgneal@232 582 topic.locked = not topic.locked
bgneal@232 583 topic.save()
bgneal@232 584 return HttpResponseRedirect(topic.get_absolute_url())
bgneal@232 585
bgneal@232 586 return HttpResponseForbidden()
bgneal@232 587
bgneal@232 588
bgneal@232 589 @login_required
bgneal@232 590 def mod_topic_delete(request, id):
bgneal@232 591 """
bgneal@232 592 This view function is for moderators to delete an entire topic.
bgneal@232 593 """
bgneal@232 594 topic = get_object_or_404(Topic.objects.select_related(), pk=id)
bgneal@232 595 if _can_moderate(topic.forum, request.user):
bgneal@232 596 forum_url = topic.forum.get_absolute_url()
bgneal@232 597 _delete_topic(topic)
bgneal@232 598 return HttpResponseRedirect(forum_url)
bgneal@232 599
bgneal@232 600 return HttpResponseForbidden()
bgneal@232 601
bgneal@232 602
bgneal@232 603 @login_required
bgneal@232 604 def mod_topic_move(request, id):
bgneal@232 605 """
bgneal@232 606 This view function is for moderators to move a topic to a different forum.
bgneal@232 607 """
bgneal@232 608 topic = get_object_or_404(Topic.objects.select_related(), pk=id)
bgneal@232 609 if not _can_moderate(topic.forum, request.user):
bgneal@232 610 return HttpResponseForbidden()
bgneal@232 611
bgneal@232 612 if request.method == 'POST':
bgneal@232 613 form = MoveTopicForm(request.user, request.POST)
bgneal@232 614 if form.is_valid():
bgneal@232 615 new_forum = form.cleaned_data['forums']
bgneal@232 616 old_forum = topic.forum
bgneal@232 617 _move_topic(topic, old_forum, new_forum)
bgneal@232 618 return HttpResponseRedirect(topic.get_absolute_url())
bgneal@232 619 else:
bgneal@232 620 form = MoveTopicForm(request.user)
bgneal@232 621
bgneal@232 622 return render_to_response('forums/move_topic.html', {
bgneal@232 623 'forum': topic.forum,
bgneal@232 624 'topic': topic,
bgneal@232 625 'form': form,
bgneal@232 626 },
bgneal@232 627 context_instance=RequestContext(request))
bgneal@232 628
bgneal@232 629
bgneal@232 630 @login_required
bgneal@232 631 def mod_forum(request, slug):
bgneal@232 632 """
bgneal@232 633 Displays a view to allow moderators to perform various operations
bgneal@232 634 on topics in a forum in bulk. We currently support mass locking/unlocking,
bgneal@232 635 stickying and unstickying, moving, and deleting topics.
bgneal@232 636 """
bgneal@232 637 forum = get_object_or_404(Forum.objects.select_related(), slug=slug)
bgneal@232 638 if not _can_moderate(forum, request.user):
bgneal@232 639 return HttpResponseForbidden()
bgneal@232 640
bgneal@232 641 topics = forum.topics.select_related('user', 'last_post', 'last_post__user')
bgneal@232 642 paginator = create_topic_paginator(topics)
bgneal@232 643 page_num = get_page_num(request)
bgneal@232 644 try:
bgneal@232 645 page = paginator.page(page_num)
bgneal@232 646 except InvalidPage:
bgneal@232 647 raise Http404
bgneal@232 648
bgneal@232 649 # we do this for the template since it is rendered twice
bgneal@232 650 page_nav = render_to_string('forums/pagination.html', {'page': page})
bgneal@232 651 form = None
bgneal@232 652
bgneal@232 653 if request.method == 'POST':
bgneal@232 654 topic_ids = request.POST.getlist('topic_ids')
bgneal@232 655 url = reverse('forums-mod_forum', kwargs={'slug':forum.slug})
bgneal@232 656 url += '?page=%s' % page_num
bgneal@232 657
bgneal@232 658 if len(topic_ids):
bgneal@232 659 if request.POST.get('sticky'):
bgneal@232 660 _bulk_sticky(forum, topic_ids)
bgneal@232 661 return HttpResponseRedirect(url)
bgneal@232 662 elif request.POST.get('lock'):
bgneal@232 663 _bulk_lock(forum, topic_ids)
bgneal@232 664 return HttpResponseRedirect(url)
bgneal@232 665 elif request.POST.get('delete'):
bgneal@232 666 _bulk_delete(forum, topic_ids)
bgneal@232 667 return HttpResponseRedirect(url)
bgneal@232 668 elif request.POST.get('move'):
bgneal@232 669 form = MoveTopicForm(request.user, request.POST, hide_label=True)
bgneal@232 670 if form.is_valid():
bgneal@232 671 _bulk_move(topic_ids, forum, form.cleaned_data['forums'])
bgneal@232 672 return HttpResponseRedirect(url)
bgneal@286 673
bgneal@232 674 if form is None:
bgneal@232 675 form = MoveTopicForm(request.user, hide_label=True)
bgneal@232 676
bgneal@232 677 return render_to_response('forums/mod_forum.html', {
bgneal@232 678 'forum': forum,
bgneal@232 679 'page': page,
bgneal@232 680 'page_nav': page_nav,
bgneal@232 681 'form': form,
bgneal@232 682 },
bgneal@232 683 context_instance=RequestContext(request))
bgneal@232 684
bgneal@232 685
bgneal@232 686 @login_required
bgneal@232 687 @require_POST
bgneal@232 688 def forum_catchup(request, slug):
bgneal@232 689 """
bgneal@232 690 This view marks all the topics in the forum as being read.
bgneal@232 691 """
bgneal@232 692 forum = get_object_or_404(Forum.objects.select_related(), slug=slug)
bgneal@232 693
bgneal@232 694 if not forum.category.can_access(request.user):
bgneal@232 695 return HttpResponseForbidden()
bgneal@232 696
bgneal@232 697 forum.catchup(request.user)
bgneal@232 698 return HttpResponseRedirect(forum.get_absolute_url())
bgneal@232 699
bgneal@232 700
bgneal@232 701 @login_required
bgneal@232 702 def mod_topic_split(request, id):
bgneal@232 703 """
bgneal@232 704 This view function allows moderators to split posts off to a new topic.
bgneal@232 705 """
bgneal@232 706 topic = get_object_or_404(Topic.objects.select_related(), pk=id)
bgneal@232 707 if not _can_moderate(topic.forum, request.user):
bgneal@232 708 return HttpResponseRedirect(topic.get_absolute_url())
bgneal@232 709
bgneal@232 710 if request.method == "POST":
bgneal@232 711 form = SplitTopicForm(request.user, request.POST)
bgneal@232 712 if form.is_valid():
bgneal@232 713 if form.split_at:
bgneal@232 714 _split_topic_at(topic, form.post_ids[0],
bgneal@232 715 form.cleaned_data['forums'],
bgneal@232 716 form.cleaned_data['name'])
bgneal@232 717 else:
bgneal@232 718 _split_topic(topic, form.post_ids,
bgneal@232 719 form.cleaned_data['forums'],
bgneal@232 720 form.cleaned_data['name'])
bgneal@232 721
bgneal@232 722 return HttpResponseRedirect(topic.get_absolute_url())
bgneal@232 723 else:
bgneal@232 724 form = SplitTopicForm(request.user)
bgneal@232 725
bgneal@232 726 posts = topic.posts.select_related()
bgneal@232 727
bgneal@232 728 return render_to_response('forums/mod_split_topic.html', {
bgneal@232 729 'forum': topic.forum,
bgneal@232 730 'topic': topic,
bgneal@232 731 'posts': posts,
bgneal@232 732 'form': form,
bgneal@232 733 },
bgneal@232 734 context_instance=RequestContext(request))
bgneal@232 735
bgneal@232 736
bgneal@232 737 @login_required
bgneal@232 738 def unread_topics(request):
bgneal@232 739 """Displays the topics with unread posts for a given user."""
bgneal@232 740
bgneal@232 741 topics = get_unread_topics(request.user)
bgneal@232 742
bgneal@232 743 paginator = create_topic_paginator(topics)
bgneal@232 744 page_num = get_page_num(request)
bgneal@232 745 try:
bgneal@232 746 page = paginator.page(page_num)
bgneal@232 747 except InvalidPage:
bgneal@232 748 raise Http404
bgneal@232 749
bgneal@232 750 attach_topic_page_ranges(page.object_list)
bgneal@232 751
bgneal@232 752 # we do this for the template since it is rendered twice
bgneal@232 753 page_nav = render_to_string('forums/pagination.html', {'page': page})
bgneal@232 754
bgneal@232 755 return render_to_response('forums/topic_list.html', {
bgneal@232 756 'title': 'Topics With Unread Posts',
bgneal@232 757 'page': page,
bgneal@232 758 'page_nav': page_nav,
bgneal@232 759 },
bgneal@232 760 context_instance=RequestContext(request))
bgneal@232 761
bgneal@232 762
bgneal@232 763 def unanswered_topics(request):
bgneal@232 764 """Displays the topics with no replies."""
bgneal@232 765
bgneal@232 766 forum_ids = Forum.objects.forum_ids_for_user(request.user)
bgneal@232 767 topics = Topic.objects.filter(forum__id__in=forum_ids,
bgneal@232 768 post_count=1).select_related(
bgneal@232 769 'forum', 'user', 'last_post', 'last_post__user')
bgneal@232 770
bgneal@232 771 paginator = create_topic_paginator(topics)
bgneal@232 772 page_num = get_page_num(request)
bgneal@232 773 try:
bgneal@232 774 page = paginator.page(page_num)
bgneal@232 775 except InvalidPage:
bgneal@232 776 raise Http404
bgneal@232 777
bgneal@232 778 attach_topic_page_ranges(page.object_list)
bgneal@232 779
bgneal@232 780 # we do this for the template since it is rendered twice
bgneal@232 781 page_nav = render_to_string('forums/pagination.html', {'page': page})
bgneal@232 782
bgneal@232 783 return render_to_response('forums/topic_list.html', {
bgneal@232 784 'title': 'Unanswered Topics',
bgneal@232 785 'page': page,
bgneal@232 786 'page_nav': page_nav,
bgneal@232 787 },
bgneal@232 788 context_instance=RequestContext(request))
bgneal@232 789
bgneal@232 790
bgneal@232 791 @login_required
bgneal@232 792 def my_posts(request):
bgneal@232 793 """Displays a list of posts the requesting user made."""
bgneal@232 794 return _user_posts(request, request.user, request.user, 'My Posts')
bgneal@232 795
bgneal@232 796
bgneal@232 797 @login_required
bgneal@232 798 def posts_for_user(request, username):
bgneal@232 799 """Displays a list of posts by the given user.
bgneal@232 800 Only the forums that the requesting user can see are examined.
bgneal@232 801 """
bgneal@232 802 target_user = get_object_or_404(User, username=username)
bgneal@232 803 return _user_posts(request, target_user, request.user, 'Posts by %s' % username)
bgneal@232 804
bgneal@232 805
bgneal@232 806 @login_required
bgneal@232 807 def post_ip_info(request, post_id):
bgneal@232 808 """Displays information about the IP address the post was made from."""
bgneal@232 809 post = get_object_or_404(Post.objects.select_related(), pk=post_id)
bgneal@232 810
bgneal@232 811 if not _can_moderate(post.topic.forum, request.user):
bgneal@232 812 return HttpResponseForbidden("You don't have permission for this post.")
bgneal@232 813
bgneal@232 814 ip_users = sorted(set(Post.objects.filter(
bgneal@232 815 user_ip=post.user_ip).values_list('user__username', flat=True)))
bgneal@232 816
bgneal@232 817 return render_to_response('forums/post_ip.html', {
bgneal@232 818 'post': post,
bgneal@232 819 'ip_users': ip_users,
bgneal@232 820 },
bgneal@232 821 context_instance=RequestContext(request))
bgneal@232 822
bgneal@232 823
bgneal@232 824 def _user_posts(request, target_user, req_user, page_title):
bgneal@286 825 """Displays a list of posts made by the target user.
bgneal@232 826 req_user is the user trying to view the posts. Only the forums
bgneal@232 827 req_user can see are searched.
bgneal@232 828 """
bgneal@232 829 forum_ids = Forum.objects.forum_ids_for_user(req_user)
bgneal@232 830 posts = Post.objects.filter(user=target_user,
bgneal@232 831 topic__forum__id__in=forum_ids).order_by(
bgneal@232 832 '-creation_date').select_related()
bgneal@232 833
bgneal@232 834 paginator = create_post_paginator(posts)
bgneal@232 835 page_num = get_page_num(request)
bgneal@232 836 try:
bgneal@232 837 page = paginator.page(page_num)
bgneal@232 838 except InvalidPage:
bgneal@232 839 raise Http404
bgneal@232 840
bgneal@232 841 # we do this for the template since it is rendered twice
bgneal@232 842 page_nav = render_to_string('forums/pagination.html', {'page': page})
bgneal@232 843
bgneal@232 844 return render_to_response('forums/post_list.html', {
bgneal@232 845 'title': page_title,
bgneal@232 846 'page': page,
bgneal@232 847 'page_nav': page_nav,
bgneal@232 848 },
bgneal@232 849 context_instance=RequestContext(request))
bgneal@232 850
bgneal@232 851
bgneal@232 852 def _can_moderate(forum, user):
bgneal@232 853 """
bgneal@232 854 Determines if a user has permission to moderate a given forum.
bgneal@232 855 """
bgneal@232 856 return user.is_authenticated() and (
bgneal@232 857 user.is_superuser or user in forum.moderators.all())
bgneal@232 858
bgneal@232 859
bgneal@232 860 def _can_post_in_topic(topic, user):
bgneal@232 861 """
bgneal@232 862 This function returns true if the given user can post in the given topic
bgneal@232 863 and false otherwise.
bgneal@232 864 """
bgneal@232 865 return (not topic.locked and topic.forum.category.can_access(user)) or \
bgneal@232 866 (user.is_superuser or user in topic.forum.moderators.all())
bgneal@232 867
bgneal@232 868
bgneal@232 869 def _bump_post_count(user):
bgneal@232 870 """
bgneal@232 871 Increments the forum_post_count for the given user.
bgneal@232 872 """
bgneal@232 873 profile = user.get_profile()
bgneal@232 874 profile.forum_post_count += 1
bgneal@232 875 profile.save()
bgneal@232 876
bgneal@232 877
bgneal@232 878 def _quote_message(who, message):
bgneal@232 879 """
bgneal@232 880 Builds a message reply by quoting the existing message in a
bgneal@232 881 typical email-like fashion. The quoting is compatible with Markdown.
bgneal@232 882 """
bgneal@232 883 header = '*%s wrote:*\n\n' % (who, )
bgneal@232 884 lines = wrap(message, 55).split('\n')
bgneal@232 885 for i, line in enumerate(lines):
bgneal@232 886 lines[i] = '> ' + line
bgneal@232 887 return header + '\n'.join(lines)
bgneal@232 888
bgneal@232 889
bgneal@232 890 def _move_topic(topic, old_forum, new_forum):
bgneal@232 891 if new_forum != old_forum:
bgneal@232 892 topic.forum = new_forum
bgneal@232 893 topic.save()
bgneal@232 894 # Have to adjust foreign keys to last_post, denormalized counts, etc.:
bgneal@232 895 old_forum.sync()
bgneal@232 896 old_forum.save()
bgneal@232 897 new_forum.sync()
bgneal@232 898 new_forum.save()
bgneal@232 899
bgneal@232 900
bgneal@232 901 def _bulk_sticky(forum, topic_ids):
bgneal@232 902 """
bgneal@232 903 Performs a toggle on the sticky status for a given list of topic ids.
bgneal@232 904 """
bgneal@232 905 topics = Topic.objects.filter(pk__in=topic_ids)
bgneal@232 906 for topic in topics:
bgneal@232 907 if topic.forum == forum:
bgneal@232 908 topic.sticky = not topic.sticky
bgneal@232 909 topic.save()
bgneal@232 910
bgneal@232 911
bgneal@232 912 def _bulk_lock(forum, topic_ids):
bgneal@232 913 """
bgneal@232 914 Performs a toggle on the locked status for a given list of topic ids.
bgneal@232 915 """
bgneal@232 916 topics = Topic.objects.filter(pk__in=topic_ids)
bgneal@232 917 for topic in topics:
bgneal@232 918 if topic.forum == forum:
bgneal@232 919 topic.locked = not topic.locked
bgneal@232 920 topic.save()
bgneal@232 921
bgneal@232 922
bgneal@232 923 def _bulk_delete(forum, topic_ids):
bgneal@232 924 """
bgneal@232 925 Deletes the list of topics.
bgneal@232 926 """
bgneal@235 927 # Because we are deleting stuff, retrieve each topic one at a
bgneal@235 928 # time since we are going to be adjusting de-normalized fields
bgneal@235 929 # during deletes. In particular, we can't do this:
bgneal@235 930 # topics = Topic.objects.filter(pk__in=topic_ids).select_related()
bgneal@235 931 # for topic in topics:
bgneal@235 932 # since topic.forum.last_post can go stale after a delete.
bgneal@235 933
bgneal@235 934 for id in topic_ids:
bgneal@235 935 try:
bgneal@235 936 topic = Topic.objects.select_related().get(pk=id)
bgneal@235 937 except Topic.DoesNotExist:
bgneal@235 938 continue
bgneal@235 939 _delete_topic(topic)
bgneal@232 940
bgneal@232 941
bgneal@232 942 def _bulk_move(topic_ids, old_forum, new_forum):
bgneal@232 943 """
bgneal@232 944 Moves the list of topics to a new forum.
bgneal@232 945 """
bgneal@232 946 topics = Topic.objects.filter(pk__in=topic_ids).select_related()
bgneal@232 947 for topic in topics:
bgneal@232 948 if topic.forum == old_forum:
bgneal@232 949 _move_topic(topic, old_forum, new_forum)
bgneal@232 950
bgneal@232 951
bgneal@232 952 def _update_last_visit(user, topic):
bgneal@232 953 """
bgneal@232 954 Does the bookkeeping for the last visit status for the user to the
bgneal@232 955 topic/forum.
bgneal@232 956 """
bgneal@232 957 now = datetime.datetime.now()
bgneal@232 958 try:
bgneal@232 959 flv = ForumLastVisit.objects.get(user=user, forum=topic.forum)
bgneal@232 960 except ForumLastVisit.DoesNotExist:
bgneal@232 961 flv = ForumLastVisit(user=user, forum=topic.forum)
bgneal@232 962 flv.begin_date = now
bgneal@232 963
bgneal@232 964 flv.end_date = now
bgneal@232 965 flv.save()
bgneal@232 966
bgneal@232 967 if topic.update_date > flv.begin_date:
bgneal@232 968 try:
bgneal@232 969 tlv = TopicLastVisit.objects.get(user=user, topic=topic)
bgneal@232 970 except TopicLastVisit.DoesNotExist:
bgneal@232 971 tlv = TopicLastVisit(user=user, topic=topic)
bgneal@232 972
bgneal@232 973 tlv.touch()
bgneal@232 974 tlv.save()
bgneal@232 975
bgneal@232 976
bgneal@232 977 def _split_topic_at(topic, post_id, new_forum, new_name):
bgneal@232 978 """
bgneal@232 979 This function splits the post given by post_id and all posts that come
bgneal@232 980 after it in the given topic to a new topic in a new forum.
bgneal@232 981 It is assumed the caller has been checked for moderator rights.
bgneal@232 982 """
bgneal@232 983 post = get_object_or_404(Post, id=post_id)
bgneal@232 984 if post.topic == topic:
bgneal@232 985 post_ids = Post.objects.filter(topic=topic,
bgneal@232 986 creation_date__gte=post.creation_date).values_list('id', flat=True)
bgneal@232 987 _split_topic(topic, post_ids, new_forum, new_name)
bgneal@232 988
bgneal@232 989
bgneal@232 990 def _split_topic(topic, post_ids, new_forum, new_name):
bgneal@232 991 """
bgneal@232 992 This function splits the posts given by the post_ids list in the
bgneal@232 993 given topic to a new topic in a new forum.
bgneal@232 994 It is assumed the caller has been checked for moderator rights.
bgneal@232 995 """
bgneal@232 996 posts = Post.objects.filter(topic=topic, id__in=post_ids)
bgneal@232 997 if len(posts) > 0:
bgneal@232 998 new_topic = Topic(forum=new_forum, name=new_name, user=posts[0].user)
bgneal@232 999 new_topic.save()
bgneal@232 1000 for post in posts:
bgneal@232 1001 post.topic = new_topic
bgneal@232 1002 post.save()
bgneal@286 1003
bgneal@232 1004 topic.post_count_update()
bgneal@232 1005 topic.save()
bgneal@232 1006 new_topic.post_count_update()
bgneal@232 1007 new_topic.save()
bgneal@232 1008 topic.forum.sync()
bgneal@232 1009 topic.forum.save()
bgneal@232 1010 new_forum.sync()
bgneal@232 1011 new_forum.save()