Refactor..
Some checks failed
DevPlace CI / test (push) Failing after 1m22s

This commit is contained in:
retoor 2026-05-23 08:34:13 +02:00
parent 18c9f20090
commit 8c9da9df98
34 changed files with 211 additions and 367 deletions

View File

@ -60,7 +60,6 @@ def cmd_news_sanitize(args):
def cmd_attachments_prune(args): def cmd_attachments_prune(args):
from devplacepy.database import db from devplacepy.database import db
from devplacepy.config import STATIC_DIR from devplacepy.config import STATIC_DIR
import os
deleted_records = 0 deleted_records = 0
deleted_files = 0 deleted_files = 0
freed_bytes = 0 freed_bytes = 0

View File

@ -326,6 +326,31 @@ def resolve_by_slug(table, slug):
return entry 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): def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page)) total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages)) page = max(1, min(page, total_pages))

View File

@ -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.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.templating import templates
from devplacepy.utils import get_current_user, time_ago 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.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.manager import service_manager
from devplacepy.services.news import NewsService from devplacepy.services.news import NewsService

View File

@ -1,10 +1,9 @@
import logging import logging
from typing import Annotated from typing import Annotated
from datetime import datetime
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
from fastapi.responses import HTMLResponse, RedirectResponse 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.templating import templates
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache 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 from devplacepy.seo import base_seo_context, site_url, website_schema

View File

@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.templating import templates 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.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm 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(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
badges = get_table("badges") award_badge(uid, "Member")
badges.insert({
"uid": generate_uid(),
"user_uid": uid,
"badge_name": "Member",
"created_at": datetime.now(timezone.utc).isoformat(),
})
token = create_session(uid) token = create_session(uid)
response = RedirectResponse(url="/feed", status_code=302) response = RedirectResponse(url="/feed", status_code=302)

View File

@ -5,8 +5,7 @@ from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table, resolve_by_slug from devplacepy.database import get_table, resolve_by_slug
from devplacepy.attachments import link_attachments, delete_target_attachments 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, create_notification, award_badge
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
from devplacepy.models import CommentForm from devplacepy.models import CommentForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -60,49 +59,20 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
if data.attachment_uids: if data.attachment_uids:
link_attachments(data.attachment_uids, "comment", comment_uid) link_attachments(data.attachment_uids, "comment", comment_uid)
badges = get_table("badges") award_badge(user["uid"], "First Comment")
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(),
})
if target_type == "post": if target_type == "post":
if parent_uid: if parent_uid:
comments_table = get_table("comments") parent = get_table("comments").find_one(uid=parent_uid)
parent = comments_table.find_one(uid=parent_uid)
if parent and parent["user_uid"] != user["uid"]: if parent and parent["user_uid"] != user["uid"]:
notifications = get_table("notifications") create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"])
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"])
else: else:
posts = get_table("posts") posts = get_table("posts")
post = posts.find_one(uid=target_uid) post = posts.find_one(uid=target_uid)
if not post: if not post:
post = posts.find_one(slug=target_uid) post = posts.find_one(slug=target_uid)
if post and post["user_uid"] != user["uid"]: if post and post["user_uid"] != user["uid"]:
notifications = get_table("notifications") create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"])
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_mention_notifications(content, user["uid"], redirect_url) create_mention_notifications(content, user["uid"], redirect_url)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}") logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")

View File

@ -3,9 +3,10 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse 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.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.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -48,19 +49,10 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
if not posts: if not posts:
return [], next_cursor return [], next_cursor
uids = [p["user_uid"] for p in posts] authors = get_users_by_uids([p["user_uid"] for p in posts])
post_uids = [p["uid"] for p in posts] counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
authors = get_users_by_uids(uids)
counts = get_comment_counts_by_post_uids(post_uids)
result = [] result = enrich_items(posts, "post", authors, {"comment_count": counts})
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),
})
return result, next_cursor return result, next_cursor

View File

