finish notifications

This commit is contained in:
rimu 2023-11-30 23:21:37 +13:00
parent f0a4e01fe9
commit 5752b8eaeb
14 changed files with 261 additions and 22 deletions

View file

@ -44,7 +44,7 @@ class CreatePostForm(FlaskForm):
# flair = SelectField(_l('Flair'), coerce=int)
nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('NSFL'))
notify_author = BooleanField(_l('Send me post reply notifications'))
notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Post'))
def validate(self, extra_validators=None) -> bool:

View file

@ -180,6 +180,7 @@ class User(UserMixin, db.Model):
indexable = db.Column(db.Boolean, default=False)
bot = db.Column(db.Boolean, default=False)
ignore_bots = db.Column(db.Boolean, default=False)
unread_notifications = db.Column(db.Integer, default=0)
avatar = db.relationship('File', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan")
cover = db.relationship('File', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan")
@ -312,6 +313,9 @@ class User(UserMixin, db.Model):
return User.query.get(id)
def purge_content(self):
files = File.query.join(Post).filter(Post.user_id == self.id).all()
for file in files:
file.delete_from_disk()
db.session.query(ActivityLog).filter(ActivityLog.user_id == self.id).delete()
db.session.query(PostVote).filter(PostVote.user_id == self.id).delete()
db.session.query(PostReplyVote).filter(PostReplyVote.user_id == self.id).delete()

View file

@ -1,9 +1,10 @@
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField
from wtforms import TextAreaField, SubmitField, BooleanField
from wtforms.validators import DataRequired, Length
from flask_babel import _, lazy_gettext as _l
class NewReplyForm(FlaskForm):
body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 3}, validators={DataRequired(), Length(min=3, max=5000)})
notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Comment'))

View file

@ -12,7 +12,7 @@ from app.community.forms import CreatePostForm
from app.post.util import post_replies, get_comment_branch, post_reply_count
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import Post, PostReply, \
PostReplyVote, PostVote
PostReplyVote, PostVote, Notification
from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish
@ -27,7 +27,12 @@ def show_post(post_id: int):
if current_user.is_authenticated and current_user.verified and form.validate_on_submit():
reply = PostReply(user_id=current_user.id, post_id=post.id, community_id=post.community.id, body=form.body.data,
body_html=markdown_to_html(form.body.data), body_html_safe=True,
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl)
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl,
notify_author=form.notify_author.data)
if post.notify_author and current_user.id != post.user_id: # todo: check if replier is blocked
notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=post.user_id,
author_id=current_user.id, url=url_for('post.show_post', post_id=post.id))
db.session.add(notification)
db.session.add(reply)
db.session.commit()
reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id,
@ -42,6 +47,7 @@ def show_post(post_id: int):
post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form
else:
replies = post_replies(post.id, 'top')
form.notify_author.data = True
og_image = post.image.source_url if post.image_id else None
description = shorten_string(markdown_to_text(post.body), 150) if post.body else None
@ -178,8 +184,13 @@ def add_reply(post_id: int, comment_id: int):
reply = PostReply(user_id=current_user.id, post_id=post.id, parent_id=comment.id, depth=comment.depth + 1,
community_id=post.community.id, body=form.body.data,
body_html=markdown_to_html(form.body.data), body_html_safe=True,
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl)
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl,
notify_author=form.notify_author.data)
db.session.add(reply)
if comment.notify_author and current_user.id != comment.user_id: # todo: check if replier is blocked
notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=comment.user_id,
author_id=current_user.id, url=url_for('post.show_post', post_id=post.id))
db.session.add(notification)
db.session.commit()
reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id,
effect=1.0)
@ -195,6 +206,7 @@ def add_reply(post_id: int, comment_id: int):
else:
return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=reply.parent_id))
else:
form.notify_author.data = True
return render_template('post/add_reply.html', title=_('Discussing %(title)s', title=post.title), post=post,
is_moderator=is_moderator, form=form, comment=comment)

View file

