import logging
from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_activity_heatmap, get_streaks
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache, not_found
from devplacepy.avatar import avatar_url
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/search")
async def search_users(request: Request, q: str = ""):
require_user_api(request)
if not q or len(q) < 1:
return JSONResponse({"results": []})
if "users" in db.tables:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT 10",
q=f"%{q}%",
)
results = [{"uid": r["uid"], "username": r["username"]} for r in rows]
else:
results = []
return JSONResponse({"results": results})
@router.get("/{username}", response_class=HTMLResponse)
async def profile_page(request: Request, username: str, tab: str = "posts"):
current_user = get_current_user(request)
users = get_table("users")
profile_user = users.find_one(username=username)
if not profile_user:
raise not_found("Profile not found")
profile_user["stars"] = get_user_stars(profile_user["uid"])
rank = get_user_rank(profile_user["uid"])
posts = []
if tab == "posts":
posts_table = get_table("posts")
raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"]))
post_uids = [p["uid"] for p in raw_posts]
counts = get_comment_counts_by_post_uids(post_uids) if raw_posts else {}
authors = {profile_user["uid"]: profile_user}
posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user)
reactions_map = get_reactions_by_targets("post", post_uids, current_user)
bookmark_set = get_user_bookmarks(current_user["uid"], "post", post_uids) if current_user else set()
polls_map = get_polls_by_post_uids(post_uids, current_user)
for item in posts:
uid = item["post"]["uid"]
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
gists_raw = list(get_table("gists").find(user_uid=profile_user["uid"]))
gists = []
for g in gists_raw:
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
posts_count = len(posts) or get_table("posts").count(user_uid=profile_user["uid"])
activities = []
if tab == "activity":
posts_table = get_table("posts")
for p in posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10):
activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "created_at": p["created_at"], "uid": p["uid"]})
comments_table = get_table("comments")
for c in comments_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10):
target_uid = c.get("target_uid", c.get("post_uid", ""))
target_type = c.get("target_type", "post")
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": target_uid, "target_type": target_type})
activities.sort(key=lambda a: a["created_at"], reverse=True)
heatmap = get_activity_heatmap(profile_user["uid"])
streak = get_streaks(profile_user["uid"])
is_following = False
if current_user:
follows = get_table("follows")
is_following = bool(follows.find_one(follower_uid=current_user["uid"], following_uid=profile_user["uid"]))
base = site_url(request)
robots = "noindex,follow" if posts_count < 2 else "index,follow"
bio = profile_user.get("bio", "")
desc = bio or f"View {profile_user['username']}'s profile on DevPlace. {posts_count} posts."
seo_ctx = base_seo_context(
request,
title=f"{profile_user['username']} (@{profile_user['username']})",
description=desc,
robots=robots,
og_type="profile",
og_image=avatar_url("multiavatar", profile_user["username"], 256),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"},
],
schemas=[
website_schema(base),
profile_page_schema(profile_user, posts_count, base),
],
)
return templates.TemplateResponse(request, "profile.html", {
**seo_ctx,
"request": request,
"user": current_user,
"profile_user": profile_user,
"posts": posts,
"badges": badges,
"projects": projects,
"gists": gists,
"current_tab": tab,
"posts_count": posts_count,
"is_following": is_following,
"activities": activities,
"rank": rank,
"heatmap": heatmap,
"streak": streak,
})
@router.post("/update")
async def update_profile(request: Request, data: Annotated[ProfileForm, Form()]):
user = require_user(request)
users = get_table("users")
users.update({
"uid": user["uid"],
"bio": data.bio.strip(),
"location": data.location.strip(),
"git_link": data.git_link.strip(),
"website": data.website.strip(),
}, ["uid"])
clear_user_cache(user["uid"])
logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)