@ -3,8 +3,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.templating import clear_unread_cache from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.utils import generate_uid, require_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -30,17 +29,7 @@ async def follow_user(request: Request, username: str):
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
notifications = get_table("notifications") create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"])
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"])
logger.info(f"{user['username']} followed {username}") logger.info(f"{user['username']} followed {username}")
return RedirectResponse(url=f"/profile/{username}", status_code=302) return RedirectResponse(url=f"/profile/{username}", status_code=302)

View File

@ -4,11 +4,11 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import GistForm, GistEditForm from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db from devplacepy.database import get_table, get_users_by_uids
from devplacepy.attachments import get_attachments, delete_target_attachments from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
from devplacepy.templating import templates 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.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, breadcrumb_schema, combine, software_source_code_schema from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -37,19 +37,8 @@ def get_gists_list(user_uid=None, language=None):
if not all_gists: if not all_gists:
return [] return []
from devplacepy.database import get_users_by_uids users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
uids = [g["user_uid"] for g in all_gists] return enrich_items(all_gists, "gist", users_map)
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
@router.get("", response_class=HTMLResponse) @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) @router.get("/{gist_slug}", response_class=HTMLResponse)
async def gist_detail(request: Request, gist_slug: str): async def gist_detail(request: Request, gist_slug: str):
user = get_current_user(request) user = get_current_user(request)
gists = get_table("gists") detail = load_detail("gists", "gist", gist_slug, user)
gist = resolve_by_slug(gists, gist_slug) if not detail:
if not gist:
raise HTTPException(status_code=404, detail="Gist not found") raise HTTPException(status_code=404, detail="Gist not found")
gist = detail["item"]
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"])
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
@ -118,13 +94,13 @@ async def gist_detail(request: Request, gist_slug: str):
"request": request, "request": request,
"user": user, "user": user,
"gist": gist, "gist": gist,
"author": author, "author": detail["author"],
"is_owner": is_owner, "is_owner": detail["is_owner"],
"star_count": star_count, "star_count": detail["star_count"],
"time_ago": time_ago(gist["created_at"]), "time_ago": detail["time_ago"],
"comments": comments, "comments": detail["comments"],
"languages": LANGUAGES, "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}") @router.post("/edit/{gist_slug}")
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]): async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
user = require_user(request) 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 language = data.language
if language not in {l[0] for l in LANGUAGES}:
valid_languages = {l[0] for l in LANGUAGES}
if language not in valid_languages:
language = "plaintext" language = "plaintext"
return edit_content_item("gists", user, gist_slug, {
gists.update({ "title": data.title.strip(),
"uid": gist["uid"], "description": data.description.strip() or None,
"title": title, "source_code": data.source_code.strip(),
"description": description or None,
"source_code": source_code,
"language": language, "language": language,
}, ["uid"]) }, "/gists")
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
@router.post("/delete/{gist_slug}") @router.post("/delete/{gist_slug}")
async def delete_gist(request: Request, gist_slug: str): async def delete_gist(request: Request, gist_slug: str):
user = require_user(request) user = require_user(request)
gists = get_table("gists") return delete_content_item("gists", "gist", user, gist_slug, "/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)

View File

@ -68,8 +68,9 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m) msgs.append(m)
msgs.sort(key=lambda m: m["created_at"]) msgs.sort(key=lambda m: m["created_at"])
with db: if "messages" in db.tables:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid) 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 from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid}) user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})

View File

@ -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.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago 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__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()

View File

@ -4,11 +4,12 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS 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.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate from devplacepy.content import edit_content_item, delete_content_item
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image 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 from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__) 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(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
badges = get_table("badges") award_badge(user["uid"], "First Post")
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(),
})
if data.attachment_uids: if data.attachment_uids:
link_attachments(data.attachment_uids, "post", uid) 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}") @router.post("/edit/{post_slug}")
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]): async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
user = require_user(request) user = require_user(request)
posts = get_table("posts") return edit_content_item("posts", user, post_slug, {
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"],
"content": data.content.strip(), "content": data.content.strip(),
"title": data.title.strip() or None, "title": data.title.strip() or None,
"topic": data.topic, "topic": data.topic,
}, ["uid"]) }, "/feed")
logger.info(f"Post {post['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
@router.post("/delete/{post_slug}") @router.post("/delete/{post_slug}")
async def delete_post(request: Request, post_slug: str): async def delete_post(request: Request, post_slug: str):
user = require_user(request) user = require_user(request)
posts = get_table("posts") return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")
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)

