From 799ff5f8f8c23434a490f38e3c841e578e0f7318 Mon Sep 17 00:00:00 2001 From: cuom1999 Date: Mon, 20 Feb 2023 17:15:13 -0600 Subject: [PATCH] Infinite scrolling and comment migration --- dmoj/urls.py | 19 +-- judge/admin/comments.py | 28 ++-- judge/comments.py | 20 +-- judge/migrations/0151_comment_content_type.py | 50 ++++++ judge/migrations/0152_migrate_comments.py | 54 +++++++ judge/models/bookmark.py | 9 +- judge/models/comment.py | 149 ++++++------------ judge/models/contest.py | 2 + judge/models/interface.py | 17 ++ judge/models/pagevote.py | 2 +- judge/models/problem.py | 18 ++- judge/views/blog.py | 74 ++++----- judge/views/bookmark.py | 10 -- judge/views/feed.py | 34 ++++ judge/views/notification.py | 1 - judge/views/organization.py | 131 +++++++-------- judge/views/pagevote.py | 10 -- judge/views/problem.py | 44 ++---- resources/pagedown_math.js | 2 +- templates/actionbar/list.html | 2 +- templates/actionbar/media-js.html | 8 +- templates/blog/content.html | 109 ++++++------- templates/blog/list.html | 16 +- templates/comments/feed.html | 39 ++--- templates/feed/feed_js.html | 30 ++++ templates/feed/has_next.html | 1 + templates/notification/list.html | 2 +- templates/organization/home.html | 8 +- templates/problem/feed.html | 127 +-------------- templates/problem/feed/problems.html | 121 ++++++++++++++ templates/problem/list-base.html | 1 + templates/three-column-content.html | 4 +- templates/ticket/feed.html | 53 ++++--- 33 files changed, 639 insertions(+), 556 deletions(-) create mode 100644 judge/migrations/0151_comment_content_type.py create mode 100644 judge/migrations/0152_migrate_comments.py create mode 100644 judge/views/feed.py create mode 100644 templates/feed/feed_js.html create mode 100644 templates/feed/has_next.html create mode 100644 templates/problem/feed/problems.html diff --git a/dmoj/urls.py b/dmoj/urls.py index 6cb55b5..72e81cb 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -231,18 +231,19 @@ urlpatterns = [ url(r"^problems/", paged_list_view(problem.ProblemList, "problem_list")), url(r"^problems/random/$", problem.RandomProblem.as_view(), name="problem_random"), url( - r"^problems/feed/", - paged_list_view(problem.ProblemFeed, "problem_feed", feed_type="for_you"), + r"^problems/feed/$", + problem.ProblemFeed.as_view(feed_type="for_you"), + name="problem_feed", ), url( - r"^problems/feed/new/", - paged_list_view(problem.ProblemFeed, "problem_feed_new", feed_type="new"), + r"^problems/feed/new/$", + problem.ProblemFeed.as_view(feed_type="new"), + name="problem_feed_new", ), url( - r"^problems/feed/volunteer/", - paged_list_view( - problem.ProblemFeed, "problem_feed_volunteer", feed_type="volunteer" - ), + r"^problems/feed/volunteer/$", + problem.ProblemFeed.as_view(feed_type="volunteer"), + name="problem_feed_volunteer", ), url( r"^problem/(?P[^/]+)", @@ -750,7 +751,7 @@ urlpatterns = [ ] ), ), - url(r"^blog/", paged_list_view(blog.PostList, "blog_post_list")), + url(r"^blog/", blog.PostList.as_view(), name="blog_post_list"), url(r"^post/(?P\d+)-(?P.*)$", blog.PostView.as_view(), name="blog_post"), url(r"^license/(?P[-\w.]+)$", license.LicenseDetail.as_view(), name="license"), url( diff --git a/judge/admin/comments.py b/judge/admin/comments.py index b9f63d6..0b2a8e1 100644 --- a/judge/admin/comments.py +++ b/judge/admin/comments.py @@ -22,11 +22,23 @@ class CommentForm(ModelForm): class CommentAdmin(VersionAdmin): fieldsets = ( - (None, {"fields": ("author", "page", "parent", "score", "hidden")}), + ( + None, + { + "fields": ( + "author", + "parent", + "score", + "hidden", + "content_type", + "object_id", + ) + }, + ), ("Content", {"fields": ("body",)}), ) - list_display = ["author", "linked_page", "time"] - search_fields = ["author__user__username", "page", "body"] + list_display = ["author", "linked_object", "time"] + search_fields = ["author__user__username", "body"] readonly_fields = ["score"] actions = ["hide_comment", "unhide_comment"] list_filter = ["hidden"] @@ -66,16 +78,6 @@ class CommentAdmin(VersionAdmin): unhide_comment.short_description = _("Unhide comments") - def linked_page(self, obj): - link = obj.link - if link is not None: - return format_html('{1}', link, obj.page) - else: - return format_html("{0}", obj.page) - - linked_page.short_description = _("Associated page") - linked_page.admin_order_field = "page" - def save_model(self, request, obj, form, change): super(CommentAdmin, self).save_model(request, obj, form, change) if obj.hidden: diff --git a/judge/comments.py b/judge/comments.py index 2509baa..16e6676 100644 --- a/judge/comments.py +++ b/judge/comments.py @@ -22,7 +22,7 @@ from reversion import revisions from reversion.models import Revision, Version from judge.dblock import LockModel -from judge.models import Comment, CommentLock, Notification +from judge.models import Comment, Notification from judge.widgets import HeavyPreviewPageDownWidget from judge.jinja2.reference import get_user_from_text @@ -90,7 +90,7 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): def is_comment_locked(self): if self.request.user.has_perm("judge.override_comment_lock"): return False - return CommentLock.objects.filter(page=self.get_comment_page()).exists() or ( + return ( self.request.in_contest and self.request.participation.contest.use_clarifications ) @@ -99,7 +99,6 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): def post(self, request, *args, **kwargs): self.object = self.get_object() page = self.get_comment_page() - if self.is_comment_locked(): return HttpResponseForbidden() @@ -110,9 +109,7 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): except ValueError: return HttpResponseNotFound() else: - if not Comment.objects.filter( - hidden=False, id=parent, page=page - ).exists(): + if not self.object.comments.filter(hidden=False, id=parent).exists(): return HttpResponseNotFound() form = CommentForm(request, request.POST) @@ -120,6 +117,7 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): comment = form.save(commit=False) comment.author = request.profile comment.page = page + comment.linked_object = self.object with LockModel( write=(Comment, Revision, Version), read=(ContentType,) @@ -136,7 +134,7 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): notification_reply.save() # add notification for page authors - page_authors = comment.page_object.authors.all() + page_authors = comment.linked_object.authors.all() for user in page_authors: if user == comment.author: continue @@ -149,7 +147,7 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): add_mention_notifications(comment) - return HttpResponseRedirect(request.path) + return HttpResponseRedirect(comment.get_absolute_url()) context = self.get_context_data(object=self.object, comment_form=form) return self.render_to_response(context) @@ -159,15 +157,13 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): return self.render_to_response( self.get_context_data( object=self.object, - comment_form=CommentForm( - request, initial={"page": self.get_comment_page(), "parent": None} - ), + comment_form=CommentForm(request, initial={"parent": None}), ) ) def get_context_data(self, **kwargs): context = super(CommentedDetailView, self).get_context_data(**kwargs) - queryset = Comment.objects.filter(hidden=False, page=self.get_comment_page()) + queryset = self.object.comments context["has_comments"] = queryset.exists() context["comment_lock"] = self.is_comment_locked() queryset = ( diff --git a/judge/migrations/0151_comment_content_type.py b/judge/migrations/0151_comment_content_type.py new file mode 100644 index 0000000..d1897d9 --- /dev/null +++ b/judge/migrations/0151_comment_content_type.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.18 on 2023-02-20 21:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("judge", "0150_alter_profile_timezone"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="content_type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="comment", + name="object_id", + field=models.PositiveIntegerField(null=True), + preserve_default=False, + ), + migrations.AlterField( + model_name="solution", + name="problem", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="solution", + to="judge.problem", + verbose_name="associated problem", + ), + ), + migrations.AddIndex( + model_name="comment", + index=models.Index( + fields=["content_type", "object_id"], + name="judge_comme_content_2dce05_idx", + ), + ), + ] diff --git a/judge/migrations/0152_migrate_comments.py b/judge/migrations/0152_migrate_comments.py new file mode 100644 index 0000000..bcf6531 --- /dev/null +++ b/judge/migrations/0152_migrate_comments.py @@ -0,0 +1,54 @@ +from django.db import migrations, models +import django.db.models.deletion +from django.core.exceptions import ObjectDoesNotExist + + +def migrate_comments(apps, schema_editor): + Comment = apps.get_model("judge", "Comment") + Problem = apps.get_model("judge", "Problem") + Solution = apps.get_model("judge", "Solution") + BlogPost = apps.get_model("judge", "BlogPost") + Contest = apps.get_model("judge", "Contest") + + for comment in Comment.objects.all(): + page = comment.page + try: + if page.startswith("p:"): + code = page[2:] + comment.linked_object = Problem.objects.get(code=code) + elif page.startswith("s:"): + code = page[2:] + comment.linked_object = Solution.objects.get(problem__code=code) + elif page.startswith("c:"): + key = page[2:] + comment.linked_object = Contest.objects.get(key=key) + elif page.startswith("b:"): + blog_id = page[2:] + comment.linked_object = BlogPost.objects.get(id=blog_id) + comment.save() + except ObjectDoesNotExist: + comment.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("judge", "0151_comment_content_type"), + ] + + operations = [ + migrations.RunPython(migrate_comments, migrations.RunPython.noop, atomic=True), + migrations.AlterField( + model_name="comment", + name="content_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + migrations.AlterField( + model_name="comment", + name="object_id", + field=models.PositiveIntegerField(), + ), + ] diff --git a/judge/models/bookmark.py b/judge/models/bookmark.py index 0fb353e..61afd0c 100644 --- a/judge/models/bookmark.py +++ b/judge/models/bookmark.py @@ -3,10 +3,7 @@ from django.db.models import CASCADE from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ObjectDoesNotExist -from judge.models import Profile -from judge.models.contest import Contest -from judge.models.interface import BlogPost -from judge.models.problem import Problem, Solution +from judge.models.profile import Profile __all__ = ["BookMark"] @@ -26,6 +23,10 @@ class BookMark(models.Model): return False def page_object(self): + from judge.models.contest import Contest + from judge.models.interface import BlogPost + from judge.models.problem import Problem, Solution + try: page = self.page if page.startswith("p:"): diff --git a/judge/models/comment.py b/judge/models/comment.py index 9486761..24fc007 100644 --- a/judge/models/comment.py +++ b/judge/models/comment.py @@ -9,6 +9,8 @@ from django.db.models import CASCADE from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from mptt.fields import TreeForeignKey from mptt.models import MPTTModel from reversion.models import Version @@ -44,6 +46,9 @@ class VersionRelation(GenericRelation): class Comment(MPTTModel): author = models.ForeignKey(Profile, verbose_name=_("commenter"), on_delete=CASCADE) time = models.DateTimeField(verbose_name=_("posted time"), auto_now_add=True) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + linked_object = GenericForeignKey("content_type", "object_id") page = models.CharField( max_length=30, verbose_name=_("associated page"), @@ -66,6 +71,9 @@ class Comment(MPTTModel): class Meta: verbose_name = _("comment") verbose_name_plural = _("comments") + indexes = [ + models.Index(fields=["content_type", "object_id"]), + ] class MPTTMeta: order_insertion_by = ["-time"] @@ -82,13 +90,9 @@ class Comment(MPTTModel): if organization: queryset = queryset.filter(author__in=organization.members.all()) - problem_access = CacheDict( - lambda code: Problem.objects.get(code=code).is_accessible_by(user) - ) - contest_access = CacheDict( - lambda key: Contest.objects.get(key=key).is_accessible_by(user) - ) - blog_access = CacheDict(lambda id: BlogPost.objects.get(id=id).can_see(user)) + problem_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)) if n == -1: n = len(queryset) @@ -102,112 +106,53 @@ class Comment(MPTTModel): if not slice: break for comment in slice: - if comment.page.startswith("p:") or comment.page.startswith("s:"): - try: - if problem_access[comment.page[2:]]: - output.append(comment) - except Problem.DoesNotExist: - pass - elif comment.page.startswith("c:"): - try: - if contest_access[comment.page[2:]]: - output.append(comment) - except Contest.DoesNotExist: - pass - elif comment.page.startswith("b:"): - try: - if blog_access[comment.page[2:]]: - output.append(comment) - except BlogPost.DoesNotExist: - pass - else: - output.append(comment) + if isinstance(comment.linked_object, Problem): + if problem_access[comment.linked_object]: + output.append(comment) + elif isinstance(comment.linked_object, Contest): + if contest_access[comment.linked_object]: + output.append(comment) + elif isinstance(comment.linked_object, BlogPost): + if blog_access[comment.linked_object]: + output.append(comment) + elif isinstance(comment.linked_object, Solution): + if problem_access[comment.linked_object.problem]: + output.append(comment) if len(output) >= n: return output return output @cached_property - def link(self): - try: - link = None - if self.page.startswith("p:"): - link = reverse("problem_detail", args=(self.page[2:],)) - elif self.page.startswith("c:"): - link = reverse("contest_view", args=(self.page[2:],)) - elif self.page.startswith("b:"): - key = "blog_slug:%s" % self.page[2:] - slug = cache.get(key) - if slug is None: - try: - slug = BlogPost.objects.get(id=self.page[2:]).slug - except ObjectDoesNotExist: - slug = "" - cache.set(key, slug, 3600) - link = reverse("blog_post", args=(self.page[2:], slug)) - elif self.page.startswith("s:"): - link = reverse("problem_editorial", args=(self.page[2:],)) - except Exception: - link = "invalid" - return link - - @classmethod - def get_page_title(cls, page): - try: - if page.startswith("p:"): - return Problem.objects.values_list("name", flat=True).get(code=page[2:]) - elif page.startswith("c:"): - return Contest.objects.values_list("name", flat=True).get(key=page[2:]) - elif page.startswith("b:"): - return BlogPost.objects.values_list("title", flat=True).get(id=page[2:]) - elif page.startswith("s:"): - return _("Editorial for %s") % Problem.objects.values_list( - "name", flat=True - ).get(code=page[2:]) - return "" - except ObjectDoesNotExist: - return "" + def page_title(self): + if isinstance(self.linked_object, Problem): + return self.linked_object.name + elif isinstance(self.linked_object, Contest): + return self.linked_object.name + elif isinstance(self.linked_object, Solution): + return _("Editorial for ") + self.linked_object.problem.name + elif isinstance(self.linked_object, BlogPost): + return self.linked_object.title @cached_property - def page_title(self): - return self.get_page_title(self.page) + def link(self): + if isinstance(self.linked_object, Problem): + return reverse("problem_detail", args=(self.linked_object.code,)) + elif isinstance(self.linked_object, Contest): + return reverse("contest_view", args=(self.linked_object.key,)) + elif isinstance(self.linked_object, Solution): + return reverse("problem_editorial", args=(self.linked_object.problem.code,)) + elif isinstance(self.linked_object, BlogPost): + return reverse( + "blog_post", + args=( + self.object_id, + self.linked_object.slug, + ), + ) def get_absolute_url(self): return "%s#comment-%d" % (self.link, self.id) - @cached_property - def page_object(self): - try: - page = self.page - if page.startswith("p:"): - return Problem.objects.get(code=page[2:]) - elif page.startswith("c:"): - return Contest.objects.get(key=page[2:]) - elif page.startswith("b:"): - return BlogPost.objects.get(id=page[2:]) - elif page.startswith("s:"): - return Solution.objects.get(problem__code=page[2:]) - return None - except ObjectDoesNotExist: - return None - - def __str__(self): - return "%(page)s by %(user)s" % { - "page": self.page, - "user": self.author.user.username, - } - - # Only use this when queried with - # .prefetch_related(Prefetch('votes', queryset=CommentVote.objects.filter(voter_id=profile_id))) - # It's rather stupid to put a query specific property on the model, but the alternative requires - # digging Django internals, and could not be guaranteed to work forever. - # Hence it is left here for when the alternative breaks. - # @property - # def vote_score(self): - # queryset = self.votes.all() - # if not queryset: - # return 0 - # return queryset[0].score - class CommentVote(models.Model): voter = models.ForeignKey(Profile, related_name="voted_comments", on_delete=CASCADE) diff --git a/judge/models/contest.py b/judge/models/contest.py index 994e3ce..678d7c0 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -6,6 +6,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext, gettext_lazy as _ +from django.contrib.contenttypes.fields import GenericRelation from jsonfield import JSONField from lupa import LuaRuntime from moss import ( @@ -297,6 +298,7 @@ class Contest(models.Model): validators=[MinValueValidator(0), MaxValueValidator(10)], help_text=_("Number of digits to round points to."), ) + comments = GenericRelation("Comment") @cached_property def format_class(self): diff --git a/judge/models/interface.py b/judge/models/interface.py index 24b05de..880b76b 100644 --- a/judge/models/interface.py +++ b/judge/models/interface.py @@ -5,10 +5,14 @@ from django.db import models from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from django.utils.functional import cached_property +from django.contrib.contenttypes.fields import GenericRelation from mptt.fields import TreeForeignKey from mptt.models import MPTTModel from judge.models.profile import Organization, Profile +from judge.models.pagevote import PageVote +from judge.models.bookmark import BookMark __all__ = ["MiscConfig", "validate_regex", "NavigationBar", "BlogPost"] @@ -91,6 +95,7 @@ class BlogPost(models.Model): is_organization_private = models.BooleanField( verbose_name=_("private to organizations"), default=False ) + comments = GenericRelation("Comment") def __str__(self): return self.title @@ -125,6 +130,18 @@ class BlogPost(models.Model): and self.authors.filter(id=user.profile.id).exists() ) + @cached_property + def pagevote(self): + page = "b:%s" % self.id + pagevote, _ = PageVote.objects.get_or_create(page=page) + return pagevote + + @cached_property + def bookmark(self): + page = "b:%s" % self.id + bookmark, _ = BookMark.objects.get_or_create(page=page) + return bookmark + 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 4923420..16f1c6c 100644 --- a/judge/models/pagevote.py +++ b/judge/models/pagevote.py @@ -2,7 +2,7 @@ from django.db import models from django.db.models import CASCADE from django.utils.translation import gettext_lazy as _ -from judge.models import Profile +from judge.models.profile import Profile __all__ = ["PageVote", "PageVoteVoter"] diff --git a/judge/models/problem.py b/judge/models/problem.py index 2b92832..b8e18da 100644 --- a/judge/models/problem.py +++ b/judge/models/problem.py @@ -13,6 +13,8 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from judge.fulltext import SearchQuerySet +from judge.models.pagevote import PageVote +from judge.models.bookmark import BookMark from judge.models.profile import Organization, Profile from judge.models.runtime import Language from judge.user_translations import gettext as user_gettext @@ -268,6 +270,7 @@ class Problem(models.Model): objects = TranslatedProblemQuerySet.as_manager() tickets = GenericRelation("Ticket") + comments = GenericRelation("Comment") organizations = models.ManyToManyField( Organization, @@ -444,6 +447,18 @@ class Problem(models.Model): def usable_common_names(self): return set(self.usable_languages.values_list("common_name", flat=True)) + @cached_property + def pagevote(self): + page = "p:%s" % self.code + pagevote, _ = PageVote.objects.get_or_create(page=page) + return pagevote + + @cached_property + def bookmark(self): + page = "p:%s" % self.code + bookmark, _ = BookMark.objects.get_or_create(page=page) + return bookmark + @property def usable_languages(self): return self.allowed_languages.filter( @@ -644,7 +659,7 @@ class LanguageTemplate(models.Model): class Solution(models.Model): problem = models.OneToOneField( Problem, - on_delete=SET_NULL, + on_delete=CASCADE, verbose_name=_("associated problem"), null=True, blank=True, @@ -654,6 +669,7 @@ class Solution(models.Model): publish_on = models.DateTimeField(verbose_name=_("publish date")) authors = models.ManyToManyField(Profile, verbose_name=_("authors"), blank=True) content = models.TextField(verbose_name=_("editorial content")) + comments = GenericRelation("Comment") def get_absolute_url(self): problem = self.problem diff --git a/judge/views/blog.py b/judge/views/blog.py index 66e55a9..346466c 100644 --- a/judge/views/blog.py +++ b/judge/views/blog.py @@ -7,8 +7,8 @@ from django.utils.translation import ugettext as _ from django.views.generic import ListView from judge.comments import CommentedDetailView -from judge.views.pagevote import PageVoteDetailView, PageVoteListView -from judge.views.bookmark import BookMarkDetailView, BookMarkListView +from judge.views.pagevote import PageVoteDetailView +from judge.views.bookmark import BookMarkDetailView from judge.models import ( BlogPost, Comment, @@ -26,28 +26,16 @@ from judge.utils.diggpaginator import DiggPaginator from judge.utils.problems import user_completed_ids from judge.utils.tickets import filter_visible_tickets from judge.utils.views import TitleMixin +from judge.views.feed import FeedView # General view for all content list on home feed -class FeedView(ListView): +class HomeFeedView(FeedView): template_name = "blog/list.html" title = None - def get_paginator( - self, queryset, per_page, orphans=0, allow_empty_first_page=True, **kwargs - ): - return DiggPaginator( - queryset, - per_page, - body=6, - padding=2, - orphans=orphans, - allow_empty_first_page=allow_empty_first_page, - **kwargs - ) - def get_context_data(self, **kwargs): - context = super(FeedView, self).get_context_data(**kwargs) + context = super(HomeFeedView, self).get_context_data(**kwargs) context["has_clarifications"] = False if self.request.user.is_authenticated: participation = self.request.profile.current_contest @@ -60,17 +48,7 @@ class FeedView(ListView): if participation.contest.is_editable_by(self.request.user): context["can_edit_contest"] = True - context["page_titles"] = CacheDict(lambda page: Comment.get_page_title(page)) - - context["user_count"] = lazy(Profile.objects.count, int, int) - context["problem_count"] = lazy( - Problem.objects.filter(is_public=True).count, int, int - ) - context["submission_count"] = lazy(Submission.objects.count, int, int) - context["language_count"] = lazy(Language.objects.count, int, int) - now = timezone.now() - visible_contests = ( Contest.get_visible_contests(self.request.user, show_own_contests_only=True) .filter(is_visible=True) @@ -102,10 +80,12 @@ class FeedView(ListView): return context -class PostList(FeedView, PageVoteListView, BookMarkListView): +class PostList(HomeFeedView): model = BlogPost - paginate_by = 10 + paginate_by = 4 context_object_name = "posts" + feed_content_template_name = "blog/content.html" + url_name = "blog_post_list" def get_queryset(self): queryset = ( @@ -121,13 +101,23 @@ class PostList(FeedView, PageVoteListView, BookMarkListView): queryset = queryset.filter(filter) return queryset + def get_feed_context(self, object_list): + post_comment_counts = { + int(page[2:]): count + for page, count in Comment.objects.filter( + page__in=["b:%d" % post.id for post in object_list], hidden=False + ) + .values_list("page") + .annotate(count=Count("page")) + .order_by() + } + return {"post_comment_counts": post_comment_counts} + def get_context_data(self, **kwargs): context = super(PostList, self).get_context_data(**kwargs) context["title"] = ( self.title or _("Page %d of Posts") % context["page_obj"].number ) - context["first_page_href"] = reverse("home") - context["page_prefix"] = reverse("blog_post_list") context["page_type"] = "blog" context["post_comment_counts"] = { int(page[2:]): count @@ -138,18 +128,17 @@ class PostList(FeedView, PageVoteListView, BookMarkListView): .annotate(count=Count("page")) .order_by() } - context = self.add_pagevote_context_data(context) - context = self.add_bookmark_context_data(context) return context def get_comment_page(self, post): return "b:%s" % post.id -class TicketFeed(FeedView): +class TicketFeed(HomeFeedView): model = Ticket context_object_name = "tickets" - paginate_by = 30 + paginate_by = 8 + feed_content_template_name = "ticket/feed.html" def get_queryset(self, is_own=True): profile = self.request.profile @@ -181,30 +170,25 @@ class TicketFeed(FeedView): def get_context_data(self, **kwargs): context = super(TicketFeed, self).get_context_data(**kwargs) context["page_type"] = "ticket" - context["first_page_href"] = self.request.path - context["page_prefix"] = "?page=" context["title"] = _("Ticket feed") - return context -class CommentFeed(FeedView): +class CommentFeed(HomeFeedView): model = Comment context_object_name = "comments" - paginate_by = 50 + paginate_by = 8 + feed_content_template_name = "comments/feed.html" def get_queryset(self): return Comment.most_recent( - self.request.user, 1000, organization=self.request.organization + self.request.user, 100, organization=self.request.organization ) def get_context_data(self, **kwargs): context = super(CommentFeed, self).get_context_data(**kwargs) - context["page_type"] = "comment" - context["first_page_href"] = self.request.path - context["page_prefix"] = "?page=" context["title"] = _("Comment feed") - + context["page_type"] = "comment" return context diff --git a/judge/views/bookmark.py b/judge/views/bookmark.py index 9c3caeb..7efd5c4 100644 --- a/judge/views/bookmark.py +++ b/judge/views/bookmark.py @@ -74,13 +74,3 @@ class BookMarkDetailView(TemplateResponseMixin, SingleObjectMixin, View): queryset = BookMark.objects.get_or_create(page=self.get_comment_page()) context["bookmark"] = queryset[0] return context - - -class BookMarkListView: - def add_bookmark_context_data(self, context, obj_list="object_list"): - for item in context[obj_list]: - bookmark, _ = BookMark.objects.get_or_create( - page=self.get_comment_page(item) - ) - setattr(item, "bookmark", bookmark) - return context diff --git a/judge/views/feed.py b/judge/views/feed.py new file mode 100644 index 0000000..89bb882 --- /dev/null +++ b/judge/views/feed.py @@ -0,0 +1,34 @@ +from django.views.generic import ListView +from django.shortcuts import render +from django.urls import reverse + +from judge.utils.infinite_paginator import InfinitePaginationMixin + + +class FeedView(InfinitePaginationMixin, ListView): + def get_feed_context(selfl, object_list): + return {} + + def get(self, request, *args, **kwargs): + only_content = request.GET.get("only_content", None) + if only_content and self.feed_content_template_name: + queryset = self.get_queryset() + paginator, page, object_list, _ = self.paginate_queryset( + queryset, self.paginate_by + ) + context = { + self.context_object_name: object_list, + "has_next_page": page.has_next(), + } + context.update(self.get_feed_context(object_list)) + return render(request, self.feed_content_template_name, context) + + return super(FeedView, self).get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + try: + context["feed_content_url"] = reverse(self.url_name) + except Exception as e: + context["feed_content_url"] = self.request.path + return context diff --git a/judge/views/notification.py b/judge/views/notification.py index ea8d55f..b0a875f 100644 --- a/judge/views/notification.py +++ b/judge/views/notification.py @@ -42,7 +42,6 @@ class NotificationList(ListView): context["unseen_count"] = self.unseen_cnt context["title"] = _("Notifications (%d unseen)" % context["unseen_count"]) context["has_notifications"] = self.queryset.exists() - context["page_titles"] = CacheDict(lambda page: Comment.get_page_title(page)) return context def get(self, request, *args, **kwargs): diff --git a/judge/views/organization.py b/judge/views/organization.py index b633a10..75fc920 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -72,9 +72,7 @@ from judge.utils.problems import user_attempted_ids, user_completed_ids from judge.views.problem import ProblemList from judge.views.contests import ContestList from judge.views.submission import AllSubmissions, SubmissionsListBase -from judge.views.pagevote import PageVoteListView -from judge.views.bookmark import BookMarkListView - +from judge.views.feed import FeedView __all__ = [ "OrganizationList", @@ -194,7 +192,7 @@ class MemberOrganizationMixin(OrganizationMixin): ) -class OrganizationHomeViewContext: +class OrganizationHomeView(OrganizationMixin): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if not hasattr(self, "organization"): @@ -221,28 +219,6 @@ class OrganizationHomeViewContext: return context -class OrganizationDetailView( - OrganizationMixin, OrganizationHomeViewContext, DetailView -): - context_object_name = "organization" - model = Organization - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - if self.object.slug != kwargs["slug"]: - return HttpResponsePermanentRedirect( - request.get_full_path().replace(kwargs["slug"], self.object.slug) - ) - context = self.get_context_data(object=self.object) - return self.render_to_response(context) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["can_edit"] = self.can_edit_organization() - context["is_member"] = self.is_member() - return context - - class OrganizationList(TitleMixin, ListView, OrganizationBase): model = Organization context_object_name = "organizations" @@ -272,51 +248,50 @@ class OrganizationList(TitleMixin, ListView, OrganizationBase): return context -class OrganizationHome(OrganizationDetailView, PageVoteListView, BookMarkListView): +class OrganizationHome(OrganizationHomeView, FeedView): template_name = "organization/home.html" - pagevote_object_name = "posts" + paginate_by = 4 + context_object_name = "posts" + feed_content_template_name = "blog/content.html" - def get_posts_and_page_obj(self): - posts = ( + def get_queryset(self): + return ( BlogPost.objects.filter( visible=True, publish_on__lte=timezone.now(), is_organization_private=True, - organizations=self.object, + organizations=self.organization, ) .order_by("-sticky", "-publish_on") .prefetch_related("authors__user", "organizations") ) - paginator = Paginator(posts, 10) - page_number = self.request.GET.get("page", 1) - posts = paginator.get_page(page_number) - return posts, paginator.page(page_number) def get_comment_page(self, post): return "b:%s" % post.id + def get_feed_context(self, object_list): + post_comment_counts = { + int(page[2:]): count + for page, count in Comment.objects.filter( + page__in=["b:%d" % post.id for post in object_list], hidden=False + ) + .values_list("page") + .annotate(count=Count("page")) + .order_by() + } + return {"post_comment_counts": post_comment_counts} + def get_context_data(self, **kwargs): context = super(OrganizationHome, self).get_context_data(**kwargs) - context["title"] = self.object.name + context["title"] = self.organization.name http = "http" if settings.DMOJ_SSL == 0 else "https" context["organization_subdomain"] = ( http + "://" - + self.object.slug + + self.organization.slug + "." + get_current_site(self.request).domain ) - context["posts"], context["page_obj"] = self.get_posts_and_page_obj() - context = self.add_pagevote_context_data(context, "posts") - context = self.add_bookmark_context_data(context, "posts") - - # Hack: This allows page_obj to have page_range for non-ListView class - setattr( - context["page_obj"], "page_range", context["posts"].paginator.page_range - ) - context["first_page_href"] = self.request.path - context["page_prefix"] = "?page=" - context["post_comment_counts"] = { int(page[2:]): count for page, count in Comment.objects.filter( @@ -331,7 +306,9 @@ class OrganizationHome(OrganizationDetailView, PageVoteListView, BookMarkListVie visible_contests = ( Contest.get_visible_contests(self.request.user) .filter( - is_visible=True, is_organization_private=True, organizations=self.object + is_visible=True, + is_organization_private=True, + organizations=self.organization, ) .order_by("start_time") ) @@ -344,11 +321,27 @@ class OrganizationHome(OrganizationDetailView, PageVoteListView, BookMarkListVie return context -class OrganizationUsers(QueryStringSortMixin, OrganizationDetailView): +class OrganizationUsers(QueryStringSortMixin, OrganizationMixin, FeedView): template_name = "organization/users.html" all_sorts = frozenset(("points", "problem_count", "rating", "performance_points")) default_desc = all_sorts default_sort = "-performance_points" + context_object_name = "users" + + def get_queryset(self): + return ranker( + self.organization.members.filter(is_unlisted=False) + .order_by(self.order, "id") + .select_related("user") + .only( + "display_rank", + "user__username", + "points", + "rating", + "performance_points", + "problem_count", + ) + ) def dispatch(self, request, *args, **kwargs): res = super(OrganizationUsers, self).dispatch(request, *args, **kwargs) @@ -363,26 +356,13 @@ class OrganizationUsers(QueryStringSortMixin, OrganizationDetailView): def get_context_data(self, **kwargs): context = super(OrganizationUsers, self).get_context_data(**kwargs) - context["title"] = _("%s Members") % self.object.name + context["title"] = _("%s Members") % self.organization.name context["partial"] = True context["kick_url"] = reverse( - "organization_user_kick", args=[self.object.id, self.object.slug] + "organization_user_kick", + args=[self.organization.id, self.organization.slug], ) - context["users"] = ranker( - self.get_object() - .members.filter(is_unlisted=False) - .order_by(self.order, "id") - .select_related("user") - .only( - "display_rank", - "user__username", - "points", - "rating", - "performance_points", - "problem_count", - ) - ) context["first_page_href"] = "." context["page_type"] = "users" context.update(self.get_sort_context()) @@ -421,8 +401,7 @@ class OrganizationProblems(LoginRequiredMixin, MemberOrganizationMixin, ProblemL class OrganizationContestMixin( LoginRequiredMixin, TitleMixin, - OrganizationMixin, - OrganizationHomeViewContext, + OrganizationHomeView, ): model = Contest @@ -613,8 +592,7 @@ class RequestJoinOrganization(LoginRequiredMixin, SingleObjectMixin, FormView): class OrganizationRequestDetail( LoginRequiredMixin, TitleMixin, - OrganizationMixin, - OrganizationHomeViewContext, + OrganizationHomeView, DetailView, ): model = OrganizationRequest @@ -639,7 +617,8 @@ OrganizationRequestFormSet = modelformset_factory( class OrganizationRequestBaseView( - OrganizationDetailView, + DetailView, + OrganizationHomeView, TitleMixin, LoginRequiredMixin, SingleObjectTemplateResponseMixin, @@ -760,7 +739,7 @@ class AddOrganizationMember( LoginRequiredMixin, TitleMixin, AdminOrganizationMixin, - OrganizationDetailView, + OrganizationHomeView, UpdateView, ): template_name = "organization/add-member.html" @@ -822,7 +801,7 @@ class EditOrganization( LoginRequiredMixin, TitleMixin, AdminOrganizationMixin, - OrganizationDetailView, + OrganizationHomeView, UpdateView, ): template_name = "organization/edit.html" @@ -1023,7 +1002,7 @@ class EditOrganizationContest( class AddOrganizationBlog( LoginRequiredMixin, TitleMixin, - OrganizationHomeViewContext, + OrganizationHomeView, MemberOrganizationMixin, CreateView, ): @@ -1074,7 +1053,7 @@ class AddOrganizationBlog( class EditOrganizationBlog( LoginRequiredMixin, TitleMixin, - OrganizationHomeViewContext, + OrganizationHomeView, MemberOrganizationMixin, UpdateView, ): @@ -1168,7 +1147,7 @@ class PendingBlogs( LoginRequiredMixin, TitleMixin, MemberOrganizationMixin, - OrganizationHomeViewContext, + OrganizationHomeView, ListView, ): model = BlogPost diff --git a/judge/views/pagevote.py b/judge/views/pagevote.py index e01f664..e4c5006 100644 --- a/judge/views/pagevote.py +++ b/judge/views/pagevote.py @@ -104,13 +104,3 @@ class PageVoteDetailView(TemplateResponseMixin, SingleObjectMixin, View): queryset = PageVote.objects.get_or_create(page=self.get_comment_page()) context["pagevote"] = queryset[0] return context - - -class PageVoteListView: - def add_pagevote_context_data(self, context, obj_list="object_list"): - for item in context[obj_list]: - pagevote, _ = PageVote.objects.get_or_create( - page=self.get_comment_page(item) - ) - setattr(item, "pagevote", pagevote) - return context diff --git a/judge/views/problem.py b/judge/views/problem.py index 4ba7ef9..566d13d 100644 --- a/judge/views/problem.py +++ b/judge/views/problem.py @@ -87,8 +87,9 @@ from judge.utils.views import ( generic_message, ) from judge.ml.collab_filter import CollabFilter -from judge.views.pagevote import PageVoteDetailView, PageVoteListView -from judge.views.bookmark import BookMarkDetailView, BookMarkListView +from judge.views.pagevote import PageVoteDetailView +from judge.views.bookmark import BookMarkDetailView +from judge.views.feed import FeedView def get_contest_problem(problem, profile): @@ -197,31 +198,34 @@ class ProblemSolution( template_name = "problem/editorial.html" def get_title(self): - return _("Editorial for {0}").format(self.object.name) + return _("Editorial for {0}").format(self.problem.name) def get_content_title(self): return format_html( _('Editorial for {0}'), - self.object.name, - reverse("problem_detail", args=[self.object.code]), + self.problem.name, + reverse("problem_detail", args=[self.problem.code]), ) + def get_object(self): + self.problem = super().get_object() + solution = get_object_or_404(Solution, problem=self.problem) + return solution + def get_context_data(self, **kwargs): context = super(ProblemSolution, self).get_context_data(**kwargs) - - solution = get_object_or_404(Solution, problem=self.object) - + solution = self.get_object() if ( not solution.is_public or solution.publish_on > timezone.now() ) and not self.request.user.has_perm("judge.see_private_solution"): raise Http404() context["solution"] = solution - context["has_solved_problem"] = self.object.id in self.get_completed_problems() + context["has_solved_problem"] = self.problem.id in self.get_completed_problems() return context def get_comment_page(self): - return "s:" + self.object.code + return "s:" + self.problem.code class ProblemRaw( @@ -830,11 +834,12 @@ class ProblemList(QueryStringSortMixin, TitleMixin, SolvedProblemMixin, ListView return HttpResponseRedirect(request.get_full_path()) -class ProblemFeed(ProblemList, PageVoteListView, BookMarkListView): +class ProblemFeed(ProblemList, FeedView): model = Problem context_object_name = "problems" template_name = "problem/feed.html" - paginate_by = 20 + feed_content_template_name = "problem/feed/problems.html" + paginate_by = 4 title = _("Problem feed") feed_type = None @@ -843,19 +848,6 @@ class ProblemFeed(ProblemList, PageVoteListView, BookMarkListView): return request.session.get(key, key == "hide_solved") return request.GET.get(key, None) == "1" - def get_paginator( - self, queryset, per_page, orphans=0, allow_empty_first_page=True, **kwargs - ): - return DiggPaginator( - queryset, - per_page, - body=6, - padding=2, - orphans=orphans, - allow_empty_first_page=allow_empty_first_page, - **kwargs - ) - def get_comment_page(self, problem): return "p:%s" % problem.code @@ -962,8 +954,6 @@ class ProblemFeed(ProblemList, PageVoteListView, BookMarkListView): context["feed_type"] = self.feed_type context["has_show_editorial_option"] = False context["has_have_editorial_option"] = False - context = self.add_pagevote_context_data(context) - context = self.add_bookmark_context_data(context) return context diff --git a/resources/pagedown_math.js b/resources/pagedown_math.js index 4ebf484..febfd8d 100644 --- a/resources/pagedown_math.js +++ b/resources/pagedown_math.js @@ -12,6 +12,6 @@ function mathjax_pagedown($) { window.mathjax_pagedown = mathjax_pagedown; -$(window).load(function () { +$(function () { (mathjax_pagedown)('$' in window ? $ : django.jQuery); }); \ No newline at end of file diff --git a/templates/actionbar/list.html b/templates/actionbar/list.html index 1f5ae66..0bb2ef1 100644 --- a/templates/actionbar/list.html +++ b/templates/actionbar/list.html @@ -45,7 +45,7 @@ + {{"share-url=" + share_url if share_url else ""}} onclick="javascript:actionbar_share(this, event)"> {{_("Share")}} diff --git a/templates/actionbar/media-js.html b/templates/actionbar/media-js.html index e775fa3..99ef8c5 100644 --- a/templates/actionbar/media-js.html +++ b/templates/actionbar/media-js.html @@ -107,15 +107,15 @@ } } }; - $(".actionbar-share").click(function(e) { + window.actionbar_share = function(element, e) { e.stopPropagation(); - link = $(this).attr("share-url") || window.location.href; + link = $(element).attr("share-url") || window.location.href; navigator.clipboard .writeText(link) .then(() => { - showTooltip(this, "Copied link", 'n'); + showTooltip(element, "Copied link", 'n'); }); - }); + }; $('.actionbar-comment').on('click', function() { $('#comment-section').show(); diff --git a/templates/blog/content.html b/templates/blog/content.html index 2b20b2a..a20512d 100644 --- a/templates/blog/content.html +++ b/templates/blog/content.html @@ -1,55 +1,58 @@ -
-
- - {% with authors=post.authors.all() %} - {%- if authors -%} - - - {%- endif -%} - {% endwith %} - • - {{ relative_time(post.publish_on, abs=_('on {time}'), rel=_('{time}')) -}} - {%- if post.sticky %} • - {% endif -%} - {% if post.is_organization_private and show_organization_private_icon %} - • - - {% for org in post.organizations.all() %} - - - {{ org.name }} - - - {% endfor %} +{% for post in posts%} +
+
+ + {% with authors=post.authors.all() %} + {%- if authors -%} + + + {%- endif -%} + {% endwith %} + • + {{ relative_time(post.publish_on, abs=_('on {time}'), rel=_('{time}')) -}} + {%- if post.sticky %} • + {% endif -%} + {% if post.is_organization_private and show_organization_private_icon %} + • + + {% for org in post.organizations.all() %} + + + {{ org.name }} + + + {% endfor %} + + {% endif %} + + + + + + {{- post_comment_counts[post.id] or 0 -}} + + - {% endif %} - - - - - - {{- post_comment_counts[post.id] or 0 -}} - - - -
-

