This commit is contained in:
rimu 2024-05-12 13:02:45 +12:00
parent f5c7e44d28
commit 3c5a86f6e6
26 changed files with 504 additions and 19 deletions

View file

@ -103,6 +103,9 @@ def create_app(config_class=Config):
from app.search import bp as search_bp from app.search import bp as search_bp
app.register_blueprint(search_bp) app.register_blueprint(search_bp)
from app.tag import bp as tag_bp
app.register_blueprint(tag_bp)
# send error reports via email # send error reports via email
if app.config['MAIL_SERVER'] and app.config['MAIL_ERRORS']: if app.config['MAIL_SERVER'] and app.config['MAIL_ERRORS']:
auth = None auth = None

View file

@ -161,7 +161,8 @@ def post_to_activity(post: Post, community: Community):
'language': { 'language': {
'identifier': post.language_code(), 'identifier': post.language_code(),
'name': post.language_name() 'name': post.language_name()
} },
'tag': post.tags_for_activitypub()
}, },
"cc": [ "cc": [
f"https://{current_app.config['SERVER_NAME']}/c/{community.name}" f"https://{current_app.config['SERVER_NAME']}/c/{community.name}"
@ -208,7 +209,12 @@ def post_to_page(post: Post, community: Community):
"sensitive": post.nsfw or post.nsfl, "sensitive": post.nsfw or post.nsfl,
"published": ap_datetime(post.created_at), "published": ap_datetime(post.created_at),
"stickied": post.sticky, "stickied": post.sticky,
"audience": f"https://{current_app.config['SERVER_NAME']}/c/{community.name}" "audience": f"https://{current_app.config['SERVER_NAME']}/c/{community.name}",
"tag": post.tags_for_activitypub(),
'language': {
'identifier': post.language_code(),
'name': post.language_name()
},
} }
if post.edited_at is not None: if post.edited_at is not None:
activity_data["updated"] = ap_datetime(post.edited_at) activity_data["updated"] = ap_datetime(post.edited_at)
@ -385,7 +391,7 @@ def find_hashtag_or_create(hashtag: str) -> Tag:
if existing_tag: if existing_tag:
return existing_tag return existing_tag
else: else:
new_tag = Tag(name=hashtag.lower(), display_as=hashtag) new_tag = Tag(name=hashtag.lower(), display_as=hashtag, post_count=1)
db.session.add(new_tag) db.session.add(new_tag)
return new_tag return new_tag

View file

@ -7,7 +7,7 @@ from datetime import datetime, timedelta
import flask import flask
from flask import json, current_app from flask import json, current_app
from flask_babel import _ from flask_babel import _
from sqlalchemy import or_, desc from sqlalchemy import or_, desc, text
from sqlalchemy.orm import configure_mappers from sqlalchemy.orm import configure_mappers
from app import db from app import db
@ -19,7 +19,8 @@ from app.auth.util import random_token
from app.constants import NOTIF_COMMUNITY, NOTIF_POST, NOTIF_REPLY from app.constants import NOTIF_COMMUNITY, NOTIF_POST, NOTIF_REPLY
from app.email import send_verification_email, send_email from app.email import send_verification_email, send_email
from app.models import Settings, BannedInstances, Interest, Role, User, RolePermission, Domain, ActivityPubLog, \ from app.models import Settings, BannedInstances, Interest, Role, User, RolePermission, Domain, ActivityPubLog, \
utcnow, Site, Instance, File, Notification, Post, CommunityMember, NotificationSubscription, PostReply, Language utcnow, Site, Instance, File, Notification, Post, CommunityMember, NotificationSubscription, PostReply, Language, \
Tag
from app.utils import file_get_contents, retrieve_block_list, blocked_domains, retrieve_peertube_block_list, \ from app.utils import file_get_contents, retrieve_block_list, blocked_domains, retrieve_peertube_block_list, \
shorten_string shorten_string
@ -168,6 +169,12 @@ def register(app):
db.session.query(ActivityPubLog).filter(ActivityPubLog.created_at < utcnow() - timedelta(days=3)).delete() db.session.query(ActivityPubLog).filter(ActivityPubLog.created_at < utcnow() - timedelta(days=3)).delete()
db.session.commit() db.session.commit()
for tag in Tag.query.all():
post_count = db.session.execute(text('SELECT COUNT(post_id) as c FROM "post_tag" WHERE tag_id = :tag_id'),
{ 'tag_id': tag.id}).scalar()
tag.post_count = post_count
db.session.commit()
@app.cli.command("spaceusage") @app.cli.command("spaceusage")
def spaceusage(): def spaceusage():
with app.app_context(): with app.app_context():

