]> git.openstreetmap.org Git - osqa.git/blob - forum/views/commands.py
e909bdf2b95036baa98fe00390cf69066f3dd0d6
[osqa.git] / forum / views / commands.py
1 # -*- coding: utf-8 -*-
2
3 import datetime
4 import logging
5
6 from urllib import urlencode
7
8 from django.core.exceptions import ObjectDoesNotExist
9 from django.core.urlresolvers import reverse
10 from django.utils import simplejson
11 from django.utils.encoding import smart_unicode
12 from django.utils.translation import ungettext, ugettext as _
13 from django.http import HttpResponse, HttpResponseRedirect, Http404
14 from django.shortcuts import get_object_or_404, render_to_response
15
16 from forum.models import *
17 from forum.utils.decorators import ajax_login_required
18 from forum.actions import *
19 from forum.modules import decorate
20 from forum import settings
21
22 from decorators import command, CommandException, RefreshPageCommand
23
24 class NotEnoughRepPointsException(CommandException):
25     def __init__(self, action, user_reputation=None, reputation_required=None):
26         if reputation_required is not None and user_reputation is not None:
27             message = _(
28                 """Sorry, but you don't have enough reputation points to %(action)s.<br />
29                 The minimum reputation required is %(reputation_required)d (yours is %(user_reputation)d).
30                 Please check the <a href='%(faq_url)s'>FAQ</a>"""
31             ) % {
32                 'action': action,
33                 'faq_url': reverse('faq'),
34                 'reputation_required' : reputation_required,
35                 'user_reputation' : user_reputation,
36             }
37         else:
38             message = _(
39                 """Sorry, but you don't have enough reputation points to %(action)s.<br />Please check the <a href='%(faq_url)s'>faq</a>"""
40             ) % {'action': action, 'faq_url': reverse('faq')}
41         super(NotEnoughRepPointsException, self).__init__(message)
42
43 class CannotDoOnOwnException(CommandException):
44     def __init__(self, action):
45         super(CannotDoOnOwnException, self).__init__(
46                 _(
47                         """Sorry but you cannot %(action)s your own post.<br />Please check the <a href='%(faq_url)s'>faq</a>"""
48                         ) % {'action': action, 'faq_url': reverse('faq')}
49                 )
50
51 class AnonymousNotAllowedException(CommandException):
52     def __init__(self, action):
53         super(AnonymousNotAllowedException, self).__init__(
54                 _(
55                         """Sorry but anonymous users cannot %(action)s.<br />Please login or create an account <a href='%(signin_url)s'>here</a>."""
56                         ) % {'action': action, 'signin_url': reverse('auth_signin')}
57                 )
58
59 class NotEnoughLeftException(CommandException):
60     def __init__(self, action, limit):
61         super(NotEnoughLeftException, self).__init__(
62                 _(
63                         """Sorry, but you don't have enough %(action)s left for today..<br />The limit is %(limit)s per day..<br />Please check the <a href='%(faq_url)s'>faq</a>"""
64                         ) % {'action': action, 'limit': limit, 'faq_url': reverse('faq')}
65                 )
66
67 class CannotDoubleActionException(CommandException):
68     def __init__(self, action):
69         super(CannotDoubleActionException, self).__init__(
70                 _(
71                         """Sorry, but you cannot %(action)s twice the same post.<br />Please check the <a href='%(faq_url)s'>faq</a>"""
72                         ) % {'action': action, 'faq_url': reverse('faq')}
73                 )
74
75
76 @decorate.withfn(command)
77 def vote_post(request, id, vote_type):
78     post = get_object_or_404(Node, id=id).leaf
79     user = request.user
80
81     if not user.is_authenticated():
82         raise AnonymousNotAllowedException(_('vote'))
83
84     if user == post.author:
85         raise CannotDoOnOwnException(_('vote'))
86
87     if not (vote_type == 'up' and user.can_vote_up() or user.can_vote_down()):
88         reputation_required = int(settings.REP_TO_VOTE_UP) if vote_type == 'up' else int(settings.REP_TO_VOTE_DOWN)
89         action_type = vote_type == 'up' and _('upvote') or _('downvote')
90         raise NotEnoughRepPointsException(action_type, user_reputation=user.reputation, reputation_required=reputation_required)
91
92     user_vote_count_today = user.get_vote_count_today()
93     user_can_vote_count_today = user.can_vote_count_today()
94
95     if user_vote_count_today >= user.can_vote_count_today():
96         raise NotEnoughLeftException(_('votes'), str(settings.MAX_VOTES_PER_DAY))
97
98     new_vote_cls = (vote_type == 'up') and VoteUpAction or VoteDownAction
99     score_inc = 0
100
101     old_vote = VoteAction.get_action_for(node=post, user=user)
102
103     if old_vote:
104         if old_vote.action_date < datetime.datetime.now() - datetime.timedelta(days=int(settings.DENY_UNVOTE_DAYS)):
105             raise CommandException(
106                     _("Sorry but you cannot cancel a vote after %(ndays)d %(tdays)s from the original vote") %
107                     {'ndays': int(settings.DENY_UNVOTE_DAYS),
108                      'tdays': ungettext('day', 'days', int(settings.DENY_UNVOTE_DAYS))}
109                     )
110
111         old_vote.cancel(ip=request.META['REMOTE_ADDR'])
112         score_inc = (old_vote.__class__ == VoteDownAction) and 1 or -1
113         vote_type = "none"
114     else:
115         new_vote_cls(user=user, node=post, ip=request.META['REMOTE_ADDR']).save()
116         score_inc = (new_vote_cls == VoteUpAction) and 1 or -1
117
118     response = {
119     'commands': {
120     'update_post_score': [id, score_inc],
121     'update_user_post_vote': [id, vote_type]
122     }
123     }
124
125     votes_left = (user_can_vote_count_today - user_vote_count_today) + (vote_type == 'none' and -1 or 1)
126
127     if int(settings.START_WARN_VOTES_LEFT) >= votes_left:
128         response['message'] = _("You have %(nvotes)s %(tvotes)s left today.") % \
129                     {'nvotes': votes_left, 'tvotes': ungettext('vote', 'votes', votes_left)}
130
131     return response
132
133 @decorate.withfn(command)
134 def flag_post(request, id):
135     if not request.POST:
136         return render_to_response('node/report.html', {'types': settings.FLAG_TYPES})
137
138     post = get_object_or_404(Node, id=id)
139     user = request.user
140
141     if not user.is_authenticated():
142         raise AnonymousNotAllowedException(_('flag posts'))
143
144     if user == post.author:
145         raise CannotDoOnOwnException(_('flag'))
146
147     if not (user.can_flag_offensive(post)):
148         raise NotEnoughRepPointsException(_('flag posts'))
149
150     user_flag_count_today = user.get_flagged_items_count_today()
151
152     if user_flag_count_today >= int(settings.MAX_FLAGS_PER_DAY):
153         raise NotEnoughLeftException(_('flags'), str(settings.MAX_FLAGS_PER_DAY))
154
155     try:
156         current = FlagAction.objects.get(canceled=False, user=user, node=post)
157         raise CommandException(
158                 _("You already flagged this post with the following reason: %(reason)s") % {'reason': current.extra})
159     except ObjectDoesNotExist:
160         reason = request.POST.get('prompt', '').strip()
161
162         if not len(reason):
163             raise CommandException(_("Reason is empty"))
164
165         FlagAction(user=user, node=post, extra=reason, ip=request.META['REMOTE_ADDR']).save()
166
167     return {'message': _("Thank you for your report. A moderator will review your submission shortly.")}
168
169 @decorate.withfn(command)
170 def like_comment(request, id):
171     comment = get_object_or_404(Comment, id=id)
172     user = request.user
173
174     if not user.is_authenticated():
175         raise AnonymousNotAllowedException(_('like comments'))
176
177     if user == comment.user:
178         raise CannotDoOnOwnException(_('like'))
179
180     if not user.can_like_comment(comment):
181         raise NotEnoughRepPointsException( _('like comments'))
182
183     like = VoteAction.get_action_for(node=comment, user=user)
184
185     if like:
186         like.cancel(ip=request.META['REMOTE_ADDR'])
187         likes = False
188     else:
189         VoteUpCommentAction(node=comment, user=user, ip=request.META['REMOTE_ADDR']).save()
190         likes = True
191
192     return {
193     'commands': {
194     'update_post_score': [comment.id, likes and 1 or -1],
195     'update_user_post_vote': [comment.id, likes and 'up' or 'none']
196     }
197     }
198
199 @decorate.withfn(command)
200 def delete_comment(request, id):
201     comment = get_object_or_404(Comment, id=id)
202     user = request.user
203
204     if not user.is_authenticated():
205         raise AnonymousNotAllowedException(_('delete comments'))
206
207     if not user.can_delete_comment(comment):
208         raise NotEnoughRepPointsException( _('delete comments'))
209
210     if not comment.nis.deleted:
211         DeleteAction(node=comment, user=user, ip=request.META['REMOTE_ADDR']).save()
212
213     return {
214     'commands': {
215     'remove_comment': [comment.id],
216     }
217     }
218
219 @decorate.withfn(command)
220 def mark_favorite(request, id):
221     question = get_object_or_404(Question, id=id)
222
223     if not request.user.is_authenticated():
224         raise AnonymousNotAllowedException(_('mark a question as favorite'))
225
226     try:
227         favorite = FavoriteAction.objects.get(canceled=False, node=question, user=request.user)
228         favorite.cancel(ip=request.META['REMOTE_ADDR'])
229         added = False
230     except ObjectDoesNotExist:
231         FavoriteAction(node=question, user=request.user, ip=request.META['REMOTE_ADDR']).save()
232         added = True
233
234     return {
235     'commands': {
236     'update_favorite_count': [added and 1 or -1],
237     'update_favorite_mark': [added and 'on' or 'off']
238     }
239     }
240
241 @decorate.withfn(command)
242 def comment(request, id):
243     post = get_object_or_404(Node, id=id)
244     user = request.user
245
246     if not user.is_authenticated():
247         raise AnonymousNotAllowedException(_('comment'))
248
249     if not request.method == 'POST':
250         raise CommandException(_("Invalid request"))
251
252     comment_text = request.POST.get('comment', '').strip()
253
254     if not len(comment_text):
255         raise CommandException(_("Comment is empty"))
256
257     if len(comment_text) < settings.FORM_MIN_COMMENT_BODY:
258         raise CommandException(_("At least %d characters required on comment body.") % settings.FORM_MIN_COMMENT_BODY)
259
260     if len(comment_text) > settings.FORM_MAX_COMMENT_BODY:
261         raise CommandException(_("No more than %d characters on comment body.") % settings.FORM_MAX_COMMENT_BODY)
262
263     if 'id' in request.POST:
264         comment = get_object_or_404(Comment, id=request.POST['id'])
265
266         if not user.can_edit_comment(comment):
267             raise NotEnoughRepPointsException( _('edit comments'))
268
269         comment = ReviseAction(user=user, node=comment, ip=request.META['REMOTE_ADDR']).save(
270                 data=dict(text=comment_text)).node
271     else:
272         if not user.can_comment(post):
273             raise NotEnoughRepPointsException( _('comment'))
274
275         comment = CommentAction(user=user, ip=request.META['REMOTE_ADDR']).save(
276                 data=dict(text=comment_text, parent=post)).node
277
278     if comment.active_revision.revision == 1:
279         return {
280         'commands': {
281         'insert_comment': [
282                 id, comment.id, comment.comment, user.decorated_name, user.get_profile_url(),
283                 reverse('delete_comment', kwargs={'id': comment.id}),
284                 reverse('node_markdown', kwargs={'id': comment.id}),
285                 reverse('convert_comment', kwargs={'id': comment.id}),
286                 user.can_convert_comment_to_answer(comment),
287                 bool(settings.SHOW_LATEST_COMMENTS_FIRST)
288                 ]
289         }
290         }
291     else:
292         return {
293         'commands': {
294         'update_comment': [comment.id, comment.comment]
295         }
296         }
297
298 @decorate.withfn(command)
299 def node_markdown(request, id):
300     user = request.user
301
302     if not user.is_authenticated():
303         raise AnonymousNotAllowedException(_('accept answers'))
304
305     node = get_object_or_404(Node, id=id)
306     return HttpResponse(node.active_revision.body, mimetype="text/plain")
307
308
309 @decorate.withfn(command)
310 def accept_answer(request, id):
311     if settings.DISABLE_ACCEPTING_FEATURE:
312         raise Http404()
313
314     user = request.user
315
316     if not user.is_authenticated():
317         raise AnonymousNotAllowedException(_('accept answers'))
318
319     answer = get_object_or_404(Answer, id=id)
320     question = answer.question
321
322     if not user.can_accept_answer(answer):
323         raise CommandException(_("Sorry but you cannot accept the answer"))
324
325     commands = {}
326
327     if answer.nis.accepted:
328         answer.nstate.accepted.cancel(user, ip=request.META['REMOTE_ADDR'])
329         commands['unmark_accepted'] = [answer.id]
330     else:
331         if settings.MAXIMUM_ACCEPTED_ANSWERS and (question.accepted_count >= settings.MAXIMUM_ACCEPTED_ANSWERS):
332             raise CommandException(ungettext("This question already has an accepted answer.",
333                 "Sorry but this question has reached the limit of accepted answers.", int(settings.MAXIMUM_ACCEPTED_ANSWERS)))
334
335         if settings.MAXIMUM_ACCEPTED_PER_USER and question.accepted_count:
336             accepted_from_author = question.accepted_answers.filter(author=answer.author).count()
337
338             if accepted_from_author >= settings.MAXIMUM_ACCEPTED_PER_USER:
339                 raise CommandException(ungettext("The author of this answer already has an accepted answer in this question.",
340                 "Sorry but the author of this answer has reached the limit of accepted answers per question.", int(settings.MAXIMUM_ACCEPTED_PER_USER)))             
341
342
343         AcceptAnswerAction(node=answer, user=user, ip=request.META['REMOTE_ADDR']).save()
344
345         # If the request is not an AJAX redirect to the answer URL rather than to the home page
346         if not request.is_ajax():
347             msg = _("""
348               Congratulations! You've accepted an answer.
349             """)
350
351             # Notify the user with a message that an answer has been accepted
352             request.user.message_set.create(message=msg)
353
354             # Redirect URL should include additional get parameters that might have been attached
355             redirect_url = answer.parent.get_absolute_url() + "?accepted_answer=true&%s" % smart_unicode(urlencode(request.GET))
356
357             return HttpResponseRedirect(redirect_url)
358
359         commands['mark_accepted'] = [answer.id]
360
361     return {'commands': commands}
362
363 @decorate.withfn(command)
364 def delete_post(request, id):
365     post = get_object_or_404(Node, id=id)
366     user = request.user
367
368     if not user.is_authenticated():
369         raise AnonymousNotAllowedException(_('delete posts'))
370
371     if not (user.can_delete_post(post)):
372         raise NotEnoughRepPointsException(_('delete posts'))
373
374     ret = {'commands': {}}
375
376     if post.nis.deleted:
377         post.nstate.deleted.cancel(user, ip=request.META['REMOTE_ADDR'])
378         ret['commands']['unmark_deleted'] = [post.node_type, id]
379     else:
380         DeleteAction(node=post, user=user, ip=request.META['REMOTE_ADDR']).save()
381
382         ret['commands']['mark_deleted'] = [post.node_type, id]
383
384     return ret
385
386 @decorate.withfn(command)
387 def close(request, id, close):
388     if close and not request.POST:
389         return render_to_response('node/report.html', {'types': settings.CLOSE_TYPES})
390
391     question = get_object_or_404(Question, id=id)
392     user = request.user
393
394     if not user.is_authenticated():
395         raise AnonymousNotAllowedException(_('close questions'))
396
397     if question.nis.closed:
398         if not user.can_reopen_question(question):
399             raise NotEnoughRepPointsException(_('reopen questions'))
400
401         question.nstate.closed.cancel(user, ip=request.META['REMOTE_ADDR'])
402     else:
403         if not request.user.can_close_question(question):
404             raise NotEnoughRepPointsException(_('close questions'))
405
406         reason = request.POST.get('prompt', '').strip()
407
408         if not len(reason):
409             raise CommandException(_("Reason is empty"))
410
411         CloseAction(node=question, user=user, extra=reason, ip=request.META['REMOTE_ADDR']).save()
412
413     return RefreshPageCommand()
414
415 @decorate.withfn(command)
416 def wikify(request, id):
417     node = get_object_or_404(Node, id=id)
418     user = request.user
419
420     if not user.is_authenticated():
421         raise AnonymousNotAllowedException(_('mark posts as community wiki'))
422
423     if node.nis.wiki:
424         if not user.can_cancel_wiki(node):
425             raise NotEnoughRepPointsException(_('cancel a community wiki post'))
426
427         if node.nstate.wiki.action_type == "wikify":
428             node.nstate.wiki.cancel()
429         else:
430             node.nstate.wiki = None
431     else:
432         if not user.can_wikify(node):
433             raise NotEnoughRepPointsException(_('mark posts as community wiki'))
434
435         WikifyAction(node=node, user=user, ip=request.META['REMOTE_ADDR']).save()
436
437     return RefreshPageCommand()
438
439 @decorate.withfn(command)
440 def convert_to_comment(request, id):
441     user = request.user
442     answer = get_object_or_404(Answer, id=id)
443     question = answer.question
444
445     # Check whether the user has the required permissions
446     if not user.is_authenticated():
447         raise AnonymousNotAllowedException(_("convert answers to comments"))
448
449     if not user.can_convert_to_comment(answer):
450         raise NotEnoughRepPointsException(_("convert answers to comments"))
451
452     if not request.POST:
453         description = lambda a: _("Answer by %(uname)s: %(snippet)s...") % {'uname': smart_unicode(a.author.username),
454                                                                             'snippet': a.summary[:10]}
455         nodes = [(question.id, _("Question"))]
456         [nodes.append((a.id, description(a))) for a in
457          question.answers.filter_state(deleted=False).exclude(id=answer.id)]
458
459         return render_to_response('node/convert_to_comment.html', {'answer': answer, 'nodes': nodes})
460
461     try:
462         new_parent = Node.objects.get(id=request.POST.get('under', None))
463     except:
464         raise CommandException(_("That is an invalid post to put the comment under"))
465
466     if not (new_parent == question or (new_parent.node_type == 'answer' and new_parent.parent == question)):
467         raise CommandException(_("That is an invalid post to put the comment under"))
468
469     AnswerToCommentAction(user=user, node=answer, ip=request.META['REMOTE_ADDR']).save(data=dict(new_parent=new_parent))
470
471     return RefreshPageCommand()
472
473 @decorate.withfn(command)
474 def convert_comment_to_answer(request, id):
475     user = request.user
476     comment = get_object_or_404(Comment, id=id)
477     parent = comment.parent
478
479     if not parent.question:
480         question = parent
481     else:
482         question = parent.question
483     
484     if not user.is_authenticated():
485         raise AnonymousNotAllowedException(_("convert comments to answers"))
486
487     if not user.can_convert_comment_to_answer(comment):
488         raise NotEnoughRepPointsException(_("convert comments to answers"))
489     
490     CommentToAnswerAction(user=user, node=comment, ip=request.META['REMOTE_ADDR']).save(data=dict(question=question))
491
492     return RefreshPageCommand()
493
494 @decorate.withfn(command)
495 def subscribe(request, id, user=None):
496     if user:
497         try:
498             user = User.objects.get(id=user)
499         except User.DoesNotExist:
500             raise Http404()
501
502         if not (request.user.is_a_super_user_or_staff() or user.is_authenticated()):
503             raise CommandException(_("You do not have the correct credentials to preform this action."))
504     else:
505         user = request.user
506
507     question = get_object_or_404(Question, id=id)
508
509     try:
510         subscription = QuestionSubscription.objects.get(question=question, user=user)
511         subscription.delete()
512         subscribed = False
513     except:
514         subscription = QuestionSubscription(question=question, user=user, auto_subscription=False)
515         subscription.save()
516         subscribed = True
517
518     return {
519         'commands': {
520             'set_subscription_button': [subscribed and _('unsubscribe me') or _('subscribe me')],
521             'set_subscription_status': ['']
522         }
523     }
524
525 #internally grouped views - used by the tagging system
526 @ajax_login_required
527 def mark_tag(request, tag=None, **kwargs):#tagging system
528     action = kwargs['action']
529     ts = MarkedTag.objects.filter(user=request.user, tag__name=tag)
530     if action == 'remove':
531         logging.debug('deleting tag %s' % tag)
532         ts.delete()
533     else:
534         reason = kwargs['reason']
535         if len(ts) == 0:
536             try:
537                 t = Tag.objects.get(name=tag)
538                 mt = MarkedTag(user=request.user, reason=reason, tag=t)
539                 mt.save()
540             except:
541                 pass
542         else:
543             ts.update(reason=reason)
544     return HttpResponse(simplejson.dumps(''), mimetype="application/json")
545
546 def matching_tags(request):
547     if len(request.GET['q']) == 0:
548         raise CommandException(_("Invalid request"))
549
550     possible_tags = Tag.active.filter(name__icontains = request.GET['q'])
551     tag_output = ''
552     for tag in possible_tags:
553         tag_output += "%s|%s|%s\n" % (tag.id, tag.name, tag.used_count)
554
555     return HttpResponse(tag_output, mimetype="text/plain")
556
557 def matching_users(request):
558     if len(request.GET['q']) == 0:
559         raise CommandException(_("Invalid request"))
560
561     possible_users = User.objects.filter(username__icontains = request.GET['q'])
562     output = ''
563
564     for user in possible_users:
565         output += ("%s|%s|%s\n" % (user.id, user.decorated_name, user.reputation))
566
567     return HttpResponse(output, mimetype="text/plain")
568
569 def related_questions(request):
570     if request.POST and request.POST.get('title', None):
571         can_rank, questions = Question.objects.search(request.POST['title'])
572
573         if can_rank and isinstance(can_rank, basestring):
574             questions = questions.order_by(can_rank)
575
576         return HttpResponse(simplejson.dumps(
577                 [dict(title=q.title, url=q.get_absolute_url(), score=q.score, summary=q.summary)
578                  for q in questions.filter_state(deleted=False)[0:10]]), mimetype="application/json")
579     else:
580         raise Http404()
581
582 @decorate.withfn(command)
583 def answer_permanent_link(request, id):
584     # Getting the current answer object
585     answer = get_object_or_404(Answer, id=id)
586
587     # Getting the current object URL -- the Application URL + the object relative URL
588     url = '%s%s' % (settings.APP_BASE_URL, answer.get_absolute_url())
589
590     if not request.POST:
591         # Display the template
592         return render_to_response('node/permanent_link.html', { 'url' : url, })
593
594     return {
595         'commands' : {
596             'copy_url' : [request.POST['permanent_link_url'],],
597         },
598         'message' : _("The permanent URL to the answer has been copied to your clipboard."),
599     }
600
601 @decorate.withfn(command)
602 def award_points(request, user_id, answer_id):
603     user = request.user
604     awarded_user = get_object_or_404(User, id=user_id)
605     answer = get_object_or_404(Answer, id=answer_id)
606
607     # Users shouldn't be able to award themselves
608     if awarded_user.id == user.id:
609         raise CannotDoOnOwnException(_("award"))
610
611     # Anonymous users cannot award  points, they just don't have such
612     if not user.is_authenticated():
613         raise AnonymousNotAllowedException(_('award'))
614
615     if not request.POST:
616         return render_to_response("node/award_points.html", { 'user' : user, 'awarded_user' : awarded_user, })
617     else:
618         points = int(request.POST['points'])
619
620         # We should check if the user has enough reputation points, otherwise we raise an exception.
621         if points < 0:
622             raise CommandException(_("The number of points to award needs to be a positive value."))
623
624         if user.reputation < points:
625             raise NotEnoughRepPointsException(_("award"))
626
627         extra = dict(message=request.POST.get('message', ''), awarding_user=request.user.id, value=points)
628
629         # We take points from the awarding user
630         AwardPointsAction(user=request.user, node=answer, extra=extra).save(data=dict(value=points, affected=awarded_user))
631
632         return { 'message' : _("You have awarded %(awarded_user)s with %(points)d points") % {'awarded_user' : awarded_user, 'points' : points} }