From 8c9da9df98b252924e467b5f6c1c6a31fbd8b453 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 23 May 2026 08:34:13 +0200 Subject: [PATCH] Refactor.. --- devplacepy/cli.py | 1 - devplacepy/database.py | 25 ++++++ devplacepy/main.py | 2 +- devplacepy/routers/admin.py | 3 +- devplacepy/routers/auth.py | 10 +-- devplacepy/routers/comments.py | 40 ++------- devplacepy/routers/feed.py | 20 ++--- devplacepy/routers/follow.py | 15 +--- devplacepy/routers/gists.py | 96 +++++----------------- devplacepy/routers/messages.py | 5 +- devplacepy/routers/news.py | 2 +- devplacepy/routers/posts.py | 43 ++-------- devplacepy/routers/profile.py | 6 +- devplacepy/routers/projects.py | 45 ++++------ devplacepy/routers/uploads.py | 6 +- devplacepy/routers/votes.py | 51 ++---------- devplacepy/seo.py | 1 - devplacepy/static/css/admin.css | 6 +- devplacepy/static/css/base.css | 28 ++----- devplacepy/static/css/feed.css | 2 +- devplacepy/static/css/gists.css | 2 +- devplacepy/static/css/news.css | 6 +- devplacepy/static/css/services.css | 10 +-- devplacepy/static/css/variables.css | 14 ++++ devplacepy/static/js/AttachmentUploader.js | 5 +- devplacepy/static/js/DomUtils.js | 10 +-- devplacepy/static/js/EmojiPicker.js | 12 +-- devplacepy/static/js/MentionInput.js | 15 ++-- devplacepy/static/js/MessageSearch.js | 8 +- devplacepy/static/js/ServiceMonitor.js | 5 +- devplacepy/static/js/VoteManager.js | 25 ++---- devplacepy/templating.py | 6 -- devplacepy/utils.py | 51 ++++++++---- tests/test_uploads.py | 2 +- 34 files changed, 211 insertions(+), 367 deletions(-) diff --git a/devplacepy/cli.py b/devplacepy/cli.py index 9b045a1..c552654 100644 --- a/devplacepy/cli.py +++ b/devplacepy/cli.py @@ -60,7 +60,6 @@ def cmd_news_sanitize(args): def cmd_attachments_prune(args): from devplacepy.database import db from devplacepy.config import STATIC_DIR - import os deleted_records = 0 deleted_files = 0 freed_bytes = 0 diff --git a/devplacepy/database.py b/devplacepy/database.py index 38b3bb9..bf3b3c3 100644 --- a/devplacepy/database.py +++ b/devplacepy/database.py @@ -326,6 +326,31 @@ def resolve_by_slug(table, slug): return entry +VOTABLE_TARGETS: dict[str, str] = { + "post": "posts", + "project": "projects", + "gist": "gists", + "comment": "comments", +} + +STAR_TARGETS: set[str] = {"post", "project", "gist"} + + +def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None: + table_name = VOTABLE_TARGETS.get(target_type) + if not table_name or target_type not in STAR_TARGETS: + return + get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"]) + + +def get_target_owner_uid(target_type: str, target_uid: str) -> str | None: + table_name = VOTABLE_TARGETS.get(target_type) + if not table_name: + return None + row = get_table(table_name).find_one(uid=target_uid) + return row["user_uid"] if row else None + + def build_pagination(page, total, per_page=25): total_pages = max(1, __import__("math").ceil(total / per_page)) page = max(1, min(page, total_pages)) diff --git a/devplacepy/main.py b/devplacepy/main.py index 1c9cf5a..df212c8 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -11,7 +11,7 @@ from devplacepy.config import STATIC_DIR, PORT from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids from devplacepy.templating import templates from devplacepy.utils import get_current_user, time_ago -from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine +from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads from devplacepy.services.manager import service_manager from devplacepy.services.news import NewsService diff --git a/devplacepy/routers/admin.py b/devplacepy/routers/admin.py index b57fac7..4871750 100644 --- a/devplacepy/routers/admin.py +++ b/devplacepy/routers/admin.py @@ -1,10 +1,9 @@ import logging from typing import Annotated -from datetime import datetime from fastapi import APIRouter, Request, Form from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm from fastapi.responses import HTMLResponse, RedirectResponse -from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache +from devplacepy.database import get_table, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache from devplacepy.templating import templates from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache from devplacepy.seo import base_seo_context, site_url, website_schema diff --git a/devplacepy/routers/auth.py b/devplacepy/routers/auth.py index 88f5fbe..948e2e6 100644 --- a/devplacepy/routers/auth.py +++ b/devplacepy/routers/auth.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form from fastapi.responses import RedirectResponse, HTMLResponse from devplacepy.database import get_table from devplacepy.templating import templates -from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user +from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge from devplacepy.seo import base_seo_context from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm @@ -82,13 +82,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]): "created_at": datetime.now(timezone.utc).isoformat(), }) - badges = get_table("badges") - badges.insert({ - "uid": generate_uid(), - "user_uid": uid, - "badge_name": "Member", - "created_at": datetime.now(timezone.utc).isoformat(), - }) + award_badge(uid, "Member") token = create_session(uid) response = RedirectResponse(url="/feed", status_code=302) diff --git a/devplacepy/routers/comments.py b/devplacepy/routers/comments.py index 0a280b2..a927f9a 100644 --- a/devplacepy/routers/comments.py +++ b/devplacepy/routers/comments.py @@ -5,8 +5,7 @@ from fastapi import APIRouter, Request, Form from fastapi.responses import RedirectResponse from devplacepy.database import get_table, resolve_by_slug from devplacepy.attachments import link_attachments, delete_target_attachments -from devplacepy.templating import clear_unread_cache -from devplacepy.utils import generate_uid, require_user, create_mention_notifications +from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge from devplacepy.models import CommentForm logger = logging.getLogger(__name__) @@ -60,49 +59,20 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()]) if data.attachment_uids: link_attachments(data.attachment_uids, "comment", comment_uid) - badges = get_table("badges") - existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment") - if not existing: - badges.insert({ - "uid": generate_uid(), - "user_uid": user["uid"], - "badge_name": "First Comment", - "created_at": datetime.now(timezone.utc).isoformat(), - }) + award_badge(user["uid"], "First Comment") if target_type == "post": if parent_uid: - comments_table = get_table("comments") - parent = comments_table.find_one(uid=parent_uid) + parent = get_table("comments").find_one(uid=parent_uid) if parent and parent["user_uid"] != user["uid"]: - notifications = get_table("notifications") - notifications.insert({ - "uid": generate_uid(), - "user_uid": parent["user_uid"], - "type": "reply", - "message": f"{user['username']} replied to your comment", - "related_uid": user["uid"], - "read": False, - "created_at": datetime.now(timezone.utc).isoformat(), - }) - clear_unread_cache(parent["user_uid"]) + create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"]) else: posts = get_table("posts") post = posts.find_one(uid=target_uid) if not post: post = posts.find_one(slug=target_uid) if post and post["user_uid"] != user["uid"]: - notifications = get_table("notifications") - notifications.insert({ - "uid": generate_uid(), - "user_uid": post["user_uid"], - "type": "comment", - "message": f"{user['username']} commented on your post", - "related_uid": user["uid"], - "read": False, - "created_at": datetime.now(timezone.utc).isoformat(), - }) - clear_unread_cache(post["user_uid"]) + create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"]) create_mention_notifications(content, user["uid"], redirect_url) logger.info(f"Comment by {user['username']} on {target_type} {target_uid}") diff --git a/devplacepy/routers/feed.py b/devplacepy/routers/feed.py index 61f37b0..8abe3ed 100644 --- a/devplacepy/routers/feed.py +++ b/devplacepy/routers/feed.py @@ -3,9 +3,10 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats from devplacepy.attachments import get_attachments_batch +from devplacepy.content import enrich_items from devplacepy.templating import templates -from devplacepy.utils import get_current_user, time_ago -from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine +from devplacepy.utils import get_current_user +from devplacepy.seo import base_seo_context, site_url, website_schema logger = logging.getLogger(__name__) router = APIRouter() @@ -48,19 +49,10 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None if not posts: return [], next_cursor - uids = [p["user_uid"] for p in posts] - post_uids = [p["uid"] for p in posts] - authors = get_users_by_uids(uids) - counts = get_comment_counts_by_post_uids(post_uids) + authors = get_users_by_uids([p["user_uid"] for p in posts]) + counts = get_comment_counts_by_post_uids([p["uid"] for p in posts]) - result = [] - for post in posts: - result.append({ - "post": post, - "author": authors.get(post["user_uid"]), - "time_ago": time_ago(post["created_at"]), - "comment_count": counts.get(post["uid"], 0), - }) + result = enrich_items(posts, "post", authors, {"comment_count": counts}) return result, next_cursor diff --git a/devplacepy/routers/follow.py b/devplacepy/routers/follow.py index c225978..b6f1958 100644 --- a/devplacepy/routers/follow.py +++ b/devplacepy/routers/follow.py @@ -3,8 +3,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Request from fastapi.responses import RedirectResponse from devplacepy.database import get_table -from devplacepy.templating import clear_unread_cache -from devplacepy.utils import generate_uid, require_user +from devplacepy.utils import generate_uid, require_user, create_notification logger = logging.getLogger(__name__) router = APIRouter() @@ -30,17 +29,7 @@ async def follow_user(request: Request, username: str): "created_at": datetime.now(timezone.utc).isoformat(), }) - notifications = get_table("notifications") - notifications.insert({ - "uid": generate_uid(), - "user_uid": target["uid"], - "type": "follow", - "message": f"{user['username']} started following you", - "related_uid": user["uid"], - "read": False, - "created_at": datetime.now(timezone.utc).isoformat(), - }) - clear_unread_cache(target["uid"]) + create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"]) logger.info(f"{user['username']} followed {username}") return RedirectResponse(url=f"/profile/{username}", status_code=302) diff --git a/devplacepy/routers/gists.py b/devplacepy/routers/gists.py index 95b2100..61c1eea 100644 --- a/devplacepy/routers/gists.py +++ b/devplacepy/routers/gists.py @@ -4,11 +4,11 @@ from datetime import datetime, timezone from fastapi import APIRouter, Request, HTTPException, Form from devplacepy.models import GistForm, GistEditForm from fastapi.responses import HTMLResponse, RedirectResponse -from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db -from devplacepy.attachments import get_attachments, delete_target_attachments +from devplacepy.database import get_table, get_users_by_uids +from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items from devplacepy.templating import templates -from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications -from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema +from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications +from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema logger = logging.getLogger(__name__) router = APIRouter() @@ -37,19 +37,8 @@ def get_gists_list(user_uid=None, language=None): if not all_gists: return [] - from devplacepy.database import get_users_by_uids - uids = [g["user_uid"] for g in all_gists] - users_map = get_users_by_uids(uids) - - result = [] - for g in all_gists: - author = users_map.get(g["user_uid"]) - result.append({ - "gist": g, - "author": author, - "time_ago": time_ago(g["created_at"]), - }) - return result + users_map = get_users_by_uids([g["user_uid"] for g in all_gists]) + return enrich_items(all_gists, "gist", users_map) @router.get("", response_class=HTMLResponse) @@ -82,23 +71,10 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non @router.get("/{gist_slug}", response_class=HTMLResponse) async def gist_detail(request: Request, gist_slug: str): user = get_current_user(request) - gists = get_table("gists") - gist = resolve_by_slug(gists, gist_slug) - if not gist: + detail = load_detail("gists", "gist", gist_slug, user) + if not detail: raise HTTPException(status_code=404, detail="Gist not found") - - from devplacepy.database import get_users_by_uids - users_map = get_users_by_uids([gist["user_uid"]]) - author = users_map.get(gist["user_uid"]) - - is_owner = user and user["uid"] == gist["user_uid"] - - ups, downs = get_vote_counts([gist["uid"]]) - star_count = ups.get(gist["uid"], 0) - downs.get(gist["uid"], 0) - - comments = load_comments("gist", gist["uid"]) - - gist_attachments = get_attachments("gist", gist["uid"]) + gist = detail["item"] base = site_url(request) seo_ctx = base_seo_context( @@ -118,13 +94,13 @@ async def gist_detail(request: Request, gist_slug: str): "request": request, "user": user, "gist": gist, - "author": author, - "is_owner": is_owner, - "star_count": star_count, - "time_ago": time_ago(gist["created_at"]), - "comments": comments, + "author": detail["author"], + "is_owner": detail["is_owner"], + "star_count": detail["star_count"], + "time_ago": detail["time_ago"], + "comments": detail["comments"], "languages": LANGUAGES, - "attachments": gist_attachments, + "attachments": detail["attachments"], }) @@ -167,46 +143,18 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]): @router.post("/edit/{gist_slug}") async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]): user = require_user(request) - gists = get_table("gists") - gist = resolve_by_slug(gists, gist_slug) - if not gist or gist["user_uid"] != user["uid"]: - return RedirectResponse(url="/gists", status_code=302) - - title = data.title.strip() - description = data.description.strip() - source_code = data.source_code.strip() language = data.language - - valid_languages = {l[0] for l in LANGUAGES} - if language not in valid_languages: + if language not in {l[0] for l in LANGUAGES}: language = "plaintext" - - gists.update({ - "uid": gist["uid"], - "title": title, - "description": description or None, - "source_code": source_code, + return edit_content_item("gists", user, gist_slug, { + "title": data.title.strip(), + "description": data.description.strip() or None, + "source_code": data.source_code.strip(), "language": language, - }, ["uid"]) - - logger.info(f"Gist {gist['uid']} edited by {user['username']}") - return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302) + }, "/gists") @router.post("/delete/{gist_slug}") async def delete_gist(request: Request, gist_slug: str): user = require_user(request) - gists = get_table("gists") - gist = resolve_by_slug(gists, gist_slug) - if gist and gist["user_uid"] == user["uid"]: - from devplacepy.attachments import delete_target_attachments - delete_target_attachments("gist", gist["uid"]) - if "comments" in db.tables: - for c in get_table("comments").find(target_type="gist", target_uid=gist["uid"]): - delete_target_attachments("comment", c["uid"]) - get_table("comments").delete(target_type="gist", target_uid=gist["uid"]) - if "votes" in db.tables: - get_table("votes").delete(target_type="gist", target_uid=gist["uid"]) - gists.delete(id=gist["id"]) - logger.info(f"Gist {gist['uid']} deleted by {user['username']}") - return RedirectResponse(url="/gists", status_code=302) + return delete_content_item("gists", "gist", user, gist_slug, "/gists") diff --git a/devplacepy/routers/messages.py b/devplacepy/routers/messages.py index a29fda0..035db60 100644 --- a/devplacepy/routers/messages.py +++ b/devplacepy/routers/messages.py @@ -68,8 +68,9 @@ def get_conversation_messages(user_uid: str, other_uid: str): msgs.append(m) msgs.sort(key=lambda m: m["created_at"]) - with db: - db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid) + if "messages" in db.tables: + with db: + db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid) from devplacepy.database import get_users_by_uids user_ids = list({m["sender_uid"] for m in msgs} | {other_uid}) diff --git a/devplacepy/routers/news.py b/devplacepy/routers/news.py index 8157ae9..24ee8ba 100644 --- a/devplacepy/routers/news.py +++ b/devplacepy/routers/news.py @@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids from devplacepy.templating import templates from devplacepy.utils import get_current_user, time_ago -from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema +from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema logger = logging.getLogger(__name__) router = APIRouter() diff --git a/devplacepy/routers/posts.py b/devplacepy/routers/posts.py index 87d450f..1a417c5 100644 --- a/devplacepy/routers/posts.py +++ b/devplacepy/routers/posts.py @@ -4,11 +4,12 @@ from datetime import datetime, timezone from fastapi import APIRouter, Request, HTTPException, Form from fastapi.responses import RedirectResponse, HTMLResponse from devplacepy.constants import TOPICS -from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug +from devplacepy.database import get_table, load_comments, db, resolve_by_slug from devplacepy.templating import templates -from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications -from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate -from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image +from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge +from devplacepy.content import edit_content_item, delete_content_item +from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate +from devplacepy.attachments import get_attachments, link_attachments, save_inline_image from devplacepy.models import PostForm, PostEditForm logger = logging.getLogger(__name__) @@ -48,15 +49,7 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]): "created_at": datetime.now(timezone.utc).isoformat(), }) - badges = get_table("badges") - existing = badges.find_one(user_uid=user["uid"], badge_name="First Post") - if not existing: - badges.insert({ - "uid": generate_uid(), - "user_uid": user["uid"], - "badge_name": "First Post", - "created_at": datetime.now(timezone.utc).isoformat(), - }) + award_badge(user["uid"], "First Post") if data.attachment_uids: link_attachments(data.attachment_uids, "post", uid) @@ -136,32 +129,14 @@ async def view_post(request: Request, post_slug: str): @router.post("/edit/{post_slug}") async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]): user = require_user(request) - posts = get_table("posts") - post = resolve_by_slug(posts, post_slug) - if not post or post["user_uid"] != user["uid"]: - return RedirectResponse(url="/feed", status_code=302) - - posts.update({ - "uid": post["uid"], + return edit_content_item("posts", user, post_slug, { "content": data.content.strip(), "title": data.title.strip() or None, "topic": data.topic, - }, ["uid"]) - - logger.info(f"Post {post['uid']} edited by {user['username']}") - return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302) + }, "/feed") @router.post("/delete/{post_slug}") async def delete_post(request: Request, post_slug: str): user = require_user(request) - posts = get_table("posts") - post = resolve_by_slug(posts, post_slug) - if post and post["user_uid"] == user["uid"]: - delete_target_attachments("post", post["uid"]) - get_table("comments").delete(post_uid=post["uid"]) - get_table("votes").delete(target_uid=post["uid"]) - delete_inline_image(post.get("image")) - posts.delete(id=post["id"]) - logger.info(f"Post {post['uid']} deleted by {user['username']}") - return RedirectResponse(url="/feed", status_code=302) + return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image") diff --git a/devplacepy/routers/profile.py b/devplacepy/routers/profile.py index 9d62ac4..3cb1150 100644 --- a/devplacepy/routers/profile.py +++ b/devplacepy/routers/profile.py @@ -5,8 +5,8 @@ from devplacepy.models import ProfileForm from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from devplacepy.database import get_table, db from devplacepy.templating import templates -from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache -from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine +from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache +from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema logger = logging.getLogger(__name__) router = APIRouter() @@ -14,7 +14,7 @@ router = APIRouter() @router.get("/search") async def search_users(request: Request, q: str = ""): - require_user(request) + require_user_api(request) if not q or len(q) < 1: return JSONResponse({"results": []}) if "users" in db.tables: diff --git a/devplacepy/routers/projects.py b/devplacepy/routers/projects.py index f622c13..8090b66 100644 --- a/devplacepy/routers/projects.py +++ b/devplacepy/routers/projects.py @@ -5,11 +5,12 @@ from sqlalchemy import or_ from fastapi import APIRouter, Request, HTTPException, Form from devplacepy.models import ProjectForm from fastapi.responses import HTMLResponse, RedirectResponse -from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats -from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments +from devplacepy.database import get_table, get_users_by_uids, get_site_stats +from devplacepy.content import load_detail, delete_content_item +from devplacepy.attachments import link_attachments from devplacepy.templating import templates -from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications -from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema +from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications +from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema logger = logging.getLogger(__name__) router = APIRouter() @@ -82,22 +83,10 @@ async def projects_page( @router.get("/{project_slug}", response_class=HTMLResponse) async def project_detail(request: Request, project_slug: str): user = get_current_user(request) - projects = get_table("projects") - project = resolve_by_slug(projects, project_slug) - if not project: + detail = load_detail("projects", "project", project_slug, user) + if not detail: raise HTTPException(status_code=404, detail="Project not found") - - users_map = get_users_by_uids([project["user_uid"]]) - author = users_map.get(project["user_uid"]) - - is_owner = user and user["uid"] == project["user_uid"] - - ups, downs = get_vote_counts([project["uid"]]) - star_count = ups.get(project["uid"], 0) - downs.get(project["uid"], 0) - - comments = load_comments("project", project["uid"]) - - project_attachments = get_attachments("project", project["uid"]) + project = detail["item"] base = site_url(request) seo_ctx = base_seo_context( @@ -116,25 +105,19 @@ async def project_detail(request: Request, project_slug: str): "request": request, "user": user, "project": project, - "author": author, - "is_owner": is_owner, - "star_count": star_count, + "author": detail["author"], + "is_owner": detail["is_owner"], + "star_count": detail["star_count"], "platforms": project.get("platforms", "").split(",") if project.get("platforms") else [], - "comments": comments, - "attachments": project_attachments, + "comments": detail["comments"], + "attachments": detail["attachments"], }) @router.post("/delete/{project_slug}") async def delete_project(request: Request, project_slug: str): user = require_user(request) - projects = get_table("projects") - project = resolve_by_slug(projects, project_slug) - if project and project["user_uid"] == user["uid"]: - delete_target_attachments("project", project["uid"]) - projects.delete(id=project["id"]) - logger.info(f"Project {project['uid']} deleted by {user['username']}") - return RedirectResponse(url="/projects", status_code=302) + return delete_content_item("projects", "project", user, project_slug, "/projects") @router.post("/create") diff --git a/devplacepy/routers/uploads.py b/devplacepy/routers/uploads.py index dfae800..a2ae36a 100644 --- a/devplacepy/routers/uploads.py +++ b/devplacepy/routers/uploads.py @@ -3,7 +3,7 @@ from pathlib import Path from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from devplacepy.database import get_table, get_setting, get_int_setting -from devplacepy.utils import require_user +from devplacepy.utils import require_user_api from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES logger = logging.getLogger(__name__) @@ -12,7 +12,7 @@ router = APIRouter() @router.post("/upload") async def upload_file(request: Request): - user = require_user(request) + user = require_user_api(request) form = await request.form() file = form.get("file") @@ -49,7 +49,7 @@ async def upload_file(request: Request): @router.delete("/delete/{attachment_uid}") async def delete_attachment_route(request: Request, attachment_uid: str): - user = require_user(request) + user = require_user_api(request) att = get_table("attachments").find_one(uid=attachment_uid) if not att: return JSONResponse({"error": "Attachment not found"}, status_code=404) diff --git a/devplacepy/routers/votes.py b/devplacepy/routers/votes.py index 4d8cae9..35215ca 100644 --- a/devplacepy/routers/votes.py +++ b/devplacepy/routers/votes.py @@ -3,14 +3,15 @@ from typing import Annotated from datetime import datetime, timezone from fastapi import APIRouter, Request, Form from fastapi.responses import RedirectResponse -from devplacepy.database import get_table -from devplacepy.templating import clear_unread_cache -from devplacepy.utils import generate_uid, require_user +from devplacepy.database import get_table, update_target_stars, get_target_owner_uid +from devplacepy.utils import generate_uid, require_user, create_notification from devplacepy.models import VoteForm logger = logging.getLogger(__name__) router = APIRouter() +NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"} + @router.post("/{target_type}/{target_uid}") async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]): @@ -39,46 +40,12 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota down_count = votes.count(target_uid=target_uid, value=-1) net = up_count - down_count - if target_type == "post": - posts = get_table("posts") - posts.update({"uid": target_uid, "stars": net}, ["uid"]) - elif target_type == "project": - projects = get_table("projects") - projects.update({"uid": target_uid, "stars": net}, ["uid"]) - elif target_type == "gist": - gists = get_table("gists") - gists.update({"uid": target_uid, "stars": net}, ["uid"]) + update_target_stars(target_type, target_uid, net) - if value == 1: - target_owner_uid = None - if target_type == "post": - target_owner = posts.find_one(uid=target_uid) - if target_owner: - target_owner_uid = target_owner["user_uid"] - elif target_type == "comment": - comments = get_table("comments") - target_comment = comments.find_one(uid=target_uid) - if target_comment: - target_owner_uid = target_comment["user_uid"] - elif target_type == "gist": - gists = get_table("gists") - target_gist = gists.find_one(uid=target_uid) - if target_gist: - target_owner_uid = target_gist["user_uid"] - - if target_owner_uid and target_owner_uid != user["uid"]: - label = {"post": "post", "comment": "comment", "gist": "gist"}.get(target_type, "gist") - notifications = get_table("notifications") - notifications.insert({ - "uid": generate_uid(), - "user_uid": target_owner_uid, - "type": "vote", - "message": f"{user['username']} ++'d your {label}", - "related_uid": user["uid"], - "read": False, - "created_at": datetime.now(timezone.utc).isoformat(), - }) - clear_unread_cache(target_owner_uid) + if value == 1 and target_type in NOTIFY_ON_VOTE: + owner_uid = get_target_owner_uid(target_type, target_uid) + if owner_uid and owner_uid != user["uid"]: + create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"]) referer = request.headers.get("Referer", "/feed") return RedirectResponse(url=referer, status_code=302) diff --git a/devplacepy/seo.py b/devplacepy/seo.py index e4f1d42..81dee88 100644 --- a/devplacepy/seo.py +++ b/devplacepy/seo.py @@ -1,6 +1,5 @@ import json import logging -from datetime import datetime from xml.etree.ElementTree import Element, tostring from xml.dom import minidom from devplacepy.config import SITE_URL diff --git a/devplacepy/static/css/admin.css b/devplacepy/static/css/admin.css index 6fdda9e..00a6278 100644 --- a/devplacepy/static/css/admin.css +++ b/devplacepy/static/css/admin.css @@ -126,12 +126,12 @@ .admin-btn-sm { font-size: 0.6875rem; - padding: 0.2rem 0.4rem; + padding: 0.25rem 0.375rem; } .admin-select { font-size: 0.75rem; - padding: 0.2rem 0.4rem; + padding: 0.25rem 0.375rem; border-radius: var(--radius); background: var(--bg-input); color: var(--text-primary); @@ -152,7 +152,7 @@ .admin-input-sm { width: 110px; font-size: 0.75rem; - padding: 0.2rem 0.4rem; + padding: 0.25rem 0.375rem; border-radius: var(--radius); background: var(--bg-input); color: var(--text-primary); diff --git a/devplacepy/static/css/base.css b/devplacepy/static/css/base.css index 3a2a403..1030377 100644 --- a/devplacepy/static/css/base.css +++ b/devplacepy/static/css/base.css @@ -408,12 +408,12 @@ img { .btn-primary { background: var(--accent); - color: #fff; + color: var(--white); } .btn-primary:hover { background: var(--accent-hover); - color: #fff; + color: var(--white); } .btn-secondary { @@ -461,13 +461,13 @@ img { letter-spacing: 0.05em; } -.badge-devlog { background: var(--topic-devlog); color: #fff; } -.badge-showcase { background: var(--topic-showcase); color: #fff; } -.badge-question { background: var(--topic-question); color: #fff; } -.badge-rant { background: var(--topic-rant); color: #fff; } +.badge-devlog { background: var(--topic-devlog); color: var(--white); } +.badge-showcase { background: var(--topic-showcase); color: var(--white); } +.badge-question { background: var(--topic-question); color: var(--white); } +.badge-rant { background: var(--topic-rant); color: var(--white); } .badge-fun { background: var(--topic-fun); color: #000; } .badge-random { background: var(--border-light); color: var(--text-secondary); } -.badge-signals { background: var(--topic-signals); color: #fff; } +.badge-signals { background: var(--topic-signals); color: var(--white); } .avatar { width: 40px; @@ -479,7 +479,7 @@ img { justify-content: center; font-weight: 700; font-size: 1rem; - color: #fff; + color: var(--white); flex-shrink: 0; overflow: hidden; } @@ -535,7 +535,7 @@ img { .topnav-icon:hover { color: var(--text-primary); } .nav-badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; - padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; + padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--white); font-size: 0.6875rem; font-weight: 700; display: flex; align-items: center; justify-content: center; } @@ -723,16 +723,6 @@ img { to { opacity: 1; transform: translateY(0); } } -.post-author-link { - font-weight: 600; - font-size: 0.875rem; - color: var(--text-primary); -} - -.post-author-link:hover { - color: var(--accent); -} - .icon { font-size: 1rem; width: 20px; diff --git a/devplacepy/static/css/feed.css b/devplacepy/static/css/feed.css index 0c728b4..0f9dd89 100644 --- a/devplacepy/static/css/feed.css +++ b/devplacepy/static/css/feed.css @@ -71,7 +71,7 @@ .post-card:hover { border-color: var(--border-light); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + box-shadow: var(--shadow-sm); } .post-header { diff --git a/devplacepy/static/css/gists.css b/devplacepy/static/css/gists.css index 65c1962..eff5286 100644 --- a/devplacepy/static/css/gists.css +++ b/devplacepy/static/css/gists.css @@ -39,7 +39,7 @@ .gist-card:hover { border-color: var(--border-light); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + box-shadow: var(--shadow-sm); } .gist-card-header { diff --git a/devplacepy/static/css/news.css b/devplacepy/static/css/news.css index f4751c8..ee1cbd5 100644 --- a/devplacepy/static/css/news.css +++ b/devplacepy/static/css/news.css @@ -56,7 +56,7 @@ } .news-card-body { - padding: 1.125rem; + padding: 1rem; display: flex; flex-direction: column; gap: 0.625rem; @@ -104,7 +104,7 @@ } .news-card-title { - font-size: 1.0625rem; + font-size: 1.125rem; font-weight: 700; line-height: 1.4; margin: 0; @@ -270,7 +270,7 @@ color: var(--accent); } -@media (max-width: 680px) { +@media (max-width: 768px) { .news-grid { grid-template-columns: 1fr; } diff --git a/devplacepy/static/css/services.css b/devplacepy/static/css/services.css index 2d3f156..0ac7ff5 100644 --- a/devplacepy/static/css/services.css +++ b/devplacepy/static/css/services.css @@ -18,9 +18,9 @@ .service-card { background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-lg); } .service-header { @@ -32,7 +32,7 @@ .service-name { font-weight: 600; - font-size: 1.1rem; + font-size: 1.125rem; color: var(--text-primary); text-transform: capitalize; } @@ -60,7 +60,7 @@ flex-wrap: wrap; gap: 12px; margin-bottom: 12px; - font-size: 0.8rem; + font-size: 0.8125rem; color: var(--text-secondary); } diff --git a/devplacepy/static/css/variables.css b/devplacepy/static/css/variables.css index 95af919..b260056 100644 --- a/devplacepy/static/css/variables.css +++ b/devplacepy/static/css/variables.css @@ -28,6 +28,20 @@ --shadow: 0 4px 12px rgba(0, 0, 0, 0.3); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15); + + --white: #fff; + --overlay-dark: rgba(0, 0, 0, 0.7); + --overlay-light: rgba(255, 255, 255, 0.05); + + --space-xs: 0.25rem; + --space-sm: 0.375rem; + --space-base: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1rem; + --space-xl: 1.25rem; + --space-2xl: 1.5rem; --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-mono: "SF Mono", Monaco, "Cascadia Code", monospace; diff --git a/devplacepy/static/js/AttachmentUploader.js b/devplacepy/static/js/AttachmentUploader.js index 63583a6..9d23207 100644 --- a/devplacepy/static/js/AttachmentUploader.js +++ b/devplacepy/static/js/AttachmentUploader.js @@ -1,3 +1,5 @@ +import { Toast } from "./Toast.js"; + export class AttachmentUploader { constructor(form) { this.form = form; @@ -172,8 +174,7 @@ export class AttachmentUploader { } showError(msg) { - this.errorEl.textContent = msg; - setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000); + Toast.flash(this.errorEl, msg, 5000, ""); } } diff --git a/devplacepy/static/js/DomUtils.js b/devplacepy/static/js/DomUtils.js index 5399ddb..a899adb 100644 --- a/devplacepy/static/js/DomUtils.js +++ b/devplacepy/static/js/DomUtils.js @@ -1,3 +1,5 @@ +import { Toast } from "./Toast.js"; + export class DomUtils { constructor() { this.initClipboardCopy(); @@ -14,9 +16,7 @@ export class DomUtils { if (!source) return; try { await navigator.clipboard.writeText(source.textContent); - const original = btn.textContent; - btn.textContent = "Copied!"; - setTimeout(() => { btn.textContent = original; }, 2000); + Toast.flash(btn, "Copied!", 2000); } catch { // silently fail } @@ -32,9 +32,7 @@ export class DomUtils { const url = new URL(btn.dataset.share || window.location.href, window.location.href).href; try { await navigator.clipboard.writeText(url); - const original = btn.textContent; - btn.textContent = "Copied!"; - setTimeout(() => { btn.textContent = original; }, 1000); + Toast.flash(btn, "Copied!", 1000); } catch { // silently fail } diff --git a/devplacepy/static/js/EmojiPicker.js b/devplacepy/static/js/EmojiPicker.js index 8a60f98..dc3c7b5 100644 --- a/devplacepy/static/js/EmojiPicker.js +++ b/devplacepy/static/js/EmojiPicker.js @@ -1,3 +1,5 @@ +import { TextInput } from "./TextInput.js"; + export class EmojiPicker { constructor(textarea) { this.textarea = textarea; @@ -35,15 +37,7 @@ export class EmojiPicker { } insert(unicode) { - const ta = this.textarea; - const start = ta.selectionStart; - const end = ta.selectionEnd; - const text = ta.value; - ta.value = text.substring(0, start) + unicode + text.substring(end); - const newPos = start + unicode.length; - ta.setSelectionRange(newPos, newPos); - ta.focus(); - ta.dispatchEvent(new Event("input", { bubbles: true })); + TextInput.insertAtCursor(this.textarea, unicode); } toggle() { diff --git a/devplacepy/static/js/MentionInput.js b/devplacepy/static/js/MentionInput.js index a2f9373..defc604 100644 --- a/devplacepy/static/js/MentionInput.js +++ b/devplacepy/static/js/MentionInput.js @@ -1,3 +1,7 @@ +import { Http } from "./Http.js"; +import { Avatar } from "./Avatar.js"; +import { TextInput } from "./TextInput.js"; + export class MentionInput { constructor(element) { this.input = element; @@ -50,8 +54,7 @@ export class MentionInput { async fetch(query) { try { - const resp = await fetch("/profile/search?q=" + encodeURIComponent(query)); - const data = await resp.json(); + const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query)); const results = data.results || []; if (results.length === 0) { this.dropdown.style.display = "none"; @@ -71,7 +74,7 @@ export class MentionInput { item.type = "button"; item.className = "mention-dropdown-item"; item.dataset.username = r.username; - item.innerHTML = '@' + r.username + ''; + item.innerHTML = Avatar.imgHtml(r.username) + "@" + r.username + ""; item.addEventListener("mousedown", (e) => { e.preventDefault(); this.insert(r.username); @@ -121,11 +124,7 @@ export class MentionInput { let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1); before = before.replace(/@+$/, ""); after = after.replace(/^@+/, ""); - this.input.value = before + "@" + username + " " + after; - const newPos = before.length + username.length + 2; - this.input.setSelectionRange(newPos, newPos); - this.input.focus(); - this.input.dispatchEvent(new Event("input", { bubbles: true })); + TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2); this.dropdown.style.display = "none"; this.lastMatch = null; } diff --git a/devplacepy/static/js/MessageSearch.js b/devplacepy/static/js/MessageSearch.js index 4b177ad..32415da 100644 --- a/devplacepy/static/js/MessageSearch.js +++ b/devplacepy/static/js/MessageSearch.js @@ -1,3 +1,6 @@ +import { Http } from "./Http.js"; +import { Avatar } from "./Avatar.js"; + export class MessageSearch { constructor() { this.initMessageSearch(); @@ -26,8 +29,7 @@ export class MessageSearch { } debounceTimer = setTimeout(async () => { try { - const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`); - const data = await resp.json(); + const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`); const results = data.results || []; if (results.length === 0) { dropdown.style.display = "none"; @@ -38,7 +40,7 @@ export class MessageSearch { const item = document.createElement("a"); item.className = "search-dropdown-item"; item.href = `/messages?with_uid=${r.uid}`; - item.innerHTML = `${r.username}`; + item.innerHTML = `${Avatar.imgHtml(r.username)}${r.username}`; dropdown.appendChild(item); } dropdown.style.display = "block"; diff --git a/devplacepy/static/js/ServiceMonitor.js b/devplacepy/static/js/ServiceMonitor.js index be66549..b7dff35 100644 --- a/devplacepy/static/js/ServiceMonitor.js +++ b/devplacepy/static/js/ServiceMonitor.js @@ -1,3 +1,5 @@ +import { Http } from "./Http.js"; + class ServiceMonitor { constructor() { this.pollInterval = 5000; @@ -29,8 +31,7 @@ class ServiceMonitor { async pollServices() { try { - const resp = await fetch("/admin/services/data"); - const data = await resp.json(); + const data = await Http.getJson("/admin/services/data"); const container = document.getElementById("services-list"); if (!container) return; for (const svc of data.services) { diff --git a/devplacepy/static/js/VoteManager.js b/devplacepy/static/js/VoteManager.js index 441e290..ac6de79 100644 --- a/devplacepy/static/js/VoteManager.js +++ b/devplacepy/static/js/VoteManager.js @@ -1,3 +1,5 @@ +import { Http } from "./Http.js"; + export class VoteManager { constructor() { this.initVoteButtons(); @@ -6,37 +8,22 @@ export class VoteManager { initVoteButtons() { document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => { - btn.addEventListener("click", async () => { + btn.addEventListener("click", () => { const targetUid = btn.dataset.target; const targetType = btn.dataset.type || "post"; - const value = btn.dataset.vote; - - const form = document.createElement("form"); - form.method = "POST"; - form.action = `/votes/${targetType}/${targetUid}`; - const input = document.createElement("input"); - input.type = "hidden"; - input.name = "value"; - input.value = value; - form.appendChild(input); - document.body.appendChild(form); - form.submit(); + Http.postForm(`/votes/${targetType}/${targetUid}`, { value: btn.dataset.vote }); }); }); } initNotificationDismiss() { document.querySelectorAll(".notification-dismiss").forEach((btn) => { - btn.addEventListener("click", async () => { + btn.addEventListener("click", () => { const uid = btn.dataset.uid; if (!uid) { return; } - const form = document.createElement("form"); - form.method = "POST"; - form.action = `/notifications/mark-read/${uid}`; - document.body.appendChild(form); - form.submit(); + Http.postForm(`/notifications/mark-read/${uid}`); }); }); } diff --git a/devplacepy/templating.py b/devplacepy/templating.py index 06f521e..8748528 100644 --- a/devplacepy/templating.py +++ b/devplacepy/templating.py @@ -5,12 +5,6 @@ from devplacepy.constants import TOPICS from devplacepy.database import get_table from devplacepy.avatar import avatar_url from devplacepy.utils import format_date as _format_date -from devplacepy.seo import ( - site_url, combine, - website_schema, breadcrumb_schema, - discussion_forum_posting, profile_page_schema, - software_application_schema, truncate, -) templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) _unread_cache = TTLCache(ttl=60) diff --git a/devplacepy/utils.py b/devplacepy/utils.py index 7a94827..b2e97d2 100644 --- a/devplacepy/utils.py +++ b/devplacepy/utils.py @@ -7,7 +7,7 @@ from passlib.hash import pbkdf2_sha256 from fastapi import Request, HTTPException, status from devplacepy.cache import TTLCache from devplacepy.database import get_table -from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE +from devplacepy.config import SESSION_MAX_AGE logger = logging.getLogger(__name__) @@ -86,6 +86,13 @@ def require_admin(request: Request): return user +def require_user_api(request: Request): + user = get_current_user(request) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") + return user + + def strip_html(text: str) -> str: if not text: return "" @@ -140,13 +147,39 @@ def extract_mentions(content: str) -> list[str]: return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content) +def create_notification(user_uid: str, notification_type: str, message: str, related_uid: str, target_url: str | None = None) -> None: + from devplacepy.templating import clear_unread_cache + get_table("notifications").insert({ + "uid": generate_uid(), + "user_uid": user_uid, + "type": notification_type, + "message": message, + "related_uid": related_uid, + "target_url": target_url, + "read": False, + "created_at": datetime.now(timezone.utc).isoformat(), + }) + clear_unread_cache(user_uid) + + +def award_badge(user_uid: str, badge_name: str) -> bool: + badges = get_table("badges") + if badges.find_one(user_uid=user_uid, badge_name=badge_name): + return False + badges.insert({ + "uid": generate_uid(), + "user_uid": user_uid, + "badge_name": badge_name, + "created_at": datetime.now(timezone.utc).isoformat(), + }) + return True + + def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None: usernames = extract_mentions(content) if not usernames: return - from devplacepy.templating import clear_unread_cache users = get_table("users") - notifs = get_table("notifications") seen = set() for username in usernames: if username in seen: @@ -154,17 +187,7 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str) seen.add(username) mentioned = users.find_one(username=username) if mentioned and mentioned["uid"] != actor_uid: - notifs.insert({ - "uid": generate_uid(), - "user_uid": mentioned["uid"], - "type": "mention", - "message": f"@{username} mentioned you", - "related_uid": actor_uid, - "target_url": target_url, - "read": False, - "created_at": datetime.now(timezone.utc).isoformat(), - }) - clear_unread_cache(mentioned["uid"]) + create_notification(mentioned["uid"], "mention", f"@{username} mentioned you", actor_uid, target_url) def format_date(dt_str: str, include_time: bool = False) -> str: diff --git a/tests/test_uploads.py b/tests/test_uploads.py index 50abe0f..8811267 100644 --- a/tests/test_uploads.py +++ b/tests/test_uploads.py @@ -60,7 +60,7 @@ def test_uploaded_file_served_as_attachment(app_server): def test_upload_requires_login(app_server): r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False) - assert r.status_code in (302, 303) + assert r.status_code == 401 def test_delete_own_allowed_other_user_forbidden(app_server):