@ -182,6 +182,10 @@
content: "\e967";
}
.fe-bell::before {
content: "\e91e";
}
.fe-image {
position: relative;
top: 2px;

View file

@ -185,6 +185,10 @@ nav, etc which are used site-wide */
content: "\e967";
}
.fe-bell::before {
content: "\e91e";
}
.fe-image {
position: relative;
top: 2px;
@ -403,6 +407,23 @@ fieldset legend {
.post_reply_form label {
display: none;
}
.post_reply_form .form-check {
position: absolute;
bottom: -14px;
left: 122px;
}
.post_reply_form .form-check label {
display: inherit;
}
.add_reply .form-control-label {
display: none;
}
.add_reply .form-check {
position: absolute;
bottom: -14px;
left: 122px;
}
.post_list .post_teaser {
border-bottom: solid 2px #ddd;
@ -548,8 +569,8 @@ fieldset legend {
border-top: solid 1px #ddd;
}
.add_reply .form-control-label {
display: none;
.table tr th {
vertical-align: middle;
}
/*# sourceMappingURL=structure.css.map */

View file

@ -115,7 +115,30 @@ nav, etc which are used site-wide */
label {
display: none;
}
.form-check {
position: absolute;
bottom: -14px;
left: 122px;
label {
display: inherit;
}
}
}
.add_reply {
.form-control-label {
display: none;
}
.form-check {
position: absolute;
bottom: -14px;
left: 122px;
}
}
.post_list {
.post_teaser {
@ -294,8 +317,8 @@ nav, etc which are used site-wide */
}
}
.add_reply {
.form-control-label {
display: none;
.table {
tr th {
vertical-align: middle;
}
}

View file

@ -184,6 +184,10 @@
content: "\e967";
}
.fe-bell::before {
content: "\e91e";
}
.fe-image {
position: relative;
top: 2px;

View file

@ -71,11 +71,13 @@
{% else %}
<li class="nav-item"><a class="nav-link" href="/">{{ _('Home') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/communities">{{ _('Communities') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/u/{{ current_user.user_name }}">{{ current_user.user_name }}</a></li>
<li class="nav-item"><a class="nav-link" href="/u/{{ current_user.user_name }}">{{ _('Account') }}</a></li>
{% if user_access('change instance settings', current_user.id) %}
<li class="nav-item"><a class="nav-link" href="/admin/">{{ _('Admin') }}</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="/auth/logout">{{ _('Log out') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/notifications"><span class="fe fe-bell"></span>
{{ current_user.unread_notifications if current_user.unread_notifications else '' }}</a></li>
{% endif %}
</ul>
</div>

View file

@ -12,8 +12,10 @@
<fieldset class="coolfieldset mt-4"><legend class="w-auto">Comment you are replying to</legend>
{{ comment.body_html | safe}}
</fieldset>
<div class="position-relative">
{{ render_form(form) }}
</div>
</div>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">

View file

@ -14,7 +14,7 @@
<div class="row post_reply_form">
<hr class="mt-1" />
<div class="col">
<div class="reply_form_inner">
<div class="reply_form_inner position-relative">
{{ render_form(form) }}
</div>
</div>

View file

@ -0,0 +1,105 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/users">{{ _('People') }}</a></li>
<li class="breadcrumb-item active">{{ user.user_name|shorten }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ _('Notifications') }}</h1>
{% if notifications %}
<table class="table table-striped">
<tr>
<th>Notification</th>
<th>When</th>
<th><a href="{{ url_for('user.notifications_all_read') }}" class="btn btn-primary btn-sm">Mark all read</a></th>
</tr>
{% for notification in notifications %}
<tr>
<td>{% if not notification.read %}<strong>{% endif %}
<a href="{{ url_for('user.notification_goto', notification_id=notification.id) }}">{{ notification.title }}</a>
{% if not notification.read %}</strong>{% endif %}
</td>
<td>{{ moment(notification.created_at).fromNow(refresh=True) }}</td>
<td>
<a href="{{ url_for('user.notification_delete', notification_id=notification.id) }}" class="no-underline"><span class="fe fe-delete"> Delete</span></a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No notifications to show.</p>
{% endif %}
</div>
<div class="col-12 col-md-4">
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Manage') }}</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-6">
<a class="w-100 btn btn-primary" href="/u/{{ current_user.user_name }}/profile">{{ _('Profile') }}</a>
</div>
<div class="col-6">
<a class="w-100 btn btn-primary" href="/u/{{ current_user.user_name }}/settings">{{ _('Settings') }}</a>
</div>
</div>
</div>
</div>
{% if len(subscribed) > 0 or len(moderates) > 0 %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Communities') }}</h2>
</div>
<div class="card-body">
{% if len(subscribed) > 0 %}
<h4>Subscribed to</h4>
<ul>
{% for community in subscribed %}
<li>
<a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />{{ community.display_name() }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if len(moderates) > 0 %}
<h4>Moderates</h4>
<ul>
{% for community in moderates %}
<li>
<a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />{{ community.display_name() }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% endif %}
{% if upvoted %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Upvoted') }}</h2>
</div>
<div class="card-body">
<ul>
{% for post in upvoted %}
<li><a href="{{ url_for('post.show_post', post_id=post.id) }}">{{ post.title }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,13 +1,15 @@
from datetime import datetime, timedelta
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _
from app import db
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification
from app.user import bp
from app.user.forms import ProfileForm, SettingsForm
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string
from sqlalchemy import desc, or_
from sqlalchemy import desc, or_, text
def show_profile(user):
@ -105,7 +107,7 @@ def ban_profile(actor):
abort(404)
if user.id == current_user.id:
flash('You cannot ban yourself.', 'error')
flash(_('You cannot ban yourself.'), 'error')
else:
user.banned = True
db.session.commit()
@ -130,7 +132,7 @@ def unban_profile(actor):
abort(404)
if user.id == current_user.id:
flash('You cannot unban yourself.', 'error')
flash(_('You cannot unban yourself.'), 'error')
else:
user.banned = False
db.session.commit()
@ -154,7 +156,7 @@ def delete_profile(actor):
if user is None:
abort(404)
if user.id == current_user.id:
flash('You cannot delete yourself.', 'error')
flash(_('You cannot delete yourself.'), 'error')
else:
user.banned = True
user.deleted = True
@ -180,7 +182,7 @@ def ban_purge_profile(actor):
abort(404)
if user.id == current_user.id:
flash('You cannot purge yourself.', 'error')
flash(_('You cannot purge yourself.'), 'error')
else:
user.banned = True
user.deleted = True
@ -199,3 +201,56 @@ def ban_purge_profile(actor):
goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}'
return redirect(goto)
@bp.route('/notifications', methods=['GET', 'POST'])
@login_required
def notifications():
"""Remove notifications older than 30 days"""
db.session.query(Notification).filter(
Notification.created_at < datetime.utcnow() - timedelta(days=30)).delete()
db.session.commit()
# Update unread notifications count
current_user.unread_notifications = Notification.query.filter_by(user_id=current_user.id, read=False).count()
db.session.commit()
notification_list = Notification.query.filter_by(user_id=current_user.id).order_by(desc(Notification.created_at)).all()
return render_template('user/notifications.html', title=_('Notifications'), notifications=notification_list, user=current_user)
@bp.route('/notification/<int:notification_id>/goto', methods=['GET', 'POST'])
@login_required
def notification_goto(notification_id):
notification = Notification.query.get_or_404(notification_id)
if notification.user_id == current_user.id:
if not notification.read:
current_user.unread_notifications -= 1
notification.read = True
db.session.commit()
return redirect(notification.url)
else:
abort(403)
@bp.route('/notification/<int:notification_id>/delete', methods=['GET', 'POST'])
@login_required
def notification_delete(notification_id):
notification = Notification.query.get_or_404(notification_id)
if notification.user_id == current_user.id:
if not notification.read:
current_user.unread_notifications -= 1
db.session.delete(notification)
db.session.commit()
return redirect(url_for('user.notifications'))
@bp.route('/notifications/all_read', methods=['GET', 'POST'])
@login_required
def notifications_all_read():
db.session.execute(text('UPDATE notification SET read=true WHERE user_id = :user_id'), {'user_id': current_user.id})
current_user.unread_notifications = 0
db.session.commit()
flash(_('All notifications marked as read.'))
return redirect(url_for('user.notifications'))

View file

@ -1,8 +1,8 @@
"""notifications
Revision ID: f5706aa6b94c
Revision ID: cae2e31293e8
Revises: ee45b9ea4a0c
Create Date: 2023-11-30 19:21:04.549875
Create Date: 2023-11-30 22:49:24.942158
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f5706aa6b94c'
revision = 'cae2e31293e8'
down_revision = 'ee45b9ea4a0c'
branch_labels = None
depends_on = None
@ -36,11 +36,17 @@ def upgrade():
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.add_column(sa.Column('notify_author', sa.Boolean(), nullable=True))
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('unread_notifications', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('unread_notifications')
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.drop_column('notify_author')