import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import db, get_table
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, generate_uid, XP_POST
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/create")
async def create_post(request: Request, data: Annotated[PostForm, Form()]):
user = require_user(request)
content = data.content.strip()
title = data.title.strip()
topic = data.topic
project_uid = data.project_uid
image_filename = None
form = await request.form()
image_file = form.get("image")
if image_file is not None and hasattr(image_file, "filename") and image_file.filename:
image_filename = save_inline_image(await image_file.read(), image_file.filename)
if image_filename:
content += f"\n\n![](/static/uploads/{image_filename})"
slug_text = title if title else content[:50]
uid, post_slug = create_content_item("posts", "post", user, {
"title": title or None,
"content": content,
"topic": topic,
"project_uid": project_uid or None,
"image": image_filename,
}, slug_text, XP_POST, "First Post", content, data.attachment_uids)
create_poll(uid, user, data.poll_question, data.poll_options)
return RedirectResponse(url=f"/posts/{post_slug}", status_code=302)
def create_poll(post_uid: str, user: dict, question: str, options: list[str]) -> None:
question = question.strip()
labels = [option.strip() for option in options if option.strip()][:6]
if not question or len(labels) < 2:
return
poll_uid = generate_uid()
get_table("polls").insert({
"uid": poll_uid,
"post_uid": post_uid,
"user_uid": user["uid"],
"question": question,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("poll_options").insert_many([{
"uid": generate_uid(),
"poll_uid": poll_uid,
"label": label,
"position": index,
} for index, label in enumerate(labels)])
@router.get("/{post_slug}", response_class=HTMLResponse)
async def view_post(request: Request, post_slug: str):
user = get_current_user(request)
detail = load_detail("posts", "post", post_slug, user)
if not detail:
raise not_found("Post not found")
post = detail["item"]
redirect = canonical_redirect("posts", post, post_slug)
if redirect:
return redirect
author = detail["author"]
top_level = detail["comments"]
def count_all(items):
total = len(items)
for item in items:
total += count_all(item.get("children", []))
return total
comment_count = count_all(top_level)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title=post.get("title") or "Post",
description=truncate(post.get("content", ""), 160),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Feed", "url": "/feed"},
{"name": post.get("title") or "Post", "url": f"/posts/{post['slug'] or post['uid']}"},
],
og_type="article",
og_image=first_image_url(post, detail["attachments"]),
schemas=[
website_schema(base),
discussion_forum_posting(post, author, comment_count, detail["star_count"], base),
],
)
related_posts = []
if "posts" in db.tables:
rows = list(db.query(
"SELECT p.*, u.username FROM posts p JOIN users u ON p.user_uid = u.uid WHERE p.topic = :t AND p.uid != :uid ORDER BY p.created_at DESC LIMIT 5",
t=post.get("topic", ""), uid=post["uid"],
))
for r in rows:
related_posts.append({
"post": r,
"author": {"username": r["username"]},
"time_ago": time_ago(r["created_at"]),
})
return templates.TemplateResponse(request, "post.html", detail_context(request, user, detail, "post", seo_ctx, {
"comment_count": comment_count,
"related_posts": related_posts,
"topics": list(TOPICS),
}))
@router.post("/edit/{post_slug}")
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
user = require_user(request)
return edit_content_item("posts", user, post_slug, {
"content": data.content.strip(),
"title": data.title.strip() or None,
"topic": data.topic,
}, "/feed")
@router.post("/delete/{post_slug}")
async def delete_post(request: Request, post_slug: str):
user = require_user(request)
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")