Add notification

This commit is contained in:
cuom1999 2020-07-02 21:50:31 -05:00
parent ab59065c0b
commit de704fc250
17 changed files with 279 additions and 56 deletions

View file

@ -6,6 +6,7 @@ from django.shortcuts import render
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.utils import timezone from django.utils import timezone
from judge.jinja2.gravatar import gravatar from judge.jinja2.gravatar import gravatar
from .models import Message, Profile from .models import Message, Profile
import json import json

View file

@ -18,7 +18,7 @@ from judge.forms import CustomAuthenticationForm
from judge.sitemap import BlogPostSitemap, ContestSitemap, HomePageSitemap, OrganizationSitemap, ProblemSitemap, \ from judge.sitemap import BlogPostSitemap, ContestSitemap, HomePageSitemap, OrganizationSitemap, ProblemSitemap, \
SolutionSitemap, UrlSitemap, UserSitemap SolutionSitemap, UrlSitemap, UserSitemap
from judge.views import TitledTemplateView, about, api, blog, comment, contests, language, license, mailgun, \ 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 ticket, totp, user, widgets
from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \ from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \
problem_data_file, problem_init_view problem_data_file, problem_init_view
@ -375,6 +375,10 @@ urlpatterns = [
url(r'^delete/$', delete_message, name='delete_message') 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', favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png', 'android-chrome-72x72.png',

View file

@ -18,9 +18,28 @@ from reversion import revisions
from reversion.models import Revision, Version from reversion.models import Revision, Version
from judge.dblock import LockModel 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.utils.raw_sql import RawSQLColumn, unique_together_left_join
from judge.widgets import HeavyPreviewPageDownWidget 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): class CommentForm(ModelForm):
@ -87,10 +106,22 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View):
comment = form.save(commit=False) comment = form.save(commit=False)
comment.author = request.profile comment.author = request.profile
comment.page = page comment.page = page
with LockModel(write=(Comment, Revision, Version), read=(ContentType,)), revisions.create_revision(): with LockModel(write=(Comment, Revision, Version), read=(ContentType,)), revisions.create_revision():
revisions.set_user(request.user) revisions.set_user(request.user)
revisions.set_comment(_('Posted comment')) revisions.set_comment(_('Posted comment'))
comment.save() 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) return HttpResponseRedirect(request.path)
context = self.get_context_data(object=self.object, comment_form=form) 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()) not profile.submission_set.filter(points=F('problem__points')).exists())
context['comment_list'] = queryset context['comment_list'] = queryset
context['vote_hide_threshold'] = settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD context['vote_hide_threshold'] = settings.DMOJ_COMMENT_VOTE_HIDE_THRESHOLD
return context return context

View file

@ -56,6 +56,13 @@ def get_user_info(usernames):
.values_list('user__username', 'display_rank', 'rating')} .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 = { reference_map = {
'user': (get_user, get_user_info), 'user': (get_user, get_user_info),
'ruser': (get_user_rating, get_user_info), 'ruser': (get_user_rating, get_user_info),

View file

@ -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')),
],
),
]

View file

@ -1,7 +1,7 @@
from reversion import revisions from reversion import revisions
from judge.models.choices import ACE_THEMES, EFFECTIVE_MATH_ENGINES, MATH_ENGINES_CHOICES, TIMEZONE 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, \ from judge.models.contest import Contest, ContestMoss, ContestParticipation, ContestProblem, ContestSubmission, \
ContestTag, Rating ContestTag, Rating
from judge.models.interface import BlogPost, MiscConfig, NavigationBar, validate_regex from judge.models.interface import BlogPost, MiscConfig, NavigationBar, validate_regex

View file

@ -19,7 +19,8 @@ from judge.models.problem import Problem
from judge.models.profile import Profile from judge.models.profile import Profile
from judge.utils.cachedict import CacheDict 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+$', comment_validator = RegexValidator(r'^[pcs]:[a-z0-9]+$|^b:\d+$',
_(r'Page code must be ^[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): def __str__(self):
return str(self.page) 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)

View file