View file

@ -93,9 +93,10 @@ class BanUserCommunityForm(FlaskForm):
class CreateDiscussionForm(FlaskForm): class CreateDiscussionForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'})
discussion_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) discussion_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)])
sticky = BooleanField(_l('Sticky')) sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW')) nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('Gore/gross')) nsfl = BooleanField(_l('Gore/gross'))
@ -105,11 +106,12 @@ class CreateDiscussionForm(FlaskForm):
class CreateLinkForm(FlaskForm): class CreateLinkForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'})
link_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) link_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
link_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], link_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')],
render_kw={'placeholder': 'https://...'}) render_kw={'placeholder': 'https://...'})
tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)])
sticky = BooleanField(_l('Sticky')) sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW')) nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('Gore/gross')) nsfl = BooleanField(_l('Gore/gross'))
@ -126,11 +128,12 @@ class CreateLinkForm(FlaskForm):
class CreateVideoForm(FlaskForm): class CreateVideoForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'})
video_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) video_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
video_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) video_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
video_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], video_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')],
render_kw={'placeholder': 'https://...'}) render_kw={'placeholder': 'https://...'})
tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)])
sticky = BooleanField(_l('Sticky')) sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW')) nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('Gore/gross')) nsfl = BooleanField(_l('Gore/gross'))
@ -147,11 +150,12 @@ class CreateVideoForm(FlaskForm):
class CreateImageForm(FlaskForm): class CreateImageForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int, render_kw={'class': 'form-select'})
image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=255)]) image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=255)])
image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
image_file = FileField(_l('Image'), validators=[DataRequired()]) image_file = FileField(_l('Image'), validators=[DataRequired()])
tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)])
sticky = BooleanField(_l('Sticky')) sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW')) nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('Gore/gross')) nsfl = BooleanField(_l('Gore/gross'))

View file

