Compare commits

..

4 Commits

Author SHA1 Message Date
bd556d102b
implement thread mod ops 2025-07-01 23:34:48 +03:00
29bb9872d3
re-add subscriptions 2025-07-01 23:20:36 +03:00
52f6484db1
add all mod actions on users 2025-07-01 21:26:52 +03:00
df239fb130
add stats to user page 2025-07-01 20:29:15 +03:00
8 changed files with 415 additions and 22 deletions

View File

@ -105,4 +105,11 @@ def create_app():
def ts_datetime(ts, format):
return datetime.utcfromtimestamp(ts or int(time.time())).strftime(format)
@app.template_filter("pluralize")
def pluralize(number, singular = "", plural = "s"):
if number == 1:
return singular
return plural
return app

View File

@ -4,6 +4,9 @@ from .constants import PermissionLevel
class Users(Model):
table = "users"
def get_avatar_url(self):
return Avatars.find({"id": self.avatar_id}).file_path
def is_guest(self):
return self.permission == PermissionLevel.GUEST.value
@ -53,6 +56,18 @@ class Users(Model):
WHERE users.id = ?"""
return db.fetch_one(q, self.id)
def get_all_subscriptions(self):
q = """
SELECT threads.title AS thread_title, threads.slug AS thread_slug
FROM
threads
JOIN
subscriptions ON subscriptions.thread_id = threads.id
WHERE
subscriptions.user_id = ?"""
return db.query(q, self.id)
class Topics(Model):
table = "topics"

View File

@ -1,9 +1,10 @@
from flask import (
Blueprint, render_template, request, redirect, url_for
Blueprint, render_template, request, redirect, url_for, flash
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db
from ..models import Threads, Topics, Posts
from ..models import Threads, Topics, Posts, Subscriptions
from ..constants import InfoboxKind
from .posts import create_post
from slugify import slugify
import math
@ -39,7 +40,17 @@ def thread(slug):
topic = Topics.find({"id": thread.topic_id})
other_topics = Topics.select()
#TODO: subscription last seen
is_subscribed = False
if is_logged_in():
subscription = Subscriptions.find({
'thread_id': thread.id,
'user_id': get_active_user().id,
})
if subscription:
subscription.update({
'last_seen': int(time.time())
})
is_subscribed = True
return render_template(
"threads/thread.html",
@ -49,6 +60,7 @@ def thread(slug):
posts = posts,
topic = topic,
topics = other_topics,
is_subscribed = is_subscribed,
)
@ -107,18 +119,94 @@ def create_form():
@bp.post("/<slug>/lock")
@login_required
def lock(slug):
pass
user = get_active_user()
thread = Threads.find({'slug': slug})
if not ((thread.user_id == user.id) or user.is_mod()):
return 'no'
target_op = request.form.get('target_op')
thread.update({
'is_locked': target_op
})
return redirect(url_for('.thread', slug=slug))
@bp.post("/<slug>/sticky")
@login_required
@mod_only(".thread", slug = lambda slug: slug)
def sticky(slug):
pass
user = get_active_user()
thread = Threads.find({'slug': slug})
if not ((thread.user_id == user.id) or user.is_mod()):
return 'no'
target_op = request.form.get('target_op')
thread.update({
'is_stickied': target_op
})
return redirect(url_for('.thread', slug=slug))
@bp.post("/<slug>/move")
@login_required
@mod_only(".thread", slug = lambda slug: slug)
def move(slug):
pass
user = get_active_user()
new_topic_id = request.form.get('new_topic_id', default=None)
if new_topic_id is None:
flash('Thread is already in this topic.', InfoboxKind.ERROR)
return redirect(url_for('.thread', slug=slug))
new_topic = Topics.find({
'id': new_topic_id
})
if not new_topic:
return 'no'
thread = Threads.find({
'slug': slug
})
if not thread:
return 'no'
if new_topic.id == thread.topic_id:
flash('Thread is already in this topic.', InfoboxKind.ERROR)
return redirect(url_for('.thread', slug=slug))
old_topic = Topics.find({'id': thread.topic_id})
thread.update({'topic_id': new_topic_id})
flash(f'Topic moved from "{old_topic.name}" to "{new_topic.name}".', InfoboxKind.INFO)
return redirect(url_for('.thread', slug=slug))
@bp.post("/<slug>/subscribe")
@login_required
def subscribe(slug):
user = get_active_user()
thread = Threads.find({'slug': slug})
if not thread:
return 'no'
subscription = Subscriptions.find({
'user_id': user.id,
'thread_id': thread.id,
})
if request.form['subscribe'] == 'subscribe':
if subscription:
subscription.delete()
Subscriptions.create({
'user_id': user.id,
'thread_id': thread.id,
'last_seen': int(time.time()),
})
elif request.form['subscribe'] == 'unsubscribe':
if not subscription:
return 'no'
subscription.delete()
elif request.form['subscribe'] == 'read':
if not subscription:
return 'no'
subscription.update({
'last_seen': int(time.time())
})
last_visible_post = request.form.get('last_visible_post', default=None)
if last_visible_post is not None:
return redirect(url_for('.thread', slug=thread.slug, after=last_visible_post))
else:
return redirect(url_for('users.inbox', username=user.username))

View File

@ -2,7 +2,8 @@ from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session, current_app
)
from functools import wraps
from ..models import Users, Sessions
from ..db import db
from ..models import Users, Sessions, Subscriptions
from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify
import secrets
@ -96,6 +97,28 @@ def mod_only(*args, **kwargs):
return decorator
def admin_only(*args, **kwargs):
def decorator(view_func):
@wraps(view_func)
def wrapper(*view_args, **view_kwargs):
if not get_active_user().is_admin():
# resolve callables
processed_kwargs = {
k: v(**view_kwargs) if callable(v) else v
for k, v in kwargs.items()
}
endpoint = args[0] if args else processed_kwargs.get("endpoint")
if endpoint.startswith("."):
blueprint = current_app.blueprints.get(view_func.__name__.split(".")[0])
if blueprint:
endpoint = endpoint.lstrip(".")
return redirect(url_for(f"{blueprint.name}.{endpoint}", **processed_kwargs))
return redirect(url_for(*args, **processed_kwargs))
return view_func(*view_args, **view_kwargs)
return wrapper
return decorator
@bp.get("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in():
@ -180,12 +203,160 @@ def settings(username):
return "stub"
@bp.post("/log_out")
@login_required
def log_out():
user = get_active_user()
session_obj = Sessions.find({"key": session['pyrom_session_key']})
session_obj.delete()
session.clear()
return redirect(url_for(".log_in"))
@bp.post("/confirm_user/<user_id>")
@login_required
@mod_only("topics.all_topics")
def confirm_user(user_id):
target_user = Users.find({"id": user_id})
if not target_user:
return "no"
if int(target_user.permission) > PermissionLevel.GUEST.value:
return "no"
target_user.update({
"permission": PermissionLevel.USER.value,
"confirmed_on": int(time.time()),
})
return redirect(url_for(".page", username=target_user.username))
@bp.post("/mod_user/<user_id>")
@login_required
@admin_only("topics.all_topics")
def mod_user(user_id):
target_user = Users.find({"id": user_id})
if not target_user:
return "no"
if target_user.is_mod():
return "no"
target_user.update({
"permission": PermissionLevel.MODERATOR.value,
})
return redirect(url_for(".page", username=target_user.username))
@bp.post("/demod_user/<user_id>")
@login_required
@admin_only("topics.all_topics")
def demod_user(user_id):
target_user = Users.find({"id": user_id})
if not target_user:
return "no"
if not target_user.is_mod():
return "no"
target_user.update({
"permission": PermissionLevel.USER.value,
})
return redirect(url_for(".page", username=target_user.username))
@bp.post("/guest_user/<user_id>")
@login_required
@admin_only("topics.all_topics")
def guest_user(user_id):
target_user = Users.find({"id": user_id})
if not target_user:
return "no"
if target_user.is_mod():
return "no"
target_user.update({
"permission": PermissionLevel.GUEST.value,
})
return redirect(url_for(".page", username=target_user.username))
@bp.get("/<username>/inbox")
@login_required
def inbox(username):
return "stub"
user = get_active_user()
if username != user.username:
return redirect(url_for(".inbox", username = user.username))
new_posts = []
subscription = Subscriptions.find({"user_id": user.id})
all_subscriptions = None
total_unreads_count = None
if subscription:
all_subscriptions = user.get_all_subscriptions()
q = """
WITH thread_metadata AS (
SELECT
posts.thread_id, threads.slug AS thread_slug, threads.title AS thread_title, COUNT(*) AS unread_count, MAX(posts.created_at) AS newest_post_time
FROM
posts
LEFT JOIN
threads ON threads.id = posts.thread_id
LEFT JOIN
subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
GROUP BY posts.thread_id
)
@bp.post("/log_out")
def log_out():
pass
SELECT
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
FROM
thread_metadata tm
JOIN
posts ON posts.thread_id = tm.thread_id
JOIN
post_history ON posts.current_revision_id = post_history.id
JOIN
users ON posts.user_id = users.id
LEFT JOIN
threads ON threads.id = posts.thread_id
LEFT JOIN
avatars ON users.avatar_id = avatars.id
LEFT JOIN
subscriptions ON subscriptions.thread_id = posts.thread_id
WHERE
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
ORDER BY
tm.newest_post_time DESC, posts.created_at ASC"""
new_posts_raw = db.query(q, user.id, user.id)
current_thread_id = None
current_thread_group = None
total_unreads_count = 0
for row in new_posts_raw:
if row['thread_id'] != current_thread_id:
current_thread_group = {
'thread_id': row['thread_id'],
'thread_title': row['thread_title'],
'unread_count': row['unread_count'],
'thread_slug': row['thread_slug'],
'newest_post_time': row['newest_post_time'],
'posts': [],
}
total_unreads_count += int(row['unread_count'])
new_posts.append(current_thread_group)
current_thread_id = row['thread_id']
current_thread_group['posts'].append({
'id': row['id'],
'created_at': row['created_at'],
'content': row['content'],
'edited_at': row['edited_at'],
'username': row['username'],
'status': row['status'],
'avatar_path': row['avatar_path'],
'thread_id': row['thread_id'],
'user_id': row['user_id'],
'original_markup': row['original_markup'],
'signature_rendered': row['signature_rendered']
})
return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions)

View File

@ -89,7 +89,7 @@
</form>
{% endmacro %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None) %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false) %}
{% set postclass = "post" %}
{% if editing %}
{% set postclass = postclass + " editing" %}

View File

@ -20,16 +20,21 @@
</span>
<div>
{% if can_subscribe %}
<form class="modform" action="{{ url_for('threads.subscribe', slug=thread.slug) }}" method="post">
<input type='hidden' name='last_visible_post' value='{{posts[-1].id}}'>
<input type='hidden' name='subscribe' value='{{ 'unsubscribe' if is_subscribed else 'subscribe' }}'>
<input type='submit' value='{{ 'Unsubscribe' if is_subscribed else 'Subscribe' }}'>
</form>
{% endif %}
{% if can_lock %}
<form class="modform" action="{{ url_for("threads.lock", slug=thread.slug) }}" method="post">
<input type=hidden value="{{ (not thread.is_locked) | int }}">
<input type=hidden name='target_op' value="{{ (not thread.is_locked) | int }}">
<input class="warn" type="submit" value="{{"Unlock thread" if thread.is_locked else "Lock thread"}}">
</form>
{% endif %}
{% if active_user.is_mod() %}
<form class="modform" action="{{ url_for("threads.sticky", slug=thread.slug) }}" method="post">
<input type=hidden value="{{ (not thread.is_stickied) | int }}">
<input type=hidden name='target_op' value="{{ (not thread.is_stickied) | int }}">
<input class="warn" type="submit" value="{{"Unsticky thread" if thread.is_stickied else "Sticky thread"}}">
</form>
<form class="modform" action="{{ url_for("threads.move", slug=thread.slug) }}" method="post">

View File

@ -0,0 +1,51 @@
{% from "common/macros.html" import timestamp, full_post %}
{% extends "base.html" %}
{% block title %}inbox{% endblock %}
{% block content %}
<div class="inbox-container">
{% if all_subscriptions is none %}
You have no subscriptions.<br>
{% else %}
Your subscriptions:
<ul>
{% for sub in all_subscriptions %}
<li>
<a href=" {{ url_for("threads.thread", slug=sub.thread_slug) }} ">{{ sub.thread_title }}</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = sub.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</li>
{% endfor %}
</ul>
{% endif %}
{% if not new_posts %}
You have no unread posts.
{% else %}
You have {{ total_unreads_count }} unread post{{(total_unreads_count | int) | pluralize }}:
{% for thread in new_posts %}
<div class="accordion">
<div class="accordion-header">
<button type="button" class="accordion-toggle"></button>
{% set latest_post_id = thread.posts[-1].id %}
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize) %}
<a class="accordion-title" href="{{ url_for("threads.thread", slug=latest_post_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="read">
<input type="submit" value="Mark thread as Read">
</form>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="unsubscribe">
<input class="warn" type="submit" value="Unsubscribe">
</form>
</div>
<div class="accordion-content">
{% for post in thread.posts %}
{{ full_post(post, no_reply = true) }}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

View File

@ -19,18 +19,74 @@
<h1 class="thread-title">Moderation controls</h1>
{% if target_user.is_guest() %}
<p>This user is a guest. They signed up on {{ timestamp(target_user['created_at']) }}</p>
<form class="modform" method="post" action="{{ url_for("users.confirm_user", user_id=target_user.id) }}">
<input type="submit" value="Confirm user">
</form>
{% else %}
<p>This user signed up on {{ timestamp(target_user['created_at']) }} and was confirmed on {{ timestamp(target_user['confirmed_on']) }}</p>
{% if (target_user.permission | int) < (active_user.permission | int) %}
<form class="modform" method="post" action="{{ url_for("users.guest_user", user_id=target_user.id) }}">
<input class="warn" type="submit" value="Demote user to guest (soft ban)">
</form>
{% endif %}
{% if active_user.is_admin() and not target_user.is_mod() %}
<form class="modform" method="post" action="{{ url_for("users.mod_user", user_id=target_user.id) }}">
<input class="warn" type="submit" value="Promote user to moderator">
</form>
{% elif target_user.is_mod() and (target_user.permission | int) < (active_user.permission | int) %}
<form class="modform" method="post" action="{{ url_for("users.demod_user", user_id=target_user.id) }}">
<input class="critical" type="submit" value="Demote user to regular user">
</form>
{% endif %}
{% endif %}
{% endif %}
</div>
<div>
{% with stats = target_user.get_post_stats() %}
<ul>
<li>Posts created: {{ stats.post_count }}</li>
<li>Threads started: {{ stats.thread_count }}</li>
<li>Latest started thread: {{ stats.latest_thread_title }}
</ul>
{% endwith %}
<div class="user-info">
<div class="user-page-usercard">
<div class="usercard-inner">
<img class="avatar" src="{{ target_user.get_avatar_url() }}">
<strong class="big">{{ target_user.username }}</strong>
{% if target_user.status %}
<em class="user-status">{{ target_user.status }}</em>
{% endif %}
{% if target_user.signature_rendered %}
Signature:
<div>{{ target_user.signature_rendered | safe }}</div>
{% endif %}
</div>
</div>
<div class="user-page-stats">
{% with stats = target_user.get_post_stats() %}
<ul class="user-stats-list">
<li>Permission: {{ target_user.permission }}</li>
<li>Posts created: {{ stats.post_count }}</li>
<li>Threads started: {{ stats.thread_count }}</li>
{% if stats.latest_thread_title %}
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
{% endif %}
</ul>
{% endwith %}
Latest posts:
{% with posts = target_user.get_latest_posts() %}
<div class="user-page-posts">
{% for post in posts %}
<div class="post-content-container">
<div class="post-info">
<a href="{{ url_for("threads.thread", slug=post.thread_slug, after=post.id) }}" title="permalink"><i>
{% if (post.edited_at | int) > (post.created_at | int) %}
Edited on {{ timestamp(post.edited_at) }}
{% else %}
Posted on {{ timestamp(post.edited_at) }}
{% endif %}
</i></a>
</div>
<div class="post-content wider user-page-post-preview">
<div class="post-inner">{{ post.content | safe }}</div>
</div>
</div>
{% endfor %}
</div>
{% endwith %}
</div>
</div>
{% endblock %}