From c6216f2588a50531b494bb5fcfd4b96649e3181f Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Thu, 11 Jan 2024 20:39:22 +1300 Subject: [PATCH] per-user keyword filters --- app/community/routes.py | 7 +- app/main/routes.py | 17 ++- app/models.py | 29 +++- app/static/structure.css | 13 +- app/static/structure.scss | 20 ++- app/templates/post/_post_teaser.html | 128 +++++++++--------- app/templates/user/edit_filters.html | 42 ++++++ app/templates/user/edit_settings.html | 1 + app/templates/user/filters.html | 47 +++++++ app/templates/user/show_profile.html | 2 +- app/user/forms.py | 17 ++- app/user/routes.py | 98 ++++++++++++-- app/utils.py | 45 +++++- .../versions/0b49f0997073_keyword_filter.py | 46 +++++++ 14 files changed, 419 insertions(+), 93 deletions(-) create mode 100644 app/templates/user/edit_filters.html create mode 100644 app/templates/user/filters.html create mode 100644 migrations/versions/0b49f0997073_keyword_filter.py diff --git a/app/community/routes.py b/app/community/routes.py index 0245cde4..d3301215 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -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 diff --git a/app/main/routes.py b/app/main/routes.py index 31024d0c..db442a46 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -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']) diff --git a/app/models.py b/app/models.py index 8e33d3bd..f4f5e27e 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/static/structure.css b/app/static/structure.css index 076c5d70..ffad637d 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -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; } diff --git a/app/static/structure.scss b/app/static/structure.scss index 9d3c2818..35be044b 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -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; } diff --git a/app/templates/post/_post_teaser.html b/app/templates/post/_post_teaser.html index 8b8e25f5..041f696c 100644 --- a/app/templates/post/_post_teaser.html +++ b/app/templates/post/_post_teaser.html @@ -1,66 +1,72 @@ -
-
-
-
-
- - {% if show_post_community %}c/{{ post.community.name }}{% endif %} - by {{ render_username(post.author) }} {{ moment(post.posted_at).fromNow() }} - +
+ +
+
+ -
-
-
- - {{ post.reply_count }} - {% if post.type == POST_TYPE_IMAGE %} -   - {% if post.image_id %} - - {% else %} - - {% endif %} - {% endif %} -
-
-
- - - - \ No newline at end of file +{% endif %} diff --git a/app/templates/user/edit_filters.html b/app/templates/user/edit_filters.html new file mode 100644 index 00000000..ad2bd647 --- /dev/null +++ b/app/templates/user/edit_filters.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} +
+
+ + {% if content_filter %} +

{{ _('Filter %(name)s', name=content_filter.title) }}

+ {% else %} +

{{ _('Add filter') }}

+ {% endif %} +
+ {{ form.csrf_token() }} + {{ render_field(form.title) }} +

{{ _('Filter in these places') }}

+ {{ render_field(form.filter_home) }} + {{ render_field(form.filter_posts) }} + {{ render_field(form.filter_replies) }} + {{ render_field(form.hide_type) }} + {{ render_field(form.keywords) }} + {{ _('One per line. Case does not matter.') }} + {{ render_field(form.expire_after) }} + {{ _('Stop applying this filter after this date. Optional.') }} + {{ render_field(form.submit) }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/user/edit_settings.html b/app/templates/user/edit_settings.html index 520f4880..9275a135 100644 --- a/app/templates/user/edit_settings.html +++ b/app/templates/user/edit_settings.html @@ -22,6 +22,7 @@ {{ render_field(form.indexable) }} {{ render_field(form.import_file) }} {{ render_field(form.submit) }} +   {{ _('Manage content filters') }} diff --git a/app/templates/user/filters.html b/app/templates/user/filters.html new file mode 100644 index 00000000..680c7672 --- /dev/null +++ b/app/templates/user/filters.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} +
+
+ + +

{{ _('Filters') }}

+

{{ _('Filters can hide posts that contain keywords you specify, either by making them less noticeable or invisible.') }}

+ {% if filters %} + + + + + + + + + {% for filter in filters %} + + + + + + + + {% endfor %} +
{{ _('Name') }}{{ _('Keywords') }}{{ _('Action') }}{{ _('Expires') }}
{{ filter.title }}{{ filter.keywords_string()|shorten(30) }}{{ _('Invisible') if filter.hide_type == 1 else _('Semi-transparent') }}{{ filter.expire_after if filter.expire_after }} + Edit | + Delete +
+ {% else %} +

{{ _('No filters defined yet.') }}

+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/user/show_profile.html b/app/templates/user/show_profile.html index 33031dfb..c1a2acff 100644 --- a/app/templates/user/show_profile.html +++ b/app/templates/user/show_profile.html @@ -112,7 +112,7 @@ {{ _('Profile') }}
- {{ _('Settings') }} + {{ _('Settings') }}
diff --git a/app/user/forms.py b/app/user/forms.py index 1ea5e73c..a740d2d8 100644 --- a/app/user/forms.py +++ b/app/user/forms.py @@ -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')) diff --git a/app/user/routes.py b/app/user/routes.py index 16ee84f4..6e1c5a44 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -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//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//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//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')) diff --git a/app/utils.py b/app/utils.py index 9699b6d2..63cfc533 100644 --- a/app/utils.py +++ b/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) diff --git a/migrations/versions/0b49f0997073_keyword_filter.py b/migrations/versions/0b49f0997073_keyword_filter.py new file mode 100644 index 00000000..c74c9f4e --- /dev/null +++ b/migrations/versions/0b49f0997073_keyword_filter.py @@ -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 ###