]> git.openstreetmap.org Git - osqa.git/blob - forum/views/commands.py
Adds a couple of options to manage the "accepting answers" workflow.
[osqa.git] / forum / views / commands.py
1 import datetime
2 from forum import settings
3 from django.core.exceptions import ObjectDoesNotExist
4 from django.utils import simplejson
5 from django.http import HttpResponse, HttpResponseRedirect, Http404
6 from django.shortcuts import get_object_or_404, render_to_response
7 from django.utils.translation import ungettext, ugettext as _
8 from django.template import RequestContext
9 from forum.models import *
10 from forum.models.node import NodeMetaClass
11 from forum.actions import *
12 from django.core.urlresolvers import reverse
13 from django.contrib.auth.decorators import login_required
14 from forum.utils.decorators import ajax_method, ajax_login_required
15 from decorators import command, CommandException, RefreshPageCommand
16 from forum.modules import decorate
17 from forum import settings
18 import logging
19
20 class NotEnoughRepPointsException(CommandException):
21     def __init__(self, action):
22         super(NotEnoughRepPointsException, self).__init__(
23                 _(
24                         """Sorry, but you don't have enough reputation points to %(action)s.<br />Please check the <a href='%(faq_url)s'>faq</a>"""
25                         ) % {'action': action, 'faq_url': reverse('faq')}
26                 )
27
28 class CannotDoOnOwnException(CommandException):
29     def __init__(self, action):
30         super(CannotDoOnOwnException, self).__init__(
31                 _(
32                         """Sorry but you cannot %(action)s your own post.<br />Please check the <a href='%(faq_url)s'>faq</a>"""
33                         ) % {'action': action, 'faq_url': reverse('faq')}
34                 )
35
36 class AnonymousNotAllowedException(CommandException):
37     def __init__(self, action):
38         super(AnonymousNotAllowedException, self).__init__(
39                 _(
40                         """Sorry but anonymous users cannot %(action)s.<br />Please login or create an account <a href='%(signin_url)s'>here</a>."""
41                         ) % {'action': action, 'signin_url': reverse('auth_signin')}
42                 )
43
44 class NotEnoughLeftException(CommandException):
45     def __init__(self, action, limit):
46         super(NotEnoughLeftException, self).__init__(
47                 _(
48                         """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>"""
49                         ) % {'action': action, 'limit': limit, 'faq_url': reverse('faq')}
50                 )
51
52 class CannotDoubleActionException(CommandException):
53     def __init__(self, action):
54         super(CannotDoubleActionException, self).__init__(
55                 _(
56                         """Sorry, but you cannot %(action)s twice the same post.<br />Please check the <a href='%(faq_url)s'>faq</a>"""
57                         ) % {'action': action, 'faq_url': reverse('faq')}
58                 )
59
60
61 @decorate.withfn(command)
62 def vote_post(request, id, vote_type):
63     post = get_object_or_404(Node, id=id).leaf
64     user = request.user
65
66     if not user.is_authenticated():
67         raise AnonymousNotAllowedException(_('vote'))
68
69     if user == post.author:
70         raise CannotDoOnOwnException(_('vote'))
71
72     if not (vote_type == 'up' and user.can_vote_up() or user.can_vote_down()):
73         raise NotEnoughRepPointsException(vote_type == 'up' and _('upvote') or _('downvote'))
74
75     user_vote_count_today = user.get_vote_count_today()
76
77     if user_vote_count_today >= int(settings.MAX_VOTES_PER_DAY):
78         raise NotEnoughLeftException(_('votes'), str(settings.MAX_VOTES_PER_DAY))
79
80     new_vote_cls = (vote_type == 'up') and VoteUpAction or VoteDownAction
81     score_inc = 0
82
83     old_vote = VoteAction.get_action_for(node=post, user=user)
84
85     if old_vote:
86         if old_vote.action_date < datetime.datetime.now() - datetime.timedelta(days=int(settings.DENY_UNVOTE_DAYS)):
87             raise CommandException(
88                     _("Sorry but you cannot cancel a vote after %(ndays)d %(tdays)s from the original vote") %
89                     {'ndays': int(settings.DENY_UNVOTE_DAYS),
90                      'tdays': ungettext('day', 'days', int(settings.DENY_UNVOTE_DAYS))}
91                     )
92
93         old_vote.cancel(ip=request.META['REMOTE_ADDR'])
94         score_inc += (old_vote.__class__ == VoteDownAction) and 1 or -1
95
96     if old_vote.__class__ != new_vote_cls:
97         new_vote_cls(user=user, node=post, ip=request.META['REMOTE_ADDR']).save()
98         score_inc += (new_vote_cls == VoteUpAction) and 1 or -1
99     else:
100         vote_type = "none"
101
102     response = {
103     'commands': {
104     'update_post_score': [id, score_inc],
105     'update_user_post_vote': [id, vote_type]
106     }
107     }
108
109     votes_left = (int(settings.MAX_VOTES_PER_DAY) - user_vote_count_today) + (vote_type == 'none' and -1 or 1)
110
111     if int(settings.START_WARN_VOTES_LEFT) >= votes_left:
112         response['message'] = _("You have %(nvotes)s %(tvotes)s left today.") % \
113                     {'nvotes': votes_left, 'tvotes': ungettext('vote', 'votes', votes_left)}
114
115     return response
116
117 @decorate.withfn(command)
118 def flag_post(request, id):
119     if not request.POST:
120         return render_to_response('node/report.html', {'types': settings.FLAG_TYPES})
121
122     post = get_object_or_404(Node, id=id)
123     user = request.user
124
125     if not user.is_authenticated():
126         raise AnonymousNotAllowedException(_('flag posts'))
127
128     if user == post.author:
129         raise CannotDoOnOwnException(_('flag'))
130
131     if not (user.can_flag_offensive(post)):
132         raise NotEnoughRepPointsException(_('flag posts'))
133
134     user_flag_count_today = user.get_flagged_items_count_today()
135
136     if user_flag_count_today >= int(settings.MAX_FLAGS_PER_DAY):
137         raise NotEnoughLeftException(_('flags'), str(settings.MAX_FLAGS_PER_DAY))
138
139     try:
140         current = FlagAction.objects.get(canceled=False, user=user, node=post)
141         raise CommandException(
142                 _("You already flagged this post with the following reason: %(reason)s") % {'reason': current.extra})
143     except ObjectDoesNotExist:
144         reason = request.POST.get('prompt', '').strip()
145
146         if not len(reason):
147             raise CommandException(_("Reason is empty"))
148
149         FlagAction(user=user, node=post, extra=reason, ip=request.META['REMOTE_ADDR']).save()
150
151     return {'message': _("Thank you for your report. A moderator will review your submission shortly.")}
152
153 @decorate.withfn(command)
154 def like_comment(request, id):
155     comment = get_object_or_404(Comment, id=id)
156     user = request.user
157
158     if not user.is_authenticated():
159         raise AnonymousNotAllowedException(_('like comments'))
160
161     if user == comment.user:
162         raise CannotDoOnOwnException(_('like'))
163
164     if not user.can_like_comment(comment):
165         raise NotEnoughRepPointsException( _('like comments'))
166
167     like = VoteAction.get_action_for(node=comment, user=user)
168
169     if like:
170         like.cancel(ip=request.META['REMOTE_ADDR'])
171         likes = False
172     else:
173         VoteUpCommentAction(node=comment, user=user, ip=request.META['REMOTE_ADDR']).save()
174         likes = True
175
176     return {
177     'commands': {
178     'update_post_score': [comment.id, likes and 1 or -1],
179     'update_user_post_vote': [comment.id, likes and 'up' or 'none']
180     }
181     }
182
183 @decorate.withfn(command)
184 def delete_comment(request, id):
185     comment = get_object_or_404(Comment, id=id)
186     user = request.user
187
188     if not user.is_authenticated():
189         raise AnonymousNotAllowedException(_('delete comments'))
190
191     if not user.can_delete_comment(comment):
192         raise NotEnoughRepPointsException( _('delete comments'))
193
194     if not comment.nis.deleted:
195         DeleteAction(node=comment, user=user, ip=request.META['REMOTE_ADDR']).save()
196
197     return {
198     'commands': {
199     'remove_comment': [comment.id],
200     }
201     }
202
203 @decorate.withfn(command)
204 def mark_favorite(request, id):
205     question = get_object_or_404(Question, id=id)
206
207     if not request.user.is_authenticated():
208         raise AnonymousNotAllowedException(_('mark a question as favorite'))
209
210     try:
211         favorite = FavoriteAction.objects.get(canceled=False, node=question, user=request.user)
212         favorite.cancel(ip=request.META['REMOTE_ADDR'])
213         added = False
214     except ObjectDoesNotExist:
215         FavoriteAction(node=question, user=request.user, ip=request.META['REMOTE_ADDR']).save()
216         added = True
217
218     return {
219     'commands': {
220     'update_favorite_count': [added and 1 or -1],
221     'update_favorite_mark': [added and 'on' or 'off']
222     }
223     }
224
225 @decorate.withfn(command)
226 def comment(request, id):
227     post = get_object_or_404(Node, id=id)
228     user = request.user
229
230     if not user.is_authenticated():
231         raise AnonymousNotAllowedException(_('comment'))
232
233     if not request.method == 'POST':
234         raise CommandException(_("Invalid request"))
235
236     comment_text = request.POST.get('comment', '').strip()
237
238     if not len(comment_text):
239         raise CommandException(_("Comment is empty"))
240
241     if len(comment_text) < settings.FORM_MIN_COMMENT_BODY:
242         raise CommandException(_("At least %d characters required on comment body.") % settings.FORM_MIN_COMMENT_BODY)
243
244     if len(comment_text) > settings.FORM_MAX_COMMENT_BODY:
245         raise CommandException(_("No more than %d characters on comment body.") % settings.FORM_MAX_COMMENT_BODY)
246
247     if 'id' in request.POST:
248         comment = get_object_or_404(Comment, id=request.POST['id'])
249
250         if not user.can_edit_comment(comment):
251             raise NotEnoughRepPointsException( _('edit comments'))
252
253         comment = ReviseAction(user=user, node=comment, ip=request.META['REMOTE_ADDR']).save(
254                 data=dict(text=comment_text)).node
255     else:
256         if not user.can_comment(post):
257             raise NotEnoughRepPointsException( _('comment'))
258
259         comment = CommentAction(user=user, ip=request.META['REMOTE_ADDR']).save(
260                 data=dict(text=comment_text, parent=post)).node
261
262     if comment.active_revision.revision == 1:
263         return {
264         'commands': {
265         'insert_comment': [
266                 id, comment.id, comment.comment, user.decorated_name, user.get_profile_url(),
267                 reverse('delete_comment', kwargs={'id': comment.id}),
268                 reverse('node_markdown', kwargs={'id': comment.id})
269                 ]
270         }
271         }
272     else:
273         return {
274         'commands': {
275         'update_comment': [comment.id, comment.comment]
276         }
277         }
278
279 @decorate.withfn(command)
280 def node_markdown(request, id):
281     user = request.user
282
283     if not user.is_authenticated():
284         raise AnonymousNotAllowedException(_('accept answers'))
285
286     node = get_object_or_404(Node, id=id)
287     return HttpResponse(node.body, mimetype="text/plain")
288
289
290 @decorate.withfn(command)
291 def accept_answer(request, id):
292     if settings.DISABLE_ACCEPTING_FEATURE:
293         raise Http404()
294
295     user = request.user
296
297     if not user.is_authenticated():
298         raise AnonymousNotAllowedException(_('accept answers'))
299
300     answer = get_object_or_404(Answer, id=id)
301     question = answer.question
302
303     if not user.can_accept_answer(answer):
304         raise CommandException(_("Sorry but you cannot accept the answer"))
305
306     commands = {}
307
308     if answer.nis.accepted:
309         answer.nstate.accepted.cancel(user, ip=request.META['REMOTE_ADDR'])
310         commands['unmark_accepted'] = [answer.id]
311     else:
312         if settings.MAXIMUM_ACCEPTED_ANSWERS and (question.accepted_count >= settings.MAXIMUM_ACCEPTED_ANSWERS):
313             raise CommandException(ungettext("This question already has an accepted answer.",
314                 "Sorry but this question has reached the limit of accepted answers.", int(settings.MAXIMUM_ACCEPTED_ANSWERS)))
315
316         if settings.MAXIMUM_ACCEPTED_PER_USER and question.accepted_count:
317             accepted_from_author = question.accepted_answers.filter(author=answer.author).count()
318
319             if accepted_from_author >= settings.MAXIMUM_ACCEPTED_PER_USER:
320                 raise CommandException(ungettext("The author of this answer already has an accepted answer in this question.",
321                 "Sorry but the author of this answer has reached the limit of accepted answers per question.", int(settings.MAXIMUM_ACCEPTED_PER_USER)))             
322
323
324         AcceptAnswerAction(node=answer, user=user, ip=request.META['REMOTE_ADDR']).save()
325         commands['mark_accepted'] = [answer.id]
326
327     return {'commands': commands}
328
329 @decorate.withfn(command)
330 def delete_post(request, id):
331     post = get_object_or_404(Node, id=id)
332     user = request.user
333
334     if not user.is_authenticated():
335         raise AnonymousNotAllowedException(_('delete posts'))
336
337     if not (user.can_delete_post(post)):
338         raise NotEnoughRepPointsException(_('delete posts'))
339
340     ret = {'commands': {}}
341
342     if post.nis.deleted:
343         post.nstate.deleted.cancel(user, ip=request.META['REMOTE_ADDR'])
344         ret['commands']['unmark_deleted'] = [post.node_type, id]
345     else:
346         DeleteAction(node=post, user=user, ip=request.META['REMOTE_ADDR']).save()
347
348         ret['commands']['mark_deleted'] = [post.node_type, id]
349
350     return ret
351
352 @decorate.withfn(command)
353 def close(request, id, close):
354     if close and not request.POST:
355         return render_to_response('node/report.html', {'types': settings.CLOSE_TYPES})
356
357     question = get_object_or_404(Question, id=id)
358     user = request.user
359
360     if not user.is_authenticated():
361         raise AnonymousNotAllowedException(_('close questions'))
362
363     if question.nis.closed:
364         if not user.can_reopen_question(question):
365             raise NotEnoughRepPointsException(_('reopen questions'))
366
367         question.nstate.closed.cancel(user, ip=request.META['REMOTE_ADDR'])
368     else:
369         if not request.user.can_close_question(question):
370             raise NotEnoughRepPointsException(_('close questions'))
371
372         reason = request.POST.get('prompt', '').strip()
373
374         if not len(reason):
375             raise CommandException(_("Reason is empty"))
376
377         CloseAction(node=question, user=user, extra=reason, ip=request.META['REMOTE_ADDR']).save()
378
379     return RefreshPageCommand()
380
381 @decorate.withfn(command)
382 def wikify(request, id):
383     node = get_object_or_404(Node, id=id)
384     user = request.user
385
386     if not user.is_authenticated():
387         raise AnonymousNotAllowedException(_('mark posts as community wiki'))
388
389     if node.nis.wiki:
390         if not user.can_cancel_wiki(node):
391             raise NotEnoughRepPointsException(_('cancel a community wiki post'))
392
393         if node.nstate.wiki.action_type == "wikify":
394             node.nstate.wiki.cancel()
395         else:
396             node.nstate.wiki = None
397     else:
398         if not user.can_wikify(node):
399             raise NotEnoughRepPointsException(_('mark posts as community wiki'))
400
401         WikifyAction(node=node, user=user, ip=request.META['REMOTE_ADDR']).save()
402
403     return RefreshPageCommand()
404
405 @decorate.withfn(command)
406 def convert_to_comment(request, id):
407     user = request.user
408     answer = get_object_or_404(Answer, id=id)
409     question = answer.question
410
411     if not request.POST:
412         description = lambda a: _("Answer by %(uname)s: %(snippet)s...") % {'uname': a.author.username,
413                                                                             'snippet': a.summary[:10]}
414         nodes = [(question.id, _("Question"))]
415         [nodes.append((a.id, description(a))) for a in
416          question.answers.filter_state(deleted=False).exclude(id=answer.id)]
417
418         return render_to_response('node/convert_to_comment.html', {'answer': answer, 'nodes': nodes})
419
420     if not user.is_authenticated():
421         raise AnonymousNotAllowedException(_("convert answers to comments"))
422
423     if not user.can_convert_to_comment(answer):
424         raise NotEnoughRepPointsException(_("convert answers to comments"))
425
426     try:
427         new_parent = Node.objects.get(id=request.POST.get('under', None))
428     except:
429         raise CommandException(_("That is an invalid post to put the comment under"))
430
431     if not (new_parent == question or (new_parent.node_type == 'answer' and new_parent.parent == question)):
432         raise CommandException(_("That is an invalid post to put the comment under"))
433
434     AnswerToCommentAction(user=user, node=answer, ip=request.META['REMOTE_ADDR']).save(data=dict(new_parent=new_parent))
435
436     return RefreshPageCommand()
437
438 @decorate.withfn(command)
439 def subscribe(request, id):
440     question = get_object_or_404(Question, id=id)
441
442     try:
443         subscription = QuestionSubscription.objects.get(question=question, user=request.user)
444         subscription.delete()
445         subscribed = False
446     except:
447         subscription = QuestionSubscription(question=question, user=request.user, auto_subscription=False)
448         subscription.save()
449         subscribed = True
450
451     return {
452     'commands': {
453     'set_subscription_button': [subscribed and _('unsubscribe me') or _('subscribe me')],
454     'set_subscription_status': ['']
455     }
456     }
457
458 #internally grouped views - used by the tagging system
459 @ajax_login_required
460 def mark_tag(request, tag=None, **kwargs):#tagging system
461     action = kwargs['action']
462     ts = MarkedTag.objects.filter(user=request.user, tag__name=tag)
463     if action == 'remove':
464         logging.debug('deleting tag %s' % tag)
465         ts.delete()
466     else:
467         reason = kwargs['reason']
468         if len(ts) == 0:
469             try:
470                 t = Tag.objects.get(name=tag)
471                 mt = MarkedTag(user=request.user, reason=reason, tag=t)
472                 mt.save()
473             except:
474                 pass
475         else:
476             ts.update(reason=reason)
477     return HttpResponse(simplejson.dumps(''), mimetype="application/json")
478
479 def matching_tags(request):
480     if len(request.GET['q']) == 0:
481         raise CommandException(_("Invalid request"))
482
483     possible_tags = Tag.active.filter(name__istartswith = request.GET['q'])
484     tag_output = ''
485     for tag in possible_tags:
486         tag_output += (tag.name + "|" + tag.name + "." + tag.used_count.__str__() + "\n")
487
488     return HttpResponse(tag_output, mimetype="text/plain")
489
490 def related_questions(request):
491     if request.POST and request.POST.get('title', None):
492         can_rank, questions = Question.objects.search(request.POST['title'])
493         return HttpResponse(simplejson.dumps(
494                 [dict(title=q.title, url=q.get_absolute_url(), score=q.score, summary=q.summary)
495                  for q in questions.filter_state(deleted=False)[0:10]]), mimetype="application/json")
496     else:
497         raise Http404()
498
499
500
501
502
503
504