- {{ post.title }} -

-
-
- {% cache 86400 'post_summary' post.id %} - {{ post.summary|default(post.content, true)|markdown(lazy_load=True)|reference|str|safe }} - {% endcache %}
-
{{_("...More")}}
-
-
- {% set pagevote = post.pagevote %} - {% set bookmark = post.bookmark %} - {% set hide_actionbar_comment = True %} - {% set include_hr = False %} - {% set share_url = request.build_absolute_uri(post.get_absolute_url()) %} - {% include "actionbar/list.html" %} -
-
\ No newline at end of file +

+ {{ post.title }} +

+
+
+ {% cache 86400 'post_summary' post.id %} + {{ post.summary|default(post.content, true)|markdown(lazy_load=True)|reference|str|safe }} + {% endcache %} +
+
{{_("...More")}}
+
+
+ {% set pagevote = post.pagevote %} + {% set bookmark = post.bookmark %} + {% set hide_actionbar_comment = True %} + {% set include_hr = False %} + {% set share_url = request.build_absolute_uri(post.get_absolute_url()) %} + {% include "actionbar/list.html" %} +
+
+{% endfor %} +{% include "feed/has_next.html" %} \ No newline at end of file diff --git a/templates/blog/list.html b/templates/blog/list.html index 270d532..0535def 100644 --- a/templates/blog/list.html +++ b/templates/blog/list.html @@ -20,6 +20,7 @@ {% block three_col_js %} {% include "actionbar/media-js.html" %} + {% include "feed/feed_js.html" %} \ No newline at end of file diff --git a/templates/feed/has_next.html b/templates/feed/has_next.html new file mode 100644 index 0000000..18415b5 --- /dev/null +++ b/templates/feed/has_next.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/notification/list.html b/templates/notification/list.html index 175c696..cd50e32 100644 --- a/templates/notification/list.html +++ b/templates/notification/list.html @@ -29,7 +29,7 @@ {% if notification.comment %} - {{ page_titles[notification.comment.page] }} + {{ notification.comment.page_title }} {% else %} {% autoescape off %} {{notification.html_link}} diff --git a/templates/organization/home.html b/templates/organization/home.html index bebfc73..80ad6dc 100644 --- a/templates/organization/home.html +++ b/templates/organization/home.html @@ -4,6 +4,7 @@ {% block org_js %} {% include "actionbar/media-js.html" %} + {% include "feed/feed_js.html" %} {% endblock %} {% block middle_title %} @@ -40,12 +41,7 @@ {% block middle_content %} {% block before_posts %}{% endblock %} {% if is_member or can_edit %} - {% for post in posts %} - {% include "blog/content.html" %} - {% endfor %} - {% if posts.paginator.num_pages > 1 %} -
{% include "list-pages.html" %}
- {% endif %} + {% include "blog/content.html" %} {% else %}