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.utils import timezone
from judge.jinja2.gravatar import gravatar
from .models import Message, Profile
import json

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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 {
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;
}

View file

@ -134,3 +134,6 @@ a {
.comment-body {
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) {
display: none;
}

View file

@ -204,48 +204,64 @@
</li>
{% endfor %}
</ul>
<span id="user-links">
<div style="float: right; display: inline;">
{% 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>
{% set unseen_cnt = request.profile.count_unseen_notifications %}
<span class="{{ 'notification-open' if unseen_cnt > 0 }}">
<a href="{{ url('notification') }}" class="fa fa-bell" id="notification" aria-hidden="true">
{% if unseen_cnt > 0 %}
<span>
{{ unseen_cnt }}
</span>
{% endif %}
</a>
</span>
{% 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 id="nav-shadow"></div>
</nav>
{% if request.in_contest %}

View file

@ -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 = `<li class="message">
<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 '' }}"
placeholder="{{ _('Search problems...') }}">
</div>
{% if has_fts %}
<!-- {% if has_fts %}
<div>
<input id="full_text" type="checkbox" name="full_text" value="1"
{% if full_text %}checked{% endif %}>
<label for="full_text">{{ _('Full text search') }}</label>
</div>
{% endif %}
{% endif %} -->
{% if request.user.is_authenticated %}
<div>
<input id="hide_solved" type="checkbox" name="hide_solved" value="1"