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