@ -796,7 +796,8 @@ def federate_post(community, post):
'language': { 'language': {
'identifier': post.language_code(), 'identifier': post.language_code(),
'name': post.language_name() 'name': post.language_name()
} },
'tag': post.tags_for_activitypub()
} }
create = { create = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",
@ -890,7 +891,8 @@ def federate_post_to_user_followers(post):
'language': { 'language': {
'identifier': post.language_code(), 'identifier': post.language_code(),
'name': post.language_name() 'name': post.language_name()
} },
'tag': post.tags_for_activitypub()
} }
create = { create = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",

View file

@ -10,14 +10,15 @@ from pillow_heif import register_heif_opener
from app import db, cache, celery from app import db, cache, celery
from app.activitypub.signature import post_request, default_context from app.activitypub.signature import post_request, default_context
from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, ensure_domains_match from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, ensure_domains_match, \
find_hashtag_or_create
from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO, NOTIF_POST from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO, NOTIF_POST
from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \
Instance, Notification, User, ActivityPubLog, NotificationSubscription, Language Instance, Notification, User, ActivityPubLog, NotificationSubscription, Language, Tag
from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \
is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \ is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \
remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases
from sqlalchemy import func, desc from sqlalchemy import func, desc, text
import os import os
@ -384,6 +385,9 @@ def save_post(form, post: Post, type: str):
return return
db.session.add(post) db.session.add(post)
else:
db.session.execute(text('DELETE FROM "post_tag" WHERE post_id = :post_id'), { 'post_id': post.id})
post.tags = tags_from_string(form.tags.data)
db.session.commit() db.session.commit()
# Notify author about replies # Notify author about replies
@ -404,6 +408,22 @@ def save_post(form, post: Post, type: str):
db.session.commit() db.session.commit()
def tags_from_string(tags: str) -> List[Tag]:
return_value = []
tags = tags.strip()
if tags == '':
return []
tag_list = tags.split(',')
tag_list = [tag.strip() for tag in tag_list]
for tag in tag_list:
if tag[0] == '#':
tag = tag[1:]
tag_to_append = find_hashtag_or_create(tag)
if tag_to_append:
return_value.append(tag_to_append)
return return_value
def delete_post_from_community(post_id): def delete_post_from_community(post_id):
if current_app.debug: if current_app.debug:
delete_post_from_community_task(post_id) delete_post_from_community_task(post_id)

View file

@ -174,6 +174,8 @@ class Tag(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(256), index=True) # lowercase version of tag, e.g. solarstorm name = db.Column(db.String(256), index=True) # lowercase version of tag, e.g. solarstorm
display_as = db.Column(db.String(256)) # Version of tag with uppercase letters, e.g. SolarStorm display_as = db.Column(db.String(256)) # Version of tag with uppercase letters, e.g. SolarStorm
post_count = db.Column(db.Integer, default=0)
banned = db.Column(db.Boolean, default=False, index=True)
class Language(db.Model): class Language(db.Model):
@ -1019,6 +1021,14 @@ class Post(db.Model):
else: else:
return 'English' return 'English'
def tags_for_activitypub(self):
return_value = []
for tag in self.tags:
return_value.append({'type': 'Hashtag',
'href': f'https://{current_app.config["SERVER_NAME"]}/tag/{tag.name}',
'name': f'#{tag.name}'})
return return_value
class PostReply(db.Model): class PostReply(db.Model):
query_class = FullTextSearchQuery query_class = FullTextSearchQuery

View file

@ -14,7 +14,7 @@ from app.community.util import save_post, send_to_remote_instance
from app.inoculation import inoculation from app.inoculation import inoculation
from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm
from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm
from app.post.util import post_replies, get_comment_branch, post_reply_count from app.post.util import post_replies, get_comment_branch, post_reply_count, tags_to_string
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \
POST_TYPE_IMAGE, \ POST_TYPE_IMAGE, \
POST_TYPE_ARTICLE, POST_TYPE_VIDEO, NOTIF_REPLY, NOTIF_POST POST_TYPE_ARTICLE, POST_TYPE_VIDEO, NOTIF_REPLY, NOTIF_POST
@ -863,6 +863,7 @@ def post_edit_discussion_post(post_id: int):
form.nsfl.data = post.nsfl form.nsfl.data = post.nsfl
form.sticky.data = post.sticky form.sticky.data = post.sticky
form.language_id.data = post.language_id form.language_id.data = post.language_id
form.tags.data = tags_to_string(post)
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True} form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit_discussion.html', title=_('Edit post'), form=form, post=post, return render_template('post/post_edit_discussion.html', title=_('Edit post'), form=form, post=post,
@ -948,6 +949,7 @@ def post_edit_image_post(post_id: int):
form.nsfl.data = post.nsfl form.nsfl.data = post.nsfl
form.sticky.data = post.sticky form.sticky.data = post.sticky
form.language_id.data = post.language_id form.language_id.data = post.language_id
form.tags.data = tags_to_string(post)
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True} form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit_image.html', title=_('Edit post'), form=form, post=post, return render_template('post/post_edit_image.html', title=_('Edit post'), form=form, post=post,
@ -1033,6 +1035,7 @@ def post_edit_link_post(post_id: int):
form.nsfl.data = post.nsfl form.nsfl.data = post.nsfl
form.sticky.data = post.sticky form.sticky.data = post.sticky
form.language_id.data = post.language_id form.language_id.data = post.language_id
form.tags.data = tags_to_string(post)
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True} form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit_link.html', title=_('Edit post'), form=form, post=post, return render_template('post/post_edit_link.html', title=_('Edit post'), form=form, post=post,
@ -1118,6 +1121,7 @@ def post_edit_video_post(post_id: int):
form.nsfl.data = post.nsfl form.nsfl.data = post.nsfl
form.sticky.data = post.sticky form.sticky.data = post.sticky
form.language_id.data = post.language_id form.language_id.data = post.language_id
form.tags.data = tags_to_string(post)
if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()):
form.sticky.render_kw = {'disabled': True} form.sticky.render_kw = {'disabled': True}
return render_template('post/post_edit_video.html', title=_('Edit post'), form=form, post=post, return render_template('post/post_edit_video.html', title=_('Edit post'), form=form, post=post,
@ -1157,7 +1161,8 @@ def federate_post_update(post):
'language': { 'language': {
'identifier': post.language_code(), 'identifier': post.language_code(),
'name': post.language_name() 'name': post.language_name()
} },
'tag': post.tags_for_activitypub()
} }
update_json = { update_json = {
'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}", 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}",
@ -1241,7 +1246,8 @@ def federate_post_edit_to_user_followers(post):
'language': { 'language': {
'identifier': post.language_code(), 'identifier': post.language_code(),
'name': post.language_name() 'name': post.language_name()
} },
'tag': post.tags_for_activitypub()
} }
update = { update = {
"id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}",

View file

@ -4,7 +4,7 @@ from flask_login import current_user
from sqlalchemy import desc, text, or_ from sqlalchemy import desc, text, or_
from app import db from app import db
from app.models import PostReply from app.models import PostReply, Post
from app.utils import blocked_instances, blocked_users from app.utils import blocked_instances, blocked_users
@ -73,3 +73,8 @@ def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[Post
def post_reply_count(post_id) -> int: def post_reply_count(post_id) -> int:
return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id'), return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id'),
{'post_id': post_id}).scalar() {'post_id': post_id}).scalar()
def tags_to_string(post: Post) -> str:
if post.tags.count() > 0:
return ', '.join([tag.name for tag in post.tags])