View File

@ -5,8 +5,8 @@ from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db from devplacepy.database import get_table, db
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache 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, breadcrumb_schema, profile_page_schema, combine from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/search") @router.get("/search")
async def search_users(request: Request, q: str = ""): async def search_users(request: Request, q: str = ""):
require_user(request) require_user_api(request)
if not q or len(q) < 1: if not q or len(q) < 1:
return JSONResponse({"results": []}) return JSONResponse({"results": []})
if "users" in db.tables: if "users" in db.tables:

View File

@ -5,11 +5,12 @@ from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import ProjectForm from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse 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.database import get_table, get_users_by_uids, get_site_stats
from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments from devplacepy.content import load_detail, delete_content_item
from devplacepy.attachments import link_attachments
from devplacepy.templating import templates 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.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, breadcrumb_schema, combine, software_application_schema from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -82,22 +83,10 @@ async def projects_page(
@router.get("/{project_slug}", response_class=HTMLResponse) @router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str): async def project_detail(request: Request, project_slug: str):
user = get_current_user(request) user = get_current_user(request)
projects = get_table("projects") detail = load_detail("projects", "project", project_slug, user)
project = resolve_by_slug(projects, project_slug) if not detail:
if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
project = detail["item"]
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"])
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
@ -116,25 +105,19 @@ async def project_detail(request: Request, project_slug: str):
"request": request, "request": request,
"user": user, "user": user,
"project": project, "project": project,
"author": author, "author": detail["author"],
"is_owner": is_owner, "is_owner": detail["is_owner"],
"star_count": star_count, "star_count": detail["star_count"],
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [], "platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
"comments": comments, "comments": detail["comments"],
"attachments": project_attachments, "attachments": detail["attachments"],
}) })
@router.post("/delete/{project_slug}") @router.post("/delete/{project_slug}")
async def delete_project(request: Request, project_slug: str): async def delete_project(request: Request, project_slug: str):
user = require_user(request) user = require_user(request)
projects = get_table("projects") return delete_content_item("projects", "project", user, project_slug, "/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)
@router.post("/create") @router.post("/create")

View File

@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting, get_int_setting 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 from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -12,7 +12,7 @@ router = APIRouter()
@router.post("/upload") @router.post("/upload")
async def upload_file(request: Request): async def upload_file(request: Request):
user = require_user(request) user = require_user_api(request)
form = await request.form() form = await request.form()
file = form.get("file") file = form.get("file")
@ -49,7 +49,7 @@ async def upload_file(request: Request):
@router.delete("/delete/{attachment_uid}") @router.delete("/delete/{attachment_uid}")
async def delete_attachment_route(request: Request, attachment_uid: str): 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) att = get_table("attachments").find_one(uid=attachment_uid)
if not att: if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404) return JSONResponse({"error": "Attachment not found"}, status_code=404)

View File

@ -3,14 +3,15 @@ from typing import Annotated
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from devplacepy.database import get_table from devplacepy.database import get_table, update_target_stars, get_target_owner_uid
from devplacepy.templating import clear_unread_cache from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import VoteForm from devplacepy.models import VoteForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}") @router.post("/{target_type}/{target_uid}")
async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]): 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) down_count = votes.count(target_uid=target_uid, value=-1)
net = up_count - down_count net = up_count - down_count
if target_type == "post": update_target_stars(target_type, target_uid, net)
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"])
if value == 1: if value == 1 and target_type in NOTIFY_ON_VOTE:
target_owner_uid = None owner_uid = get_target_owner_uid(target_type, target_uid)
if target_type == "post": if owner_uid and owner_uid != user["uid"]:
target_owner = posts.find_one(uid=target_uid) create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["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)
referer = request.headers.get("Referer", "/feed") referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302) return RedirectResponse(url=referer, status_code=302)

