implement guild subscriptions, blocking, aesthetic improvements

This commit is contained in:
Yusur 2025-07-06 00:50:57 +02:00
parent c1c005cc4e
commit 05dca27149
23 changed files with 292 additions and 68 deletions

View file

@ -2,10 +2,13 @@
## 0.4.0
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou)
- Added user strikes, memberships and user blocks
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
- Added user blocks
- Added user strikes: a strike logs the content of a removed message for future use
- Implemented guild subscriptions
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
- Added ✨color themes✨
- Users can now set their display name and biography in `/settings`
- Users can now set their display name, biography and color theme in `/settings`
## 0.3.3

View file

@ -6,11 +6,13 @@ AJAX hooks for the website.
'''
import re
from flask import Blueprint, request
from flask import Blueprint, abort, flash, redirect, request
from sqlalchemy import delete, insert, select
from .models import Guild, db, User, Post, PostUpvote
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote
from flask_login import current_user, login_required
current_user: User
bp = Blueprint('ajax', __name__)
@bp.route('/username_availability/<username>')
@ -71,3 +73,81 @@ def post_upvote(id):
db.session.commit()
return { 'status': 'ok', 'count': p.upvotes() }
@bp.route('/@<username>/block', methods=['POST'])
@login_required
def block_user(username):
u = db.session.execute(select(User).where(User.username == username)).scalar()
if u is None:
abort(404)
is_block = 'reverse' not in request.form
is_unblock = request.form.get('reverse') == '1'
if is_block:
if current_user.has_blocked(u):
flash(f'{u.handle()} is already blocked')
else:
db.session.execute(insert(UserBlock).values(
actor_id = current_user.id,
target_id = u.id
))
db.session.commit()
flash(f'{u.handle()} is now blocked')
if is_unblock:
if not current_user.has_blocked(u):
flash('You didn\'t block this user')
else:
db.session.execute(delete(UserBlock).where(
UserBlock.c.actor_id == current_user.id,
UserBlock.c.target_id == u.id
))
db.session.commit()
flash(f'Removed block on {u.handle()}')
return redirect(request.args.get('next', u.url())), 303
@bp.route('/+<name>/subscribe', methods=['POST'])
@login_required
def subscribe_guild(name):
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
if gu is None:
abort(404)
is_join = 'reverse' not in request.form
is_leave = request.form.get('reverse') == '1'
membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar()
if is_join:
if membership is None:
membership = db.session.execute(insert(Member).values(
guild_id = gu.id,
user_id = current_user.id,
is_subscribed = True
).returning(Member)).scalar()
elif membership.is_subscribed == False:
membership.is_subscribed = True
db.session.add(membership)
else:
return redirect(gu.url()), 303
db.session.commit()
flash(f"You are now subscribed to {gu.handle()}")
if is_leave:
if membership is None:
return redirect(gu.url()), 303
elif membership.is_subscribed == True:
membership.is_subscribed = False
db.session.add(membership)
else:
return redirect(gu.url()), 303
db.session.commit()
flash(f"Unsubscribed from {gu.handle()}.")
return redirect(gu.url()), 303

View file

@ -1,8 +1,10 @@
from flask_login import current_user
from sqlalchemy import func, select
from .models import db, Post, Guild, User
from sqlalchemy import and_, distinct, func, select
from .models import Comment, Member, db, Post, Guild, User
current_user: User
def cuser() -> User:
return current_user if current_user.is_authenticated else None
@ -22,13 +24,18 @@ def topic_timeline(gname):
def user_timeline(user_id):
return select(Post).join(User, User.id == Post.author_id).where(
Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
).order_by(Post.created_at.desc())
def top_guilds_query():
q_post_count = func.count().label('post_count')
qr = select(Guild, q_post_count)\
.join(Post, Post.topic_id == Guild.id).group_by(Guild)\
.having(q_post_count > 5).order_by(q_post_count.desc())
q_post_count = func.count(distinct(Post.id)).label('post_count')
q_sub_count = func.count(distinct(Member.id)).label('sub_count')
qr = select(Guild, q_post_count, q_sub_count)\
.join(Post, Post.topic_id == Guild.id, isouter=True)\
.join(Member, and_(Member.guild_id == Guild.id, Member.is_subscribed == True), isouter=True)\
.group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc())
return qr
def new_comments(p: Post):
return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc())

View file

@ -2,7 +2,7 @@
import markdown
from markupsafe import Markup
from suou import Snowflake
from suou import Siq, Snowflake
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
from . import app
@ -29,6 +29,12 @@ def to_b32l(n):
app.template_filter('b32l')(to_b32l)
@app.template_filter()
def to_cb32(n):
return '0' + Siq.from_bytes(n).to_cb32()
app.template_filter('cb32')(to_cb32)
@app.template_filter()
def append(text, l: list):
l.append(text)

View file

@ -214,10 +214,28 @@ class User(Base):
def not_suspended(cls):
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
def has_blocked(self, other: User | None) -> bool:
if other is None or not other.is_authenticated:
return False
return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar())
@not_implemented()
def end_friendship(self, other: User):
"""
Remove any relationship between two users.
Executed before block.
"""
# TODO implement in 0.5
...
def has_subscriber(self, other: User) -> bool:
# TODO implement in 0.5
return False #bool(db.session.execute(select(Friendship).where(...)).scalar())
@classmethod
def has_not_blocked(cls, actor, target):
"""
Filter out a content if the author has blocked current user.
Filter out a content if the author has blocked current user. Returns a query.
XXX untested.
"""
@ -242,6 +260,8 @@ class User(Base):
def strike_count(self) -> int:
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
# UserBlock table is at the top !!
## END User
class Guild(Base):
@ -272,9 +292,16 @@ class Guild(Base):
def handle(self):
return f'+{self.name}'
def subscriber_count(self):
return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar()
# utilities
posts = relationship('Post', back_populates='guild')
def has_subscriber(self, other: User) -> bool:
if other is None or not other.is_authenticated:
return False
return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar())
Topic = deprecated('renamed to Guild')(Guild)
@ -306,7 +333,7 @@ class Member(Base):
user = relationship(User, primaryjoin = lambda: User.id == Member.user_id)
guild = relationship(Guild)
banned_by = relationship(User, primaryjoin= lambda: User.id == Member.banned_by_id)
banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id)
@property
def is_banned(self):
@ -356,9 +383,9 @@ class Post(Base):
def url(self):
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
@not_implemented
def generate_slug(self):
return slugify.slugify(self.title, max_length=64)
@not_implemented('slugify is not a dependency as of now')
def generate_slug(self) -> str:
return "slugify.slugify(self.title, max_length=64)"
def upvotes(self) -> int:
return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar()
@ -397,8 +424,8 @@ class Post(Base):
return Post.removed_at == None
@classmethod
def visible_by(cls, user: User):
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1)))
def visible_by(cls, user_id: int | None):
return or_(Post.author_id == user_id, Post.privacy.in_((0, 1)))
class Comment(Base):

View file

@ -1,15 +1,20 @@
from flask import Blueprint
from flask_restx import Resource, Api
from flask import Blueprint, redirect, url_for
from flask_restx import Resource
from sqlalchemy import select
from suou import Snowflake
from suou.flask_sqlalchemy import require_auth
from suou.flask_restx import Api
from ..models import Post, User, db
rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
rest = Api(rest_bp)
auth_required = require_auth(User, db)
@rest.route('/nurupo')
class Nurupo(Resource):
def get(self):
@ -18,9 +23,20 @@ class Nurupo(Resource):
## TODO coverage of REST is still partial, but it's planned
## to get complete sooner or later
## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not
## redirect, neither is able to get user injected.
## Auth-based REST endpoints won't be fully functional until 0.6 in most cases
@rest.route('/user/@me')
class UserInfoMe(Resource):
@auth_required(required=True)
def get(self, user: User):
return redirect(url_for('rest.UserInfo', user.id)), 302
@rest.route('/user/<b32l:id>')
class UserInfo(Resource):
def get(self, id: int):
## TODO sanizize REST to make blocked users inaccessible
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
if u is None:
return dict(error='User not found'), 404
@ -34,6 +50,7 @@ class UserInfo(Resource):
)
return dict(users={f'{Snowflake(id):l}': uj})
@rest.route('/post/<b32l:id>')
class SinglePost(Resource):
def get(self, id: int):

View file

@ -15,7 +15,7 @@
--c7-accent: #606080
--c8-accent: #aeaac0
--c9-accent: #3ae0b8
--c10-accent: #a828ba
--c10-accent: #8828ea
--c11-accent: #1871d8
--c12-accent: #885a18
--c13-accent: #38a856

View file

@ -14,15 +14,15 @@ blockquote
padding-right: 1em
border-right: 4px solid var(--border)
.callout
color: var(--text-alt)
.success
color: var(--success)
.error
color: var(--error)
.callout
color: var(--text-alt)
.message-content
p
margin: 4px 0

View file

@ -103,9 +103,9 @@ header.header
// __ aside styles __ //
aside.card
overflow: hidden
> :first-child
> :is(h1, h2, h3, h4, h5, h6):first-child
background-color: var(--accent)
padding: 12px
padding: 6px 12px
margin: -12px -12px 0 -12px
position: relative
a
@ -120,6 +120,9 @@ aside.card
padding: 12px
&:last-child
border-bottom: none
> p
padding: 12px
margin: 0
.flash
@ -171,8 +174,6 @@ ul.message-options
list-style: none
padding: 0
font-size: smaller
.comment-frame &
margin-bottom: -4px
.post-frame
margin-left: 3em
@ -321,12 +322,30 @@ button, [type="submit"], [type="reset"], [type="button"]
.comment-frame
border: 1px solid var(--border)
padding: 12px
background: var(--background)
padding: 12px 12px 6px
border-radius: 24px
border-start-start-radius: 0
min-width: 50%
width: 0
margin-right: auto
margin-inline-end: auto
margin-bottom: 12px
position: relative
&::before
content: ''
border: 1px solid var(--border)
border-inline-end: 0
border-bottom: 0
background: var(--background)
height: 1em
width: 1em
position: absolute
left: calc(-1px - .5em)
top: -1px
transform: skewX(45deg)
li:has(> &)
list-style: none
.border-accent
border: var(--accent) 1px solid
@ -352,3 +371,8 @@ button.card
background-color: var(--accent)
color: var(--background)
.big_icon
display: block
margin: 12px auto
font-size: 36px
text-align: center

View file

@ -11,18 +11,18 @@
{% endfor %}
</head>
<body class="admin">
<div class="header">
<header class="header">
<h1><a href="{{ url_for('admin.homepage') }}"><span class="faint">{{ app_name }}:</span> Admin</a></h1>
</div>
</header>
<div class="content">
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %}
</div>
<div class="footer">
<footer class="footer">
<p><a href="/">Back to {{ app_name }}</a>.</p>
</div>
</footer>
<script src="/static/lib.js"></script>
</body>
</html>

View file

@ -5,7 +5,7 @@
<ul>
{% for strike in strike_list %}
<li>
<p><strong>#{{ strike.id }}</strong> to {{ strike.user.handle() }}</p>
<p><strong>#{{ strike.id | to_cb32 }}</strong> to {{ strike.user.handle() }}</p>
<ul class="inline">
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
<!-- you might not want to see why -->

View file

@ -9,7 +9,6 @@
{% endblock %}
{% block content %}
<div class="card">
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% from "macros/title.html" import title_tag with context %}
{% from "macros/create.html" import privacy_select with context %}
{% from "macros/icon.html" import icon, callout with context %}
{% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %}
@ -17,9 +18,11 @@
<textarea name="text" placeholder="What's happening?" class="create_text">{{ p.text_content }}</textarea></dd>
</div>
<div>{{ privacy_select(p.privacy) }}</div>
<div><input type="submit" value="Save" /></dd>
<div>
<input type="submit" value="Save" />
</div>
</form>
<p class="error">{{ icon('delete') }} <a href="/delete/post/{{ p.id | to_b32l }}">Delete post</a></p>
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% from "macros/icon.html" import icon with context%}
{% macro block_button(target, blocked = False) %}
<form method="POST" action="{{ target.url() }}/block">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if blocked %}
<input type="hidden" name="reverse" value="1" />
<button type="submit" class="card">{{ icon('self') }} Remove block</button>
{% else %}
<button type="submit" class="card">{{ icon('block') }} Block</button>
{% endif %}
</form>
{% endmacro %}
{% macro subscribe_button(target, subbed = False) %}
<form method="POST" action="{{ target.url() }}/subscribe">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if subbed %}
<input type="hidden" name="reverse" value="1" />
<button type="submit" class="card">{{ icon('leave') }} Unsubscribe</button>
{% else %}
<button type="submit" class="card">{{ icon('join') }} Subscribe</button>
{% endif %}
</form>
{% endmacro %}

View file

@ -1,4 +1,6 @@
{% from "macros/icon.html" import icon with context %}
{% macro checked_if(cond) %}
{% if cond -%}
checked=""
@ -12,11 +14,11 @@ disabled=""
{% endmacro %}
{% macro privacy_select(value = 0) %}
<ul>
<li><input type="radio" name="privacy" value="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >Public <small class="faint">(everyone in your profile or public timeline)</small></label></li>
<li><input type="radio" name="privacy" value="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >Unlisted <small class="faint">(everyone in your profile, hide from public timeline)</small></label></li>
<li><input type="radio" name="privacy" value="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >Friends <small class="faint">(only people you follow each other)</small></label></li>
<li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >Only you <small class="faint">(nobody else)</small></label></li>
<ul class="grid">
<li><input type="radio" name="privacy" value="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >{{ icon('topic_travel') }} Public <small class="faint">(everyone in your profile or public timeline)</small></label></li>
<li><input type="radio" name="privacy" value="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >{{ icon('link_post') }} Unlisted <small class="faint">(everyone in your profile, hide from public timeline)</small></label></li>
<li><input type="radio" name="privacy" value="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >{{ icon('custom_feed') }} Friends <small class="faint">(only people you follow each other)</small></label></li>
<li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >{{ icon('lock') }} Only you <small class="faint">(nobody else)</small></label></li>
</ul>
{% endmacro %}

View file

@ -3,9 +3,13 @@
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
{% endmacro %}
{% macro callout(useicon = "spoiler") %}
<div class="callout">
{% macro callout(useicon = "spoiler", classes = "") %}
<div class="callout {{ classes }}">
{{ icon(useicon) }}
{{ caller() }}
</div>
{% endmacro %}
{% macro big_icon(name, fill = False) %}
<div class="big_icon">{{ icon(name, fill) }}</div>
{% endmacro %}

View file

@ -1,4 +1,7 @@
{% from "macros/icon.html" import icon with context %}
{% from "macros/button.html" import block_button, subscribe_button with context %}
{% macro nav_guild(gu) %}
<aside class="card">
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
@ -6,10 +9,13 @@
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
<li>
<strong>{{ gu.posts | count }}</strong> posts -
<strong>-</strong> subscribers
<strong>{{ gu.subscriber_count() }}</strong> subscribers
</li>
</ul>
</aside>
{% if current_user.is_authenticated %}
{{ subscribe_button(gu, gu.has_subscriber(current_user)) }}
{% endif %}
{% endmacro %}
{% macro nav_user(user) %}
@ -19,8 +25,21 @@
{% if user.biography %}
<li>{{ icon('info') }} {{ user.biography }}</li>
{% endif %}
{% if current_user.is_authenticated and current_user.age() >= 18 and user.age() < 18 %}
<li class="error">{{ icon('spoiler') }} MINOR</li>
{% endif %}
</ul>
</aside>
{% if user == current_user %}
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
{% elif current_user.is_authenticated %}
{{ block_button(user, current_user.has_blocked(user)) }}
{{ subscribe_button(user, user.has_subscriber(current_user)) }}
{% else %}
<aside class="card">
<p><a href="/login">Log in</a> to subscribe and interact with {{ user.handle() }}</p>
</aside>
{% endif %}
{% endmacro %}
{% macro nav_top_communities(top_communities) %}

View file

@ -40,12 +40,12 @@
{% endif %}
</div>
{% if current_user.is_administrator and p.report_count() %}
{% call callout() %}
{% call callout('spoiler', 'error') %}
<strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a>
{% endcall %}
{% endif %}
{% if p.is_removed %}
{% call callout('delete') %}
{% call callout('delete', 'error') %}
This post has been removed
{% endcall %}
{% endif %}
@ -69,7 +69,7 @@
{{ comment_area(p.url()) }}
<div class="comment-section">
<ul>
{% for comment in p.top_level_comments() %}
{% for comment in comments %}
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
{{ single_comment(comment) }}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
{% from "macros/title.html" import title_tag with context %}
{% from "macros/icon.html" import icon, callout with context %}
{% from "macros/icon.html" import icon, big_icon, callout with context %}
{% from "macros/nav.html" import nav_user with context %}
{% block title %}{{ title_tag(user.handle() + 's content') }}{% endblock %}
@ -16,9 +16,8 @@
{% endblock %}
{% block nav %}
{% if user.is_active and not user.has_blocked(current_user) %}
{{ nav_user(user) }}
{% if user == current_user %}
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
{% endif %}
{% endblock %}
@ -40,9 +39,10 @@
{% endif %}
</ul>
{% elif not user.is_active %}
{{ big_icon('ban') }}
<p class="centered">{{ user.handle() }} is suspended</p>
{% else %}
<p class="centered">{{ user.handle() }} never posted any content</p>
<p class="centered">{{ user.handle() }} has never posted any content</p>
{% endif %}
{% endblock %}

View file

@ -29,7 +29,7 @@
<h2>Appearance</h2>
<div>
<label>Color scheme</label>
<ul class="apply-theme">
<ul class="apply-theme grid">
<li><input type="radio" id="US__color_scheme_dark" name="color_scheme" value="dark" {{ checked_if((current_user.color_theme // 256) == 2) }}><label for="US__color_scheme_dark">Dark</label></li>
<li><input type="radio" id="US__color_scheme_light" name="color_scheme" value="light" {{ checked_if((current_user.color_theme // 256) == 1) }}><label for="US__color_scheme_light">Light</label></li>
<li><input type="radio" id="US__color_scheme_unset" name="color_scheme" value="unset" {{ checked_if((current_user.color_theme // 256) == 0) }}><label for="US__color_scheme_unset">System</label></li>
@ -37,7 +37,7 @@
</div>
<div>
<label>Color theme</label>
<ul class="apply-theme">
<ul class="apply-theme grid">
{% for color in colors %}
<li><input type="radio" id="US__color_theme_{{ color.code }}" name="color_theme" value="{{ color.code }}" {{ checked_if((current_user.color_theme % 256) == color.code) }}><label for="US__color_theme_{{ color.code }}">{{ color.name }}</label></li>
{% endfor %}

View file

@ -1,4 +1,5 @@
from typing import Iterable
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
from flask_login import current_user
from sqlalchemy import insert, select
@ -6,7 +7,7 @@ from suou import Snowflake
from ..utils import is_b32l
from ..models import Comment, Guild, db, User, Post
from ..algorithms import user_timeline
from ..algorithms import new_comments, user_timeline
bp = Blueprint('detail', __name__)
@ -64,12 +65,17 @@ def post_detail(id: int):
else:
abort(404)
def comments_of(p: Post) -> Iterable[Comment]:
## TODO add sort argument
return db.paginate(new_comments(p))
@bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def user_post_detail(username: str, id: int, slug: str = ''):
post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar()
if post is None or (post.is_removed and post.author != current_user):
if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user):
abort(404)
if post.slug and slug != post.slug:
@ -78,14 +84,14 @@ def user_post_detail(username: str, id: int, slug: str = ''):
if request.method == 'POST':
single_post_post_hook(post)
return render_template('singlepost.html', p=post)
return render_template('singlepost.html', p=post, comments=comments_of(post))
@bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
def guild_post_detail(gname, id, slug=''):
post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar()
if post is None or (post.is_removed and post.author != current_user):
if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user):
abort(404)
if post.slug and slug != post.slug:
@ -94,7 +100,7 @@ def guild_post_detail(gname, id, slug=''):
if request.method == 'POST':
single_post_post_hook(post)
return render_template('singlepost.html', p=post)
return render_template('singlepost.html', p=post, comments=comments_of(post))

View file

@ -11,7 +11,7 @@ bp = Blueprint('frontpage', __name__)
@bp.route('/')
def homepage():
top_communities = [(x[0], x[1], 0) for x in
top_communities = [(x[0], x[1], x[2]) for x in
db.session.execute(top_guilds_query().limit(10)).fetchall()]
if current_user and current_user.is_authenticated:

View file

@ -18,7 +18,7 @@ dependencies = [
"PsycoPG2-binary",
"libsass",
"setuptools>=78.1.0",
"sakuragasaki46-suou>=0.3.3"
"sakuragasaki46-suou>=0.3.4"
]
requires-python = ">=3.10"
classifiers = [