per-user keyword filters

This commit is contained in:
rimu 2024-01-11 20:39:22 +13:00
parent 29a75c045a
commit c6216f2588
14 changed files with 419 additions and 93 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
@ -64,3 +69,4 @@
</div>
{% endif %}

View 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 %}

View file

@ -22,6 +22,7 @@
{{ render_field(form.indexable) }}
{{ render_field(form.import_file) }}
{{ render_field(form.submit) }}
&nbsp;&nbsp;<a href="{{ url_for('user.user_settings_filters') }}">{{ _('Manage content filters') }}</a>
</form>
</div>
</div>

View 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 %}

View file

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

View file

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

View file

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

View file

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

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