From f96274807385e0c979c3bc82b3f8031393a443cc Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 22 Apr 2026 12:37:32 -0700 Subject: [PATCH] feat(recipe-tags): community subcategory tagging API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/endpoints/recipe_tags.py | 166 +++++++++++++++++++++++++++++++ app/api/routes.py | 2 + 2 files changed, 168 insertions(+) create mode 100644 app/api/endpoints/recipe_tags.py diff --git a/app/api/endpoints/recipe_tags.py b/app/api/endpoints/recipe_tags.py new file mode 100644 index 0000000..68a9f30 --- /dev/null +++ b/app/api/endpoints/recipe_tags.py @@ -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) diff --git a/app/api/routes.py b/app/api/routes.py index c5cc59f..a426572 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,6 +1,7 @@ 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.community import router as community_router +from app.api.endpoints.recipe_tags import router as recipe_tags_router 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(shopping.router, prefix="/shopping", tags=["shopping"]) api_router.include_router(community_router) +api_router.include_router(recipe_tags_router)