From 3c5a86f6e67301cb8c603d4f71cc115002a65f96 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sun, 12 May 2024 13:02:45 +1200 Subject: [PATCH] hashtag UI #184 --- app/__init__.py | 3 + app/activitypub/util.py | 12 +- app/cli.py | 11 +- app/community/forms.py | 12 +- app/community/routes.py | 6 +- app/community/util.py | 28 +++- app/models.py | 10 ++ app/post/routes.py | 12 +- app/post/util.py | 7 +- app/static/styles.css | 8 ++ app/static/styles.scss | 9 ++ app/tag/__init__.py | 5 + app/tag/routes.py | 133 ++++++++++++++++++ .../community/add_discussion_post.html | 2 + app/templates/community/add_image_post.html | 2 + app/templates/community/add_link_post.html | 2 + app/templates/community/add_video_post.html | 2 + app/templates/post/_post_full.html | 11 ++ app/templates/post/post_edit_discussion.html | 2 + app/templates/post/post_edit_image.html | 2 + app/templates/post/post_edit_link.html | 2 + app/templates/post/post_edit_video.html | 2 + app/templates/tag/tag.html | 72 ++++++++++ app/templates/tag/tags.html | 66 +++++++++ app/templates/tag/tags_blocked.html | 66 +++++++++ migrations/versions/9752fb47d7a6_tag_ban.py | 36 +++++ 26 files changed, 504 insertions(+), 19 deletions(-) create mode 100644 app/tag/__init__.py create mode 100644 app/tag/routes.py create mode 100644 app/templates/tag/tag.html create mode 100644 app/templates/tag/tags.html create mode 100644 app/templates/tag/tags_blocked.html create mode 100644 migrations/versions/9752fb47d7a6_tag_ban.py diff --git a/app/__init__.py b/app/__init__.py index 2862426a..9154cdae 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -103,6 +103,9 @@ def create_app(config_class=Config): from app.search import bp as 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 if app.config['MAIL_SERVER'] and app.config['MAIL_ERRORS']: auth = None diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 0f6da1b1..28b47e46 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -161,7 +161,8 @@ def post_to_activity(post: Post, community: Community): 'language': { 'identifier': post.language_code(), 'name': post.language_name() - } + }, + 'tag': post.tags_for_activitypub() }, "cc": [ 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, "published": ap_datetime(post.created_at), "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: activity_data["updated"] = ap_datetime(post.edited_at) @@ -385,7 +391,7 @@ def find_hashtag_or_create(hashtag: str) -> Tag: if existing_tag: return existing_tag 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) return new_tag diff --git a/app/cli.py b/app/cli.py index 6739fcd4..898abc1f 100644 --- a/app/cli.py +++ b/app/cli.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta import flask from flask import json, current_app from flask_babel import _ -from sqlalchemy import or_, desc +from sqlalchemy import or_, desc, text from sqlalchemy.orm import configure_mappers 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.email import send_verification_email, send_email 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, \ shorten_string @@ -168,6 +169,12 @@ def register(app): db.session.query(ActivityPubLog).filter(ActivityPubLog.created_at < utcnow() - timedelta(days=3)).delete() 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") def spaceusage(): with app.app_context(): diff --git a/app/community/forms.py b/app/community/forms.py index 8eaea74a..12c87028 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -93,9 +93,10 @@ class BanUserCommunityForm(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_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')) nsfw = BooleanField(_l('NSFW')) nsfl = BooleanField(_l('Gore/gross')) @@ -105,11 +106,12 @@ class CreateDiscussionForm(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_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://"')], render_kw={'placeholder': 'https://...'}) + tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)]) sticky = BooleanField(_l('Sticky')) nsfw = BooleanField(_l('NSFW')) nsfl = BooleanField(_l('Gore/gross')) @@ -126,11 +128,12 @@ class CreateLinkForm(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_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://"')], render_kw={'placeholder': 'https://...'}) + tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)]) sticky = BooleanField(_l('Sticky')) nsfw = BooleanField(_l('NSFW')) nsfl = BooleanField(_l('Gore/gross')) @@ -147,11 +150,12 @@ class CreateVideoForm(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_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_file = FileField(_l('Image'), validators=[DataRequired()]) + tags = StringField(_l('Tags'), validators=[Optional(), Length(min=3, max=5000)]) sticky = BooleanField(_l('Sticky')) nsfw = BooleanField(_l('NSFW')) nsfl = BooleanField(_l('Gore/gross')) diff --git a/app/community/routes.py b/app/community/routes.py index 69b485af..c0e14049 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -796,7 +796,8 @@ def federate_post(community, post): 'language': { 'identifier': post.language_code(), 'name': post.language_name() - } + }, + 'tag': post.tags_for_activitypub() } create = { "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", @@ -890,7 +891,8 @@ def federate_post_to_user_followers(post): 'language': { 'identifier': post.language_code(), 'name': post.language_name() - } + }, + 'tag': post.tags_for_activitypub() } create = { "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", diff --git a/app/community/util.py b/app/community/util.py index 222a72c5..2c859440 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -10,14 +10,15 @@ from pillow_heif import register_heif_opener from app import db, cache, celery 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.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, \ is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \ remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases -from sqlalchemy import func, desc +from sqlalchemy import func, desc, text import os @@ -384,7 +385,10 @@ def save_post(form, post: Post, type: str): return db.session.add(post) - db.session.commit() + 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() # Notify author about replies # Remove any subscription that currently exists @@ -404,6 +408,22 @@ def save_post(form, post: Post, type: str): 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): if current_app.debug: delete_post_from_community_task(post_id) diff --git a/app/models.py b/app/models.py index aeb5edee..e202529c 100644 --- a/app/models.py +++ b/app/models.py @@ -174,6 +174,8 @@ class Tag(db.Model): id = db.Column(db.Integer, primary_key=True) 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 + post_count = db.Column(db.Integer, default=0) + banned = db.Column(db.Boolean, default=False, index=True) class Language(db.Model): @@ -1019,6 +1021,14 @@ class Post(db.Model): else: 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): query_class = FullTextSearchQuery diff --git a/app/post/routes.py b/app/post/routes.py index e006128e..9c9ffca9 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -14,7 +14,7 @@ from app.community.util import save_post, send_to_remote_instance from app.inoculation import inoculation from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm 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, \ POST_TYPE_IMAGE, \ 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.sticky.data = post.sticky 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()): form.sticky.render_kw = {'disabled': True} 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.sticky.data = post.sticky 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()): form.sticky.render_kw = {'disabled': True} 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.sticky.data = post.sticky 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()): form.sticky.render_kw = {'disabled': True} 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.sticky.data = post.sticky 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()): form.sticky.render_kw = {'disabled': True} 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': { 'identifier': post.language_code(), 'name': post.language_name() - } + }, + 'tag': post.tags_for_activitypub() } update_json = { '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': { 'identifier': post.language_code(), 'name': post.language_name() - } + }, + 'tag': post.tags_for_activitypub() } update = { "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", diff --git a/app/post/util.py b/app/post/util.py index c7dd5b73..14b22d59 100644 --- a/app/post/util.py +++ b/app/post/util.py @@ -4,7 +4,7 @@ from flask_login import current_user from sqlalchemy import desc, text, or_ from app import db -from app.models import PostReply +from app.models import PostReply, Post 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: return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id'), {'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]) diff --git a/app/static/styles.css b/app/static/styles.css index 274a5bf8..41dc628c 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -774,6 +774,14 @@ div.navbar { border: dotted 2px transparent; } +.post_tags { + list-style: none; + padding-left: 0; +} +.post_tags .post_tag { + display: inline-block; +} + /* high contrast */ @media (prefers-contrast: more) { :root { diff --git a/app/static/styles.scss b/app/static/styles.scss index dd3750af..a969f3e4 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -366,6 +366,15 @@ div.navbar { } } +.post_tags { + list-style: none; + padding-left: 0; + + .post_tag { + display: inline-block; + } +} + /* high contrast */ @media (prefers-contrast: more) { diff --git a/app/tag/__init__.py b/app/tag/__init__.py new file mode 100644 index 00000000..57991ded --- /dev/null +++ b/app/tag/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('tag', __name__) + +from app.tag import routes diff --git a/app/tag/routes.py b/app/tag/routes.py new file mode 100644 index 00000000..3a182955 --- /dev/null +++ b/app/tag/routes.py @@ -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/', 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//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//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)) diff --git a/app/templates/community/add_discussion_post.html b/app/templates/community/add_discussion_post.html index 0cb3eeb1..63cc4a83 100644 --- a/app/templates/community/add_discussion_post.html +++ b/app/templates/community/add_discussion_post.html @@ -44,6 +44,8 @@ {% endif %} {% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
diff --git a/app/templates/community/add_image_post.html b/app/templates/community/add_image_post.html index a3408741..b794c9e1 100644 --- a/app/templates/community/add_image_post.html +++ b/app/templates/community/add_image_post.html @@ -46,6 +46,8 @@ {% endif %} {% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
diff --git a/app/templates/community/add_link_post.html b/app/templates/community/add_link_post.html index 87c58189..0cf9eadb 100644 --- a/app/templates/community/add_link_post.html +++ b/app/templates/community/add_link_post.html @@ -45,6 +45,8 @@ {% endif %} {% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
diff --git a/app/templates/community/add_video_post.html b/app/templates/community/add_video_post.html index 40f417f6..68a55185 100644 --- a/app/templates/community/add_video_post.html +++ b/app/templates/community/add_video_post.html @@ -46,6 +46,8 @@ {% endif %} {% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
diff --git a/app/templates/post/_post_full.html b/app/templates/post/_post_full.html index 631a50c3..8c77aad7 100644 --- a/app/templates/post/_post_full.html +++ b/app/templates/post/_post_full.html @@ -138,7 +138,18 @@
{% endif %} +
+ {% if post.tags.count() > 0 %} + + {% endif %} {% if post.cross_posts %}
{% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
diff --git a/app/templates/post/post_edit_image.html b/app/templates/post/post_edit_image.html index 953c3641..7b5f25db 100644 --- a/app/templates/post/post_edit_image.html +++ b/app/templates/post/post_edit_image.html @@ -29,6 +29,8 @@ }); {% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
diff --git a/app/templates/post/post_edit_link.html b/app/templates/post/post_edit_link.html index 50f37bbe..399f5912 100644 --- a/app/templates/post/post_edit_link.html +++ b/app/templates/post/post_edit_link.html @@ -27,6 +27,8 @@ }); {% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
{{ render_field(form.notify_author) }} diff --git a/app/templates/post/post_edit_video.html b/app/templates/post/post_edit_video.html index 31bd08f6..46a3a50b 100644 --- a/app/templates/post/post_edit_video.html +++ b/app/templates/post/post_edit_video.html @@ -27,6 +27,8 @@ }); {% endif %} + {{ render_field(form.tags) }} + {{ _('Separate each tag with a comma.') }}
{{ render_field(form.notify_author) }} diff --git a/app/templates/tag/tag.html b/app/templates/tag/tag.html new file mode 100644 index 00000000..3c010a4a --- /dev/null +++ b/app/templates/tag/tag.html @@ -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 %} +
+
+ +

{{ tag.name }}

+
+ {% for post in posts.items %} + {% include 'post/_post_teaser.html' %} + {% else %} +

{{ _('No posts in this tag yet.') }}

+ {% endfor %} +
+ + +
+ + +
+
+ + +
+{% endblock %} diff --git a/app/templates/tag/tags.html b/app/templates/tag/tags.html new file mode 100644 index 00000000..88e41836 --- /dev/null +++ b/app/templates/tag/tags.html @@ -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 %} +
+
+ {% if search == '' %} +

{{ _('Tags') }}

+ {% else %} +

{{ _('Tags containing "%(search)s"', search=search) }}

+ {% endif %} + {% if not current_user.is_anonymous and current_user.trustworthy() %} + + {% endif %} + +
+ + + + + + {% for tag in tags %} + + + + + {% endfor %} +
Tag# Posts
{{ tag.display_as }}{{ tag.post_count }}
+
+ +
+
+ + + +{% endblock %} + diff --git a/app/templates/tag/tags_blocked.html b/app/templates/tag/tags_blocked.html new file mode 100644 index 00000000..88e41836 --- /dev/null +++ b/app/templates/tag/tags_blocked.html @@ -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 %} +
+
+ {% if search == '' %} +

{{ _('Tags') }}

+ {% else %} +

{{ _('Tags containing "%(search)s"', search=search) }}

+ {% endif %} + {% if not current_user.is_anonymous and current_user.trustworthy() %} + + {% endif %} + +
+ + + + + + {% for tag in tags %} + + + + + {% endfor %} +
Tag# Posts
{{ tag.display_as }}{{ tag.post_count }}
+
+ +
+
+ + + +{% endblock %} + diff --git a/migrations/versions/9752fb47d7a6_tag_ban.py b/migrations/versions/9752fb47d7a6_tag_ban.py new file mode 100644 index 00000000..e4187e8d --- /dev/null +++ b/migrations/versions/9752fb47d7a6_tag_ban.py @@ -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 ###