import json from django import forms from django.conf import settings from django.contrib.auth.context_processors import PermWrapper from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied, ValidationError from django.db import IntegrityError from django.db.models import Count, F, FilteredRelation, Q from django.db.models.expressions import Value from django.db.models.functions import Coalesce from django.forms import ModelForm from django.http import ( Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound, HttpResponseRedirect, ) from django.shortcuts import get_object_or_404, render from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.utils.datastructures import MultiValueDictKeyError from django.views.decorators.http import require_POST from django.views.generic import DetailView, UpdateView, View from django.views.generic.base import TemplateResponseMixin from django.views.generic.detail import SingleObjectMixin from django_ratelimit.decorators import ratelimit from django.contrib.contenttypes.models import ContentType from reversion import revisions from reversion.models import Revision, Version from judge.jinja2.reference import get_user_from_text from judge.models import BlogPost, Comment, CommentVote, Notification from judge.models.notification import make_notification from judge.models.comment import get_visible_comment_count from judge.utils.views import TitleMixin from judge.widgets import HeavyPreviewPageDownWidget __all__ = [ "upvote_comment", "downvote_comment", "CommentEditAjax", "CommentContent", "CommentEdit", ] DEFAULT_OFFSET = 10 def _get_html_link_notification(comment): return f'{comment.page_title}' def add_mention_notifications(comment): users_mentioned = get_user_from_text(comment.body).exclude(id=comment.author.id) link = _get_html_link_notification(comment) make_notification(users_mentioned, "Mention", link, comment.author) @ratelimit(key="user", rate=settings.RL_VOTE) @login_required def vote_comment(request, delta): if abs(delta) != 1: return HttpResponseBadRequest( _("Messing around, are we?"), content_type="text/plain" ) if request.method != "POST": return HttpResponseForbidden() if "id" not in request.POST: return HttpResponseBadRequest() if ( not request.user.is_staff and not request.profile.submission_set.filter( points=F("problem__points") ).exists() ): return HttpResponseBadRequest( _("You must solve at least one problem before you can vote."), content_type="text/plain", ) try: comment_id = int(request.POST["id"]) except ValueError: return HttpResponseBadRequest() else: if not Comment.objects.filter(id=comment_id).exists(): raise Http404() vote = CommentVote() vote.comment_id = comment_id vote.voter = request.profile vote.score = delta try: vote.save() except IntegrityError: try: vote = CommentVote.objects.get(comment_id=comment_id, voter=request.profile) except CommentVote.DoesNotExist: raise Http404() if -vote.score != delta: return HttpResponseBadRequest( _("You already voted."), content_type="text/plain" ) vote.delete() Comment.objects.filter(id=comment_id).update(score=F("score") - vote.score) else: Comment.objects.filter(id=comment_id).update(score=F("score") + delta) return HttpResponse("success", content_type="text/plain") def upvote_comment(request): return vote_comment(request, 1) def downvote_comment(request): return vote_comment(request, -1) def get_comments(request, limit=10): try: comment_id = int(request.GET["id"]) parent_none = int(request.GET["parent_none"]) except (ValueError, MultiValueDictKeyError): return HttpResponseBadRequest() else: if comment_id and not Comment.objects.filter(id=comment_id).exists(): raise Http404() offset = 0 if "offset" in request.GET: try: offset = int(request.GET["offset"]) except ValueError: return HttpResponseBadRequest() target_comment = -1 if "target_comment" in request.GET: target_comment = int(request.GET["target_comment"]) comment_root_id = 0 if comment_id: comment_obj = Comment.objects.get(pk=comment_id) comment_root_id = comment_obj.id else: comment_obj = None queryset = comment_obj.linked_object.comments if parent_none: queryset = queryset.filter(parent=None, hidden=False) queryset = queryset.exclude(pk=target_comment) else: queryset = queryset.filter(parent=comment_obj, hidden=False) comment_count = len(queryset) queryset = ( queryset.select_related("author__user") .defer("author__about") .annotate( count_replies=Count("replies", distinct=True), )[offset : offset + limit] ) profile = None if request.user.is_authenticated: profile = request.profile queryset = queryset.annotate( my_vote=FilteredRelation("votes", condition=Q(votes__voter_id=profile.id)), ).annotate(vote_score=Coalesce(F("my_vote__score"), Value(0))) new_offset = offset + min(len(queryset), limit) return render( request, "comments/content-list.html", { "profile": profile, "comment_root_id": comment_root_id, "comment_list": queryset, "vote_hide_threshold": settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD, "perms": PermWrapper(request.user), "offset": new_offset, "limit": limit, "comment_count": comment_count, "comment_parent_none": parent_none, "target_comment": target_comment, "comment_more": comment_count - new_offset, }, ) def get_show_more(request): return get_comments(request) def get_replies(request): return get_comments(request) class CommentMixin(object): model = Comment pk_url_kwarg = "id" context_object_name = "comment" class CommentRevisionAjax(CommentMixin, DetailView): template_name = "comments/revision-ajax.html" def get_context_data(self, **kwargs): context = super(CommentRevisionAjax, self).get_context_data(**kwargs) revisions = Version.objects.get_for_object(self.object).order_by("-revision") if len(revisions) == 0: raise Http404 try: wanted = min( max(int(self.request.GET.get("revision", 0)), 0), len(revisions) - 1 ) revision = revisions[wanted] except (ValueError, IndexError): raise Http404 data = json.loads(revision.serialized_data) try: context["body"] = data[0]["fields"]["body"] except Exception: context["body"] = "" return context def get_object(self, queryset=None): comment = super(CommentRevisionAjax, self).get_object(queryset) if comment.hidden and not self.request.user.has_perm("judge.change_comment"): raise Http404() return comment class CommentEditForm(ModelForm): class Meta: model = Comment fields = ["body"] widgets = { "body": HeavyPreviewPageDownWidget( id="id-edit-comment-body", preview=reverse_lazy("comment_preview"), preview_timeout=1000, hide_preview_button=True, ), } class CommentEditAjax(LoginRequiredMixin, CommentMixin, UpdateView): template_name = "comments/edit-ajax.html" form_class = CommentEditForm def form_valid(self, form): # update notifications comment = form.instance add_mention_notifications(comment) comment.revision_count = comment.versions.count() + 1 comment.save(update_fields=["revision_count"]) with revisions.create_revision(): revisions.set_comment(_("Edited from site")) revisions.set_user(self.request.user) return super(CommentEditAjax, self).form_valid(form) def get_success_url(self): return self.object.get_absolute_url() def get_object(self, queryset=None): comment = super(CommentEditAjax, self).get_object(queryset) if self.request.user.has_perm("judge.change_comment"): return comment profile = self.request.profile if profile != comment.author or profile.mute or comment.hidden: raise Http404() return comment class CommentEdit(TitleMixin, CommentEditAjax): template_name = "comments/edit.html" def get_title(self): return _("Editing comment") class CommentContent(CommentMixin, DetailView): template_name = "comments/content.html" class CommentVotesAjax(PermissionRequiredMixin, CommentMixin, DetailView): template_name = "comments/votes.html" permission_required = "judge.change_commentvote" def get_context_data(self, **kwargs): context = super(CommentVotesAjax, self).get_context_data(**kwargs) context["votes"] = self.object.votes.select_related("voter__user").only( "id", "voter__display_rank", "voter__user__username", "score" ) return context @require_POST def comment_hide(request): if not request.user.has_perm("judge.change_comment"): raise PermissionDenied() try: comment_id = int(request.POST["id"]) except ValueError: return HttpResponseBadRequest() comment = get_object_or_404(Comment, id=comment_id) comment.get_descendants(include_self=True).update(hidden=True) get_visible_comment_count.dirty(comment.content_type, comment.object_id) return HttpResponse("ok") class CommentForm(ModelForm): class Meta: model = Comment fields = ["body", "parent"] widgets = { "parent": forms.HiddenInput(), } if HeavyPreviewPageDownWidget is not None: widgets["body"] = HeavyPreviewPageDownWidget( preview=reverse_lazy("comment_preview"), preview_timeout=1000, hide_preview_button=True, ) def __init__(self, request, *args, **kwargs): self.request = request super(CommentForm, self).__init__(*args, **kwargs) self.fields["body"].widget.attrs.update({"placeholder": _("Comment body")}) def clean(self): if self.request is not None and self.request.user.is_authenticated: profile = self.request.profile if profile.mute: raise ValidationError(_("Your part is silent, little toad.")) elif ( not self.request.user.is_staff and not profile.submission_set.filter( points=F("problem__points") ).exists() ): raise ValidationError( _( "You need to have solved at least one problem " "before your voice can be heard." ) ) return super(CommentForm, self).clean() class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): comment_page = None def is_comment_locked(self): if self.request.user.has_perm("judge.override_comment_lock"): return False return ( self.request.in_contest and self.request.participation.contest.use_clarifications ) @method_decorator(ratelimit(key="user", rate=settings.RL_COMMENT)) @method_decorator(login_required) def post(self, request, *args, **kwargs): self.object = self.get_object() if self.is_comment_locked(): return HttpResponseForbidden() parent = request.POST.get("parent") if parent: try: parent = int(parent) except ValueError: return HttpResponseNotFound() else: if not self.object.comments.filter(hidden=False, id=parent).exists(): return HttpResponseNotFound() form = CommentForm(request, request.POST) if form.is_valid(): comment = form.save(commit=False) comment.author = request.profile comment.linked_object = self.object with revisions.create_revision(): revisions.set_user(request.user) revisions.set_comment(_("Posted comment")) comment.save() # add notification for reply comment_notif_link = _get_html_link_notification(comment) if comment.parent and comment.parent.author != comment.author: make_notification( [comment.parent.author], "Reply", comment_notif_link, comment.author ) # add notification for page authors page_authors = comment.linked_object.authors.all() make_notification( page_authors, "Comment", comment_notif_link, comment.author ) add_mention_notifications(comment) get_visible_comment_count.dirty(comment.content_type, comment.object_id) return HttpResponseRedirect(comment.get_absolute_url()) context = self.get_context_data(object=self.object, comment_form=form) return self.render_to_response(context) def get(self, request, *args, **kwargs): target_comment = None self.object = self.get_object() if "comment-id" in request.GET: try: comment_id = int(request.GET["comment-id"]) comment_obj = Comment.objects.get(id=comment_id) except (Comment.DoesNotExist, ValueError): raise Http404 if comment_obj.linked_object != self.object: raise Http404 target_comment = comment_obj.get_root() return self.render_to_response( self.get_context_data( object=self.object, target_comment=target_comment, comment_form=CommentForm(request, initial={"parent": None}), ) ) def _get_queryset(self, target_comment): if target_comment: queryset = target_comment.get_descendants(include_self=True) queryset = queryset.filter(hidden=False) else: queryset = self.object.comments queryset = queryset.filter(parent=None, hidden=False) queryset = queryset.filter(hidden=False).annotate( count_replies=Count("replies", distinct=True), )[:DEFAULT_OFFSET] if self.request.user.is_authenticated: profile = self.request.profile queryset = queryset.annotate( my_vote=FilteredRelation( "votes", condition=Q(votes__voter_id=profile.id) ), ).annotate(vote_score=Coalesce(F("my_vote__score"), Value(0))) return queryset def get_context_data(self, target_comment=None, **kwargs): context = super(CommentedDetailView, self).get_context_data(**kwargs) queryset = self._get_queryset(target_comment) comment_count = self.object.comments.filter(parent=None, hidden=False).count() content_type = ContentType.objects.get_for_model(self.object) all_comment_count = get_visible_comment_count(content_type, self.object.pk) if target_comment != None: context["target_comment"] = target_comment.id else: context["target_comment"] = -1 if self.request.user.is_authenticated: context["is_new_user"] = ( not self.request.user.is_staff and not self.request.profile.submission_set.filter( points=F("problem__points") ).exists() ) context["comment_lock"] = self.is_comment_locked() context["comment_list"] = list(queryset) context["has_comments"] = len(context["comment_list"]) > 0 context["vote_hide_threshold"] = settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD if queryset.exists(): context["comment_root_id"] = context["comment_list"][0].id else: context["comment_root_id"] = 0 context["comment_parent_none"] = 1 if target_comment != None: context["offset"] = 0 context["comment_more"] = comment_count - 1 else: context["offset"] = DEFAULT_OFFSET context["comment_more"] = comment_count - DEFAULT_OFFSET context["limit"] = DEFAULT_OFFSET context["comment_count"] = comment_count context["profile"] = self.request.profile context["all_comment_count"] = all_comment_count return context