From 2380a3ae616769d355e1fc731b4febeac15a97f2 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sun, 2 Jun 2024 16:45:21 +1200 Subject: [PATCH] soft deletes and deleted content admin area #193 --- app/activitypub/routes.py | 7 +- app/activitypub/util.py | 41 +++++---- app/admin/routes.py | 38 +++++++- app/cli.py | 9 ++ app/community/routes.py | 5 +- app/community/util.py | 4 +- app/domain/routes.py | 4 +- app/main/routes.py | 10 ++- app/models.py | 16 +++- app/post/routes.py | 89 ++++++++++++++++--- app/post/util.py | 6 +- app/tag/routes.py | 2 +- app/templates/admin/deleted_posts.html | 41 +++++++++ app/templates/base.html | 1 + app/templates/post/post_options.html | 61 +++++++------ app/templates/post/post_reply_options.html | 45 +++++----- app/topic/routes.py | 5 +- app/user/routes.py | 4 +- .../versions/5e84668d279e_soft_deletes.py | 45 ++++++++++ 19 files changed, 326 insertions(+), 107 deletions(-) create mode 100644 app/templates/admin/deleted_posts.html create mode 100644 migrations/versions/5e84668d279e_soft_deletes.py diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 498fdb92..52918213 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -1010,7 +1010,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): post.delete_dependencies() post.community.post_count -= 1 announce_activity_to_followers(post.community, post.author, request_json) - db.session.delete(post) + post.deleted = True db.session.commit() activity_log.result = 'success' else: @@ -1023,6 +1023,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): reply.body_html = '

deleted

' reply.body = 'deleted' reply.post.reply_count -= 1 + reply.deleted = True announce_activity_to_followers(reply.community, reply.author, request_json) db.session.commit() activity_log.result = 'success' @@ -1213,7 +1214,7 @@ def community_outbox(actor): actor = actor.strip() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() if community is not None: - posts = community.posts.limit(50).all() + posts = community.posts.filter(Post.deleted == False).limit(50).all() community_data = { "@context": default_context(), @@ -1234,7 +1235,7 @@ def community_featured(actor): actor = actor.strip() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() if community is not None: - posts = Post.query.filter_by(community_id=community.id, sticky=True).all() + posts = Post.query.filter_by(community_id=community.id, sticky=True, deleted=False).all() community_data = { "@context": default_context(), diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 4a23dd92..51cfc839 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -78,11 +78,11 @@ def active_day(): def local_posts(): - return db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE instance_id = 1')).scalar() + return db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE instance_id = 1 AND deleted is false')).scalar() def local_comments(): - return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE instance_id = 1')).scalar() + return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE instance_id = 1 and deleted is false')).scalar() def local_communities(): @@ -1409,7 +1409,7 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id if deletor.is_admin() or community.is_moderator(deletor) or community.is_instance_admin(deletor) or to_delete.author.id == deletor.id: if isinstance(to_delete, Post): to_delete.delete_dependencies() - db.session.delete(to_delete) + to_delete.deleted = True community.post_count -= 1 db.session.commit() elif isinstance(to_delete, PostReply): @@ -1419,7 +1419,7 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id to_delete.body_html = lemmy_markdown_to_html(to_delete.body) else: to_delete.delete_dependencies() - db.session.delete(to_delete) + to_delete.deleted = True db.session.commit() @@ -1447,31 +1447,30 @@ def remove_data_from_banned_user_task(deletor_ap_id, user_ap_id, target): # community bans by mods elif community and community.is_moderator(deletor): - post_replies = PostReply.query.filter_by(user_id=user.id, community_id=community.id) - posts = Post.query.filter_by(user_id=user.id, community_id=community.id) - + post_replies = PostReply.query.filter_by(user_id=user.id, community_id=community.id, deleted=False) + posts = Post.query.filter_by(user_id=user.id, community_id=community.id, deleted=False) else: return - for pr in post_replies: - pr.post.reply_count -= 1 - if pr.has_replies(): - pr.body = 'Banned' - pr.body_html = lemmy_markdown_to_html(pr.body) + for post_reply in post_replies: + post_reply.post.reply_count -= 1 + if post_reply.has_replies(): + post_reply.body = 'Banned' + post_reply.body_html = lemmy_markdown_to_html(post_reply.body) else: - pr.delete_dependencies() - db.session.delete(pr) + post_reply.delete_dependencies() + post_reply.deleted = True db.session.commit() - for p in posts: - if p.cross_posts: - old_cross_posts = Post.query.filter(Post.id.in_(p.cross_posts)).all() + for post in posts: + if post.cross_posts: + old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() for ocp in old_cross_posts: if ocp.cross_posts is not None: - ocp.cross_posts.remove(p.id) - p.delete_dependencies() - db.session.delete(p) - p.community.post_count -= 1 + ocp.cross_posts.remove(post.id) + post.delete_dependencies() + post.deleted = True + post.community.post_count -= 1 db.session.commit() diff --git a/app/admin/routes.py b/app/admin/routes.py index e1c477e0..6faa4f5d 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -552,7 +552,7 @@ def admin_content_trash(): page = request.args.get('page', 1, type=int) - posts = Post.query.filter(Post.posted_at > utcnow() - timedelta(days=3)).order_by(Post.score) + posts = Post.query.filter(Post.posted_at > utcnow() - timedelta(days=3), Post.deleted == False).order_by(Post.score) posts = posts.paginate(page=page, per_page=100, error_out=False) next_url = url_for('admin.admin_content_trash', page=posts.next_num) if posts.has_next else None @@ -577,12 +577,14 @@ def admin_content_spam(): posts = Post.query.join(User, User.id == Post.user_id).\ filter(User.created > utcnow() - timedelta(days=3)).\ filter(Post.posted_at > utcnow() - timedelta(days=3)).\ + filter(Post.deleted == False).\ filter(Post.score <= 0).order_by(Post.score) posts = posts.paginate(page=page, per_page=100, error_out=False) post_replies = PostReply.query.join(User, User.id == PostReply.user_id). \ filter(User.created > utcnow() - timedelta(days=3)). \ filter(PostReply.posted_at > utcnow() - timedelta(days=3)). \ + filter(PostReply.deleted == False). \ filter(PostReply.score <= 0).order_by(PostReply.score) post_replies = post_replies.paginate(page=replies_page, per_page=100, error_out=False) @@ -602,6 +604,40 @@ def admin_content_spam(): ) +@bp.route('/content/deleted', methods=['GET']) +@login_required +@permission_required('administer all users') +def admin_content_deleted(): + # Shows all soft deleted posts + page = request.args.get('page', 1, type=int) + replies_page = request.args.get('replies_page', 1, type=int) + + posts = Post.query.\ + filter(Post.deleted == True).\ + order_by(Post.posted_at) + posts = posts.paginate(page=page, per_page=100, error_out=False) + + post_replies = PostReply.query. \ + filter(PostReply.deleted == True). \ + order_by(PostReply.posted_at) + post_replies = post_replies.paginate(page=replies_page, per_page=100, error_out=False) + + next_url = url_for('admin.admin_content_deleted', page=posts.next_num) if posts.has_next else None + prev_url = url_for('admin.admin_content_deleted', page=posts.prev_num) if posts.has_prev and page != 1 else None + next_url_replies = url_for('admin.admin_content_deleted', replies_page=post_replies.next_num) if post_replies.has_next else None + prev_url_replies = url_for('admin.admin_content_deleted', replies_page=post_replies.prev_num) if post_replies.has_prev and replies_page != 1 else None + + return render_template('admin/deleted_posts.html', title=_('Deleted content'), + next_url=next_url, prev_url=prev_url, + next_url_replies=next_url_replies, prev_url_replies=prev_url_replies, + posts=posts, post_replies=post_replies, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + menu_topics=menu_topics(), + site=g.site + ) + + @bp.route('/approve_registrations', methods=['GET']) @login_required @permission_required('approve registrations') diff --git a/app/cli.py b/app/cli.py index a424e826..7680ee29 100644 --- a/app/cli.py +++ b/app/cli.py @@ -266,6 +266,15 @@ def register(app): InstanceRole.role == 'admin').delete() db.session.commit() + # Delete soft-deleted content after 7 days + for post_reply in PostReply.query.filter(PostReply.deleted == True, PostReply.posted_at < utcnow() - timedelta(days=7)).all(): + db.session.delete(post_reply) + + for post in Post.query.filter(Post.deleted == True, Post.posted_at < utcnow() - timedelta(days=7)).all(): + db.session.delete(post) + + db.session.commit() + @app.cli.command("spaceusage") def spaceusage(): with app.app_context(): diff --git a/app/community/routes.py b/app/community/routes.py index 669b1202..8ce767a9 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -197,7 +197,7 @@ def show_community(community: Community): # filter out nsfw and nsfl if desired if current_user.is_anonymous: - posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) + posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False, Post.deleted == False) content_filters = {} else: if current_user.ignore_bots: @@ -207,6 +207,7 @@ def show_community(community: Community): if current_user.show_nsfw is False: posts = posts.filter(Post.nsfw == False) content_filters = user_filters_posts(current_user.id) + posts = posts.filter(Post.deleted == False) # filter domains and instances domains_ids = blocked_domains(current_user.id) @@ -319,7 +320,7 @@ def show_community_rss(actor): if request_etag_matches(current_etag): return return_304(current_etag, 'application/rss+xml') - posts = community.posts.filter(Post.from_bot == False).order_by(desc(Post.created_at)).limit(100).all() + posts = community.posts.filter(Post.from_bot == False, Post.deleted == False).order_by(desc(Post.created_at)).limit(100).all() description = shorten_string(community.description, 150) if community.description else None og_image = community.image.source_url if community.image_id else None fg = FeedGenerator() diff --git a/app/community/util.py b/app/community/util.py index a2d85345..07e20656 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -541,7 +541,7 @@ def delete_post_from_community_task(post_id): post = Post.query.get(post_id) community = post.community post.delete_dependencies() - db.session.delete(post) + post.deleted = True db.session.commit() if not community.local_only: @@ -600,7 +600,7 @@ def delete_post_reply_from_community_task(post_reply_id): post_reply.body_html = markdown_to_html(post_reply.body) else: post_reply.delete_dependencies() - db.session.delete(post_reply) + post_reply.deleted = True db.session.commit() # federate delete diff --git a/app/domain/routes.py b/app/domain/routes.py index ddc1011d..cd96ca17 100644 --- a/app/domain/routes.py +++ b/app/domain/routes.py @@ -26,10 +26,10 @@ def show_domain(domain_id): if domain: if current_user.is_anonymous or current_user.ignore_bots: posts = Post.query.join(Community, Community.id == Post.community_id).\ - filter(Post.from_bot == False, Post.domain_id == domain.id, Community.banned == False).\ + filter(Post.from_bot == False, Post.domain_id == domain.id, Community.banned == False, Post.deleted == False).\ order_by(desc(Post.posted_at)) else: - posts = Post.query.join(Community).filter(Post.domain_id == domain.id, Community.banned == False).order_by(desc(Post.posted_at)) + posts = Post.query.join(Community).filter(Post.domain_id == domain.id, Community.banned == False, Post.deleted == False).order_by(desc(Post.posted_at)) if current_user.is_authenticated: instance_ids = blocked_instances(current_user.id) diff --git a/app/main/routes.py b/app/main/routes.py index 00b76fc8..a3251cda 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -30,7 +30,7 @@ from app.utils import render_template, get_setting, gibberish, request_etag_matc blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts, \ generate_image_from_video_url, blocked_users, microblog_content_to_title, menu_topics from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, \ - InstanceRole, Notification, Language, community_language + InstanceRole, Notification, Language, community_language, PostReply from PIL import Image import pytesseract @@ -73,7 +73,7 @@ def home_page(type, sort): if current_user.is_anonymous: 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 = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False, Post.deleted == False) posts = posts.join(Community, Community.id == Post.community_id) if type == 'home': posts = posts.filter(Community.show_home == True) @@ -368,7 +368,7 @@ def robots(): @bp.route('/sitemap.xml') @cache.cached(timeout=6000) def sitemap(): - posts = Post.query.filter(Post.from_bot == False) + posts = Post.query.filter(Post.from_bot == False, Post.deleted == False) posts = posts.join(Community, Community.id == Post.community_id) posts = posts.filter(Community.show_all == True, Community.ap_id == None) # sitemap.xml only includes local posts if not g.site.enable_nsfw: @@ -396,6 +396,10 @@ def list_files(directory): @bp.route('/test') def test(): + #for community in Community.query.filter(Community.content_retention != -1): + # for post in community.posts.filter(Post.posted_at < utcnow() - timedelta(days=Community.content_retention)): + # post.delete_dependencies() + return 'done' diff --git a/app/models.py b/app/models.py index 7e2d6478..e2075c94 100644 --- a/app/models.py +++ b/app/models.py @@ -880,7 +880,7 @@ class User(UserMixin, db.Model): db.session.query(Notification).filter(Notification.user_id == self.id).delete() db.session.query(PollChoiceVote).filter(PollChoiceVote.user_id == self.id).delete() - def purge_content(self): + def purge_content(self, soft=True): files = File.query.join(Post).filter(Post.user_id == self.id).all() for file in files: file.delete_from_disk() @@ -888,12 +888,18 @@ class User(UserMixin, db.Model): posts = Post.query.filter_by(user_id=self.id).all() for post in posts: post.delete_dependencies() - db.session.delete(post) + if soft: + post.deleted = True + else: + db.session.delete(post) db.session.commit() post_replies = PostReply.query.filter_by(user_id=self.id).all() for reply in post_replies: reply.delete_dependencies() - db.session.delete(reply) + if soft: + reply.deleted = True + else: + db.session.delete(reply) db.session.commit() def mention_tag(self): @@ -938,6 +944,7 @@ class Post(db.Model): body_html = db.Column(db.Text) type = db.Column(db.Integer) comments_enabled = db.Column(db.Boolean, default=True) + deleted = db.Column(db.Boolean, default=False, index=True) mea_culpa = db.Column(db.Boolean, default=False) has_embed = db.Column(db.Boolean, default=False) reply_count = db.Column(db.Integer, default=0) @@ -1066,6 +1073,7 @@ class PostReply(db.Model): notify_author = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, index=True, default=utcnow) posted_at = db.Column(db.DateTime, index=True, default=utcnow) + deleted = db.Column(db.Boolean, default=False, index=True) ip = db.Column(db.String(50)) from_bot = db.Column(db.Boolean, default=False) up_votes = db.Column(db.Integer, default=0) @@ -1142,7 +1150,7 @@ class PostReply(db.Model): return PostReply.query.filter_by(parent_id=self.id).all() def has_replies(self): - reply = PostReply.query.filter_by(parent_id=self.id).first() + reply = PostReply.query.filter_by(parent_id=self.id).filter(PostReply.deleted == False).first() return reply is not None def blocked_by_content_filter(self, content_filters): diff --git a/app/post/routes.py b/app/post/routes.py index b9041662..9ac2497a 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -36,7 +36,7 @@ def show_post(post_id: int): post = Post.query.get_or_404(post_id) community: Community = post.community - if community.banned: + if community.banned or post.deleted: abort(404) sort = request.args.get('sort', 'hot') @@ -231,6 +231,7 @@ def show_post(post_id: int): og_image = post.image.source_url if post.image_id else None description = shorten_string(markdown_to_text(post.body), 150) if post.body else None + # Breadcrumbs breadcrumbs = [] breadcrumb = namedtuple("Breadcrumb", ['text', 'url']) breadcrumb.text = _('Home') @@ -288,15 +289,15 @@ def show_post(post_id: int): poll_total_votes = 0 if post.type == POST_TYPE_POLL: poll_data = Poll.query.get(post.id) - poll_choices = PollChoice.query.filter_by(post_id=post.id).order_by(PollChoice.sort_order).all() - poll_total_votes = poll_data.total_votes() - # Show poll results to everyone after the poll finishes, to the poll creator and to those who have voted - if (current_user.is_authenticated and (poll_data.has_voted(current_user.id))) \ - or poll_data.end_poll < datetime.utcnow(): - - poll_results = True - else: - poll_form = True + if poll_data: + poll_choices = PollChoice.query.filter_by(post_id=post.id).order_by(PollChoice.sort_order).all() + poll_total_votes = poll_data.total_votes() + # Show poll results to everyone after the poll finishes, to the poll creator and to those who have voted + if (current_user.is_authenticated and (poll_data.has_voted(current_user.id))) \ + or poll_data.end_poll < datetime.utcnow(): + poll_results = True + else: + poll_form = True response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community, breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list, @@ -612,7 +613,7 @@ def poll_vote(post_id): def continue_discussion(post_id, comment_id): post = Post.query.get_or_404(post_id) comment = PostReply.query.get_or_404(comment_id) - if post.community.banned: + if post.community.banned or post.deleted or comment.deleted: abort(404) mods = post.community.moderators() is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) @@ -844,6 +845,10 @@ def add_reply(post_id: int, comment_id: int): @bp.route('/post//options', methods=['GET']) def post_options(post_id: int): post = Post.query.get_or_404(post_id) + if current_user.is_anonymous or not current_user.is_admin(): + if post.deleted: + abort(404) + return render_template('post/post_options.html', post=post, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), @@ -854,6 +859,9 @@ def post_options(post_id: int): def post_reply_options(post_id: int, comment_id: int): post = Post.query.get_or_404(post_id) post_reply = PostReply.query.get_or_404(comment_id) + if current_user.is_anonymous or not current_user.is_admin(): + if post.deleted or post_reply.deleted: + abort(404) return render_template('post/post_reply_options.html', post=post, post_reply=post_reply, moderating_communities=moderating_communities(current_user.get_id()), joined_communities=joined_communities(current_user.get_id()), @@ -1467,7 +1475,7 @@ def post_delete(post_id: int): if ocp.cross_posts is not None: ocp.cross_posts.remove(post.id) post.delete_dependencies() - db.session.delete(post) + post.deleted = True g.site.last_active = community.last_active = utcnow() db.session.commit() flash(_('Post deleted.')) @@ -1524,6 +1532,61 @@ def post_delete(post_id: int): return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name)) +@bp.route('/post//restore', methods=['GET', 'POST']) +@login_required +def post_restore(post_id: int): + post = Post.query.get_or_404(post_id) + if post.community.is_moderator() or post.community.is_owner() or current_user.is_admin(): + post.deleted = False + db.session.commit() + + # Federate un-delete + if post.is_local(): + delete_json = { + "actor": current_user.profile_id(), + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/delete/{gibberish(15)}", + 'type': 'Delete', + 'actor': current_user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [post.community.profile_id(), 'https://www.w3.org/ns/activitystreams#Public'], + 'published': ap_datetime(utcnow()), + 'cc': [ + current_user.followers_url() + ], + 'object': post.ap_id, + 'uri': post.ap_id, + "summary": "bad post", + }, + "cc": [post.community.profile_id()], + "audience": post.author.profile_id(), + "type": "Undo", + "id": f"https://{current_app.config['SERVER_NAME']}/activities/undo/{gibberish(15)}" + } + + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": post.community.ap_profile_id, + "cc": [ + post.community.ap_followers_url + ], + '@context': default_context(), + 'object': delete_json + } + + for instance in post.community.following_instances(): + if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + send_to_remote_instance(instance.id, post.community.id, announce) + + flash(_('Post has been restored.')) + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + + @bp.route('/post//report', methods=['GET', 'POST']) @login_required def post_report(post_id: int): @@ -1906,7 +1969,7 @@ def post_reply_delete(post_id: int, comment_id: int): post_reply.body_html = markdown_to_html(post_reply.body) else: post_reply.delete_dependencies() - db.session.delete(post_reply) + post_reply.deleted = True g.site.last_active = community.last_active = utcnow() db.session.commit() flash(_('Comment deleted.')) diff --git a/app/post/util.py b/app/post/util.py index 14b22d59..8aa2a2b2 100644 --- a/app/post/util.py +++ b/app/post/util.py @@ -10,7 +10,7 @@ from app.utils import blocked_instances, blocked_users # replies to a post, in a tree, sorted by a variety of methods def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostReply]: - comments = PostReply.query.filter_by(post_id=post_id) + comments = PostReply.query.filter_by(post_id=post_id).filter(PostReply.deleted == False) if current_user.is_authenticated: instance_ids = blocked_instances(current_user.id) if instance_ids: @@ -46,7 +46,7 @@ def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[Post if parent_comment is None: return [] - comments = PostReply.query.filter(PostReply.post_id == post_id) + comments = PostReply.query.filter(PostReply.post_id == post_id, PostReply.deleted == False) if current_user.is_authenticated: instance_ids = blocked_instances(current_user.id) if instance_ids: @@ -71,7 +71,7 @@ def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[Post # The number of replies a post has 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 AND deleted is false'), {'post_id': post_id}).scalar() diff --git a/app/tag/routes.py b/app/tag/routes.py index 5bcca20f..950b934e 100644 --- a/app/tag/routes.py +++ b/app/tag/routes.py @@ -22,7 +22,7 @@ def show_tag(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) + filter(Community.banned == False, Post.deleted == False) if current_user.is_anonymous or current_user.ignore_bots: posts = posts.filter(Post.from_bot == False) diff --git a/app/templates/admin/deleted_posts.html b/app/templates/admin/deleted_posts.html new file mode 100644 index 00000000..1ad63f32 --- /dev/null +++ b/app/templates/admin/deleted_posts.html @@ -0,0 +1,41 @@ +{% 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 %} +{% set active_child = 'admin_content_deleted' %} + +{% block app_content %} +
+
+

