mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 19:36:56 -08:00
per-user keyword filters
This commit is contained in:
parent
29a75c045a
commit
c6216f2588
14 changed files with 419 additions and 93 deletions
|
@ -17,7 +17,7 @@ from app.models import User, Community, CommunityMember, CommunityJoinRequest, C
|
|||
from app.community 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, community_membership, ap_datetime, \
|
||||
request_etag_matches, return_304, instance_banned, can_create, can_upvote, can_downvote
|
||||
request_etag_matches, return_304, instance_banned, can_create, can_upvote, can_downvote, user_filters_posts
|
||||
from feedgen.feed import FeedGenerator
|
||||
from datetime import timezone, timedelta
|
||||
|
||||
|
@ -118,7 +118,9 @@ def show_community(community: Community):
|
|||
|
||||
if current_user.is_anonymous or current_user.ignore_bots:
|
||||
posts = community.posts.filter(Post.from_bot == False)
|
||||
content_filters = {}
|
||||
else:
|
||||
content_filters = user_filters_posts(current_user.id)
|
||||
posts = community.posts
|
||||
if sort == '' or sort == 'hot':
|
||||
posts = posts.order_by(desc(Post.ranking))
|
||||
|
@ -141,7 +143,8 @@ def show_community(community: Community):
|
|||
og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING,
|
||||
SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, etag=f"{community.id}_{hash(community.last_active)}",
|
||||
next_url=next_url, prev_url=prev_url, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1',
|
||||
rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} posts on PieFed")
|
||||
rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} posts on PieFed",
|
||||
content_filters=content_filters)
|
||||
|
||||
|
||||
# RSS feed of the community
|
||||
|
|
|
@ -14,7 +14,7 @@ from flask_babel import _, get_locale
|
|||
from sqlalchemy import select, desc, text
|
||||
from sqlalchemy_searchable import search
|
||||
from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \
|
||||
ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text
|
||||
ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text, user_filters_home
|
||||
from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic
|
||||
|
||||
|
||||
|
@ -38,12 +38,14 @@ def index():
|
|||
flash(_('Create an account to tailor this feed to your interests.'))
|
||||
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
|
||||
posts = posts.join(Community, Community.id == Post.community_id).filter(Community.show_home == True)
|
||||
content_filters = {}
|
||||
else:
|
||||
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(CommunityMember.is_banned == False)
|
||||
posts = posts.join(User, CommunityMember.user_id == User.id).filter(User.id == current_user.id)
|
||||
domains_ids = blocked_domains(current_user.id)
|
||||
if domains_ids:
|
||||
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
|
||||
content_filters = user_filters_home(current_user.id)
|
||||
|
||||
posts = posts.order_by(desc(Post.ranking)).paginate(page=page, per_page=100, error_out=False)
|
||||
|
||||
|
@ -57,7 +59,8 @@ def index():
|
|||
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
|
||||
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
|
||||
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", rss_feed_name=f"Posts on " + g.site.name,
|
||||
title=f"{g.site.name} - {g.site.description}", description=shorten_string(markdown_to_text(g.site.sidebar), 150))
|
||||
title=f"{g.site.name} - {g.site.description}", description=shorten_string(markdown_to_text(g.site.sidebar), 150),
|
||||
content_filters=content_filters)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['HEAD', 'GET', 'POST'])
|
||||
|
@ -73,6 +76,7 @@ def new_posts():
|
|||
|
||||
if current_user.is_anonymous:
|
||||
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
|
||||
content_filters = {}
|
||||
else:
|
||||
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
|
||||
CommunityMember.is_banned == False)
|
||||
|
@ -80,6 +84,7 @@ def new_posts():
|
|||
domains_ids = blocked_domains(current_user.id)
|
||||
if domains_ids:
|
||||
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
|
||||
content_filters = user_filters_home(current_user.id)
|
||||
|
||||
posts = posts.order_by(desc(Post.posted_at)).paginate(page=page, per_page=100, error_out=False)
|
||||
|
||||
|
@ -93,7 +98,8 @@ def new_posts():
|
|||
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
|
||||
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
|
||||
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
|
||||
rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1')
|
||||
rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1',
|
||||
content_filters=content_filters)
|
||||
|
||||
|
||||
@bp.route('/top', methods=['HEAD', 'GET', 'POST'])
|
||||
|
@ -109,6 +115,7 @@ def top_posts():
|
|||
|
||||
if current_user.is_anonymous:
|
||||
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
|
||||
content_filters = {}
|
||||
else:
|
||||
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
|
||||
CommunityMember.is_banned == False)
|
||||
|
@ -116,6 +123,7 @@ def top_posts():
|
|||
domains_ids = blocked_domains(current_user.id)
|
||||
if domains_ids:
|
||||
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
|
||||
content_filters = user_filters_home(current_user.id)
|
||||
|
||||
posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.score)).paginate(page=page, per_page=100, error_out=False)
|
||||
|
||||
|
@ -129,7 +137,8 @@ def top_posts():
|
|||
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
|
||||
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
|
||||
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
|
||||
rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1')
|
||||
rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1',
|
||||
content_filters=content_filters)
|
||||
|
||||
|
||||
@bp.route('/communities', methods=['GET'])
|
||||
|
|
|
@ -665,6 +665,14 @@ class Post(db.Model):
|
|||
else:
|
||||
return f"https://{current_app.config['SERVER_NAME']}/post/{self.id}"
|
||||
|
||||
def blocked_by_content_filter(self, content_filters):
|
||||
lowercase_title = self.title.lower()
|
||||
for name, keywords in content_filters.items() if content_filters else {}:
|
||||
for keyword in keywords:
|
||||
if keyword in lowercase_title:
|
||||
return name
|
||||
return False
|
||||
|
||||
def flush_cache(self):
|
||||
cache.delete(f'/post/{self.id}_False')
|
||||
cache.delete(f'/post/{self.id}_True')
|
||||
|
@ -750,6 +758,14 @@ class PostReply(db.Model):
|
|||
reply = PostReply.query.filter_by(parent_id=self.id).first()
|
||||
return reply is not None
|
||||
|
||||
def blocked_by_content_filter(self, content_filters):
|
||||
lowercase_body = self.body.lower()
|
||||
for name, keywords in content_filters.items() if content_filters else {}:
|
||||
for keyword in keywords:
|
||||
if keyword in lowercase_body:
|
||||
return name
|
||||
return False
|
||||
|
||||
|
||||
class Domain(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
@ -864,17 +880,18 @@ class ActivityPubLog(db.Model):
|
|||
class Filter(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(50))
|
||||
filter_home = db.Column(db.Boolean, default=True)
|
||||
filter_posts = db.Column(db.Boolean, default=True)
|
||||
filter_replies = db.Column(db.Boolean, default=False)
|
||||
hide_type = db.Column(db.Integer, default=0) # 0 = hide with warning, 1 = hide completely
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
expire_after = db.Column(db.Date)
|
||||
keywords = db.Column(db.String(500))
|
||||
|
||||
|
||||
class FilterKeyword(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
keyword = db.Column(db.String(100))
|
||||
filter_id = db.Column(db.Integer, db.ForeignKey('filter.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
def keywords_string(self):
|
||||
if self.keywords is None or self.keywords == '':
|
||||
return ''
|
||||
return ', '.join(self.keywords.split('\n'))
|
||||
|
||||
|
||||
class Role(db.Model):
|
||||
|
|
|
@ -553,6 +553,17 @@ fieldset legend {
|
|||
width: 133px;
|
||||
}
|
||||
}
|
||||
.post_list .post_teaser.blocked {
|
||||
opacity: 0.2;
|
||||
font-size: 80%;
|
||||
}
|
||||
.post_list .post_teaser.blocked .voting_buttons {
|
||||
width: 46px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.post_list .post_teaser.blocked .voting_buttons .upvote_button, .post_list .post_teaser.blocked .voting_buttons .downvote_button {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.url_thumbnail {
|
||||
float: right;
|
||||
|
@ -770,7 +781,7 @@ fieldset legend {
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.profile_action_buttons {
|
||||
.profile_action_buttons, .rh_action_buttons {
|
||||
float: right;
|
||||
}
|
||||
|
||||
|
|
|
@ -163,6 +163,10 @@ nav, etc which are used site-wide */
|
|||
.post_list {
|
||||
.post_teaser {
|
||||
|
||||
border-bottom: solid 2px $light-grey;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
h3 {
|
||||
font-size: 110%;
|
||||
margin-top: 4px;
|
||||
|
@ -193,10 +197,18 @@ nav, etc which are used site-wide */
|
|||
}
|
||||
}
|
||||
}
|
||||
&.blocked {
|
||||
opacity: 0.2;
|
||||
font-size: 80%;
|
||||
|
||||
border-bottom: solid 2px $light-grey;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
.voting_buttons {
|
||||
width: 46px;
|
||||
line-height: 24px;
|
||||
.upvote_button, .downvote_button {
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -454,7 +466,7 @@ fieldset {
|
|||
}
|
||||
}
|
||||
|
||||
.profile_action_buttons {
|
||||
.profile_action_buttons, .rh_action_buttons {
|
||||
float: right;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
<div class="post_teaser {{ 'reported' if post.reports and current_user.is_authenticated and post.community.is_moderator() }}">
|
||||
{% set content_blocked = post.blocked_by_content_filter(content_filters) %}
|
||||
{% if content_blocked and content_blocked == '-1' %}
|
||||
{# do nothing - blocked by keyword filter #}
|
||||
{% else %}
|
||||
<div class="post_teaser{{ ' reported' if post.reports and current_user.is_authenticated and post.community.is_moderator() }}{{ ' blocked' if content_blocked }}"
|
||||
{% if content_blocked %} title="{{ _('Filtered: ') }}{{ content_blocked }}"{% endif %}>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="row main_row">
|
||||
|
@ -63,4 +68,5 @@
|
|||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
42
app/templates/user/edit_filters.html
Normal file
42
app/templates/user/edit_filters.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'bootstrap/form.html' import render_field %}
|
||||
|
||||
{% block app_content %}
|
||||
<div class="row">
|
||||
<div class="col 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="/u/{{ user.link() }}">{{ user.display_name() }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/user/settings">{{ _('Settings') }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/user/settings/filters">{{ _('Filters') }}</a></li>
|
||||
{% if content_filter %}
|
||||
<li class="breadcrumb-item active">{{ _('Edit filter') }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item active">{{ _('Add filter') }}</li>
|
||||
{% endif %}
|
||||
|
||||
</ol>
|
||||
</nav>
|
||||
{% if content_filter %}
|
||||
<h1 class="mt-2">{{ _('Filter %(name)s', name=content_filter.title) }}</h1>
|
||||
{% else %}
|
||||
<h1 class="mt-2">{{ _('Add filter') }}</h1>
|
||||
{% endif %}
|
||||
<form method='post'>
|
||||
{{ form.csrf_token() }}
|
||||
{{ render_field(form.title) }}
|
||||
<h4>{{ _('Filter in these places') }}</h4>
|
||||
{{ render_field(form.filter_home) }}
|
||||
{{ render_field(form.filter_posts) }}
|
||||
{{ render_field(form.filter_replies) }}
|
||||
{{ render_field(form.hide_type) }}
|
||||
{{ render_field(form.keywords) }}
|
||||
<small class="field_hint">{{ _('One per line. Case does not matter.') }}</small>
|
||||
{{ render_field(form.expire_after) }}
|
||||
<small class="field_hint">{{ _('Stop applying this filter after this date. Optional.') }}</small>
|
||||
{{ render_field(form.submit) }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -22,6 +22,7 @@
|
|||
{{ render_field(form.indexable) }}
|
||||
{{ render_field(form.import_file) }}
|
||||
{{ render_field(form.submit) }}
|
||||
<a href="{{ url_for('user.user_settings_filters') }}">{{ _('Manage content filters') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
47
app/templates/user/filters.html
Normal file
47
app/templates/user/filters.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'bootstrap/form.html' import render_field %}
|
||||
|
||||
{% block app_content %}
|
||||
<div class="row">
|
||||
<div class="col 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="/u/{{ user.link() }}">{{ user.display_name() }}</a></li>
|
||||
<li class="breadcrumb-item active"><a href="/user/settings">{{ _('Settings') }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ _('Filters') }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="rh_action_buttons">
|
||||
<a class="btn btn-primary" href="{{ url_for('user.user_settings_filters_add') }}">{{ _('Add filter') }}</a>
|
||||
</div>
|
||||
<h1 class="mt-2">{{ _('Filters') }}</h1>
|
||||
<p>{{ _('Filters can hide posts that contain keywords you specify, either by making them less noticeable or invisible.') }}</p>
|
||||
{% if filters %}
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
<th>{{ _('Name') }}</th>
|
||||
<th>{{ _('Keywords') }}</th>
|
||||
<th>{{ _('Action') }}</th>
|
||||
<th>{{ _('Expires') }}</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
{% for filter in filters %}
|
||||
<tr>
|
||||
<td>{{ filter.title }}</td>
|
||||
<td>{{ filter.keywords_string()|shorten(30) }}</td>
|
||||
<td>{{ _('Invisible') if filter.hide_type == 1 else _('Semi-transparent') }}</td>
|
||||
<td>{{ filter.expire_after if filter.expire_after }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('user.user_settings_filters_edit', filter_id=filter.id) }}">Edit</a> |
|
||||
<a href="{{ url_for('user.user_settings_filters_delete', filter_id=filter.id) }}" class="confirm_first">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>{{ _('No filters defined yet.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -112,7 +112,7 @@
|
|||
<a class="w-100 btn btn-primary" href="/u/{{ user.user_name }}/profile">{{ _('Profile') }}</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<a class="w-100 btn btn-primary" href="/u/{{ user.user_name }}/settings">{{ _('Settings') }}</a>
|
||||
<a class="w-100 btn btn-primary" href="/user/settings">{{ _('Settings') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
from flask import session
|
||||
from flask_login import current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SubmitField, PasswordField, BooleanField, EmailField, TextAreaField, FileField
|
||||
from wtforms import StringField, SubmitField, PasswordField, BooleanField, EmailField, TextAreaField, FileField, \
|
||||
RadioField, DateField
|
||||
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
|
||||
from flask_babel import _, lazy_gettext as _l
|
||||
|
||||
|
@ -69,3 +70,17 @@ class ReportUserForm(FlaskForm):
|
|||
if choice[0] == reason_id:
|
||||
result.append(str(choice[1]))
|
||||
return ', '.join(result)
|
||||
|
||||
|
||||
class FilterEditForm(FlaskForm):
|
||||
title = StringField(_l('Name'), validators={DataRequired(), Length(min=3, max=50)})
|
||||
filter_home = BooleanField(_l('Home feed'), default=True)
|
||||
filter_posts = BooleanField(_l('Posts in communities'))
|
||||
filter_replies = BooleanField(_l('Comments on posts'))
|
||||
hide_type_choices = [(0, _l('Make semi-transparent')), (1, _l('Hide completely'))]
|
||||
hide_type = RadioField(_l('Action to take'), choices=hide_type_choices, default=1, coerce=int)
|
||||
keywords = TextAreaField(_l('Keywords that trigger this filter'),
|
||||
render_kw={'placeholder': 'One keyword or phrase per line', 'rows': 3},
|
||||
validators={DataRequired(), Length(min=3, max=500)})
|
||||
expire_after = DateField(_l('Expire after'), validators={Optional()})
|
||||
submit = SubmitField(_l('Save'))
|
||||
|
|
|
@ -11,11 +11,12 @@ from app.activitypub.util import default_context, find_actor_or_create
|
|||
from app.community.util import save_icon_file, save_banner_file, retrieve_mods_and_backfill
|
||||
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING
|
||||
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \
|
||||
Instance, Report, UserBlock, CommunityBan, CommunityJoinRequest, CommunityBlock
|
||||
Instance, Report, UserBlock, CommunityBan, CommunityJoinRequest, CommunityBlock, Filter
|
||||
from app.user import bp
|
||||
from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm
|
||||
from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm, FilterEditForm
|
||||
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \
|
||||
is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership
|
||||
is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \
|
||||
user_filters_posts, user_filters_replies
|
||||
from sqlalchemy import desc, or_, text
|
||||
import os
|
||||
|
||||
|
@ -136,15 +137,12 @@ def edit_profile(actor):
|
|||
return render_template('user/edit_profile.html', title=_('Edit profile'), form=form, user=current_user)
|
||||
|
||||
|
||||
@bp.route('/u/<actor>/settings', methods=['GET', 'POST'])
|
||||
@bp.route('/user/settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_settings(actor):
|
||||
actor = actor.strip()
|
||||
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
|
||||
def change_settings():
|
||||
user = User.query.filter_by(id=current_user.id, deleted=False, banned=False, ap_id=None).first()
|
||||
if user is None:
|
||||
abort(404)
|
||||
if current_user.id != user.id:
|
||||
abort(401)
|
||||
form = SettingsForm()
|
||||
if form.validate_on_submit():
|
||||
current_user.newsletter = form.newsletter.data
|
||||
|
@ -174,7 +172,7 @@ def change_settings(actor):
|
|||
db.session.commit()
|
||||
|
||||
flash(_('Your changes have been saved.'), 'success')
|
||||
return redirect(url_for('user.change_settings', actor=actor))
|
||||
return redirect(url_for('user.change_settings'))
|
||||
elif request.method == 'GET':
|
||||
form.newsletter.data = current_user.newsletter
|
||||
form.ignore_bots.data = current_user.ignore_bots
|
||||
|
@ -573,3 +571,83 @@ def import_settings_task(user_id, filename):
|
|||
...
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@bp.route('/user/settings/filters', methods=['GET'])
|
||||
@login_required
|
||||
def user_settings_filters():
|
||||
filters = Filter.query.filter_by(user_id=current_user.id).order_by(Filter.title).all()
|
||||
return render_template('user/filters.html', filters=filters, user=current_user)
|
||||
|
||||
|
||||
@bp.route('/user/settings/filters/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def user_settings_filters_add():
|
||||
form = FilterEditForm()
|
||||
form.filter_replies.render_kw = {'disabled': True}
|
||||
if form.validate_on_submit():
|
||||
content_filter = Filter(title=form.title.data, filter_home=form.filter_home.data, filter_posts=form.filter_posts.data,
|
||||
filter_replies=form.filter_replies.data, hide_type=form.hide_type.data, keywords=form.keywords.data,
|
||||
expire_after=form.expire_after.data, user_id=current_user.id)
|
||||
db.session.add(content_filter)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(user_filters_home, current_user.id)
|
||||
cache.delete_memoized(user_filters_posts, current_user.id)
|
||||
cache.delete_memoized(user_filters_replies, current_user.id)
|
||||
|
||||
flash(_('Your changes have been saved.'), 'success')
|
||||
return redirect(url_for('user.user_settings_filters'))
|
||||
|
||||
return render_template('user/edit_filters.html', title=_('Add filter'), form=form, user=current_user)
|
||||
|
||||
|
||||
@bp.route('/user/settings/filters/<int:filter_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def user_settings_filters_edit(filter_id):
|
||||
|
||||
content_filter = Filter.query.get_or_404(filter_id)
|
||||
if current_user.id != content_filter.user_id:
|
||||
abort(401)
|
||||
form = FilterEditForm()
|
||||
form.filter_replies.render_kw = {'disabled': True}
|
||||
if form.validate_on_submit():
|
||||
content_filter.title = form.title.data
|
||||
content_filter.filter_home = form.filter_home.data
|
||||
content_filter.filter_posts = form.filter_posts.data
|
||||
content_filter.filter_replies = form.filter_replies.data
|
||||
content_filter.hide_type = form.hide_type.data
|
||||
content_filter.keywords = form.keywords.data
|
||||
content_filter.expire_after = form.expire_after.data
|
||||
db.session.commit()
|
||||
cache.delete_memoized(user_filters_home, current_user.id)
|
||||
cache.delete_memoized(user_filters_posts, current_user.id)
|
||||
cache.delete_memoized(user_filters_replies, current_user.id)
|
||||
|
||||
flash(_('Your changes have been saved.'), 'success')
|
||||
|
||||
return redirect(url_for('user.user_settings_filters'))
|
||||
elif request.method == 'GET':
|
||||
form.title.data = content_filter.title
|
||||
form.filter_home.data = content_filter.filter_home
|
||||
form.filter_posts.data = content_filter.filter_posts
|
||||
form.filter_replies.data = content_filter.filter_replies
|
||||
form.hide_type.data = content_filter.hide_type
|
||||
form.keywords.data = content_filter.keywords
|
||||
form.expire_after.data = content_filter.expire_after
|
||||
|
||||
return render_template('user/edit_filters.html', title=_('Edit filter'), form=form, content_filter=content_filter, user=current_user)
|
||||
|
||||
|
||||
@bp.route('/user/settings/filters/<int:filter_id>/delete', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def user_settings_filters_delete(filter_id):
|
||||
content_filter = Filter.query.get_or_404(filter_id)
|
||||
if current_user.id != content_filter.user_id:
|
||||
abort(401)
|
||||
db.session.delete(content_filter)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(user_filters_home, current_user.id)
|
||||
cache.delete_memoized(user_filters_posts, current_user.id)
|
||||
cache.delete_memoized(user_filters_replies, current_user.id)
|
||||
flash(_('Filter deleted.'))
|
||||
return redirect(url_for('user.user_settings_filters'))
|
||||
|
|
45
app/utils.py
45
app/utils.py
|
@ -1,7 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import List, Literal, Union
|
||||
|
||||
import markdown2
|
||||
|
@ -15,14 +16,14 @@ import os
|
|||
import imghdr
|
||||
from flask import current_app, json, redirect, url_for, request, make_response, Response, g
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import text, or_
|
||||
from wtforms.fields import SelectField, SelectMultipleField
|
||||
from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
|
||||
from app import db, cache
|
||||
import re
|
||||
|
||||
from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \
|
||||
Site, Post, PostReply, utcnow
|
||||
Site, Post, PostReply, utcnow, Filter
|
||||
|
||||
|
||||
# Flask's render_template function, with support for themes added
|
||||
|
@ -514,6 +515,44 @@ def shorten_number(number):
|
|||
return f'{number / 1000000:.1f}M'
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def user_filters_home(user_id):
|
||||
filters = Filter.query.filter_by(user_id=user_id, filter_home=True).filter(or_(Filter.expire_after > date.today(), Filter.expire_after == None))
|
||||
result = defaultdict(set)
|
||||
for filter in filters:
|
||||
keywords = [keyword.strip().lower() for keyword in filter.keywords.splitlines()]
|
||||
if filter.hide_type == 0:
|
||||
result[filter.title].update(keywords)
|
||||
else: # type == 1 means hide completely. These posts are excluded from output by the jinja template
|
||||
result['-1'].update(keywords)
|
||||
return result
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def user_filters_posts(user_id):
|
||||
filters = Filter.query.filter_by(user_id=user_id, filter_posts=True).filter(or_(Filter.expire_after > date.today(), Filter.expire_after == None))
|
||||
result = defaultdict(set)
|
||||
for filter in filters:
|
||||
keywords = [keyword.strip().lower() for keyword in filter.keywords.splitlines()]
|
||||
if filter.hide_type == 0:
|
||||
result[filter.title].update(keywords)
|
||||
else:
|
||||
result['-1'].update(keywords)
|
||||
return result
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def user_filters_replies(user_id):
|
||||
filters = Filter.query.filter_by(user_id=user_id, filter_replies=True).filter(or_(Filter.expire_after > date.today(), Filter.expire_after == None))
|
||||
result = defaultdict(set)
|
||||
for filter in filters:
|
||||
keywords = [keyword.strip().lower() for keyword in filter.keywords.splitlines()]
|
||||
if filter.hide_type == 0:
|
||||
result[filter.title].update(keywords)
|
||||
else:
|
||||
result['-1'].update(keywords)
|
||||
return result
|
||||
|
||||
|
||||
# All the following post/comment ranking math is explained at https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
|
||||
epoch = datetime(1970, 1, 1)
|
||||
|
|
46
migrations/versions/0b49f0997073_keyword_filter.py
Normal file
46
migrations/versions/0b49f0997073_keyword_filter.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
"""keyword filter
|
||||
|
||||
Revision ID: 0b49f0997073
|
||||
Revises: aaf434e9e7bf
|
||||
Create Date: 2024-01-11 16:02:01.944862
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0b49f0997073'
|
||||
down_revision = 'aaf434e9e7bf'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('filter_keyword')
|
||||
with op.batch_alter_table('filter', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('filter_home', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('expire_after', sa.Date(), nullable=True))
|
||||
batch_op.add_column(sa.Column('keywords', sa.String(length=500), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('filter', schema=None) as batch_op:
|
||||
batch_op.drop_column('keywords')
|
||||
batch_op.drop_column('expire_after')
|
||||
batch_op.drop_column('filter_home')
|
||||
|
||||
op.create_table('filter_keyword',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('keyword', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('filter_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.ForeignKeyConstraint(['filter_id'], ['filter.id'], name='filter_keyword_filter_id_fkey'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='filter_keyword_user_id_fkey'),
|
||||
sa.PrimaryKeyConstraint('id', name='filter_keyword_pkey')
|
||||
)
|
||||
# ### end Alembic commands ###
|
Loading…
Reference in a new issue