feat: manual match pin/unpin endpoints

This commit is contained in:
2026-05-06 06:24:17 -07:00
parent db4b01337e
commit 3d5f0c2668
4 changed files with 147 additions and 2 deletions
+71 -2
View File
@@ -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)
+1
View File
@@ -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)
+13
View File
@@ -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."""
+62
View File
@@ -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",