diff --git a/judge/comments.py b/judge/comments.py deleted file mode 100644 index 3869194..0000000 --- a/judge/comments.py +++ /dev/null @@ -1,230 +0,0 @@ -from django import forms -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError -from django.db.models import Count, FilteredRelation, Q -from django.db.models.expressions import F, Value -from django.db.models.functions import Coalesce -from django.forms import ModelForm -from django.http import ( - HttpResponseForbidden, - HttpResponseNotFound, - HttpResponseRedirect, - Http404, -) -from django.urls import reverse_lazy -from django.utils.decorators import method_decorator -from django.utils.translation import gettext as _ -from django.views.generic import View -from django.views.generic.base import TemplateResponseMixin -from django.views.generic.detail import SingleObjectMixin -from reversion import revisions -from reversion.models import Revision, Version -from django_ratelimit.decorators import ratelimit - -from judge.models import Comment, Notification -from judge.widgets import HeavyPreviewPageDownWidget -from judge.jinja2.reference import get_user_from_text -from judge.models.notification import make_notification - - -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) - - -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) - - 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.select_related("author__user") - .filter(hidden=False) - .defer("author__about") - ) - else: - queryset = self.object.comments - queryset = queryset.filter(parent=None, hidden=False) - queryset = ( - queryset.select_related("author__user") - .defer("author__about") - .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() - context["target_comment"] = -1 - if target_comment != None: - context["target_comment"] = target_comment.id - - 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["has_comments"] = queryset.exists() - context["comment_lock"] = self.is_comment_locked() - context["comment_list"] = list(queryset) - - 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 - return context diff --git a/judge/jinja2/__init__.py b/judge/jinja2/__init__.py index 93ab0ad..e24ea8c 100644 --- a/judge/jinja2/__init__.py +++ b/judge/jinja2/__init__.py @@ -22,6 +22,7 @@ from . import ( social, spaceless, timedelta, + comment, ) from . import registry diff --git a/judge/jinja2/comment.py b/judge/jinja2/comment.py new file mode 100644 index 0000000..6baa365 --- /dev/null +++ b/judge/jinja2/comment.py @@ -0,0 +1,12 @@ +from . import registry + +from django.contrib.contenttypes.models import ContentType + +from judge.models.comment import get_visible_comment_count +from judge.caching import cache_wrapper + + +@registry.function +def comment_count(obj): + content_type = ContentType.objects.get_for_model(obj) + return get_visible_comment_count(content_type, obj.pk) diff --git a/judge/jinja2/reference.py b/judge/jinja2/reference.py index 312ac4f..584c6da 100644 --- a/judge/jinja2/reference.py +++ b/judge/jinja2/reference.py @@ -155,16 +155,14 @@ def item_title(item): @registry.function @registry.render_with("user/link.html") -def link_user(user): +def link_user(user, show_image=False): if isinstance(user, Profile): profile = user elif isinstance(user, AbstractUser): profile = user.profile - elif type(user).__name__ == "ContestRankingProfile": - profile = user else: raise ValueError("Expected profile or user, got %s" % (type(user),)) - return {"profile": profile} + return {"profile": profile, "show_image": show_image} @registry.function diff --git a/judge/models/bookmark.py b/judge/models/bookmark.py index 718141f..6a25411 100644 --- a/judge/models/bookmark.py +++ b/judge/models/bookmark.py @@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from judge.models.profile import Profile +from judge.caching import cache_wrapper __all__ = ["BookMark"] @@ -21,12 +22,9 @@ class BookMark(models.Model): object_id = models.PositiveIntegerField() linked_object = GenericForeignKey("content_type", "object_id") + @cache_wrapper(prefix="BMgb") def get_bookmark(self, user): - userqueryset = MakeBookMark.objects.filter(bookmark=self, user=user) - if userqueryset.exists(): - return True - else: - return False + return MakeBookMark.objects.filter(bookmark=self, user=user).exists() class Meta: verbose_name = _("bookmark") @@ -55,11 +53,22 @@ class MakeBookMark(models.Model): verbose_name_plural = _("make bookmarks") +@cache_wrapper(prefix="gocb") +def _get_or_create_bookmark(content_type, object_id): + bookmark, created = BookMark.objects.get_or_create( + content_type=content_type, + object_id=object_id, + ) + return bookmark + + class Bookmarkable: def get_or_create_bookmark(self): - if self.bookmark.count(): - return self.bookmark.first() - new_bookmark = BookMark() - new_bookmark.linked_object = self - new_bookmark.save() - return new_bookmark + content_type = ContentType.objects.get_for_model(self) + object_id = self.pk + return _get_or_create_bookmark(content_type, object_id) + + +def dirty_bookmark(bookmark, profile): + bookmark.get_bookmark.dirty(bookmark, profile) + _get_or_create_bookmark.dirty(bookmark.content_type, bookmark.object_id) diff --git a/judge/models/comment.py b/judge/models/comment.py index 0588dfa..bd78338 100644 --- a/judge/models/comment.py +++ b/judge/models/comment.py @@ -20,6 +20,7 @@ from judge.models.interface import BlogPost from judge.models.problem import Problem, Solution from judge.models.profile import Profile from judge.utils.cachedict import CacheDict +from judge.caching import cache_wrapper __all__ = ["Comment", "CommentLock", "CommentVote", "Notification"] @@ -75,16 +76,16 @@ class Comment(MPTTModel): queryset = ( cls.objects.filter(hidden=False) .select_related("author__user") - .defer("author__about", "body") + .defer("author__about") .order_by("-id") ) if organization: queryset = queryset.filter(author__in=organization.members.all()) - problem_access = CacheDict(lambda p: p.is_accessible_by(user)) + object_access = CacheDict(lambda p: p.is_accessible_by(user)) contest_access = CacheDict(lambda c: c.is_accessible_by(user)) - blog_access = CacheDict(lambda b: b.can_see(user)) + blog_access = CacheDict(lambda b: b.is_accessible_by(user)) if n == -1: n = len(queryset) @@ -174,3 +175,10 @@ class CommentLock(models.Model): def __str__(self): return str(self.page) + + +@cache_wrapper(prefix="gcc") +def get_visible_comment_count(content_type, object_id): + return Comment.objects.filter( + content_type=content_type, object_id=object_id, hidden=False + ).count() diff --git a/judge/models/interface.py b/judge/models/interface.py index f7626dc..8ff3fff 100644 --- a/judge/models/interface.py +++ b/judge/models/interface.py @@ -13,6 +13,7 @@ from mptt.models import MPTTModel from judge.models.profile import Organization, Profile from judge.models.pagevote import PageVotable from judge.models.bookmark import Bookmarkable +from judge.caching import cache_wrapper __all__ = ["MiscConfig", "validate_regex", "NavigationBar", "BlogPost"] @@ -105,7 +106,7 @@ class BlogPost(models.Model, PageVotable, Bookmarkable): def get_absolute_url(self): return reverse("blog_post", args=(self.id, self.slug)) - def can_see(self, user): + def is_accessible_by(self, user): if self.visible and self.publish_on <= timezone.now(): if not self.is_organization_private: return True @@ -132,6 +133,10 @@ class BlogPost(models.Model, PageVotable, Bookmarkable): and self.authors.filter(id=user.profile.id).exists() ) + @cache_wrapper(prefix="BPga") + def get_authors(self): + return self.authors.only("id") + class Meta: permissions = (("edit_all_post", _("Edit all posts")),) verbose_name = _("blog post") diff --git a/judge/models/pagevote.py b/judge/models/pagevote.py index 7accd01..9186197 100644 --- a/judge/models/pagevote.py +++ b/judge/models/pagevote.py @@ -31,11 +31,8 @@ class PageVote(models.Model): @cache_wrapper(prefix="PVvs") def vote_score(self, user): - page_vote = PageVoteVoter.objects.filter(pagevote=self, voter=user) - if page_vote.exists(): - return page_vote.first().score - else: - return 0 + page_vote = PageVoteVoter.objects.filter(pagevote=self, voter=user).first() + return page_vote.score if page_vote else 0 def __str__(self): return f"pagevote for {self.linked_object}" @@ -52,11 +49,22 @@ class PageVoteVoter(models.Model): verbose_name_plural = _("pagevote votes") +@cache_wrapper(prefix="gocp") +def _get_or_create_pagevote(content_type, object_id): + pagevote, created = PageVote.objects.get_or_create( + content_type=content_type, + object_id=object_id, + ) + return pagevote + + class PageVotable: def get_or_create_pagevote(self): - if self.pagevote.count(): - return self.pagevote.first() - new_pagevote = PageVote() - new_pagevote.linked_object = self - new_pagevote.save() - return new_pagevote + content_type = ContentType.objects.get_for_model(self) + object_id = self.pk + return _get_or_create_pagevote(content_type, object_id) + + +def dirty_pagevote(pagevote, profile): + pagevote.vote_score.dirty(pagevote, profile) + _get_or_create_pagevote.dirty(pagevote.content_type, pagevote.object_id) diff --git a/judge/models/problem.py b/judge/models/problem.py index 6625fab..6493682 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -24,6 +24,7 @@ from judge.models.problem_data import ( problem_data_storage, problem_directory_file_helper, ) +from judge.caching import cache_wrapper __all__ = [ "ProblemGroup", @@ -439,6 +440,10 @@ class Problem(models.Model, PageVotable, Bookmarkable): "profile_id", flat=True ) + @cache_wrapper(prefix="Pga") + def get_authors(self): + return self.authors.only("id") + @cached_property def editor_ids(self): return self.author_ids.union( diff --git a/judge/models/profile.py b/judge/models/profile.py index 8975bb3..66e6d7f 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -141,6 +141,14 @@ class Organization(models.Model): def get_submissions_url(self): return reverse("organization_submissions", args=(self.id, self.slug)) + @cache_wrapper("Oia") + def is_admin(self, profile): + return self.admins.filter(id=profile.id).exists() + + @cache_wrapper("Oim") + def is_member(self, profile): + return profile in self + class Meta: ordering = ["name"] permissions = ( @@ -280,6 +288,14 @@ class Profile(models.Model): def is_muted(self): return self._cached_info["mute"] + @cached_property + def cached_display_rank(self): + return self._cached_info.get("display_rank") + + @cached_property + def cached_rating(self): + return self._cached_info.get("rating") + @cached_property def profile_image_url(self): return self._cached_info.get("profile_image_url") @@ -374,7 +390,7 @@ class Profile(models.Model): @cached_property def css_class(self): - return self.get_user_css_class(self.display_rank, self.rating) + return self.get_user_css_class(self.cached_display_rank, self.cached_rating) def get_friends(self): # list of ids, including you friend_obj = self.following_users.prefetch_related("users") @@ -388,11 +404,7 @@ class Profile(models.Model): if not self.user.is_authenticated: return False profile_id = self.id - return ( - org.admins.filter(id=profile_id).exists() - or org.registrant_id == profile_id - or self.user.is_superuser - ) + return org.is_admin(self) or self.user.is_superuser @classmethod def prefetch_profile_cache(self, profile_ids): @@ -544,7 +556,7 @@ def on_profile_save(sender, instance, **kwargs): _get_basic_info.dirty(instance.id) -@cache_wrapper(prefix="Pgbi2") +@cache_wrapper(prefix="Pgbi3") def _get_basic_info(profile_id): profile = ( Profile.objects.select_related("user") @@ -556,6 +568,8 @@ def _get_basic_info(profile_id): "user__email", "user__first_name", "user__last_name", + "display_rank", + "rating", ) .get(id=profile_id) ) @@ -569,6 +583,8 @@ def _get_basic_info(profile_id): "profile_image_url": profile.profile_image.url if profile.profile_image else None, + "display_rank": profile.display_rank, + "rating": profile.rating, } res = {k: v for k, v in res.items() if v is not None} return res diff --git a/judge/signals.py b/judge/signals.py index d859e45..2b0f8b9 100644 --- a/judge/signals.py +++ b/judge/signals.py @@ -55,18 +55,13 @@ def problem_update(sender, instance, **kwargs): for lang, _ in settings.LANGUAGES ] ) - cache.delete_many( - [ - make_template_fragment_key("problem_authors", (instance.id, lang)) - for lang, _ in settings.LANGUAGES - ] - ) cache.delete_many( [ "generated-meta-problem:%s:%d" % (lang, instance.id) for lang, _ in settings.LANGUAGES ] ) + Problem.get_authors.dirty(instance) for lang, _ in settings.LANGUAGES: unlink_if_exists(get_pdf_path("%s.%s.pdf" % (instance.code, lang))) @@ -129,6 +124,7 @@ def post_update(sender, instance, **kwargs): ] + [make_template_fragment_key("post_content", (instance.id,))] ) + BlogPost.get_authors.dirty(instance) @receiver(post_delete, sender=Submission) @@ -146,6 +142,8 @@ def contest_submission_delete(sender, instance, **kwargs): @receiver(post_save, sender=Organization) def organization_update(sender, instance, **kwargs): cache.delete_many([make_template_fragment_key("organization_html", (instance.id,))]) + for admin in instance.admins.all(): + Organization.is_admin.dirty(instance, admin) _misc_config_i18n = [code for code, _ in settings.LANGUAGES] diff --git a/judge/views/blog.py b/judge/views/blog.py index 2e29127..194ae49 100644 --- a/judge/views/blog.py +++ b/judge/views/blog.py @@ -6,7 +6,7 @@ from django.utils.functional import lazy from django.utils.translation import ugettext as _ from django.views.generic import ListView -from judge.comments import CommentedDetailView +from judge.views.comment import CommentedDetailView from judge.views.pagevote import PageVoteDetailView from judge.views.bookmark import BookMarkDetailView from judge.models import ( @@ -69,13 +69,18 @@ class HomeFeedView(FeedView): profile_queryset = Profile.objects if self.request.organization: profile_queryset = self.request.organization.members - context["top_rated"] = profile_queryset.filter(is_unlisted=False).order_by( - "-rating" - )[:10] - context["top_scorer"] = profile_queryset.filter(is_unlisted=False).order_by( - "-performance_points" - )[:10] - + context["top_rated"] = ( + profile_queryset.filter(is_unlisted=False) + .order_by("-rating") + .only("id", "rating")[:10] + ) + context["top_scorer"] = ( + profile_queryset.filter(is_unlisted=False) + .order_by("-performance_points") + .only("id", "performance_points")[:10] + ) + Profile.prefetch_profile_cache([p.id for p in context["top_rated"]]) + Profile.prefetch_profile_cache([p.id for p in context["top_scorer"]]) return context @@ -90,7 +95,7 @@ class PostList(HomeFeedView): queryset = ( BlogPost.objects.filter(visible=True, publish_on__lte=timezone.now()) .order_by("-sticky", "-publish_on") - .prefetch_related("authors__user", "organizations") + .prefetch_related("organizations") ) filter = Q(is_organization_private=False) if self.request.user.is_authenticated: @@ -198,6 +203,6 @@ class PostView(TitleMixin, CommentedDetailView, PageVoteDetailView, BookMarkDeta def get_object(self, queryset=None): post = super(PostView, self).get_object(queryset) - if not post.can_see(self.request.user): + if not post.is_accessible_by(self.request.user): raise Http404() return post diff --git a/judge/views/bookmark.py b/judge/views/bookmark.py index 7f31c1f..a05f715 100644 --- a/judge/views/bookmark.py +++ b/judge/views/bookmark.py @@ -8,12 +8,12 @@ from django.http import ( HttpResponseForbidden, ) from django.utils.translation import gettext as _ -from judge.models.bookmark import BookMark, MakeBookMark from django.views.generic.base import TemplateResponseMixin from django.views.generic.detail import SingleObjectMixin from django.views.generic import View, ListView +from judge.models.bookmark import BookMark, MakeBookMark, dirty_bookmark __all__ = [ "dobookmark_page", @@ -32,30 +32,31 @@ def bookmark_page(request, delta): try: bookmark_id = int(request.POST["id"]) - bookmark_page = BookMark.objects.filter(id=bookmark_id) + bookmark = BookMark.objects.get(id=bookmark_id) except ValueError: return HttpResponseBadRequest() - else: - if not bookmark_page.exists(): - raise Http404() + except BookMark.DoesNotExist: + raise Http404() if delta == 0: bookmarklist = MakeBookMark.objects.filter( - bookmark=bookmark_page.first(), user=request.profile + bookmark=bookmark, user=request.profile ) if not bookmarklist.exists(): newbookmark = MakeBookMark( - bookmark=bookmark_page.first(), + bookmark=bookmark, user=request.profile, ) newbookmark.save() else: bookmarklist = MakeBookMark.objects.filter( - bookmark=bookmark_page.first(), user=request.profile + bookmark=bookmark, user=request.profile ) if bookmarklist.exists(): bookmarklist.delete() + dirty_bookmark(bookmark, request.profile) + return HttpResponse("success", content_type="text/plain") diff --git a/judge/views/comment.py b/judge/views/comment.py index 15009f2..2e372c8 100644 --- a/judge/views/comment.py +++ b/judge/views/comment.py @@ -1,36 +1,45 @@ -from django.conf import settings +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.auth.context_processors import PermWrapper -from django.core.exceptions import PermissionDenied +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied, ValidationError from django.db import IntegrityError -from django.db.models import Q, F, Count, FilteredRelation +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.db.models.expressions import F, Value -from django.forms.models import ModelForm +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.views.decorators.http import require_POST -from django.views.generic import DetailView, UpdateView -from django.urls import reverse_lazy -from reversion import revisions -from reversion.models import Version -from django.conf import settings +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 judge.models import Comment, CommentVote, Notification, BlogPost +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 -from judge.comments import add_mention_notifications - -import json __all__ = [ "upvote_comment", @@ -40,6 +49,18 @@ __all__ = [ "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 @@ -290,4 +311,204 @@ def comment_hide(request): 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.select_related("author__user") + .filter(hidden=False) + .defer("author__about") + ) + else: + queryset = self.object.comments + queryset = queryset.filter(parent=None, hidden=False) + queryset = ( + queryset.select_related("author__user") + .defer("author__about") + .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["has_comments"] = queryset.exists() + context["comment_lock"] = self.is_comment_locked() + context["comment_list"] = list(queryset) + + 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 diff --git a/judge/views/contests.py b/judge/views/contests.py index 107e281..51e9297 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -55,7 +55,7 @@ from django.views.generic.detail import ( ) from judge import event_poster as event -from judge.comments import CommentedDetailView +from judge.views.comment import CommentedDetailView from judge.forms import ContestCloneForm from judge.models import ( Contest, @@ -956,7 +956,7 @@ class ContestStats(TitleMixin, ContestMixin, DetailView): ContestRankingProfile = namedtuple( "ContestRankingProfile", - "id user css_class username points cumtime tiebreaker organization participation " + "id user points cumtime tiebreaker participation " "participation_rating problem_cells result_cell", ) @@ -976,13 +976,10 @@ def make_contest_ranking_profile( user = participation.user return ContestRankingProfile( id=user.id, - user=user.user, - css_class=user.css_class, - username=user.username, + user=user, points=points, cumtime=cumtime, tiebreaker=participation.tiebreaker, - organization=user.organization, participation_rating=participation.rating.rating if hasattr(participation, "rating") else None, @@ -1000,12 +997,24 @@ def make_contest_ranking_profile( def base_contest_ranking_list(contest, problems, queryset, show_final=False): - return [ + participation_fields = [ + field.name + for field in ContestParticipation._meta.get_fields() + if field.concrete and not field.many_to_many + ] + fields_to_fetch = participation_fields + [ + "user__id", + "rating__rating", + ] + + res = [ make_contest_ranking_profile(contest, participation, problems, show_final) - for participation in queryset.select_related("user__user", "rating").defer( - "user__about", "user__organizations__about" + for participation in queryset.select_related("user", "rating").only( + *fields_to_fetch ) ] + Profile.prefetch_profile_cache([p.id for p in res]) + return res def contest_ranking_list(contest, problems, queryset=None, show_final=False): @@ -1016,18 +1025,18 @@ def contest_ranking_list(contest, problems, queryset=None, show_final=False): return base_contest_ranking_list( contest, problems, - queryset.prefetch_related("user__organizations") - .extra(select={"round_score": "round(score, 6)"}) - .order_by("is_disqualified", "-round_score", "cumtime", "tiebreaker"), + queryset.extra(select={"round_score": "round(score, 6)"}).order_by( + "is_disqualified", "-round_score", "cumtime", "tiebreaker" + ), show_final, ) else: return base_contest_ranking_list( contest, problems, - queryset.prefetch_related("user__organizations") - .extra(select={"round_score": "round(score_final, 6)"}) - .order_by("is_disqualified", "-round_score", "cumtime_final", "tiebreaker"), + queryset.extra(select={"round_score": "round(score_final, 6)"}).order_by( + "is_disqualified", "-round_score", "cumtime_final", "tiebreaker" + ), show_final, ) diff --git a/judge/views/organization.py b/judge/views/organization.py index ea7bd69..4a110fa 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -104,15 +104,15 @@ class OrganizationBase(object): def is_member(self, org=None): if org is None: org = self.object - return ( - self.request.profile in org if self.request.user.is_authenticated else False - ) + if self.request.profile: + return org.is_member(self.request.profile) + return False def is_admin(self, org=None): if org is None: org = self.object if self.request.profile: - return org.admins.filter(id=self.request.profile.id).exists() + return org.is_admin(self.request.profile) return False def can_access(self, org): @@ -222,12 +222,19 @@ class OrganizationHomeView(OrganizationMixin): organizations=self.organization, authors=self.request.profile, ).count() - context["top_rated"] = self.organization.members.filter( - is_unlisted=False - ).order_by("-rating")[:10] - context["top_scorer"] = self.organization.members.filter( - is_unlisted=False - ).order_by("-performance_points")[:10] + context["top_rated"] = ( + self.organization.members.filter(is_unlisted=False) + .order_by("-rating") + .only("id", "rating")[:10] + ) + context["top_scorer"] = ( + self.organization.members.filter(is_unlisted=False) + .order_by("-performance_points") + .only("id", "performance_points")[:10] + ) + Profile.prefetch_profile_cache([p.id for p in context["top_rated"]]) + Profile.prefetch_profile_cache([p.id for p in context["top_scorer"]]) + return context @@ -516,6 +523,7 @@ class JoinOrganization(OrganizationMembershipChange): profile.organizations.add(org) profile.save() cache.delete(make_template_fragment_key("org_member_count", (org.id,))) + Organization.is_member.dirty(org, profile) class LeaveOrganization(OrganizationMembershipChange): @@ -528,6 +536,7 @@ class LeaveOrganization(OrganizationMembershipChange): ) profile.organizations.remove(org) cache.delete(make_template_fragment_key("org_member_count", (org.id,))) + Organization.is_member.dirty(org, profile) class OrganizationRequestForm(Form): diff --git a/judge/views/pagevote.py b/judge/views/pagevote.py index 6816ebb..a24680c 100644 --- a/judge/views/pagevote.py +++ b/judge/views/pagevote.py @@ -8,14 +8,13 @@ from django.http import ( HttpResponseForbidden, ) from django.utils.translation import gettext as _ -from judge.models.pagevote import PageVote, PageVoteVoter from django.views.generic.base import TemplateResponseMixin from django.views.generic.detail import SingleObjectMixin - from django.views.generic import View, ListView from django_ratelimit.decorators import ratelimit from django.conf import settings +from judge.models.pagevote import PageVote, PageVoteVoter, dirty_pagevote __all__ = [ "upvote_page", @@ -54,9 +53,11 @@ def vote_page(request, delta): pagevote_id = int(request.POST["id"]) except ValueError: return HttpResponseBadRequest() - else: - if not PageVote.objects.filter(id=pagevote_id).exists(): - raise Http404() + + try: + pagevote = PageVote.objects.get(id=pagevote_id) + except PageVote.DoesNotExist: + raise Http404() vote = PageVoteVoter() vote.pagevote_id = pagevote_id @@ -76,7 +77,9 @@ def vote_page(request, delta): PageVote.objects.filter(id=pagevote_id).update(score=F("score") - vote.score) else: PageVote.objects.filter(id=pagevote_id).update(score=F("score") + delta) - _dirty_vote_score(pagevote_id, request.profile) + + dirty_pagevote(pagevote, request.profile) + return HttpResponse("success", content_type="text/plain") @@ -100,8 +103,3 @@ class PageVoteDetailView(TemplateResponseMixin, SingleObjectMixin, View): context = super(PageVoteDetailView, self).get_context_data(**kwargs) context["pagevote"] = self.object.get_or_create_pagevote() return context - - -def _dirty_vote_score(pagevote_id, profile): - pv = PageVote(id=pagevote_id) - pv.vote_score.dirty(pv, profile) diff --git a/judge/views/problem.py b/judge/views/problem.py index 3b68b45..f12b795 100644 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -44,7 +44,7 @@ from django.views.generic import ListView, View from django.views.generic.base import TemplateResponseMixin from django.views.generic.detail import SingleObjectMixin -from judge.comments import CommentedDetailView +from judge.views.comment import CommentedDetailView from judge.forms import ProblemCloneForm, ProblemSubmitForm, ProblemPointsVoteForm from judge.models import ( ContestProblem, @@ -820,7 +820,7 @@ class ProblemFeed(ProblemList, FeedView): model = Problem context_object_name = "problems" template_name = "problem/feed.html" - feed_content_template_name = "problem/feed/problems.html" + feed_content_template_name = "problem/feed/items.html" paginate_by = 4 title = _("Problem feed") feed_type = None diff --git a/judge/views/submission.py b/judge/views/submission.py index a05f24f..d7c59a2 100644 --- a/judge/views/submission.py +++ b/judge/views/submission.py @@ -50,11 +50,9 @@ from judge.utils.timedelta import nice_repr def submission_related(queryset): - return queryset.select_related("user__user", "problem", "language").only( + return queryset.select_related("user", "problem", "language").only( "id", - "user__user__username", - "user__display_rank", - "user__rating", + "user_id", "problem__name", "problem__code", "problem__is_public", diff --git a/resources/users.scss b/resources/users.scss index 24efe54..d82623e 100644 --- a/resources/users.scss +++ b/resources/users.scss @@ -29,6 +29,17 @@ th.header.rank { padding-left: 5px; } +.user-with-img { + display: inline-flex; + gap: 0.5em; + align-items: center; + + .user-img { + height: 1.5em; + width: 1.5em; + } +} + #search-handle { width: 100%; height: 2.3em; diff --git a/templates/actionbar/list.html b/templates/actionbar/list.html index 6b8e1d6..4a38aaf 100644 --- a/templates/actionbar/list.html +++ b/templates/actionbar/list.html @@ -27,9 +27,9 @@ - {% if comment_count %} + {% if all_comment_count %} - ({{ comment_count }}) + ({{ all_comment_count }}) {% endif %} diff --git a/templates/blog/blog.html b/templates/blog/blog.html index 6793ba0..502ff20 100644 --- a/templates/blog/blog.html +++ b/templates/blog/blog.html @@ -19,7 +19,7 @@