View file

@ -774,6 +774,14 @@ div.navbar {
border: dotted 2px transparent; border: dotted 2px transparent;
} }
.post_tags {
list-style: none;
padding-left: 0;
}
.post_tags .post_tag {
display: inline-block;
}
/* high contrast */ /* high contrast */
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
:root { :root {

View file

@ -366,6 +366,15 @@ div.navbar {
} }
} }
.post_tags {
list-style: none;
padding-left: 0;
.post_tag {
display: inline-block;
}
}
/* high contrast */ /* high contrast */
@media (prefers-contrast: more) { @media (prefers-contrast: more) {

5
app/tag/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('tag', __name__)
from app.tag import routes

133
app/tag/routes.py Normal file
View file

@ -0,0 +1,133 @@
from random import randint
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, constants, cache
from app.inoculation import inoculation
from app.models import Post, Community, Tag, post_tag
from app.tag import bp
from app.utils import render_template, permission_required, joined_communities, moderating_communities, \
user_filters_posts, blocked_instances, blocked_users, blocked_domains
from sqlalchemy import desc, or_
@bp.route('/tag/<tag>', methods=['GET'])
def show_tag(tag):
page = request.args.get('page', 1, type=int)
tag = Tag.query.filter(Tag.name == tag.lower()).first()
if tag:
posts = Post.query.join(Community, Community.id == Post.community_id). \
join(post_tag, post_tag.c.post_id == Post.id).filter(post_tag.c.tag_id == tag.id). \
filter(Community.banned == False)
if current_user.is_anonymous or current_user.ignore_bots:
posts = posts.filter(Post.from_bot == False)
if current_user.is_authenticated:
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))
instance_ids = blocked_instances(current_user.id)
if instance_ids:
posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None))
# filter blocked users
blocked_accounts = blocked_users(current_user.id)
if blocked_accounts:
posts = posts.filter(Post.user_id.not_in(blocked_accounts))
content_filters = user_filters_posts(current_user.id)
else:
content_filters = {}
posts = posts.order_by(desc(Post.posted_at))
# pagination
posts = posts.paginate(page=page, per_page=100, error_out=False)
next_url = url_for('tag.show_tag', tag=tag, page=posts.next_num) if posts.has_next else None
prev_url = url_for('tag.show_tag', tag=tag, page=posts.prev_num) if posts.has_prev and page != 1 else None
return render_template('tag/tag.html', tag=tag, title=tag.name, posts=posts,
POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE, POST_TYPE_LINK=constants.POST_TYPE_LINK,
POST_TYPE_VIDEO=constants.POST_TYPE_VIDEO,
next_url=next_url, prev_url=prev_url,
content_filters=content_filters,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(404)
@bp.route('/tags', methods=['GET'])
def tags():
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
tags = Tag.query.filter_by(banned=False)
if search != '':
tags = tags.filter(Tag.name.ilike(f'%{search}%'))
tags = tags.order_by(Tag.name)
tags = tags.paginate(page=page, per_page=100, error_out=False)
ban_visibility_permission = False
if not current_user.is_anonymous:
if not current_user.created_recently() and current_user.reputation > 100 or current_user.is_admin():
ban_visibility_permission = True
next_url = url_for('tag.tags', page=tags.next_num) if tags.has_next else None
prev_url = url_for('tag.tags', page=tags.prev_num) if tags.has_prev and page != 1 else None
return render_template('tag/tags.html', title='All known tags', tags=tags,
next_url=next_url, prev_url=prev_url, search=search, ban_visibility_permission=ban_visibility_permission)
@bp.route('/tags/banned', methods=['GET'])
@login_required
def tags_blocked_list():
if not current_user.trustworthy():
abort(404)
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
tags = Tag.query.filter_by(banned=True)
if search != '':
tags = tags.filter(Tag.name.ilike(f'%{search}%'))
tags = tags.order_by(Tag.name)
tags = tags.paginate(page=page, per_page=100, error_out=False)
next_url = url_for('tag.tags', page=tags.next_num) if tags.has_next else None
prev_url = url_for('tag.tags', page=tags.prev_num) if tags.has_prev and page != 1 else None
return render_template('tag/tags_blocked.html', title='Tags blocked on this instance', tags=tags,
next_url=next_url, prev_url=prev_url, search=search)
@bp.route('/tag/<tag>/ban')
@login_required
@permission_required('manage users')
def tag_ban(tag):
tag = Tag.query.filter(Tag.name == tag.lower()).first()
if tag:
tag.banned = True
db.session.commit()
tag.purge_content()
flash(_('%(name)s banned for all users and all content deleted.', name=tag.name))
return redirect(url_for('tag.tags'))
@bp.route('/tag/<tag>/unban')
@login_required
@permission_required('manage users')
def tag_unban(tag):
tag = Tag.query.filter(Tag.name == tag.lower()).first()
if tag:
tag.banned = False
db.session.commit()
flash(_('%(name)s un-banned for all users.', name=tag.name))
return redirect(url_for('tag.show_tag', tag=tag.name))

View file

@ -44,6 +44,8 @@
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a> <a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">

View file

@ -46,6 +46,8 @@
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="image_body">{{ _('Enable markdown editor') }}</a> <a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="image_body">{{ _('Enable markdown editor') }}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">

View file

@ -45,6 +45,8 @@
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="link_body">{{ _('Enable markdown editor') }}</a> <a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="link_body">{{ _('Enable markdown editor') }}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">

View file

@ -46,6 +46,8 @@
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="link_body">{{ _('Enable markdown editor') }}</a> <a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="link_body">{{ _('Enable markdown editor') }}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">

View file

@ -138,7 +138,18 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="post_utilities_bar"> <div class="post_utilities_bar">
{% if post.tags.count() > 0 %}
<nav role="navigation">
<h3 class="visually-hidden">{{ _('Hashtags') }}</h3>
<ul class="post_tags">
{% for tag in post.tags %}
<li class="post_tag small"><a href="{{ url_for('tag.show_tag', tag=tag.name) }}">#{{ tag.display_as }}</a></li>
{% endfor %}
</ul>
</nav>
{% endif %}
{% if post.cross_posts %} {% if post.cross_posts %}
<div class="cross_post_button"> <div class="cross_post_button">
<a href="{{ url_for('post.post_cross_posts', post_id=post.id) }}" aria-label="{{ _('Show cross-posts') }}" <a href="{{ url_for('post.post_cross_posts', post_id=post.id) }}" aria-label="{{ _('Show cross-posts') }}"

View file

@ -26,6 +26,8 @@
}); });
</script> </script>
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">

