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