feat(community): KiwiCommunityStore, pseudonym helpers, community API endpoints + router wiring
This commit is contained in:
parent
fd49d0ca5c
commit
62d8e36316
7 changed files with 614 additions and 1 deletions
339
app/api/endpoints/community.py
Normal file
339
app/api/endpoints/community.py
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
# app/api/endpoints/community.py
|
||||||
|
# MIT License
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
|
||||||
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.store import Store
|
||||||
|
from app.services.community.feed import posts_to_rss
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/community", tags=["community"])
|
||||||
|
|
||||||
|
# Module-level KiwiCommunityStore — None when COMMUNITY_DB_URL is not set.
|
||||||
|
# Browse endpoints degrade gracefully to empty; write endpoints return 503.
|
||||||
|
_community_store = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_community_store():
|
||||||
|
"""Return the module-level KiwiCommunityStore, or None if community DB is unavailable."""
|
||||||
|
return _community_store
|
||||||
|
|
||||||
|
|
||||||
|
def init_community_store(community_db_url: str | None) -> None:
|
||||||
|
"""Called from main.py lifespan when COMMUNITY_DB_URL is set."""
|
||||||
|
global _community_store
|
||||||
|
if not community_db_url:
|
||||||
|
logger.info(
|
||||||
|
"COMMUNITY_DB_URL not set — community write features disabled. "
|
||||||
|
"Browse still works via cloud fallback."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from circuitforge_core.community import CommunityDB
|
||||||
|
from app.services.community.community_store import KiwiCommunityStore
|
||||||
|
db = CommunityDB(dsn=community_db_url)
|
||||||
|
db.run_migrations()
|
||||||
|
_community_store = KiwiCommunityStore(db)
|
||||||
|
logger.info("Community store initialized (PostgreSQL).")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Community store init failed — community writes disabled: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Browse (no auth required — Free tier) ────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/posts")
|
||||||
|
async def list_posts(
|
||||||
|
post_type: str | None = None,
|
||||||
|
dietary_tags: str | None = None,
|
||||||
|
allergen_exclude: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
):
|
||||||
|
"""Paginated community post list. Available on all tiers (read-only)."""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
return {"posts": [], "total": 0, "note": "Community DB not available on this instance."}
|
||||||
|
|
||||||
|
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
|
||||||
|
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
|
||||||
|
offset = (page - 1) * min(page_size, 100)
|
||||||
|
|
||||||
|
posts = await asyncio.to_thread(
|
||||||
|
store.list_posts,
|
||||||
|
limit=min(page_size, 100),
|
||||||
|
offset=offset,
|
||||||
|
post_type=post_type,
|
||||||
|
dietary_tags=dietary,
|
||||||
|
allergen_exclude=allergen_ex,
|
||||||
|
)
|
||||||
|
return {"posts": [_post_to_dict(p) for p in posts], "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{slug}")
|
||||||
|
async def get_post(slug: str, request: Request):
|
||||||
|
"""Single post. Returns AP JSON-LD when Accept: application/activity+json."""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Community DB not available on this instance.")
|
||||||
|
|
||||||
|
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found.")
|
||||||
|
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
if "application/activity+json" in accept:
|
||||||
|
from app.services.community.ap_compat import post_to_ap_json_ld
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url)
|
||||||
|
|
||||||
|
return _post_to_dict(post)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed.rss")
|
||||||
|
async def get_rss_feed(request: Request):
|
||||||
|
"""RSS 2.0 feed of recent community posts."""
|
||||||
|
store = _get_community_store()
|
||||||
|
posts_data: list[dict] = []
|
||||||
|
if store is not None:
|
||||||
|
posts = await asyncio.to_thread(store.list_posts, limit=50)
|
||||||
|
posts_data = [_post_to_dict(p) for p in posts]
|
||||||
|
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
rss = posts_to_rss(posts_data, base_url=base_url)
|
||||||
|
return Response(content=rss, media_type="application/rss+xml; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/local-feed")
|
||||||
|
async def local_feed():
|
||||||
|
"""LAN peer endpoint: last 50 posts from this instance. No auth required."""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
return []
|
||||||
|
posts = await asyncio.to_thread(store.list_posts, limit=50)
|
||||||
|
return [_post_to_dict(p) for p in posts]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Write endpoints (auth required) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/posts", status_code=201)
|
||||||
|
async def publish_post(
|
||||||
|
body: dict,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Publish a plan or outcome to the community feed. Requires Paid tier."""
|
||||||
|
from app.tiers import can_use
|
||||||
|
if not can_use("community_publish", session.tier, session.has_byok):
|
||||||
|
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
|
||||||
|
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="This Kiwi instance is not connected to a community database. "
|
||||||
|
"Publishing is only available on cloud instances.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve pseudonym (first-time setup inline via publish modal)
|
||||||
|
from app.services.community.community_store import get_or_create_pseudonym
|
||||||
|
db_path = session.db
|
||||||
|
|
||||||
|
def _get_pseudonym():
|
||||||
|
s = Store(db_path)
|
||||||
|
try:
|
||||||
|
return get_or_create_pseudonym(
|
||||||
|
store=s,
|
||||||
|
directus_user_id=session.user_id,
|
||||||
|
requested_name=body.get("pseudonym_name"),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
||||||
|
|
||||||
|
# Compute element snapshot from corpus recipes in the plan
|
||||||
|
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
|
||||||
|
from app.services.community.element_snapshot import compute_snapshot
|
||||||
|
|
||||||
|
def _snapshot():
|
||||||
|
s = Store(db_path)
|
||||||
|
try:
|
||||||
|
return compute_snapshot(recipe_ids=recipe_ids, store=s)
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
snapshot = await asyncio.to_thread(_snapshot)
|
||||||
|
|
||||||
|
# Build deterministic slug
|
||||||
|
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
post_type = body.get("post_type", "plan")
|
||||||
|
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from circuitforge_core.community.models import CommunityPost
|
||||||
|
post = CommunityPost(
|
||||||
|
slug=slug,
|
||||||
|
pseudonym=pseudonym,
|
||||||
|
post_type=post_type,
|
||||||
|
published=datetime.now(timezone.utc),
|
||||||
|
title=body.get("title", "Untitled"),
|
||||||
|
description=body.get("description"),
|
||||||
|
photo_url=body.get("photo_url"),
|
||||||
|
slots=body.get("slots", []),
|
||||||
|
recipe_id=body.get("recipe_id"),
|
||||||
|
recipe_name=body.get("recipe_name"),
|
||||||
|
level=body.get("level"),
|
||||||
|
outcome_notes=body.get("outcome_notes"),
|
||||||
|
seasoning_score=snapshot.seasoning_score,
|
||||||
|
richness_score=snapshot.richness_score,
|
||||||
|
brightness_score=snapshot.brightness_score,
|
||||||
|
depth_score=snapshot.depth_score,
|
||||||
|
aroma_score=snapshot.aroma_score,
|
||||||
|
structure_score=snapshot.structure_score,
|
||||||
|
texture_profile=snapshot.texture_profile,
|
||||||
|
dietary_tags=list(snapshot.dietary_tags),
|
||||||
|
allergen_flags=list(snapshot.allergen_flags),
|
||||||
|
flavor_molecules=list(snapshot.flavor_molecules),
|
||||||
|
fat_pct=snapshot.fat_pct,
|
||||||
|
protein_pct=snapshot.protein_pct,
|
||||||
|
moisture_pct=snapshot.moisture_pct,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=503, detail="Community module not available.")
|
||||||
|
|
||||||
|
inserted = await asyncio.to_thread(store.insert_post, post)
|
||||||
|
return _post_to_dict(inserted)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/posts/{slug}", status_code=204)
|
||||||
|
async def delete_post(slug: str, session: CloudUser = Depends(get_session)):
|
||||||
|
"""Hard-delete a post. Only succeeds if the caller is the post author (pseudonym match)."""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Community DB not available.")
|
||||||
|
|
||||||
|
def _get_pseudonym():
|
||||||
|
s = Store(session.db)
|
||||||
|
try:
|
||||||
|
return s.get_current_pseudonym(session.user_id)
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
||||||
|
if not pseudonym:
|
||||||
|
raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.")
|
||||||
|
|
||||||
|
deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found or you are not the author.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{slug}/fork", status_code=201)
|
||||||
|
async def fork_post(slug: str, session: CloudUser = Depends(get_session)):
|
||||||
|
"""Exact-copy fork: creates a new meal plan in the caller's DB with matching slots. Free."""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Community DB not available.")
|
||||||
|
|
||||||
|
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found.")
|
||||||
|
if post.post_type != "plan":
|
||||||
|
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
def _create_plan():
|
||||||
|
s = Store(session.db)
|
||||||
|
try:
|
||||||
|
week_start = date.today().strftime("%Y-%m-%d")
|
||||||
|
meal_types = list({slot["meal_type"] for slot in post.slots}) or ["dinner"]
|
||||||
|
plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types)
|
||||||
|
for slot in post.slots:
|
||||||
|
s.assign_recipe_to_slot(
|
||||||
|
plan_id=plan["id"],
|
||||||
|
day_of_week=slot["day"],
|
||||||
|
meal_type=slot["meal_type"],
|
||||||
|
recipe_id=slot["recipe_id"],
|
||||||
|
)
|
||||||
|
return plan
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
plan = await asyncio.to_thread(_create_plan)
|
||||||
|
return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{slug}/fork-adapt", status_code=201)
|
||||||
|
async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)):
|
||||||
|
"""Fork with LLM pantry adaptation. Paid/BYOK. Returns suggestions for user review."""
|
||||||
|
from app.tiers import can_use
|
||||||
|
if not can_use("community_fork_adapt", session.tier, session.has_byok):
|
||||||
|
raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.")
|
||||||
|
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Community DB not available.")
|
||||||
|
|
||||||
|
post = await asyncio.to_thread(store.get_post_by_slug, slug)
|
||||||
|
if post is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found.")
|
||||||
|
|
||||||
|
# BSL 1.1 call site — LLM adaptation
|
||||||
|
from app.services.meal_plan.llm_planner import adapt_plan_to_pantry
|
||||||
|
suggestions = await adapt_plan_to_pantry(
|
||||||
|
slots=list(post.slots),
|
||||||
|
db_path=session.db,
|
||||||
|
tier=session.tier,
|
||||||
|
has_byok=session.has_byok,
|
||||||
|
)
|
||||||
|
return {"suggestions": suggestions, "forked_from": slug}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _post_to_dict(post) -> dict:
|
||||||
|
"""Convert a CommunityPost (frozen dataclass) to a JSON-serializable dict."""
|
||||||
|
return {
|
||||||
|
"slug": post.slug,
|
||||||
|
"pseudonym": post.pseudonym,
|
||||||
|
"post_type": post.post_type,
|
||||||
|
"published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published),
|
||||||
|
"title": post.title,
|
||||||
|
"description": post.description,
|
||||||
|
"photo_url": post.photo_url,
|
||||||
|
"slots": list(post.slots),
|
||||||
|
"recipe_id": post.recipe_id,
|
||||||
|
"recipe_name": post.recipe_name,
|
||||||
|
"level": post.level,
|
||||||
|
"outcome_notes": post.outcome_notes,
|
||||||
|
"element_profiles": {
|
||||||
|
"seasoning_score": post.seasoning_score,
|
||||||
|
"richness_score": post.richness_score,
|
||||||
|
"brightness_score": post.brightness_score,
|
||||||
|
"depth_score": post.depth_score,
|
||||||
|
"aroma_score": post.aroma_score,
|
||||||
|
"structure_score": post.structure_score,
|
||||||
|
"texture_profile": post.texture_profile,
|
||||||
|
},
|
||||||
|
"dietary_tags": list(post.dietary_tags),
|
||||||
|
"allergen_flags": list(post.allergen_flags),
|
||||||
|
"flavor_molecules": list(post.flavor_molecules),
|
||||||
|
"fat_pct": post.fat_pct,
|
||||||
|
"protein_pct": post.protein_pct,
|
||||||
|
"moisture_pct": post.moisture_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post_type_prefix(post_type: str) -> str:
|
||||||
|
return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post")
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans
|
||||||
|
from app.api.endpoints.community import router as community_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -15,3 +16,4 @@ api_router.include_router(feedback.router, prefix="/feedback", tags=["f
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
||||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||||
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
|
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
|
||||||
|
api_router.include_router(community_router)
|
||||||
|
|
@ -1128,3 +1128,31 @@ class Store:
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
|
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
|
||||||
|
|
||||||
|
# ── Community pseudonyms ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_current_pseudonym(self, directus_user_id: str) -> str | None:
|
||||||
|
"""Return the current community pseudonym for this user, or None if not set."""
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT pseudonym FROM community_pseudonyms "
|
||||||
|
"WHERE directus_user_id = ? AND is_current = 1 LIMIT 1",
|
||||||
|
(directus_user_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row["pseudonym"] if row else None
|
||||||
|
|
||||||
|
def set_pseudonym(self, directus_user_id: str, pseudonym: str) -> None:
|
||||||
|
"""Set the current community pseudonym for this user.
|
||||||
|
|
||||||
|
Marks any previous pseudonym as non-current (retains history for attribution).
|
||||||
|
"""
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE community_pseudonyms SET is_current = 0 WHERE directus_user_id = ?",
|
||||||
|
(directus_user_id,),
|
||||||
|
)
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) "
|
||||||
|
"VALUES (?, ?, 1)",
|
||||||
|
(pseudonym, directus_user_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ async def lifespan(app: FastAPI):
|
||||||
settings.ensure_dirs()
|
settings.ensure_dirs()
|
||||||
register_kiwi_programs()
|
register_kiwi_programs()
|
||||||
|
|
||||||
|
# Initialize community store (fail-soft if COMMUNITY_DB_URL not set)
|
||||||
|
from app.api.endpoints.community import init_community_store
|
||||||
|
init_community_store(settings.COMMUNITY_DB_URL)
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
from app.tasks.scheduler import get_scheduler
|
from app.tasks.scheduler import get_scheduler
|
||||||
get_scheduler(settings.DB_PATH)
|
get_scheduler(settings.DB_PATH)
|
||||||
|
|
|
||||||
108
app/services/community/community_store.py
Normal file
108
app/services/community/community_store.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# app/services/community/community_store.py
|
||||||
|
# MIT License
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_pseudonym(
|
||||||
|
store,
|
||||||
|
directus_user_id: str,
|
||||||
|
requested_name: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Return the user's current pseudonym, creating it if it doesn't exist.
|
||||||
|
|
||||||
|
If the user has an existing pseudonym, return it (ignore requested_name).
|
||||||
|
If not, create using requested_name (must be provided for first-time setup).
|
||||||
|
|
||||||
|
Raises ValueError if no existing pseudonym and requested_name is None or blank.
|
||||||
|
"""
|
||||||
|
existing = store.get_current_pseudonym(directus_user_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
if not requested_name or not requested_name.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"A pseudonym is required for first publish. "
|
||||||
|
"Pass requested_name with your chosen display name."
|
||||||
|
)
|
||||||
|
|
||||||
|
name = requested_name.strip()
|
||||||
|
if "@" in name:
|
||||||
|
raise ValueError(
|
||||||
|
"Pseudonym must not contain '@' — use a display name, not an email address."
|
||||||
|
)
|
||||||
|
|
||||||
|
store.set_pseudonym(directus_user_id, name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from circuitforge_core.community import SharedStore, CommunityPost
|
||||||
|
|
||||||
|
class KiwiCommunityStore(SharedStore):
|
||||||
|
"""Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore."""
|
||||||
|
|
||||||
|
def list_meal_plans(
|
||||||
|
self,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
dietary_tags: list[str] | None = None,
|
||||||
|
allergen_exclude: list[str] | None = None,
|
||||||
|
) -> list[CommunityPost]:
|
||||||
|
return self.list_posts(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
post_type="plan",
|
||||||
|
dietary_tags=dietary_tags,
|
||||||
|
allergen_exclude=allergen_exclude,
|
||||||
|
source_product="kiwi",
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_outcomes(
|
||||||
|
self,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
post_type: str | None = None,
|
||||||
|
) -> list[CommunityPost]:
|
||||||
|
if post_type in ("recipe_success", "recipe_blooper"):
|
||||||
|
return self.list_posts(
|
||||||
|
limit=limit, offset=offset,
|
||||||
|
post_type=post_type, source_product="kiwi",
|
||||||
|
)
|
||||||
|
# Fetch both types and merge by published date
|
||||||
|
success = self.list_posts(
|
||||||
|
limit=limit, offset=0, post_type="recipe_success", source_product="kiwi",
|
||||||
|
)
|
||||||
|
bloopers = self.list_posts(
|
||||||
|
limit=limit, offset=0, post_type="recipe_blooper", source_product="kiwi",
|
||||||
|
)
|
||||||
|
merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True)
|
||||||
|
return merged[:limit]
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# cf-core community module not yet merged — stub for local dev without community DB
|
||||||
|
class KiwiCommunityStore: # type: ignore[no-redef]
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def list_meal_plans(self, **kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_outcomes(self, **kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_posts(self, **kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_post_by_slug(self, slug):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def insert_post(self, post):
|
||||||
|
return post
|
||||||
|
|
||||||
|
def delete_post(self, slug, pseudonym):
|
||||||
|
return False
|
||||||
68
tests/api/test_community_endpoints.py
Normal file
68
tests/api/test_community_endpoints.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# tests/api/test_community_endpoints.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_community_posts_no_db_returns_empty():
|
||||||
|
"""When COMMUNITY_DB_URL is not set, GET /community/posts returns empty list (no 500)."""
|
||||||
|
with patch("app.api.endpoints.community._community_store", None):
|
||||||
|
response = client.get("/api/v1/community/posts")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "posts" in data
|
||||||
|
assert isinstance(data["posts"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_community_post_not_found():
|
||||||
|
"""GET /community/posts/{slug} returns 404 when slug doesn't exist."""
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_post_by_slug.return_value = None
|
||||||
|
with patch("app.api.endpoints.community._community_store", mock_store):
|
||||||
|
response = client.get("/api/v1/community/posts/nonexistent-slug")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_community_rss():
|
||||||
|
"""GET /community/feed.rss returns XML content-type."""
|
||||||
|
with patch("app.api.endpoints.community._community_store", None):
|
||||||
|
response = client.get("/api/v1/community/feed.rss")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "xml" in response.headers.get("content-type", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_community_no_store_returns_503():
|
||||||
|
"""POST /community/posts returns 503 when community DB is not configured.
|
||||||
|
|
||||||
|
In local/dev mode the session auth is bypassed (local session). The endpoint
|
||||||
|
should then fail-soft with 503, not 500. Production cloud mode enforces auth
|
||||||
|
before the store check — tested in integration tests.
|
||||||
|
"""
|
||||||
|
with patch("app.api.endpoints.community._community_store", None):
|
||||||
|
response = client.post("/api/v1/community/posts", json={"title": "Test"})
|
||||||
|
# 503 = no community store; 402 = tier gate fired first; both are acceptable
|
||||||
|
assert response.status_code in (402, 503)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_post_no_store_returns_503():
|
||||||
|
"""DELETE /community/posts/{slug} returns 503 when community DB is not configured."""
|
||||||
|
with patch("app.api.endpoints.community._community_store", None):
|
||||||
|
response = client.delete("/api/v1/community/posts/some-slug")
|
||||||
|
assert response.status_code in (400, 503)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fork_post_route_exists():
|
||||||
|
"""POST /community/posts/{slug}/fork route must exist (not 404)."""
|
||||||
|
response = client.post("/api/v1/community/posts/some-slug/fork")
|
||||||
|
assert response.status_code != 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_feed_returns_json():
|
||||||
|
"""GET /community/local-feed returns JSON list for LAN peers."""
|
||||||
|
with patch("app.api.endpoints.community._community_store", None):
|
||||||
|
response = client.get("/api/v1/community/local-feed")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json(), list)
|
||||||
64
tests/services/community/test_community_store.py
Normal file
64
tests/services/community/test_community_store.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# tests/services/community/test_community_store.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_pseudonym_new_user():
|
||||||
|
"""First-time publish: creates a new pseudonym in per-user SQLite."""
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_current_pseudonym.return_value = None
|
||||||
|
result = get_or_create_pseudonym(
|
||||||
|
store=mock_store,
|
||||||
|
directus_user_id="user-123",
|
||||||
|
requested_name="PastaWitch",
|
||||||
|
)
|
||||||
|
mock_store.set_pseudonym.assert_called_once_with("user-123", "PastaWitch")
|
||||||
|
assert result == "PastaWitch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_pseudonym_existing():
|
||||||
|
"""If user already has a pseudonym, return it without creating a new one."""
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_current_pseudonym.return_value = "PastaWitch"
|
||||||
|
result = get_or_create_pseudonym(
|
||||||
|
store=mock_store,
|
||||||
|
directus_user_id="user-123",
|
||||||
|
requested_name=None,
|
||||||
|
)
|
||||||
|
mock_store.set_pseudonym.assert_not_called()
|
||||||
|
assert result == "PastaWitch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_pseudonym_email_rejected():
|
||||||
|
"""Pseudonyms with @ are rejected to prevent PII leakage."""
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_current_pseudonym.return_value = None
|
||||||
|
with pytest.raises(ValueError, match="@"):
|
||||||
|
get_or_create_pseudonym(
|
||||||
|
store=mock_store,
|
||||||
|
directus_user_id="user-123",
|
||||||
|
requested_name="user@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_pseudonym_blank_name_raises():
|
||||||
|
"""Empty requested_name raises when no existing pseudonym."""
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_current_pseudonym.return_value = None
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
get_or_create_pseudonym(
|
||||||
|
store=mock_store,
|
||||||
|
directus_user_id="user-123",
|
||||||
|
requested_name="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiwi_community_store_list_meal_plans_filters_by_plan_type():
|
||||||
|
"""KiwiCommunityStore.list_meal_plans calls list_posts with post_type='plan'."""
|
||||||
|
store = KiwiCommunityStore.__new__(KiwiCommunityStore)
|
||||||
|
with patch.object(store, "list_posts", return_value=[]) as mock_list:
|
||||||
|
result = store.list_meal_plans(limit=10)
|
||||||
|
mock_list.assert_called_once()
|
||||||
|
call_kwargs = mock_list.call_args.kwargs
|
||||||
|
assert call_kwargs.get("post_type") == "plan"
|
||||||
Loading…
Reference in a new issue