From de704fc250c9b6d19bf3d2d22ef722a4084f18b4 Mon Sep 17 00:00:00 2001 From: cuom1999 Date: Thu, 2 Jul 2020 21:50:31 -0500 Subject: [PATCH] Add notification --- chat_box/views.py | 1 + dmoj/urls.py | 6 +- judge/comments.py | 35 +++++++++- judge/jinja2/reference.py | 7 ++ judge/migrations/0107_notification.py | 25 ++++++++ judge/models/__init__.py | 2 +- judge/models/comment.py | 11 +++- judge/models/profile.py | 8 +++ judge/views/comment.py | 8 ++- judge/views/notification.py | 52 +++++++++++++++ resources/base.scss | 29 ++++++--- resources/comments.scss | 3 + resources/problem.scss | 2 +- templates/base.html | 92 ++++++++++++++++----------- templates/chat/chat.html | 11 +++- templates/notification/list.html | 39 ++++++++++++ templates/problem/search-form.html | 4 +- 17 files changed, 279 insertions(+), 56 deletions(-) create mode 100644 judge/migrations/0107_notification.py create mode 100644 judge/views/notification.py create mode 100644 templates/notification/list.html diff --git a/chat_box/views.py b/chat_box/views.py index d067cb0..868436c 100644 --- a/chat_box/views.py +++ b/chat_box/views.py @@ -6,6 +6,7 @@ from django.shortcuts import render from django.forms.models import model_to_dict from django.utils import timezone + from judge.jinja2.gravatar import gravatar from .models import Message, Profile import json diff --git a/dmoj/urls.py b/dmoj/urls.py index 33798d9..3037741 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -18,7 +18,7 @@ from judge.forms import CustomAuthenticationForm from judge.sitemap import BlogPostSitemap, ContestSitemap, HomePageSitemap, OrganizationSitemap, ProblemSitemap, \ SolutionSitemap, UrlSitemap, UserSitemap from judge.views import TitledTemplateView, about, api, blog, comment, contests, language, license, mailgun, \ - organization, preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tasks, \ + notification, organization, preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tasks, \ ticket, totp, user, widgets from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \ problem_data_file, problem_init_view @@ -375,6 +375,10 @@ urlpatterns = [ url(r'^delete/$', delete_message, name='delete_message') ])), + + url(r'^notifications/', + login_required(notification.NotificationList.as_view()), + name='notification') ] favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png', 'android-chrome-72x72.png', diff --git a/judge/comments.py b/judge/comments.py index 4444755..6e7c7e6 100644 --- a/judge/comments.py +++ b/judge/comments.py @@ -18,9 +18,28 @@ from reversion import revisions from reversion.models import Revision, Version from judge.dblock import LockModel -from judge.models import Comment, CommentLock, CommentVote +from judge.models import Comment, CommentLock, CommentVote, Notification from judge.utils.raw_sql import RawSQLColumn, unique_together_left_join from judge.widgets import HeavyPreviewPageDownWidget +from judge.jinja2.reference import get_user_from_text + + + +def add_mention_notifications(comment): + user_referred = get_user_from_text(comment.body).exclude(id=comment.author.id) + for user in user_referred: + notification_ref = Notification(owner=user, + comment=comment, + category='Mention') + notification_ref.save() + +def del_mention_notifications(comment): + query = { + 'comment': comment, + 'category': 'Mention' + } + Notification.objects.filter(**query).delete() + class CommentForm(ModelForm): @@ -87,10 +106,22 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): comment = form.save(commit=False) comment.author = request.profile comment.page = page + + with LockModel(write=(Comment, Revision, Version), read=(ContentType,)), revisions.create_revision(): revisions.set_user(request.user) revisions.set_comment(_('Posted comment')) comment.save() + + # add notification for reply + if comment.parent and comment.parent.author != comment.author: + notification_rep = Notification(owner=comment.parent.author, + comment=comment, + category='Reply') + notification_rep.save() + + add_mention_notifications(comment) + return HttpResponseRedirect(request.path) context = self.get_context_data(object=self.object, comment_form=form) @@ -118,5 +149,5 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View): not profile.submission_set.filter(points=F('problem__points')).exists()) context['comment_list'] = queryset context['vote_hide_threshold'] = settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD - return context + diff --git a/judge/jinja2/reference.py b/judge/jinja2/reference.py index 239f279..184e109 100644 --- a/judge/jinja2/reference.py +++ b/judge/jinja2/reference.py @@ -56,6 +56,13 @@ def get_user_info(usernames): .values_list('user__username', 'display_rank', 'rating')} +def get_user_from_text(text): + user_list = set() + for i in rereference.finditer(text): + user_list.add(text[i.start() + 6: i.end() - 1]) + return Profile.objects.filter(user__username__in=user_list) + + reference_map = { 'user': (get_user, get_user_info), 'ruser': (get_user_rating, get_user_info), diff --git a/judge/migrations/0107_notification.py b/judge/migrations/0107_notification.py new file mode 100644 index 0000000..d8857d6 --- /dev/null +++ b/judge/migrations/0107_notification.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.12 on 2020-07-03 01:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0106_friend'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True, verbose_name='posted time')), + ('read', models.BooleanField(default=False, verbose_name='read')), + ('category', models.CharField(max_length=10, verbose_name='category')), + ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='judge.Comment', verbose_name='comment')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='judge.Profile', verbose_name='owner')), + ], + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 06bb71e..23e0734 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -1,7 +1,7 @@ from reversion import revisions from judge.models.choices import ACE_THEMES, EFFECTIVE_MATH_ENGINES, MATH_ENGINES_CHOICES, TIMEZONE -from judge.models.comment import Comment, CommentLock, CommentVote +from judge.models.comment import Comment, CommentLock, CommentVote, Notification from judge.models.contest import Contest, ContestMoss, ContestParticipation, ContestProblem, ContestSubmission, \ ContestTag, Rating from judge.models.interface import BlogPost, MiscConfig, NavigationBar, validate_regex diff --git a/judge/models/comment.py b/judge/models/comment.py index d14d613..ff3879c 100644 --- a/judge/models/comment.py +++ b/judge/models/comment.py @@ -19,7 +19,8 @@ from judge.models.problem import Problem from judge.models.profile import Profile from judge.utils.cachedict import CacheDict -__all__ = ['Comment', 'CommentLock', 'CommentVote'] + +__all__ = ['Comment', 'CommentLock', 'CommentVote', 'Notification'] comment_validator = RegexValidator(r'^[pcs]:[a-z0-9]+$|^b:\d+$', _(r'Page code must be ^[pcs]:[a-z0-9]+$|^b:\d+$')) @@ -183,3 +184,11 @@ class CommentLock(models.Model): def __str__(self): return str(self.page) + + +class Notification(models.Model): + owner = models.ForeignKey(Profile, verbose_name=_('owner'), related_name="notifications", on_delete=CASCADE) + time = models.DateTimeField(verbose_name=_('posted time'), auto_now_add=True) + comment = models.ForeignKey(Comment, verbose_name=_('comment'), on_delete=CASCADE) + read = models.BooleanField(verbose_name=_('read'), default=False) + category = models.CharField(verbose_name=_('category'), max_length=10) \ No newline at end of file diff --git a/judge/models/profile.py b/judge/models/profile.py index 250dc88..d73ed70 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -125,6 +125,14 @@ class Profile(models.Model): def username(self): return self.user.username + @cached_property + def count_unseen_notifications(self): + query = { + 'read': False, + 'comment__hidden': False, + } + return self.notifications.filter(**query).count() + _pp_table = [pow(settings.DMOJ_PP_STEP, i) for i in range(settings.DMOJ_PP_ENTRIES)] def calculate_points(self, table=_pp_table): diff --git a/judge/views/comment.py b/judge/views/comment.py index f216bba..7550d97 100644 --- a/judge/views/comment.py +++ b/judge/views/comment.py @@ -13,9 +13,10 @@ from reversion import revisions from reversion.models import Version from judge.dblock import LockModel -from judge.models import Comment, CommentVote +from judge.models import Comment, CommentVote, Notification from judge.utils.views import TitleMixin from judge.widgets import MathJaxPagedownWidget +from judge.comments import add_mention_notifications, del_mention_notifications __all__ = ['upvote_comment', 'downvote_comment', 'CommentEditAjax', 'CommentContent', 'CommentEdit'] @@ -116,6 +117,11 @@ class CommentEditAjax(LoginRequiredMixin, CommentMixin, UpdateView): form_class = CommentEditForm def form_valid(self, form): + # update notifications + comment = form.instance + del_mention_notifications(comment) + add_mention_notifications(comment) + with transaction.atomic(), revisions.create_revision(): revisions.set_comment(_('Edited from site')) revisions.set_user(self.request.user) diff --git a/judge/views/notification.py b/judge/views/notification.py new file mode 100644 index 0000000..c3540e5 --- /dev/null +++ b/judge/views/notification.py @@ -0,0 +1,52 @@ +from django.contrib.auth.decorators import login_required +from django.views.generic import ListView +from django.utils.translation import ugettext as _ +from django.utils.timezone import now +from django.db.models import BooleanField, Value + +from judge.utils.cachedict import CacheDict +from judge.models import Profile, Comment, Notification + +__all__ = ['NotificationList'] + +class NotificationList(ListView): + model = Notification + context_object_name = 'notifications' + template_name = 'notification/list.html' + + def get_queryset(self): + self.unseen_cnt = self.request.profile.count_unseen_notifications + + query = { + 'owner': self.request.profile, + 'comment__hidden': False, + } + self.queryset = Notification.objects.filter(**query)\ + .order_by('-time')[:100] \ + .annotate(seen=Value(True, output_field=BooleanField())) + + # Mark the several first unseen + for cnt, q in enumerate(self.queryset): + if (cnt < self.unseen_cnt): + q.seen = False + else: + break + + return self.queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + 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): + ret = super().get(request, *args, **kwargs) + + # update after rendering + Notification.objects.filter(owner=self.request.profile).update(read=True) + + return ret + diff --git a/resources/base.scss b/resources/base.scss index ac13a63..15107b9 100644 --- a/resources/base.scss +++ b/resources/base.scss @@ -167,12 +167,12 @@ header { } #user-links { - top: 0; - right: 0; - position: absolute; + // display: inline; + float: right; color: #5c5954; .anon { + margin-top: 1em; padding-right: 10px; display: inline-flex; min-height: 100%; @@ -640,10 +640,6 @@ math { } #user-links { - bottom: 6px; - right: 6px; - position: absolute; - & > ul > li { & > a > span { padding-top: 4px; @@ -665,7 +661,7 @@ math { @media not all and (max-width: 760px) { #nav-list { - display: block !important; + display: inline !important; li { &.home-menu-item { @@ -682,3 +678,20 @@ math { } } } + +#notification { + color: gray; + float: left; + margin-top: 0.8em; + margin-right: 0.8em; + font-size: 1.3em; +} +@media (max-width: 500px) { + #notification { + margin-top: 0.6em; + } +} + +.notification-open #notification { + color: green !important; +} \ No newline at end of file diff --git a/resources/comments.scss b/resources/comments.scss index 2febe54..8494cf1 100644 --- a/resources/comments.scss +++ b/resources/comments.scss @@ -134,3 +134,6 @@ a { .comment-body { word-wrap: break-word; } +.highlight { + background: #fff897; +} \ No newline at end of file diff --git a/resources/problem.scss b/resources/problem.scss index 158b58f..7ab1a63 100644 --- a/resources/problem.scss +++ b/resources/problem.scss @@ -233,7 +233,7 @@ ul.problem-list { } } -@media (max-width: 450px) { +@media (max-width: 500px) { #problem-table tr :nth-child(4) { display: none; } diff --git a/templates/base.html b/templates/base.html index 2689ad0..a5ee45c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -204,48 +204,64 @@ {% endfor %} - - +
{% if request.user.is_authenticated %} - - {% else %} - - {{ _('Log in') }} -  {{ _('or') }}  - {{ _('Sign up') }} + {% set unseen_cnt = request.profile.count_unseen_notifications %} + + + {% endif %} - + + {% if request.user.is_authenticated %} + + + {% else %} + + {{ _('Log in') }} +  {{ _('or') }}  + {{ _('Sign up') }} + + {% endif %} + +
+ + {% if request.in_contest %} diff --git a/templates/chat/chat.html b/templates/chat/chat.html index 878a673..f1b1663 100644 --- a/templates/chat/chat.html +++ b/templates/chat/chat.html @@ -37,11 +37,20 @@ return '&#'+i.charCodeAt(0)+';'; }); } + const datesAreOnSameDay = (first, second) => + first.getFullYear() === second.getFullYear() && + first.getMonth() === second.getMonth() && + first.getDate() === second.getDate(); function loadMessage(content, user, time, messid, image, css_class, isNew) { // if (isNew) content = encodeHTML(content) time = new Date(time); - time = moment(time).format("HH:mm DD-MM-YYYY"); + if (datesAreOnSameDay(time, new Date())) { + time = moment(time).format("HH:mm"); + } + else { + time = moment(time).format("HH:mm DD-MM-YYYY"); + } content = encodeHTML(content); li = `
  • diff --git a/templates/notification/list.html b/templates/notification/list.html new file mode 100644 index 0000000..176d04f --- /dev/null +++ b/templates/notification/list.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block body %} + +{% if not has_notifications %} + +

    {{ _('You have no notifications') }}

    + +{% else %} + + + + + + + + {% for notification in notifications %} + + + + + + + {% endfor %} +
    {{ _('User') }}{{ _('Activity') }}{{ _('Comment') }}{{ _('Time') }}
    + {{ link_user(notification.comment.author) }} + + {{ notification.category }} + + {{ page_titles[notification.comment.page] }} + + {{ relative_time(notification.time) }} +
    +{% endif %} + +{% endblock %} + + \ No newline at end of file diff --git a/templates/problem/search-form.html b/templates/problem/search-form.html index 5e1b6d8..968ecc6 100644 --- a/templates/problem/search-form.html +++ b/templates/problem/search-form.html @@ -7,13 +7,13 @@ - {% if has_fts %} + {% if request.user.is_authenticated %}