@ -125,6 +125,14 @@ class Profile(models.Model):
def username(self): def username(self):
return self.user.username 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)] _pp_table = [pow(settings.DMOJ_PP_STEP, i) for i in range(settings.DMOJ_PP_ENTRIES)]
def calculate_points(self, table=_pp_table): def calculate_points(self, table=_pp_table):

View file

@ -13,9 +13,10 @@ from reversion import revisions
from reversion.models import Version from reversion.models import Version
from judge.dblock import LockModel 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.utils.views import TitleMixin
from judge.widgets import MathJaxPagedownWidget from judge.widgets import MathJaxPagedownWidget
from judge.comments import add_mention_notifications, del_mention_notifications
__all__ = ['upvote_comment', 'downvote_comment', 'CommentEditAjax', 'CommentContent', __all__ = ['upvote_comment', 'downvote_comment', 'CommentEditAjax', 'CommentContent',
'CommentEdit'] 'CommentEdit']
@ -116,6 +117,11 @@ class CommentEditAjax(LoginRequiredMixin, CommentMixin, UpdateView):
form_class = CommentEditForm form_class = CommentEditForm
def form_valid(self, form): def form_valid(self, form):
# update notifications
comment = form.instance
del_mention_notifications(comment)
add_mention_notifications(comment)
with transaction.atomic(), revisions.create_revision(): with transaction.atomic(), revisions.create_revision():
revisions.set_comment(_('Edited from site')) revisions.set_comment(_('Edited from site'))
revisions.set_user(self.request.user) revisions.set_user(self.request.user)

View file

@ -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

View file

