From 3d5f0c26688401582129aa8972a1fdb050a5cb7f Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 6 May 2026 06:24:17 -0700 Subject: [PATCH] feat: manual match pin/unpin endpoints --- backend/app/api/ingredients.py | 73 +++++++++++++++++++++++++++- backend/app/main.py | 1 + backend/tests/conftest.py | 13 +++++ backend/tests/test_ingredient_api.py | 62 +++++++++++++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/backend/app/api/ingredients.py b/backend/app/api/ingredients.py index 623ba96..17ab7a4 100644 --- a/backend/app/api/ingredients.py +++ b/backend/app/api/ingredients.py @@ -1,16 +1,23 @@ from __future__ import annotations +from decimal import Decimal from typing import List, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from pydantic import BaseModel from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.database import get_db -from app.models import Ingredient -from app.schemas.ingredient import IngredientCreate, IngredientRead, IngredientUpdate +from app.models import Ingredient, IngredientGroceryMatch, IngredientMatchSource +from app.schemas.ingredient import ( + IngredientCreate, + IngredientGroceryMatchRead, + IngredientRead, + IngredientUpdate, +) from app.security import require_admin @@ -105,3 +112,65 @@ def delete_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)): db.delete(row) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) + + +class _PinMatchBody(BaseModel): + grocery_item_id: UUID + confidence: float = 1.0 + + +@admin_router.post( + "/{ingredient_id}/matches", + response_model=IngredientGroceryMatchRead, + status_code=status.HTTP_201_CREATED, +) +def pin_match( + ingredient_id: UUID, + payload: _PinMatchBody, + db: Session = Depends(get_db), +): + existing = ( + db.query(IngredientGroceryMatch) + .filter( + IngredientGroceryMatch.ingredient_id == ingredient_id, + IngredientGroceryMatch.grocery_item_id == payload.grocery_item_id, + ) + .first() + ) + if existing: + existing.source = IngredientMatchSource.MANUAL + existing.confidence = Decimal(str(round(payload.confidence, 3))) + db.commit() + db.refresh(existing) + return existing + row = IngredientGroceryMatch( + ingredient_id=ingredient_id, + grocery_item_id=payload.grocery_item_id, + confidence=Decimal(str(round(payload.confidence, 3))), + source=IngredientMatchSource.MANUAL, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +_match_admin_router = APIRouter( + prefix="/api/admin/ingredient-matches", + tags=["ingredients-admin"], + dependencies=[Depends(require_admin)], +) + + +@_match_admin_router.delete( + "/{match_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_class=Response, +) +def unpin_match(match_id: UUID, db: Session = Depends(get_db)) -> Response: + row = db.query(IngredientGroceryMatch).filter(IngredientGroceryMatch.id == match_id).first() + if row is None: + raise HTTPException(status_code=404, detail="match not found") + db.delete(row) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/main.py b/backend/app/main.py index cac08ca..dce5ea7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -41,5 +41,6 @@ app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(ingredients_api.public_router) app.include_router(ingredients_api.admin_router) +app.include_router(ingredients_api._match_admin_router) app.include_router(recipes_api.public_router) app.include_router(recipes_api.admin_router) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1b62e8a..a356a86 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -155,6 +155,19 @@ def db(_engine): connection.close() +@pytest.fixture() +def db_session(): + """Plain SessionLocal for tests that need to seed data outside the + transactional ``db`` fixture (e.g. setting up rows the API will read).""" + from app.database import SessionLocal + + s = SessionLocal() + try: + yield s + finally: + s.close() + + @pytest.fixture() def client(db): """TestClient with get_db overridden to yield the test session.""" diff --git a/backend/tests/test_ingredient_api.py b/backend/tests/test_ingredient_api.py index b6a36a5..7a3693d 100644 --- a/backend/tests/test_ingredient_api.py +++ b/backend/tests/test_ingredient_api.py @@ -63,6 +63,68 @@ def test_update_ingredient_replaces_aliases(client): assert r.json()["aliases"] == ["yellow onion", "spanish onion"] +def _seed_grocery(db_session, name: str) -> str: + from datetime import datetime, timezone + from decimal import Decimal + from uuid import uuid4 + + from app.models import GroceryItem + + gid = uuid4() + db_session.add( + GroceryItem( + id=gid, + name=name, + source="lucky_california", + external_id=f"ext-{gid}", + current_price=Decimal("4.99"), + regular_price=Decimal("4.99"), + is_on_sale=False, + scraped_at=datetime.now(timezone.utc), + ) + ) + db_session.commit() + return str(gid) + + +def test_pin_manual_match(client, db_session): + create = client.post( + "/api/admin/ingredients", + json={"name": "Test Manual Pin Veggie", "aliases": [], "aisle": "produce", "unit": "ea"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + gid = _seed_grocery(db_session, "Some Other Veggie Brand Test") + + r = client.post( + f"/api/admin/ingredients/{iid}/matches", + json={"grocery_item_id": gid, "confidence": 1.0}, + headers=_admin_headers(), + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["source"] == "manual" + assert body["grocery_item_id"] == gid + + +def test_unpin_manual_match(client, db_session): + create = client.post( + "/api/admin/ingredients", + json={"name": "Test Unpin Test Item", "aliases": [], "aisle": "produce", "unit": "ea"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + gid = _seed_grocery(db_session, "Brand X Test Product") + pin = client.post( + f"/api/admin/ingredients/{iid}/matches", + json={"grocery_item_id": gid, "confidence": 1.0}, + headers=_admin_headers(), + ) + match_id = pin.json()["id"] + r = client.delete(f"/api/admin/ingredient-matches/{match_id}", headers=_admin_headers()) + assert r.status_code == 204 + + def test_delete_ingredient_removes_row(client): create = client.post( "/api/admin/ingredients",