View file

@ -29,6 +29,8 @@
}); });
</script> </script>
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">

View file

@ -27,6 +27,8 @@
}); });
</script> </script>
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">
{{ render_field(form.notify_author) }} {{ render_field(form.notify_author) }}

View file

@ -27,6 +27,8 @@
}); });
</script> </script>
{% endif %} {% endif %}
{{ render_field(form.tags) }}
<small class="field_hint">{{ _('Separate each tag with a comma.') }}</small>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3"> <div class="col-md-3">
{{ render_field(form.notify_author) }} {{ render_field(form.notify_author) }}

View file

@ -0,0 +1,72 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% 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="/tags">{{ _('Tags') }}</a></li>
<li class="breadcrumb-item active">{{ tag.name|shorten }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ tag.name }}</h1>
<div class="post_list">
{% for post in posts.items %}
{% include 'post/_post_teaser.html' %}
{% else %}
<p>{{ _('No posts in this tag yet.') }}</p>
{% endfor %}
</div>
<nav aria-label="Pagination" class="mt-4" role="navigation">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary" rel='nofollow'>
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary" rel='nofollow'>
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
<aside id="side_pane" class="col-12 col-md-4 side_pane" role="complementary">
{% if current_user.is_authenticated %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Tag management') }}</h2>
</div>
<div class="card-body">
<div class="row">
{% if user_access('ban users', current_user.id) or user_access('manage users', current_user.id) %}
{% if tag.banned %}
<div class="col-8">
<a class="w-100 btn btn-primary confirm_first" href="/tag/{{ tag.name }}/unban">{{ _('Unban') }}</a>
</div>
{% else %}
<div class="col-8">
<a class="w-100 btn btn-primary confirm_first" href="/tag/{{ tag.name }}/ban">{{ _('Ban instance-wide') }}</a>
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endif %}
{% include "_inoculation_links.html" %}
</aside>
</div>
<div class="row">
</div>
{% endblock %}