View File

@ -1,6 +1,5 @@
import json import json
import logging import logging
from datetime import datetime
from xml.etree.ElementTree import Element, tostring from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom from xml.dom import minidom
from devplacepy.config import SITE_URL from devplacepy.config import SITE_URL

View File

@ -126,12 +126,12 @@
.admin-btn-sm { .admin-btn-sm {
font-size: 0.6875rem; font-size: 0.6875rem;
padding: 0.2rem 0.4rem; padding: 0.25rem 0.375rem;
} }
.admin-select { .admin-select {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.2rem 0.4rem; padding: 0.25rem 0.375rem;
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-input); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);
@ -152,7 +152,7 @@
.admin-input-sm { .admin-input-sm {
width: 110px; width: 110px;
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.2rem 0.4rem; padding: 0.25rem 0.375rem;
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-input); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);

View File

@ -408,12 +408,12 @@ img {
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: #fff; color: var(--white);
} }
.btn-primary:hover { .btn-primary:hover {
background: var(--accent-hover); background: var(--accent-hover);
color: #fff; color: var(--white);
} }
.btn-secondary { .btn-secondary {
@ -461,13 +461,13 @@ img {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.badge-devlog { background: var(--topic-devlog); color: #fff; } .badge-devlog { background: var(--topic-devlog); color: var(--white); }
.badge-showcase { background: var(--topic-showcase); color: #fff; } .badge-showcase { background: var(--topic-showcase); color: var(--white); }
.badge-question { background: var(--topic-question); color: #fff; } .badge-question { background: var(--topic-question); color: var(--white); }
.badge-rant { background: var(--topic-rant); color: #fff; } .badge-rant { background: var(--topic-rant); color: var(--white); }
.badge-fun { background: var(--topic-fun); color: #000; } .badge-fun { background: var(--topic-fun); color: #000; }
.badge-random { background: var(--border-light); color: var(--text-secondary); } .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 { .avatar {
width: 40px; width: 40px;
@ -479,7 +479,7 @@ img {
justify-content: center; justify-content: center;
font-weight: 700; font-weight: 700;
font-size: 1rem; font-size: 1rem;
color: #fff; color: var(--white);
flex-shrink: 0; flex-shrink: 0;
overflow: hidden; overflow: hidden;
} }
@ -535,7 +535,7 @@ img {
.topnav-icon:hover { color: var(--text-primary); } .topnav-icon:hover { color: var(--text-primary); }
.nav-badge { .nav-badge {
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; 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; font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
} }
@ -723,16 +723,6 @@ img {
to { opacity: 1; transform: translateY(0); } 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 { .icon {
font-size: 1rem; font-size: 1rem;
width: 20px; width: 20px;

View File

@ -71,7 +71,7 @@
.post-card:hover { .post-card:hover {
border-color: var(--border-light); border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-sm);
} }
.post-header { .post-header {

View File

@ -39,7 +39,7 @@
.gist-card:hover { .gist-card:hover {
border-color: var(--border-light); border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-sm);
} }
.gist-card-header { .gist-card-header {

View File

@ -56,7 +56,7 @@
} }
.news-card-body { .news-card-body {
padding: 1.125rem; padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.625rem; gap: 0.625rem;
@ -104,7 +104,7 @@
} }
.news-card-title { .news-card-title {
font-size: 1.0625rem; font-size: 1.125rem;
font-weight: 700; font-weight: 700;
line-height: 1.4; line-height: 1.4;
margin: 0; margin: 0;
@ -270,7 +270,7 @@
color: var(--accent); color: var(--accent);
} }
@media (max-width: 680px) { @media (max-width: 768px) {
.news-grid { .news-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -18,9 +18,9 @@
.service-card { .service-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius);
padding: 16px; padding: var(--space-lg);
} }
.service-header { .service-header {
@ -32,7 +32,7 @@
.service-name { .service-name {
font-weight: 600; font-weight: 600;
font-size: 1.1rem; font-size: 1.125rem;
color: var(--text-primary); color: var(--text-primary);
text-transform: capitalize; text-transform: capitalize;
} }
@ -60,7 +60,7 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
font-size: 0.8rem; font-size: 0.8125rem;
color: var(--text-secondary); color: var(--text-secondary);
} }

View File

@ -28,6 +28,20 @@
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3); --shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); --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-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace; --font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;

View File

@ -1,3 +1,5 @@
import { Toast } from "./Toast.js";
export class AttachmentUploader { export class AttachmentUploader {
constructor(form) { constructor(form) {
this.form = form; this.form = form;
@ -172,8 +174,7 @@ export class AttachmentUploader {
} }
showError(msg) { showError(msg) {
this.errorEl.textContent = msg; Toast.flash(this.errorEl, msg, 5000, "");
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
} }
} }

View File

@ -1,3 +1,5 @@
import { Toast } from "./Toast.js";
export class DomUtils { export class DomUtils {
constructor() { constructor() {
this.initClipboardCopy(); this.initClipboardCopy();
@ -14,9 +16,7 @@ export class DomUtils {
if (!source) return; if (!source) return;
try { try {
await navigator.clipboard.writeText(source.textContent); await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent; Toast.flash(btn, "Copied!", 2000);
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
} catch { } catch {
// silently fail // silently fail
} }
@ -32,9 +32,7 @@ export class DomUtils {
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href; const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
const original = btn.textContent; Toast.flash(btn, "Copied!", 1000);
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1000);
} catch { } catch {
// silently fail // silently fail
} }

View File

@ -1,3 +1,5 @@
import { TextInput } from "./TextInput.js";
export class EmojiPicker { export class EmojiPicker {
constructor(textarea) { constructor(textarea) {
this.textarea = textarea; this.textarea = textarea;
@ -35,15 +37,7 @@ export class EmojiPicker {
} }
insert(unicode) { insert(unicode) {
const ta = this.textarea; TextInput.insertAtCursor(this.textarea, unicode);
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 }));
} }
toggle() { toggle() {

View File

@ -1,3 +1,7 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { TextInput } from "./TextInput.js";
export class MentionInput { export class MentionInput {
constructor(element) { constructor(element) {
this.input = element; this.input = element;
@ -50,8 +54,7 @@ export class MentionInput {
async fetch(query) { async fetch(query) {
try { try {
const resp = await fetch("/profile/search?q=" + encodeURIComponent(query)); const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
const data = await resp.json();
const results = data.results || []; const results = data.results || [];
if (results.length === 0) { if (results.length === 0) {
this.dropdown.style.display = "none"; this.dropdown.style.display = "none";
@ -71,7 +74,7 @@ export class MentionInput {
item.type = "button"; item.type = "button";
item.className = "mention-dropdown-item"; item.className = "mention-dropdown-item";
item.dataset.username = r.username; item.dataset.username = r.username;
item.innerHTML = '<img src="/avatar/multiavatar/' + encodeURIComponent(r.username) + '?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>@' + r.username + '</span>'; item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>";
item.addEventListener("mousedown", (e) => { item.addEventListener("mousedown", (e) => {
e.preventDefault(); e.preventDefault();
this.insert(r.username); this.insert(r.username);
@ -121,11 +124,7 @@ export class MentionInput {
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1); let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
before = before.replace(/@+$/, ""); before = before.replace(/@+$/, "");
after = after.replace(/^@+/, ""); after = after.replace(/^@+/, "");
this.input.value = before + "@" + username + " " + after; TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
const newPos = before.length + username.length + 2;
this.input.setSelectionRange(newPos, newPos);
this.input.focus();
this.input.dispatchEvent(new Event("input", { bubbles: true }));
this.dropdown.style.display = "none"; this.dropdown.style.display = "none";
this.lastMatch = null; this.lastMatch = null;
} }

View File

@ -1,3 +1,6 @@
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
export class MessageSearch { export class MessageSearch {
constructor() { constructor() {
this.initMessageSearch(); this.initMessageSearch();
@ -26,8 +29,7 @@ export class MessageSearch {
} }
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
try { try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`); const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const results = data.results || []; const results = data.results || [];
if (results.length === 0) { if (results.length === 0) {
dropdown.style.display = "none"; dropdown.style.display = "none";
@ -38,7 +40,7 @@ export class MessageSearch {
const item = document.createElement("a"); const item = document.createElement("a");
item.className = "search-dropdown-item"; item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`; item.href = `/messages?with_uid=${r.uid}`;
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`; item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
dropdown.appendChild(item); dropdown.appendChild(item);
} }
dropdown.style.display = "block"; dropdown.style.display = "block";

View File

@ -1,3 +1,5 @@
import { Http } from "./Http.js";
class ServiceMonitor { class ServiceMonitor {
constructor() { constructor() {
this.pollInterval = 5000; this.pollInterval = 5000;
@ -29,8 +31,7 @@ class ServiceMonitor {
async pollServices() { async pollServices() {
try { try {
const resp = await fetch("/admin/services/data"); const data = await Http.getJson("/admin/services/data");
const data = await resp.json();
const container = document.getElementById("services-list"); const container = document.getElementById("services-list");
if (!container) return; if (!container) return;
for (const svc of data.services) { for (const svc of data.services) {

View File

@ -1,3 +1,5 @@
import { Http } from "./Http.js";
export class VoteManager { export class VoteManager {
constructor() { constructor() {
this.initVoteButtons(); this.initVoteButtons();
@ -6,37 +8,22 @@ export class VoteManager {
initVoteButtons() { initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => { document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => { btn.addEventListener("click", () => {
const targetUid = btn.dataset.target; const targetUid = btn.dataset.target;
const targetType = btn.dataset.type || "post"; const targetType = btn.dataset.type || "post";
const value = btn.dataset.vote; Http.postForm(`/votes/${targetType}/${targetUid}`, { 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();
}); });
}); });
} }
initNotificationDismiss() { initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => { document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => { btn.addEventListener("click", () => {
const uid = btn.dataset.uid; const uid = btn.dataset.uid;
if (!uid) { if (!uid) {
return; return;
} }
const form = document.createElement("form"); Http.postForm(`/notifications/mark-read/${uid}`);
form.method = "POST";
form.action = `/notifications/mark-read/${uid}`;
document.body.appendChild(form);
form.submit();
}); });
}); });
} }

View File

@ -5,12 +5,6 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.avatar import avatar_url from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date 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)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60) _unread_cache = TTLCache(ttl=60)

View File

@ -7,7 +7,7 @@ from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache from devplacepy.cache import TTLCache
from devplacepy.database import get_table 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__) logger = logging.getLogger(__name__)
@ -86,6 +86,13 @@ def require_admin(request: Request):
return user 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: def strip_html(text: str) -> str:
if not text: if not text:
return "" return ""
@ -140,13 +147,39 @@ def extract_mentions(content: str) -> list[str]:
return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content) 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: def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content) usernames = extract_mentions(content)
if not usernames: if not usernames:
return return
from devplacepy.templating import clear_unread_cache
users = get_table("users") users = get_table("users")
notifs = get_table("notifications")
seen = set() seen = set()
for username in usernames: for username in usernames:
if username in seen: if username in seen:
@ -154,17 +187,7 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str)
seen.add(username) seen.add(username)
mentioned = users.find_one(username=username) mentioned = users.find_one(username=username)
if mentioned and mentioned["uid"] != actor_uid: if mentioned and mentioned["uid"] != actor_uid:
notifs.insert({ create_notification(mentioned["uid"], "mention", f"@{username} mentioned you", actor_uid, target_url)
"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"])
def format_date(dt_str: str, include_time: bool = False) -> str: def format_date(dt_str: str, include_time: bool = False) -> str:

View File

@ -60,7 +60,7 @@ def test_uploaded_file_served_as_attachment(app_server):
def test_upload_requires_login(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) 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): def test_delete_own_allowed_other_user_forbidden(app_server):