@ -167,12 +167,12 @@ header {
} }
#user-links { #user-links {
top: 0; // display: inline;
right: 0; float: right;
position: absolute;
color: #5c5954; color: #5c5954;
.anon { .anon {
margin-top: 1em;
padding-right: 10px; padding-right: 10px;
display: inline-flex; display: inline-flex;
min-height: 100%; min-height: 100%;
@ -640,10 +640,6 @@ math {
} }
#user-links { #user-links {
bottom: 6px;
right: 6px;
position: absolute;
& > ul > li { & > ul > li {
& > a > span { & > a > span {
padding-top: 4px; padding-top: 4px;
@ -665,7 +661,7 @@ math {
@media not all and (max-width: 760px) { @media not all and (max-width: 760px) {
#nav-list { #nav-list {
display: block !important; display: inline !important;
li { li {
&.home-menu-item { &.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;
}

View file

@ -134,3 +134,6 @@ a {
.comment-body { .comment-body {
word-wrap: break-word; word-wrap: break-word;
} }
.highlight {
background: #fff897;
}

View file

@ -233,7 +233,7 @@ ul.problem-list {
} }
} }
@media (max-width: 450px) { @media (max-width: 500px) {
#problem-table tr :nth-child(4) { #problem-table tr :nth-child(4) {
display: none; display: none;
} }

View file

@ -204,48 +204,64 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div style="float: right; display: inline;">
<span id="user-links">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<ul> {% set unseen_cnt = request.profile.count_unseen_notifications %}
<li> <span class="{{ 'notification-open' if unseen_cnt > 0 }}">
<a href="{{ url('user_page') }}"> <a href="{{ url('notification') }}" class="fa fa-bell" id="notification" aria-hidden="true">
<span> {% if unseen_cnt > 0 %}
<img src="{{ gravatar(request.user, 32) }}" height="24" width="24">{# -#} <span>
<span> {{ unseen_cnt }}
{%- trans username=request.user.username -%}
Hello, <b>{{ username }}</b>.
{%- endtrans %}
</span>
</span>
</a>
<ul style="width: 150px">
{% if request.user.is_staff or request.user.is_superuser %}
<li><a href="{{ url('admin:index') }}">{{ _('Admin') }}</a></li>
{% endif %}
<li><a href="{{ url('user_edit_profile') }}">{{ _('Edit profile') }}</a></li>
{% if request.user.is_impersonate %}
<li><a href="{{ url('impersonate-stop') }}">Stop impersonating</a></li>
{% else %}
<li>
<form action="{{ url('auth_logout') }}" method="POST">
{% csrf_token %}
<button type="submit">{{ _('Log out') }}</button>
</form>
</li>
{% endif %}
</ul>
</li>
</ul>
{% else %}
<span class="anon">
<a href="{{ url('auth_login') }}?next={{ LOGIN_RETURN_PATH|urlencode }}"><b>{{ _('Log in') }}</b></a>
&nbsp;{{ _('or') }}&nbsp;
<a href="{{ url('registration_register') }}"><b>{{ _('Sign up') }}</b></a>
</span> </span>
{% endif %}
</a>
</span>
{% endif %} {% endif %}
</span> <span id="user-links">
{% if request.user.is_authenticated %}
<ul>
<li>
<a href="{{ url('user_page') }}">
<span>
<img src="{{ gravatar(request.user, 32) }}" height="24" width="24">{# -#}
<span>
{%- trans username=request.user.username -%}
Hello, <b>{{ username }}</b>.
{%- endtrans %}
</span>
</span>
</a>
<ul style="width: 150px">
{% if request.user.is_staff or request.user.is_superuser %}
<li><a href="{{ url('admin:index') }}">{{ _('Admin') }}</a></li>
{% endif %}
<li><a href="{{ url('user_edit_profile') }}">{{ _('Edit profile') }}</a></li>
{% if request.user.is_impersonate %}
<li><a href="{{ url('impersonate-stop') }}">Stop impersonating</a></li>
{% else %}
<li>
<form action="{{ url('auth_logout') }}" method="POST">
{% csrf_token %}
<button type="submit">{{ _('Log out') }}</button>
</form>
</li>
{% endif %}
</ul>
</li>
</ul>
{% else %}
<span class="anon">
<a href="{{ url('auth_login') }}?next={{ LOGIN_RETURN_PATH|urlencode }}"><b>{{ _('Log in') }}</b></a>
&nbsp;{{ _('or') }}&nbsp;
<a href="{{ url('registration_register') }}"><b>{{ _('Sign up') }}</b></a>
</span>
{% endif %}
</span>
</div>
</div> </div>
<div id="nav-shadow"></div> <div id="nav-shadow"></div>
</nav> </nav>
{% if request.in_contest %} {% if request.in_contest %}

View file

@ -37,11 +37,20 @@
return '&#'+i.charCodeAt(0)+';'; 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) { function loadMessage(content, user, time, messid, image, css_class, isNew) {
// if (isNew) content = encodeHTML(content) // if (isNew) content = encodeHTML(content)
time = new Date(time); 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); content = encodeHTML(content);
li = `<li class="message"> li = `<li class="message">
<img src="${image}" class="profile-pic"> <img src="${image}" class="profile-pic">

View file

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block body %}
{% if not has_notifications %}
<h2 style="text-align: center;">{{ _('You have no notifications') }}</h2>
{% else %}
<table class="table">
<tr>
<th>{{ _('User') }}</th>
<th>{{ _('Activity') }}</th>
<th>{{ _('Comment') }}</th>
<th>{{ _('Time') }}</th>
</tr>
{% for notification in notifications %}
<tr class="{{ 'highlight' if not notification.seen }}">
<td>
{{ link_user(notification.comment.author) }}
</td>
<td>
{{ notification.category }}
</td>
<td>
<a href="{{ notification.comment.link }}#comment-{{ notification.comment.id }}">{{ page_titles[notification.comment.page] }}</a>
</td>
<td>
{{ relative_time(notification.time) }}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}
<!--
-->

View file

@ -7,13 +7,13 @@
<input id="search" type="text" name="search" value="{{ search_query or '' }}" <input id="search" type="text" name="search" value="{{ search_query or '' }}"
placeholder="{{ _('Search problems...') }}"> placeholder="{{ _('Search problems...') }}">
</div> </div>
{% if has_fts %} <!-- {% if has_fts %}
<div> <div>
<input id="full_text" type="checkbox" name="full_text" value="1" <input id="full_text" type="checkbox" name="full_text" value="1"
{% if full_text %}checked{% endif %}> {% if full_text %}checked{% endif %}>
<label for="full_text">{{ _('Full text search') }}</label> <label for="full_text">{{ _('Full text search') }}</label>
</div> </div>
{% endif %} {% endif %} -->
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div> <div>
<input id="hide_solved" type="checkbox" name="hide_solved" value="1" <input id="hide_solved" type="checkbox" name="hide_solved" value="1"