diff --git a/dmoj/settings.py b/dmoj/settings.py index a96cf3f..4967ee7 100644 --- a/dmoj/settings.py +++ b/dmoj/settings.py @@ -69,6 +69,7 @@ DMOJ_SUBMISSIONS_REJUDGE_LIMIT = 10 # Maximum number of submissions a single user can queue without the `spam_submission` permission DMOJ_SUBMISSION_LIMIT = 3 DMOJ_BLOG_NEW_PROBLEM_COUNT = 7 +DMOJ_BLOG_NEW_CONTEST_COUNT = 7 DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT = 7 DMOJ_TOTP_TOLERANCE_HALF_MINUTES = 1 DMOJ_USER_MAX_ORGANIZATION_COUNT = 3 diff --git a/judge/admin/interface.py b/judge/admin/interface.py index 0bfec14..4691c8b 100644 --- a/judge/admin/interface.py +++ b/judge/admin/interface.py @@ -49,6 +49,8 @@ class BlogPostForm(ModelForm): class Meta: widgets = { 'authors': AdminHeavySelect2MultipleWidget(data_view='profile_select2', attrs={'style': 'width: 100%'}), + 'organizations': AdminHeavySelect2MultipleWidget(data_view='organization_select2', + attrs={'style': 'width: 100%'}), } if HeavyPreviewAdminPageDownWidget is not None: @@ -58,7 +60,8 @@ class BlogPostForm(ModelForm): class BlogPostAdmin(VersionAdmin): fieldsets = ( - (None, {'fields': ('title', 'slug', 'authors', 'visible', 'sticky', 'publish_on')}), + (None, {'fields': ('title', 'slug', 'authors', 'visible', 'sticky', 'publish_on', + 'is_organization_private', 'organizations')}), (_('Content'), {'fields': ('content', 'og_image')}), (_('Summary'), {'classes': ('collapse',), 'fields': ('summary',)}), ) diff --git a/judge/feed.py b/judge/feed.py index 47e9d51..f0fa81c 100644 --- a/judge/feed.py +++ b/judge/feed.py @@ -15,7 +15,8 @@ class ProblemFeed(Feed): description = 'The latest problems added on the %s website' % settings.SITE_LONG_NAME def items(self): - return Problem.objects.filter(is_public=True, is_organization_private=False).order_by('-date', '-id')[:25] + return BlogPost.objects.filter(visible=True, publish_on__lte=timezone.now(), is_organization_private=False) \ + .order_by('-sticky', '-publish_on') def item_title(self, problem): return problem.name diff --git a/judge/migrations/0114_auto_20201228_1041.py b/judge/migrations/0114_auto_20201228_1041.py new file mode 100644 index 0000000..3103b3c --- /dev/null +++ b/judge/migrations/0114_auto_20201228_1041.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.17 on 2020-12-28 03:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0113_auto_20201228_0911'), + ] + + operations = [ + migrations.AddField( + model_name='blogpost', + name='is_organization_private', + field=models.BooleanField(default=False, verbose_name='private to organizations'), + ), + migrations.AddField( + model_name='blogpost', + name='organizations', + field=models.ManyToManyField(blank=True, help_text='If private, only these organizations may see the blog post.', to='judge.Organization', verbose_name='organizations'), + ), + ] diff --git a/judge/models/interface.py b/judge/models/interface.py index ed25d63..6d94d52 100644 --- a/judge/models/interface.py +++ b/judge/models/interface.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from mptt.fields import TreeForeignKey from mptt.models import MPTTModel -from judge.models.profile import Profile +from judge.models.profile import Organization, Profile __all__ = ['MiscConfig', 'validate_regex', 'NavigationBar', 'BlogPost'] @@ -72,6 +72,9 @@ class BlogPost(models.Model): content = models.TextField(verbose_name=_('post content')) summary = models.TextField(verbose_name=_('post summary'), blank=True) og_image = models.CharField(verbose_name=_('openGraph image'), default='', max_length=150, blank=True) + organizations = models.ManyToManyField(Organization, blank=True, verbose_name=_('organizations'), + help_text=_('If private, only these organizations may see the blog post.')) + is_organization_private = models.BooleanField(verbose_name=_('private to organizations'), default=False) def __str__(self): return self.title @@ -81,11 +84,22 @@ class BlogPost(models.Model): def can_see(self, user): if self.visible and self.publish_on <= timezone.now(): - return True + if not self.is_organization_private: + return True + if user.is_authenticated and \ + self.organizations.filter(id__in=user.profile.organizations.all()).exists(): + return True if user.has_perm('judge.edit_all_post'): return True return user.is_authenticated and self.authors.filter(id=user.profile.id).exists() + def is_editable_by(self, user): + if not user.is_authenticated: + return False + if user.has_perm('judge.edit_all_post'): + return True + return user.has_perm('judge.change_blogpost') and self.authors.filter(id=user.profile.id).exists() + class Meta: permissions = ( ('edit_all_post', _('Edit all posts')), diff --git a/judge/sitemap.py b/judge/sitemap.py index 5678d76..6ae8657 100644 --- a/judge/sitemap.py +++ b/judge/sitemap.py @@ -56,8 +56,9 @@ class BlogPostSitemap(Sitemap): priority = 0.7 def items(self): - return BlogPost.objects.filter(visible=True, publish_on__lte=timezone.now()).values_list('id', 'slug') - + return (BlogPost.objects.filter(visible=True, is_organization_private=False, publish_on__lte=timezone.now()) + .values_list('id', 'slug')) + def location(self, obj): return reverse('blog_post', args=obj) diff --git a/judge/views/blog.py b/judge/views/blog.py index 5f90c91..4244107 100644 --- a/judge/views/blog.py +++ b/judge/views/blog.py @@ -30,8 +30,15 @@ class PostList(ListView): orphans=orphans, allow_empty_first_page=allow_empty_first_page, **kwargs) def get_queryset(self): - return (BlogPost.objects.filter(visible=True, publish_on__lte=timezone.now()).order_by('-sticky', '-publish_on') - .prefetch_related('authors__user')) + queryset = BlogPost.objects.filter(visible=True, publish_on__lte=timezone.now()) \ + .order_by('-sticky', '-publish_on') \ + .prefetch_related('authors__user', 'organizations') + if not self.request.user.has_perm('judge.edit_all_post'): + filter = Q(is_organization_private=False) + if self.request.user.is_authenticated: + filter |= Q(organizations__in=self.request.profile.organizations.all()) + queryset = queryset.filter(filter) + return queryset def get_context_data(self, **kwargs): context = super(PostList, self).get_context_data(**kwargs) @@ -107,7 +114,7 @@ class PostView(TitleMixin, CommentedDetailView): model = BlogPost pk_url_kwarg = 'id' context_object_name = 'post' - template_name = 'blog/content.html' + template_name = 'blog/blog.html' def get_title(self): return self.object.title diff --git a/judge/views/organization.py b/judge/views/organization.py index a9c1053..e9b9637 100644 --- a/judge/views/organization.py +++ b/judge/views/organization.py @@ -6,17 +6,18 @@ from django.core.cache import cache from django.core.cache.utils import make_template_fragment_key from django.core.exceptions import PermissionDenied from django.db import transaction -from django.db.models import Count, Q +from django.db.models import Count, Q, Value, BooleanField from django.forms import Form, modelformset_factory from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext as _, gettext_lazy, ungettext from django.views.generic import DetailView, FormView, ListView, UpdateView, View from django.views.generic.detail import SingleObjectMixin, SingleObjectTemplateResponseMixin from reversion import revisions from judge.forms import EditOrganizationForm -from judge.models import Organization, OrganizationRequest, Profile +from judge.models import BlogPost, Comment, Organization, OrganizationRequest, Problem, Profile, Contest from judge.utils.ranker import ranker from judge.utils.views import TitleMixin, generic_message @@ -25,8 +26,21 @@ __all__ = ['OrganizationList', 'OrganizationHome', 'OrganizationUsers', 'Organiz 'OrganizationRequestDetail', 'OrganizationRequestView', 'OrganizationRequestLog', 'KickUserWidgetView'] +class OrganizationBase(object): + def can_edit_organization(self, org=None): + if org is None: + org = self.object + if not self.request.user.is_authenticated: + return False + profile_id = self.request.profile.id + return org.admins.filter(id=profile_id).exists() or org.registrant_id == profile_id -class OrganizationMixin(object): + def is_member(self, org=None): + if org is None: + org = self.object + return self.request.profile in org if self.request.user.is_authenticated else False + +class OrganizationMixin(OrganizationBase): context_object_name = 'organization' model = Organization @@ -46,15 +60,7 @@ class OrganizationMixin(object): else: return generic_message(request, _('No such organization'), _('Could not find such organization.')) - - def can_edit_organization(self, org=None): - if org is None: - org = self.object - if not self.request.user.is_authenticated: - return False - profile_id = self.request.profile.id - return org.admins.filter(id=profile_id).exists() or org.registrant_id == profile_id - + class OrganizationDetailView(OrganizationMixin, DetailView): def get(self, request, *args, **kwargs): @@ -65,7 +71,7 @@ class OrganizationDetailView(OrganizationMixin, DetailView): return self.render_to_response(context) -class OrganizationList(TitleMixin, ListView): +class OrganizationList(TitleMixin, ListView, OrganizationBase): model = Organization context_object_name = 'organizations' template_name = 'organization/list.html' @@ -74,6 +80,15 @@ class OrganizationList(TitleMixin, ListView): def get_queryset(self): return super(OrganizationList, self).get_queryset().annotate(member_count=Count('member')) + def get_context_data(self, **kwargs): + context = super(OrganizationList, self).get_context_data(**kwargs) + context['my_organizations'] = set() + + for organization in context['organizations']: + if self.can_edit_organization(organization) or self.is_member(organization): + context['my_organizations'].add(organization) + + return context class OrganizationHome(OrganizationDetailView): template_name = 'organization/home.html' @@ -82,6 +97,23 @@ class OrganizationHome(OrganizationDetailView): context = super(OrganizationHome, self).get_context_data(**kwargs) context['title'] = self.object.name context['can_edit'] = self.can_edit_organization() + context['is_member'] = self.is_member() + context['new_problems'] = Problem.objects.filter(is_public=True, is_organization_private=True, + organizations=self.object) \ + .order_by('-date', '-id')[:settings.DMOJ_BLOG_NEW_PROBLEM_COUNT] + context['new_contests'] = Contest.objects.filter(is_visible=True, is_organization_private=True, + organizations=self.object) \ + .order_by('-end_time', '-id')[:settings.DMOJ_BLOG_NEW_CONTEST_COUNT] + + context['posts'] = BlogPost.objects.filter(visible=True, publish_on__lte=timezone.now(), + is_organization_private=True, organizations=self.object) \ + .order_by('-sticky', '-publish_on') \ + .prefetch_related('authors__user', 'organizations') + context['post_comment_counts'] = { + int(page[2:]): count for page, count in + Comment.objects.filter(page__in=['b:%d' % post.id for post in context['posts']], hidden=False) + .values_list('page').annotate(count=Count('page')).order_by() + } return context diff --git a/resources/blog.scss b/resources/blog.scss index 55eda69..cd6aeb2 100644 --- a/resources/blog.scss +++ b/resources/blog.scss @@ -76,6 +76,18 @@ } } +.blog-comment-count { + font-size: 12px; +} + +.blog-comment-icon { + padding: 0.1em 0.2em 0 0.5em; +} + +.blog-comment-count-link { + color: #555; +} + @media (min-width: 800px) { .blog-content, .blog-sidebar { display: block !important; diff --git a/templates/blog/blog.html b/templates/blog/blog.html new file mode 100644 index 0000000..640913d --- /dev/null +++ b/templates/blog/blog.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block js_media %} + {% include "comments/media-js.html" %} +{% endblock %} + +{% block media %} + {% include "comments/media-css.html" %} +{% endblock %} + +{% block header %} + {% if post.is_editable_by(request.user) %} +
[{{ _('Edit') }}]
+ {% endif %} +{% endblock %} + +{% block body %} +
+
+ {% with authors=post.authors.all() %} + {% if authors %} + + {% endif %} + {% endwith %} + + {% trans time=post.publish_on|date(_("N j, Y, g:i a")) %} + posted on {{ time }} + {% endtrans %} + +
+
+ {% cache 86400 'post_content' post.id MATH_ENGINE %} + {{ post.content|markdown('blog', MATH_ENGINE)|reference|str|safe}} + {% endcache %} +
+
+
+ + {{ post_to_gplus(request, post, '') }} + {{ post_to_facebook(request, post, '') }} + {{ post_to_twitter(request, SITE_NAME + ':', post, '') }} + + {% include "comments/list.html" %} +{% endblock %} + +{% block bodyend %} + {{ super() }} + {% if REQUIRE_JAX %} + {% include "mathjax-load.html" %} + {% endif %} + {% include "comments/math.html" %} +{% endblock %} \ No newline at end of file diff --git a/templates/blog/content.html b/templates/blog/content.html index 1ce27f8..2c0776b 100644 --- a/templates/blog/content.html +++ b/templates/blog/content.html @@ -1,53 +1,40 @@ -{% extends "base.html" %} - -{% block js_media %} - {% include "comments/media-js.html" %} -{% endblock %} - -{% block media %} - {% include "comments/media-css.html" %} -{% endblock %} - -{% block header %} - {% if perms.judge.change_blogpost %} -
[{{ _('Edit') }}] -
- {% endif %} -{% endblock %} - -{% block body %} -
-
+
+ + + {%- if post.sticky %}{% endif -%} {% with authors=post.authors.all() %} - {% if authors %} + {%- if authors -%} - {% endif %} + {%- endif -%} {% endwith %} - - {% trans time=post.publish_on|date(_("N j, Y, g:i a")) %} - posted on {{ time }} - {% endtrans %} - -
-
- {% cache 86400 'post_content' post.id MATH_ENGINE %} - {{ post.content|markdown('blog', MATH_ENGINE)|reference|str|safe}} - {% endcache %} -
-
-
- - {{ post_to_gplus(request, post, '') }} - {{ post_to_facebook(request, post, '') }} - {{ post_to_twitter(request, SITE_NAME + ':', post, '') }} + {{ relative_time(post.publish_on, abs=_('posted on {time}'), rel=_('posted {time}')) -}} + + + + + + {{- post_comment_counts[post.id] or 0 -}} + + + - {% include "comments/list.html" %} -{% endblock %} - -{% block bodyend %} - {{ super() }} - {% if REQUIRE_JAX %} - {% include "mathjax-load.html" %} +

+ {{ post.title }} +

+ {% if post.is_organization_private and show_organization_private_icon %} +
+ {% for org in post.organizations.all() %} + + + {{ org.name }} + + + {% endfor %} +
{% endif %} - {% include "comments/math.html" %} -{% endblock %} +
+ {% cache 86400 'post_summary' post.id %} + {{ post.summary|default(post.content, true)|markdown('blog', 'svg', lazy_load=True)|reference|str|safe }} + {% endcache %} +
+ \ No newline at end of file diff --git a/templates/blog/list.html b/templates/blog/list.html index ed3c62c..cf11c72 100644 --- a/templates/blog/list.html +++ b/templates/blog/list.html @@ -27,18 +27,6 @@ margin-top: 0.6em; } - .comment-count { - font-size: 12px; - } - - .comment-icon { - padding: 0.1em 0.2em 0 0.5em; - } - - .comment-count-link { - color: #555; - } - .own-open-tickets .title a, .open-tickets .title a { display: block; } @@ -103,35 +91,9 @@