{{ _('Deleted posts') }}

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

{{ _('No deleted posts.') }}

+ {% endfor %} +
+ {% if post_replies %} +

Deleted comments

+
+ {% for post_reply in post_replies.items %} + {% include 'post/_post_reply_teaser.html' %} + {% else %} +

{{ _('No deleted comments.') }}

+ {% endfor %} +
+ {% else %} +

{{ _('No comments yet.') }}

+ {% endif %} +
+
+
+
+
+ {% include 'admin/_nav.html' %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 6524d204..67772d3c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -213,6 +213,7 @@
  • {{ _('Monitoring - users') }}
  • {{ _('Monitoring - content') }}
  • {{ _('Monitoring - spammy content') }}
  • +
  • {{ _('Deleted content') }}
  • {% if g.site.registration_mode == 'RequireApplication' %}
  • {{ _('Registration applications') }}
  • {% endif %} diff --git a/app/templates/post/post_options.html b/app/templates/post/post_options.html index a1a680a9..2386e687 100644 --- a/app/templates/post/post_options.html +++ b/app/templates/post/post_options.html @@ -1,51 +1,56 @@ -{% 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 %} +{% 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 %} +{% block app_content -%}
    -{% endblock %} \ No newline at end of file +{% endblock -%} \ No newline at end of file diff --git a/app/templates/post/post_reply_options.html b/app/templates/post/post_reply_options.html index bee8919e..5dc8cb84 100644 --- a/app/templates/post/post_reply_options.html +++ b/app/templates/post/post_reply_options.html @@ -1,35 +1,40 @@ -{% 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 %} +{% 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 %} +{% block app_content -%}
    -{% endblock %} \ No newline at end of file +{% endblock -%} \ No newline at end of file diff --git a/app/topic/routes.py b/app/topic/routes.py index 24691fbf..a29ad1b9 100644 --- a/app/topic/routes.py +++ b/app/topic/routes.py @@ -54,7 +54,7 @@ def show_topic(topic_path): # filter out nsfw and nsfl if desired if current_user.is_anonymous: - posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) + posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False, Post.deleted == False) content_filters = {} else: if current_user.ignore_bots: @@ -63,6 +63,7 @@ def show_topic(topic_path): posts = posts.filter(Post.nsfl == False) if current_user.show_nsfw is False: posts = posts.filter(Post.nsfw == False) + posts = posts.filter(Post.deleted == False) content_filters = user_filters_posts(current_user.id) # filter blocked domains and instances @@ -138,7 +139,7 @@ def show_topic_rss(topic_path): if topic: posts = Post.query.join(Community, Post.community_id == Community.id).filter(Community.topic_id == topic.id, Community.banned == False) - posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) + posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False, Post.deleted == False) posts = posts.order_by(desc(Post.created_at)).limit(100).all() fg = FeedGenerator() diff --git a/app/user/routes.py b/app/user/routes.py index 623baade..d69b6e25 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -59,7 +59,7 @@ def show_profile(user): post_page = request.args.get('post_page', 1, type=int) replies_page = request.args.get('replies_page', 1, type=int) - posts = Post.query.filter_by(user_id=user.id).order_by(desc(Post.posted_at)).paginate(page=post_page, per_page=50, error_out=False) + posts = Post.query.filter_by(user_id=user.id).filter(Post.deleted == False).order_by(desc(Post.posted_at)).paginate(page=post_page, per_page=50, error_out=False) moderates = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == user.id)\ .filter(or_(CommunityMember.is_moderator, CommunityMember.is_owner)) if current_user.is_authenticated and (user.id == current_user.get_id() or current_user.is_admin()): @@ -69,7 +69,7 @@ def show_profile(user): subscribed = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == user.id).all() if current_user.is_anonymous or user.id != current_user.id: moderates = moderates.filter(Community.private_mods == False) - post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).paginate(page=replies_page, per_page=50, error_out=False) + post_replies = PostReply.query.filter_by(user_id=user.id).filter(PostReply.deleted == False).order_by(desc(PostReply.posted_at)).paginate(page=replies_page, per_page=50, error_out=False) # profile info canonical = user.ap_public_url if user.ap_public_url else None diff --git a/migrations/versions/5e84668d279e_soft_deletes.py b/migrations/versions/5e84668d279e_soft_deletes.py new file mode 100644 index 00000000..89a381ee --- /dev/null +++ b/migrations/versions/5e84668d279e_soft_deletes.py @@ -0,0 +1,45 @@ +"""soft deletes + +Revision ID: 5e84668d279e +Revises: 2191fa36c09d +Create Date: 2024-06-02 14:50:17.295862 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5e84668d279e' +down_revision = '2191fa36c09d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.add_column(sa.Column('deleted', sa.Boolean(), nullable=True)) + batch_op.create_index(batch_op.f('ix_post_deleted'), ['deleted'], unique=False) + + with op.batch_alter_table('post_reply', schema=None) as batch_op: + batch_op.add_column(sa.Column('deleted', sa.Boolean(), nullable=True)) + batch_op.create_index(batch_op.f('ix_post_reply_deleted'), ['deleted'], unique=False) + + op.execute(sa.DDL('UPDATE "post" SET deleted = false')) + op.execute(sa.DDL('UPDATE "post_reply" SET deleted = false')) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post_reply', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_post_reply_deleted')) + batch_op.drop_column('deleted') + + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_post_deleted')) + batch_op.drop_column('deleted') + + # ### end Alembic commands ###