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