Infinite scrolling and comment migration
This commit is contained in:
parent
4b558bd656
commit
799ff5f8f8
33 changed files with 639 additions and 556 deletions
19
dmoj/urls.py
19
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<problem>[^/]+)",
|
||||
|
@ -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<id>\d+)-(?P<slug>.*)$", blog.PostView.as_view(), name="blog_post"),
|
||||
url(r"^license/(?P<key>[-\w.]+)$", license.LicenseDetail.as_view(), name="license"),
|
||||
url(
|
||||
|
|
|
@ -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('<a href="{0}">{1}</a>', 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:
|
||||
|
|
|
@ -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 = (
|
||||
|
|
50
judge/migrations/0151_comment_content_type.py
Normal file
50
judge/migrations/0151_comment_content_type.py
Normal file
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
54
judge/migrations/0152_migrate_comments.py
Normal file
54
judge/migrations/0152_migrate_comments.py
Normal file
|
@ -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(),
|
||||
),
|
||||
]
|
|
@ -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:"):
|
||||
|
|
|
@ -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:]]:
|
||||
if isinstance(comment.linked_object, Problem):
|
||||
if problem_access[comment.linked_object]:
|
||||
output.append(comment)
|
||||
except Problem.DoesNotExist:
|
||||
pass
|
||||
elif comment.page.startswith("c:"):
|
||||
try:
|
||||
if contest_access[comment.page[2:]]:
|
||||
elif isinstance(comment.linked_object, Contest):
|
||||
if contest_access[comment.linked_object]:
|
||||
output.append(comment)
|
||||
except Contest.DoesNotExist:
|
||||
pass
|
||||
elif comment.page.startswith("b:"):
|
||||
try:
|
||||
if blog_access[comment.page[2:]]:
|
||||
elif isinstance(comment.linked_object, BlogPost):
|
||||
if blog_access[comment.linked_object]:
|
||||
output.append(comment)
|
||||
except BlogPost.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
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 "<unknown>"
|
||||
except ObjectDoesNotExist:
|
||||
return "<deleted>"
|
||||
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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
34
judge/views/feed.py
Normal file
34
judge/views/feed.py
Normal file
|
@ -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
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <a href="{1}">{0}</a>'),
|
||||
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
|
||||
|
||||
|
|
|
@ -12,6 +12,6 @@ function mathjax_pagedown($) {
|
|||
|
||||
window.mathjax_pagedown = mathjax_pagedown;
|
||||
|
||||
$(window).load(function () {
|
||||
$(function () {
|
||||
(mathjax_pagedown)('$' in window ? $ : django.jQuery);
|
||||
});
|
|
@ -45,7 +45,7 @@
|
|||
</span>
|
||||
<span class="actionbar-block" style="justify-content: flex-end;">
|
||||
<span class="actionbar-button actionbar-share" style="position: relative"
|
||||
{{"share-url=" + share_url if share_url else ""}}>
|
||||
{{"share-url=" + share_url if share_url else ""}} onclick="javascript:actionbar_share(this, event)">
|
||||
<i class=" fa fa-share" style="font-size: large;"></i>
|
||||
<span class="actionbar-text">{{_("Share")}}</span>
|
||||
</span>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<section class="{% if post.sticky %}sticky {% endif %}blog-box">
|
||||
{% for post in posts%}
|
||||
<section class="{% if post.sticky %}sticky {% endif %}blog-box">
|
||||
<div style="margin-bottom: 0.5em">
|
||||
<span class="time">
|
||||
{% with authors=post.authors.all() %}
|
||||
|
@ -52,4 +53,6 @@
|
|||
{% set share_url = request.build_absolute_uri(post.get_absolute_url()) %}
|
||||
{% include "actionbar/list.html" %}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% include "feed/has_next.html" %}
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
{% block three_col_js %}
|
||||
{% include "actionbar/media-js.html" %}
|
||||
{% include "feed/feed_js.html" %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
$('.time-remaining').each(function () {
|
||||
|
@ -50,24 +51,15 @@
|
|||
{% block middle_content %}
|
||||
{% set show_organization_private_icon=True %}
|
||||
{% if page_type == 'blog' %}
|
||||
{% for post in posts %}
|
||||
{% include "blog/content.html" %}
|
||||
{% endfor %}
|
||||
{% elif page_type == 'ticket' %}
|
||||
{% if tickets %}
|
||||
{% for ticket in tickets %}
|
||||
{% include "ticket/feed.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<h3 style="text-align: center">{{_('You have no ticket')}}</h3>
|
||||
{% endif %}
|
||||
{% elif page_type == 'comment' %}
|
||||
{% for comment in comments %}
|
||||
{% include "comments/feed.html" %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if page_obj.num_pages > 1 %}
|
||||
<div style="margin-bottom:10px;margin-top:10px">{% include "list-pages.html" %}</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<div class="blog-box">
|
||||
{% for comment in comments %}
|
||||
<div class="blog-box">
|
||||
<h3 class="problem-feed-name">
|
||||
<a href="{{ comment.link }}#comment-{{ comment.id }}">
|
||||
{{ page_titles[comment.page] }}
|
||||
<a href="{{ comment.get_absolute_url() }}">
|
||||
{{ comment.page_title }}
|
||||
</a>
|
||||
</h3>
|
||||
{% with author=comment.author %}
|
||||
|
@ -16,4 +17,6 @@
|
|||
{{ comment.body|markdown(lazy_load=True)|reference|str|safe }}
|
||||
<div class="show-more"> {{_("...More")}} </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% include "feed/has_next.html" %}
|
30
templates/feed/feed_js.html
Normal file
30
templates/feed/feed_js.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
<script>
|
||||
window.page = {{page_obj.number}};
|
||||
window.has_next_page = {{1 if page_obj.has_next() else 0}};
|
||||
window.loading_page = false;
|
||||
$(function() {
|
||||
$(window).on("scroll", function() {
|
||||
if (window.loading_page || !window.has_next_page) return;
|
||||
var distanceFromBottom = $(document).height() - ($(window).scrollTop() + $(window).height());
|
||||
if (distanceFromBottom < 500) {
|
||||
window.loading_page = true;
|
||||
var params = {
|
||||
"only_content": 1,
|
||||
"page": window.page + 1,
|
||||
};
|
||||
|
||||
$.get("{{feed_content_url}}", params)
|
||||
.done(function(data) {
|
||||
$(".has_next").remove();
|
||||
$(".middle-content").append(data);
|
||||
window.loading_page = false;
|
||||
window.has_next_page = parseInt($(".has_next").attr("value"));
|
||||
window.page++;
|
||||
MathJax.typeset($('.middle-content')[0]);
|
||||
onWindowReady();
|
||||
activateBlogBoxOnClick();
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
1
templates/feed/has_next.html
Normal file
1
templates/feed/has_next.html
Normal file
|
@ -0,0 +1 @@
|
|||
<div class="has_next" style="display: none;" value="{{1 if has_next_page else 0}}"></div>
|
|
@ -29,7 +29,7 @@
|
|||
</td>
|
||||
<td>
|
||||
{% if notification.comment %}
|
||||
<a href="{{ notification.comment.link }}#comment-{{ notification.comment.id }}">{{ page_titles[notification.comment.page] }}</a>
|
||||
<a href="{{ notification.comment.link }}#comment-{{ notification.comment.id }}">{{ notification.comment.page_title }}</a>
|
||||
{% else %}
|
||||
{% autoescape off %}
|
||||
{{notification.html_link}}
|
||||
|
|
|
@ -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 %}
|
||||
<div style="margin-bottom:10px;margin-top:10px">{% include "list-pages.html" %}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="blog-sidebox sidebox">
|
||||
<h3>{{ _('About') }}<i class="fa fa-info-circle"></i></h3>
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
{% block left_sidebar %}
|
||||
{% include "problem/left-sidebar.html" %}
|
||||
{% endblock %}
|
||||
{% block problem_list_js %}
|
||||
{% include "feed/feed_js.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block middle_content %}
|
||||
<div class="problem-feed-option">
|
||||
|
@ -24,127 +27,5 @@
|
|||
<li><a href="{{url('admin:judge_volunteerproblemvote_changelist')}}">{{_('View your votes')}}</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% for problem in problems %}
|
||||
<div class="blog-box">
|
||||
<h3 class="problem-feed-name">
|
||||
<a href="{{ url('problem_detail', problem.code) }}">
|
||||
{{ problem.i18n_name }}
|
||||
</a>
|
||||
{% if problem.id in completed_problem_ids %}
|
||||
<i class="solved-problem-color fa fa-check-circle"></i>
|
||||
{% elif problem.id in attempted_problems %}
|
||||
<i class="attempted-problem-color fa fa-minus-circle"></i>
|
||||
{% else %}
|
||||
<i class="unsolved-problem-color fa fa-minus-circle"></i>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% with authors=problem.authors.all() %}
|
||||
{% if authors %}
|
||||
<div class="problem-feed-info-entry">
|
||||
<i class="fa fa-pencil-square-o fa-fw"></i>
|
||||
<span class="pi-value">{{ link_users(authors) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if show_types %}
|
||||
<div class="problem-feed-types">
|
||||
<i class="fa fa-tag"></i>
|
||||
{% for type in problem.types_list %}
|
||||
<span class="type-tag">{{ type }}</span>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}, *{{problem.points | int}}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="blog-description">
|
||||
<div class='content-description'>
|
||||
{% cache 86400 'problem_html' problem.id MATH_ENGINE LANGUAGE_CODE %}
|
||||
{{ problem.description|markdown(lazy_load=True)|reference|str|safe }}
|
||||
{% endcache %}
|
||||
{% if problem.pdf_description %}
|
||||
<embed src="{{url('problem_pdf_description', problem.code)}}" width="100%" height="500" type="application/pdf"
|
||||
style="margin-top: 0.5em">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if feed_type=='volunteer' and request.user.has_perm('judge.suggest_problem_changes') %}
|
||||
<br>
|
||||
<a href="#" class="view-statement-src">{{ _('View source') }}</a>
|
||||
<pre class="statement-src" style="display: none">{{ problem.description|str }}</pre>
|
||||
<hr>
|
||||
<center>
|
||||
<h3>{{_('Volunteer form')}}</h3>
|
||||
</center>
|
||||
<br>
|
||||
<button class="edit-btn" id="edit-{{problem.id}}" pid="{{problem.id}}" style="float: right">{{_('Edit')}}</button>
|
||||
<form class="volunteer-form" id="form-{{problem.id}}" pid="{{problem.id}}" style="display: none;" method="POST">
|
||||
<input type="submit" class="volunteer-submit-btn" id="submit-{{problem.id}}" pid="{{problem.id}}"
|
||||
pcode="{{problem.code}}" style="float: right" value="{{_('Submit')}}">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
</th>
|
||||
<th>
|
||||
{{_('Value')}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="knowledge_point-{{problem.id}}"><i>{{ _('Knowledge point') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<input id="knowledge_point-{{problem.id}}" type="number" class="point-input" required>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="thinking_point-{{problem.id}}"><i>{{ _('Thinking point') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<input id="thinking_point-{{problem.id}}" type="number" class="point-input" required>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="types"><i>{{ _('Problem types') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<select id="volunteer-types-{{problem.id}}" name="types" multiple>
|
||||
{% for type in problem_types %}
|
||||
<option value="{{ type.id }}" {% if type in problem.types.all() %} selected{% endif %}>
|
||||
{{ type.full_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="feedback"><i>{{ _('Feedback') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<textarea id="feedback-{{problem.id}}" rows="2" style="width: 100%"
|
||||
placeholder="{{_('Any additional note here')}}"></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<center id="thank-{{problem.id}}" style="display: none; margin-top: 3em"></center>
|
||||
{% endif %}
|
||||
<div class="show-more"> {{_("...More")}} </div>
|
||||
</div>
|
||||
<div class="actionbar-box">
|
||||
{% set pagevote = problem.pagevote %}
|
||||
{% set bookmark = problem.bookmark %}
|
||||
{% set hide_actionbar_comment = True %}
|
||||
{% set include_hr = False %}
|
||||
{% set share_url = request.build_absolute_uri(problem.get_absolute_url()) %}
|
||||
{% include "actionbar/list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if page_obj.num_pages > 1 %}
|
||||
<div style="margin-top:10px;">{% include "list-pages.html" %}</div>
|
||||
{% endif %}
|
||||
{% include "problem/feed/problems.html" %}
|
||||
{% endblock %}
|
121
templates/problem/feed/problems.html
Normal file
121
templates/problem/feed/problems.html
Normal file
|
@ -0,0 +1,121 @@
|
|||
{% for problem in problems %}
|
||||
<div class="blog-box">
|
||||
<h3 class="problem-feed-name">
|
||||
<a href="{{ url('problem_detail', problem.code) }}">
|
||||
{{ problem.i18n_name }}
|
||||
</a>
|
||||
{% if problem.id in completed_problem_ids %}
|
||||
<i class="solved-problem-color fa fa-check-circle"></i>
|
||||
{% elif problem.id in attempted_problems %}
|
||||
<i class="attempted-problem-color fa fa-minus-circle"></i>
|
||||
{% else %}
|
||||
<i class="unsolved-problem-color fa fa-minus-circle"></i>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% with authors=problem.authors.all() %}
|
||||
{% if authors %}
|
||||
<div class="problem-feed-info-entry">
|
||||
<i class="fa fa-pencil-square-o fa-fw"></i>
|
||||
<span class="pi-value">{{ link_users(authors) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if show_types %}
|
||||
<div class="problem-feed-types">
|
||||
<i class="fa fa-tag"></i>
|
||||
{% for type in problem.types_list %}
|
||||
<span class="type-tag">{{ type }}</span>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}, *{{problem.points | int}}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="blog-description">
|
||||
<div class='content-description'>
|
||||
{% cache 86400 'problem_html' problem.id MATH_ENGINE LANGUAGE_CODE %}
|
||||
{{ problem.description|markdown(lazy_load=True)|reference|str|safe }}
|
||||
{% endcache %}
|
||||
{% if problem.pdf_description %}
|
||||
<embed src="{{url('problem_pdf_description', problem.code)}}" width="100%" height="500" type="application/pdf"
|
||||
style="margin-top: 0.5em">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if feed_type=='volunteer' and request.user.has_perm('judge.suggest_problem_changes') %}
|
||||
<br>
|
||||
<a href="#" class="view-statement-src">{{ _('View source') }}</a>
|
||||
<pre class="statement-src" style="display: none">{{ problem.description|str }}</pre>
|
||||
<hr>
|
||||
<center>
|
||||
<h3>{{_('Volunteer form')}}</h3>
|
||||
</center>
|
||||
<br>
|
||||
<button class="edit-btn" id="edit-{{problem.id}}" pid="{{problem.id}}" style="float: right">{{_('Edit')}}</button>
|
||||
<form class="volunteer-form" id="form-{{problem.id}}" pid="{{problem.id}}" style="display: none;" method="POST">
|
||||
<input type="submit" class="volunteer-submit-btn" id="submit-{{problem.id}}" pid="{{problem.id}}"
|
||||
pcode="{{problem.code}}" style="float: right" value="{{_('Submit')}}">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
</th>
|
||||
<th>
|
||||
{{_('Value')}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="knowledge_point-{{problem.id}}"><i>{{ _('Knowledge point') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<input id="knowledge_point-{{problem.id}}" type="number" class="point-input" required>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="thinking_point-{{problem.id}}"><i>{{ _('Thinking point') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<input id="thinking_point-{{problem.id}}" type="number" class="point-input" required>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="types"><i>{{ _('Problem types') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<select id="volunteer-types-{{problem.id}}" name="types" multiple>
|
||||
{% for type in problem_types %}
|
||||
<option value="{{ type.id }}" {% if type in problem.types.all() %} selected{% endif %}>
|
||||
{{ type.full_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="30%">
|
||||
<label for="feedback"><i>{{ _('Feedback') }}</i></label>
|
||||
</td>
|
||||
<td>
|
||||
<textarea id="feedback-{{problem.id}}" rows="2" style="width: 100%"
|
||||
placeholder="{{_('Any additional note here')}}"></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<center id="thank-{{problem.id}}" style="display: none; margin-top: 3em"></center>
|
||||
{% endif %}
|
||||
<div class="show-more"> {{_("...More")}} </div>
|
||||
</div>
|
||||
<div class="actionbar-box">
|
||||
{% set pagevote = problem.pagevote %}
|
||||
{% set bookmark = problem.bookmark %}
|
||||
{% set hide_actionbar_comment = True %}
|
||||
{% set include_hr = False %}
|
||||
{% set share_url = request.build_absolute_uri(problem.get_absolute_url()) %}
|
||||
{% include "actionbar/list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% include "feed/has_next.html" %}
|
|
@ -55,6 +55,7 @@
|
|||
|
||||
{% block three_col_js %}
|
||||
{% include "actionbar/media-js.html" %}
|
||||
{% block problem_list_js %}{% endblock %}
|
||||
<script>
|
||||
window.point_start = {{point_start}};
|
||||
window.point_end = {{point_end}};
|
||||
|
|
|
@ -67,12 +67,13 @@
|
|||
$('.left-sidebar-item').removeClass('active');
|
||||
$elem.addClass('active');
|
||||
}
|
||||
$(window).off("scroll");
|
||||
$('.middle-right-content').html(loading_page);
|
||||
$.get(url, function (data) {
|
||||
var reload_content = $(data).find('.middle-right-content');
|
||||
|
||||
if (reload_content.length) {
|
||||
window.history.pushState("", "", url);
|
||||
$('html, body').animate({scrollTop: 0}, 'fast');
|
||||
$('.middle-right-content').html(reload_content.first().html());
|
||||
if (reload_content.hasClass("wrapper")) {
|
||||
$('.middle-right-content').addClass("wrapper");
|
||||
|
@ -86,6 +87,7 @@
|
|||
activateBlogBoxOnClick();
|
||||
$('.xdsoft_datetimepicker').hide();
|
||||
registerNavigation();
|
||||
|
||||
}
|
||||
else {
|
||||
window.location.href = url;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<div class="blog-box">
|
||||
{% for ticket in tickets %}
|
||||
<div class="blog-box">
|
||||
<h3 class="problem-feed-name">
|
||||
<a href="{{ ticket.linked_item.get_absolute_url() }}">
|
||||
{{ ticket.linked_item|item_title }}</a>
|
||||
|
@ -23,4 +24,6 @@
|
|||
{{ ticket.messages.last().body|markdown(lazy_load=True)|reference|str|safe }}
|
||||
<div class="show-more"> {{_("...More")}} </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% include "feed/has_next.html" %}
|
Loading…
Reference in a new issue