feat(recipe-tags): community subcategory tagging API endpoints
GET /recipes/community-tags/{recipe_id} — all tags for a recipe
POST /recipes/community-tags — submit tag (requires pseudonym)
POST /recipes/community-tags/{id}/upvote — vote on a tag
Validates (domain, category, subcategory) against DOMAINS taxonomy before
accepting. Returns 409 on duplicate submission or double-vote. Fails soft
(503) when community Postgres is unavailable so the browse path is unaffected.
Refs kiwi#118.
This commit is contained in:
parent
a507deddbf
commit
f962748073
2 changed files with 168 additions and 0 deletions
166
app/api/endpoints/recipe_tags.py
Normal file
166
app/api/endpoints/recipe_tags.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# app/api/endpoints/recipe_tags.py
|
||||||
|
"""Community subcategory tagging for corpus recipes.
|
||||||
|
|
||||||
|
Users can tag a recipe they're viewing with a domain/category/subcategory
|
||||||
|
from the browse taxonomy. Tags require a community pseudonym and reach
|
||||||
|
public visibility once two independent users have tagged the same recipe
|
||||||
|
to the same location (upvotes >= 2).
|
||||||
|
|
||||||
|
All tiers may submit and upvote tags — community contribution is free.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.endpoints.community import _get_community_store
|
||||||
|
from app.api.endpoints.session import get_session
|
||||||
|
from app.cloud_session import CloudUser
|
||||||
|
from app.services.recipe.browser_domains import DOMAINS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
ACCEPT_THRESHOLD = 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── Request / response models ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TagSubmitBody(BaseModel):
|
||||||
|
recipe_id: int
|
||||||
|
domain: str
|
||||||
|
category: str
|
||||||
|
subcategory: str | None = None
|
||||||
|
pseudonym: str
|
||||||
|
|
||||||
|
|
||||||
|
class TagResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
recipe_id: int
|
||||||
|
domain: str
|
||||||
|
category: str
|
||||||
|
subcategory: str | None
|
||||||
|
pseudonym: str
|
||||||
|
upvotes: int
|
||||||
|
accepted: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(row: dict) -> TagResponse:
|
||||||
|
return TagResponse(
|
||||||
|
id=row["id"],
|
||||||
|
recipe_id=int(row["recipe_ref"]),
|
||||||
|
domain=row["domain"],
|
||||||
|
category=row["category"],
|
||||||
|
subcategory=row.get("subcategory"),
|
||||||
|
pseudonym=row["pseudonym"],
|
||||||
|
upvotes=row["upvotes"],
|
||||||
|
accepted=row["upvotes"] >= ACCEPT_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_location(domain: str, category: str, subcategory: str | None) -> None:
|
||||||
|
"""Raise 422 if (domain, category, subcategory) isn't in the known taxonomy."""
|
||||||
|
if domain not in DOMAINS:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Unknown domain '{domain}'.")
|
||||||
|
cats = DOMAINS[domain].get("categories", {})
|
||||||
|
if category not in cats:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
||||||
|
)
|
||||||
|
if subcategory is not None:
|
||||||
|
subcats = cats[category].get("subcategories", {})
|
||||||
|
if subcategory not in subcats:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Unknown subcategory '{subcategory}' in '{domain}/{category}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/recipes/community-tags/{recipe_id}", response_model=list[TagResponse])
|
||||||
|
async def list_recipe_tags(
|
||||||
|
recipe_id: int,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> list[TagResponse]:
|
||||||
|
"""Return all community tags for a corpus recipe, accepted ones first."""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
return []
|
||||||
|
tags = store.list_tags_for_recipe(recipe_id)
|
||||||
|
return [_to_response(r) for r in tags]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes/community-tags", response_model=TagResponse, status_code=201)
|
||||||
|
async def submit_recipe_tag(
|
||||||
|
body: TagSubmitBody,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> TagResponse:
|
||||||
|
"""Tag a corpus recipe with a browse taxonomy location.
|
||||||
|
|
||||||
|
Requires the user to have a community pseudonym set. Returns 409 if this
|
||||||
|
user has already tagged this recipe to this exact location.
|
||||||
|
"""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Community features are not available on this instance.",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_location(body.domain, body.category, body.subcategory)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg2.errors # type: ignore[import]
|
||||||
|
row = store.submit_recipe_tag(
|
||||||
|
recipe_id=body.recipe_id,
|
||||||
|
domain=body.domain,
|
||||||
|
category=body.category,
|
||||||
|
subcategory=body.subcategory,
|
||||||
|
pseudonym=body.pseudonym,
|
||||||
|
)
|
||||||
|
return _to_response(row)
|
||||||
|
except Exception as exc:
|
||||||
|
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="You have already tagged this recipe to this location.",
|
||||||
|
)
|
||||||
|
logger.error("submit_recipe_tag failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to submit tag.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/recipes/community-tags/{tag_id}/upvote", response_model=TagResponse)
|
||||||
|
async def upvote_recipe_tag(
|
||||||
|
tag_id: int,
|
||||||
|
pseudonym: str,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> TagResponse:
|
||||||
|
"""Upvote an existing community tag.
|
||||||
|
|
||||||
|
Returns 409 if this pseudonym has already voted on this tag.
|
||||||
|
Returns 404 if the tag doesn't exist.
|
||||||
|
"""
|
||||||
|
store = _get_community_store()
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Community features unavailable.")
|
||||||
|
|
||||||
|
tag_row = store.get_recipe_tag_by_id(tag_id)
|
||||||
|
if tag_row is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_upvotes = store.upvote_recipe_tag(tag_id, pseudonym)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
||||||
|
except Exception as exc:
|
||||||
|
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
||||||
|
raise HTTPException(status_code=409, detail="You have already voted on this tag.")
|
||||||
|
logger.error("upvote_recipe_tag failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to upvote tag.")
|
||||||
|
|
||||||
|
tag_row["upvotes"] = new_upvotes
|
||||||
|
return _to_response(tag_row)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
||||||
from app.api.endpoints.community import router as community_router
|
from app.api.endpoints.community import router as community_router
|
||||||
|
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -22,3 +23,4 @@ api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=
|
||||||
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
||||||
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
||||||
api_router.include_router(community_router)
|
api_router.include_router(community_router)
|
||||||
|
api_router.include_router(recipe_tags_router)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue