Public Access
feat: manual match pin/unpin endpoints
This commit is contained in:
@@ -1,16 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Ingredient
|
from app.models import Ingredient, IngredientGroceryMatch, IngredientMatchSource
|
||||||
from app.schemas.ingredient import IngredientCreate, IngredientRead, IngredientUpdate
|
from app.schemas.ingredient import (
|
||||||
|
IngredientCreate,
|
||||||
|
IngredientGroceryMatchRead,
|
||||||
|
IngredientRead,
|
||||||
|
IngredientUpdate,
|
||||||
|
)
|
||||||
from app.security import require_admin
|
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.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
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)
|
||||||
|
|||||||
@@ -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(admin.router, prefix="/api/admin", tags=["admin"])
|
||||||
app.include_router(ingredients_api.public_router)
|
app.include_router(ingredients_api.public_router)
|
||||||
app.include_router(ingredients_api.admin_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.public_router)
|
||||||
app.include_router(recipes_api.admin_router)
|
app.include_router(recipes_api.admin_router)
|
||||||
|
|||||||
@@ -155,6 +155,19 @@ def db(_engine):
|
|||||||
connection.close()
|
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()
|
@pytest.fixture()
|
||||||
def client(db):
|
def client(db):
|
||||||
"""TestClient with get_db overridden to yield the test session."""
|
"""TestClient with get_db overridden to yield the test session."""
|
||||||
|
|||||||
@@ -63,6 +63,68 @@ def test_update_ingredient_replaces_aliases(client):
|
|||||||
assert r.json()["aliases"] == ["yellow onion", "spanish onion"]
|
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):
|
def test_delete_ingredient_removes_row(client):
|
||||||
create = client.post(
|
create = client.post(
|
||||||
"/api/admin/ingredients",
|
"/api/admin/ingredients",
|
||||||
|
|||||||
Reference in New Issue
Block a user