# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import Depends, APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import (
db,
get_table,
resolve_by_slug,
resolve_object_url,
mark_notifications_read_by_target,
)
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.responses import respond, action_result
from devplacepy.schemas import PostDetailOut
from devplacepy.seo import (
base_seo_context,
site_url,
website_schema,
discussion_forum_posting,
)
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
from devplacepy.services.audit import record as audit
from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/create")
async def create_post(request: Request, data: Annotated[PostForm, Depends(json_or_form(PostForm))]):
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,
request,
)
create_poll(uid, user, data.poll_question, data.poll_options, request)
url = f"/posts/{post_slug}"
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
def create_poll(
post_uid: str, user: dict, question: str, options: list[str], request=None
) -> 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(),
"deleted_at": None,
"deleted_by": None,
}
)
get_table("poll_options").insert_many(
[
{
"uid": generate_uid(),
"poll_uid": poll_uid,
"label": label,
"position": index,
"deleted_at": None,
"deleted_by": None,
}
for index, label in enumerate(labels)
]
)
audit.record(
request,
"poll.create",
user=user,
target_type="poll",
target_uid=poll_uid,
target_label=question,
metadata={"option_count": len(labels), "question": question},
summary=f"{user['username']} added a poll to post {post_uid}",
links=[audit.poll(poll_uid, question), audit.parent("post", post_uid)],
)
@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
if user:
mark_notifications_read_by_target(
user["uid"], resolve_object_url("post", post["uid"])
)
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=post.get("content", ""),
seo_target=("post", post["uid"]),
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 AND p.deleted_at IS NULL 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 respond(
request,
"post.html",
detail_context(
request,
user,
detail,
"post",
seo_ctx,
{
"comment_count": comment_count,
"related_posts": related_posts,
"topics": list(TOPICS),
},
),
model=PostDetailOut,
)
@router.post("/edit/{post_slug}")
async def edit_post(
request: Request, post_slug: str, data: Annotated[PostEditForm, Depends(json_or_form(PostEditForm))]
):
user = require_user(request)
result = edit_content_item(
request,
"posts",
user,
post_slug,
{
"content": data.content.strip(),
"title": data.title.strip() or None,
"topic": data.topic,
},
"/feed",
target_type="post",
)
if data.poll_question.strip():
post = resolve_by_slug(get_table("posts"), post_slug)
if (
post
and post["user_uid"] == user["uid"]
and not get_table("polls").find_one(post_uid=post["uid"], deleted_at=None)
):
create_poll(
post["uid"], user, data.poll_question, data.poll_options, request
)
return result
@router.post("/delete/{post_slug}")
async def delete_post(request: Request, post_slug: str):
user = require_user(request)
return delete_content_item(
request, "posts", "post", user, post_slug, "/feed", inline_image_field="image"
)