View file

@ -0,0 +1,66 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
{% if search == '' %}
<h1>{{ _('Tags') }}</h1>
{% else %}
<h1>{{ _('Tags containing "%(search)s"', search=search) }}</h1>
{% endif %}
{% if not current_user.is_anonymous and current_user.trustworthy() %}
<div class="row">
<div class="col-auto">
<div class="btn-group">
<a href="/tags" class="btn {{ 'btn-primary' if request.path == '/tags' else 'btn-outline-secondary' }}">
{{ _('Tags') }}
</a>
<a href="/tags/banned" class="btn {{ 'btn-primary' if request.path == '/tags/banned' else 'btn-outline-secondary' }}">
{{ _('Banned tags') }}
</a>
</div>
</div>
<div class="col-auto">
<form method="get"><input type="search" name="search" value="{{ search }}" placeholder="{{ _('Search') }}" autofocus></form>
</div>
</div>
{% endif %}
<div class="table-responsive-sm pt-4">
<table class="table table-striped">
<tr>
<th>Tag</th>
<th><span title="{{ _('How many times has something using this tag been posted') }}"># Posts</span></th>
</tr>
{% for tag in tags %}
<tr>
<td><a href="{{ url_for('tag.show_tag', tag=tag.name) }}">{{ tag.display_as }}</a></td>
<td>{{ tag.post_count }}</td>
</tr>
{% endfor %}
</table>
</div>
<nav aria-label="Pagination" class="mt-4" role="navigation">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary" rel="nofollow">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary" rel="nofollow">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,66 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
{% if search == '' %}
<h1>{{ _('Tags') }}</h1>
{% else %}
<h1>{{ _('Tags containing "%(search)s"', search=search) }}</h1>
{% endif %}
{% if not current_user.is_anonymous and current_user.trustworthy() %}
<div class="row">
<div class="col-auto">
<div class="btn-group">
<a href="/tags" class="btn {{ 'btn-primary' if request.path == '/tags' else 'btn-outline-secondary' }}">
{{ _('Tags') }}
</a>
<a href="/tags/banned" class="btn {{ 'btn-primary' if request.path == '/tags/banned' else 'btn-outline-secondary' }}">
{{ _('Banned tags') }}
</a>
</div>
</div>
<div class="col-auto">
<form method="get"><input type="search" name="search" value="{{ search }}" placeholder="{{ _('Search') }}" autofocus></form>
</div>
</div>
{% endif %}
<div class="table-responsive-sm pt-4">
<table class="table table-striped">
<tr>
<th>Tag</th>
<th><span title="{{ _('How many times has something using this tag been posted') }}"># Posts</span></th>
</tr>
{% for tag in tags %}
<tr>
<td><a href="{{ url_for('tag.show_tag', tag=tag.name) }}">{{ tag.display_as }}</a></td>
<td>{{ tag.post_count }}</td>
</tr>
{% endfor %}
</table>
</div>
<nav aria-label="Pagination" class="mt-4" role="navigation">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary" rel="nofollow">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary" rel="nofollow">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,36 @@
"""tag ban
Revision ID: 9752fb47d7a6
Revises: 20ee24728510
Create Date: 2024-05-12 12:46:09.954438
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9752fb47d7a6'
down_revision = '20ee24728510'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tag', schema=None) as batch_op:
batch_op.add_column(sa.Column('post_count', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('banned', sa.Boolean(), nullable=True))
batch_op.create_index(batch_op.f('ix_tag_banned'), ['banned'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tag', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_tag_banned'))
batch_op.drop_column('banned')
batch_op.drop_column('post_count')
# ### end Alembic commands ###