feat(api): scope products and inventory by owner and household
This commit is contained in:
parent
1f47974f48
commit
803bc3b4cd
5 changed files with 520 additions and 44 deletions
|
|
@ -143,7 +143,11 @@ def can_update_inventory(
|
||||||
return False
|
return False
|
||||||
if _is_admin(current_user):
|
if _is_admin(current_user):
|
||||||
return True
|
return True
|
||||||
return inventory.user_id == current_user.user_id
|
if inventory.user_id == current_user.user_id:
|
||||||
|
return True
|
||||||
|
if not inventory.is_household_shared or inventory.user_id is None:
|
||||||
|
return False
|
||||||
|
return _is_same_household(session, inventory.user_id, current_user)
|
||||||
|
|
||||||
|
|
||||||
def is_product_visible(
|
def is_product_visible(
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,29 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from db import get_session
|
from db import get_session
|
||||||
|
from innercontext.api.auth_deps import get_current_user
|
||||||
|
from innercontext.api.authz import (
|
||||||
|
can_update_inventory,
|
||||||
|
check_household_inventory_access,
|
||||||
|
)
|
||||||
from innercontext.api.products import InventoryUpdate
|
from innercontext.api.products import InventoryUpdate
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override
|
||||||
|
from innercontext.auth import CurrentUser
|
||||||
from innercontext.models import ProductInventory
|
from innercontext.models import ProductInventory
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{inventory_id}", response_model=ProductInventory)
|
@router.get("/{inventory_id}", response_model=ProductInventory)
|
||||||
def get_inventory(inventory_id: UUID, session: Session = Depends(get_session)):
|
def get_inventory(
|
||||||
return get_or_404(session, ProductInventory, inventory_id)
|
inventory_id: UUID,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return check_household_inventory_access(session, inventory_id, current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{inventory_id}", response_model=ProductInventory)
|
@router.patch("/{inventory_id}", response_model=ProductInventory)
|
||||||
|
|
@ -21,7 +31,10 @@ def update_inventory(
|
||||||
inventory_id: UUID,
|
inventory_id: UUID,
|
||||||
data: InventoryUpdate,
|
data: InventoryUpdate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
if not can_update_inventory(session, inventory_id, current_user):
|
||||||
|
raise HTTPException(status_code=404, detail="ProductInventory not found")
|
||||||
entry = get_or_404(session, ProductInventory, inventory_id)
|
entry = get_or_404(session, ProductInventory, inventory_id)
|
||||||
for key, value in data.model_dump(exclude_unset=True).items():
|
for key, value in data.model_dump(exclude_unset=True).items():
|
||||||
setattr(entry, key, value)
|
setattr(entry, key, value)
|
||||||
|
|
@ -32,7 +45,16 @@ def update_inventory(
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{inventory_id}", status_code=204)
|
@router.delete("/{inventory_id}", status_code=204)
|
||||||
def delete_inventory(inventory_id: UUID, session: Session = Depends(get_session)):
|
def delete_inventory(
|
||||||
entry = get_or_404(session, ProductInventory, inventory_id)
|
inventory_id: UUID,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
entry = get_owned_or_404_admin_override(
|
||||||
|
session,
|
||||||
|
ProductInventory,
|
||||||
|
inventory_id,
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
session.delete(entry)
|
session.delete(entry)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# pyright: reportImportCycles=false, reportIncompatibleVariableOverride=false
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
@ -13,6 +15,8 @@ from sqlalchemy import select as sa_select
|
||||||
from sqlmodel import Field, Session, SQLModel, col, select
|
from sqlmodel import Field, Session, SQLModel, col, select
|
||||||
|
|
||||||
from db import get_session
|
from db import get_session
|
||||||
|
from innercontext.api.auth_deps import get_current_user
|
||||||
|
from innercontext.api.authz import is_product_visible
|
||||||
from innercontext.api.llm_context import build_user_profile_context
|
from innercontext.api.llm_context import build_user_profile_context
|
||||||
from innercontext.api.product_llm_tools import (
|
from innercontext.api.product_llm_tools import (
|
||||||
PRODUCT_DETAILS_FUNCTION_DECLARATION,
|
PRODUCT_DETAILS_FUNCTION_DECLARATION,
|
||||||
|
|
@ -24,7 +28,8 @@ from innercontext.api.product_llm_tools import (
|
||||||
build_last_used_on_by_product,
|
build_last_used_on_by_product,
|
||||||
build_product_details_tool_handler,
|
build_product_details_tool_handler,
|
||||||
)
|
)
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override
|
||||||
|
from innercontext.auth import CurrentUser
|
||||||
from innercontext.llm import (
|
from innercontext.llm import (
|
||||||
call_gemini,
|
call_gemini,
|
||||||
call_gemini_with_function_tools,
|
call_gemini_with_function_tools,
|
||||||
|
|
@ -42,6 +47,7 @@ from innercontext.models import (
|
||||||
SkinConcern,
|
SkinConcern,
|
||||||
SkinConditionSnapshot,
|
SkinConditionSnapshot,
|
||||||
)
|
)
|
||||||
|
from innercontext.models import Role
|
||||||
from innercontext.models.ai_log import AICallLog
|
from innercontext.models.ai_log import AICallLog
|
||||||
from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics
|
from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics
|
||||||
from innercontext.models.enums import (
|
from innercontext.models.enums import (
|
||||||
|
|
@ -67,6 +73,34 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_inventory_visible_to_user(
|
||||||
|
inventory: ProductInventory,
|
||||||
|
session: Session,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> bool:
|
||||||
|
if current_user.role is Role.ADMIN:
|
||||||
|
return True
|
||||||
|
if inventory.user_id == current_user.user_id:
|
||||||
|
return True
|
||||||
|
if not inventory.is_household_shared:
|
||||||
|
return False
|
||||||
|
if inventory.user_id is None:
|
||||||
|
return False
|
||||||
|
return is_product_visible(session, inventory.product_id, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_inventory_for_product(
|
||||||
|
inventories: list[ProductInventory],
|
||||||
|
session: Session,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
) -> list[ProductInventory]:
|
||||||
|
return [
|
||||||
|
inventory
|
||||||
|
for inventory in inventories
|
||||||
|
if _is_inventory_visible_to_user(inventory, session, current_user)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None:
|
def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None:
|
||||||
"""Build ResponseMetadata from AICallLog for Phase 3 observability."""
|
"""Build ResponseMetadata from AICallLog for Phase 3 observability."""
|
||||||
if not log_id:
|
if not log_id:
|
||||||
|
|
@ -214,15 +248,15 @@ class ProductListItem(SQLModel):
|
||||||
|
|
||||||
class AIActiveIngredient(ActiveIngredient):
|
class AIActiveIngredient(ActiveIngredient):
|
||||||
# Gemini API rejects int-enum values in response_schema; override with plain int.
|
# Gemini API rejects int-enum values in response_schema; override with plain int.
|
||||||
strength_level: Optional[int] = None # type: ignore[assignment]
|
strength_level: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
irritation_potential: Optional[int] = None # type: ignore[assignment]
|
irritation_potential: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
|
||||||
class ProductParseLLMResponse(ProductParseResponse):
|
class ProductParseLLMResponse(ProductParseResponse):
|
||||||
# Gemini response schema currently requires enum values to be strings.
|
# Gemini response schema currently requires enum values to be strings.
|
||||||
# Strength fields are numeric in our domain (1-3), so keep them as ints here
|
# Strength fields are numeric in our domain (1-3), so keep them as ints here
|
||||||
# and convert via ProductParseResponse validation afterward.
|
# and convert via ProductParseResponse validation afterward.
|
||||||
actives: Optional[list[AIActiveIngredient]] = None # type: ignore[assignment]
|
actives: Optional[list[AIActiveIngredient]] = None # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
|
|
||||||
class InventoryCreate(SQLModel):
|
class InventoryCreate(SQLModel):
|
||||||
|
|
@ -610,6 +644,7 @@ def list_products(
|
||||||
is_medication: Optional[bool] = None,
|
is_medication: Optional[bool] = None,
|
||||||
is_tool: Optional[bool] = None,
|
is_tool: Optional[bool] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
stmt = select(Product)
|
stmt = select(Product)
|
||||||
if category is not None:
|
if category is not None:
|
||||||
|
|
@ -622,6 +657,12 @@ def list_products(
|
||||||
stmt = stmt.where(Product.is_tool == is_tool)
|
stmt = stmt.where(Product.is_tool == is_tool)
|
||||||
|
|
||||||
products = list(session.exec(stmt).all())
|
products = list(session.exec(stmt).all())
|
||||||
|
if current_user.role is not Role.ADMIN:
|
||||||
|
products = [
|
||||||
|
product
|
||||||
|
for product in products
|
||||||
|
if is_product_visible(session, product.id, current_user)
|
||||||
|
]
|
||||||
|
|
||||||
# Filter by targets (JSON column — done in Python)
|
# Filter by targets (JSON column — done in Python)
|
||||||
if targets:
|
if targets:
|
||||||
|
|
@ -646,20 +687,28 @@ def list_products(
|
||||||
if product_ids
|
if product_ids
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
inv_by_product: dict = {}
|
inv_by_product: dict[UUID, list[ProductInventory]] = {}
|
||||||
for inv in inventory_rows:
|
for inv in inventory_rows:
|
||||||
inv_by_product.setdefault(inv.product_id, []).append(inv)
|
inv_by_product.setdefault(inv.product_id, []).append(inv)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for p in products:
|
for p in products:
|
||||||
r = ProductWithInventory.model_validate(p, from_attributes=True)
|
r = ProductWithInventory.model_validate(p, from_attributes=True)
|
||||||
r.inventory = inv_by_product.get(p.id, [])
|
r.inventory = _visible_inventory_for_product(
|
||||||
|
inv_by_product.get(p.id, []),
|
||||||
|
session,
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
results.append(r)
|
results.append(r)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ProductPublic, status_code=201)
|
@router.post("", response_model=ProductPublic, status_code=201)
|
||||||
def create_product(data: ProductCreate, session: Session = Depends(get_session)):
|
def create_product(
|
||||||
|
data: ProductCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
payload = data.model_dump()
|
payload = data.model_dump()
|
||||||
if payload.get("price_currency"):
|
if payload.get("price_currency"):
|
||||||
payload["price_currency"] = str(payload["price_currency"]).upper()
|
payload["price_currency"] = str(payload["price_currency"]).upper()
|
||||||
|
|
@ -667,6 +716,7 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
|
||||||
product_id = uuid4()
|
product_id = uuid4()
|
||||||
product = Product(
|
product = Product(
|
||||||
id=product_id,
|
id=product_id,
|
||||||
|
user_id=current_user.user_id,
|
||||||
short_id=str(product_id)[:8],
|
short_id=str(product_id)[:8],
|
||||||
**payload,
|
**payload,
|
||||||
)
|
)
|
||||||
|
|
@ -849,10 +899,12 @@ def list_products_summary(
|
||||||
is_medication: Optional[bool] = None,
|
is_medication: Optional[bool] = None,
|
||||||
is_tool: Optional[bool] = None,
|
is_tool: Optional[bool] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
product_table = inspect(Product).local_table
|
product_table = inspect(Product).local_table
|
||||||
stmt = sa_select(
|
stmt = sa_select(
|
||||||
product_table.c.id,
|
product_table.c.id,
|
||||||
|
product_table.c.user_id,
|
||||||
product_table.c.name,
|
product_table.c.name,
|
||||||
product_table.c.brand,
|
product_table.c.brand,
|
||||||
product_table.c.category,
|
product_table.c.category,
|
||||||
|
|
@ -872,6 +924,10 @@ def list_products_summary(
|
||||||
stmt = stmt.where(product_table.c.is_tool == is_tool)
|
stmt = stmt.where(product_table.c.is_tool == is_tool)
|
||||||
|
|
||||||
rows = list(session.execute(stmt).all())
|
rows = list(session.execute(stmt).all())
|
||||||
|
if current_user.role is not Role.ADMIN:
|
||||||
|
rows = [
|
||||||
|
row for row in rows if is_product_visible(session, row[0], current_user)
|
||||||
|
]
|
||||||
|
|
||||||
if targets:
|
if targets:
|
||||||
target_values = {t.value for t in targets}
|
target_values = {t.value for t in targets}
|
||||||
|
|
@ -884,26 +940,11 @@ def list_products_summary(
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
product_ids = [row[0] for row in rows]
|
|
||||||
inventory_rows = (
|
|
||||||
session.exec(
|
|
||||||
select(ProductInventory).where(
|
|
||||||
col(ProductInventory.product_id).in_(product_ids)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
if product_ids
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
owned_ids = {
|
|
||||||
inv.product_id
|
|
||||||
for inv in inventory_rows
|
|
||||||
if inv.product_id is not None and inv.finished_at is None
|
|
||||||
}
|
|
||||||
|
|
||||||
results: list[ProductListItem] = []
|
results: list[ProductListItem] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
(
|
(
|
||||||
product_id,
|
product_id,
|
||||||
|
product_user_id,
|
||||||
name,
|
name,
|
||||||
brand_value,
|
brand_value,
|
||||||
category_value,
|
category_value,
|
||||||
|
|
@ -921,7 +962,7 @@ def list_products_summary(
|
||||||
category=category_value,
|
category=category_value,
|
||||||
recommended_time=recommended_time,
|
recommended_time=recommended_time,
|
||||||
targets=row_targets or [],
|
targets=row_targets or [],
|
||||||
is_owned=product_id in owned_ids,
|
is_owned=product_user_id == current_user.user_id,
|
||||||
price_tier=price_tier,
|
price_tier=price_tier,
|
||||||
price_per_use_pln=price_per_use_pln,
|
price_per_use_pln=price_per_use_pln,
|
||||||
price_tier_source=price_tier_source,
|
price_tier_source=price_tier_source,
|
||||||
|
|
@ -932,22 +973,35 @@ def list_products_summary(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}", response_model=ProductWithInventory)
|
@router.get("/{product_id}", response_model=ProductWithInventory)
|
||||||
def get_product(product_id: UUID, session: Session = Depends(get_session)):
|
def get_product(
|
||||||
|
product_id: UUID,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
product = get_or_404(session, Product, product_id)
|
product = get_or_404(session, Product, product_id)
|
||||||
|
if not is_product_visible(session, product_id, current_user):
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
inventory = session.exec(
|
inventory = session.exec(
|
||||||
select(ProductInventory).where(ProductInventory.product_id == product_id)
|
select(ProductInventory).where(ProductInventory.product_id == product_id)
|
||||||
).all()
|
).all()
|
||||||
result = ProductWithInventory.model_validate(product, from_attributes=True)
|
result = ProductWithInventory.model_validate(product, from_attributes=True)
|
||||||
result.inventory = list(inventory)
|
result.inventory = _visible_inventory_for_product(
|
||||||
|
list(inventory), session, current_user
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{product_id}", response_model=ProductPublic)
|
@router.patch("/{product_id}", response_model=ProductPublic)
|
||||||
def update_product(
|
def update_product(
|
||||||
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
|
product_id: UUID,
|
||||||
|
data: ProductUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
product = get_or_404(session, Product, product_id)
|
product = get_owned_or_404_admin_override(
|
||||||
|
session, Product, product_id, current_user
|
||||||
|
)
|
||||||
patch_data = data.model_dump(exclude_unset=True)
|
patch_data = data.model_dump(exclude_unset=True)
|
||||||
if patch_data.get("price_currency"):
|
if patch_data.get("price_currency"):
|
||||||
patch_data["price_currency"] = str(patch_data["price_currency"]).upper()
|
patch_data["price_currency"] = str(patch_data["price_currency"]).upper()
|
||||||
|
|
@ -962,8 +1016,14 @@ def update_product(
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{product_id}", status_code=204)
|
@router.delete("/{product_id}", status_code=204)
|
||||||
def delete_product(product_id: UUID, session: Session = Depends(get_session)):
|
def delete_product(
|
||||||
product = get_or_404(session, Product, product_id)
|
product_id: UUID,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
product = get_owned_or_404_admin_override(
|
||||||
|
session, Product, product_id, current_user
|
||||||
|
)
|
||||||
session.delete(product)
|
session.delete(product)
|
||||||
enqueue_pricing_recalc(session)
|
enqueue_pricing_recalc(session)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -975,10 +1035,17 @@ def delete_product(product_id: UUID, session: Session = Depends(get_session)):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}/inventory", response_model=list[ProductInventory])
|
@router.get("/{product_id}/inventory", response_model=list[ProductInventory])
|
||||||
def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)):
|
def list_product_inventory(
|
||||||
|
product_id: UUID,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
get_or_404(session, Product, product_id)
|
get_or_404(session, Product, product_id)
|
||||||
|
if not is_product_visible(session, product_id, current_user):
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
|
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
|
||||||
return session.exec(stmt).all()
|
inventories = list(session.exec(stmt).all())
|
||||||
|
return _visible_inventory_for_product(inventories, session, current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -988,10 +1055,14 @@ def create_product_inventory(
|
||||||
product_id: UUID,
|
product_id: UUID,
|
||||||
data: InventoryCreate,
|
data: InventoryCreate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
get_or_404(session, Product, product_id)
|
product = get_owned_or_404_admin_override(
|
||||||
|
session, Product, product_id, current_user
|
||||||
|
)
|
||||||
entry = ProductInventory(
|
entry = ProductInventory(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
|
user_id=product.user_id or current_user.user_id,
|
||||||
product_id=product_id,
|
product_id=product_id,
|
||||||
**data.model_dump(),
|
**data.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
@ -1018,11 +1089,16 @@ def _ev(v: object) -> str:
|
||||||
def _build_shopping_context(
|
def _build_shopping_context(
|
||||||
session: Session,
|
session: Session,
|
||||||
reference_date: date,
|
reference_date: date,
|
||||||
|
current_user: CurrentUser,
|
||||||
*,
|
*,
|
||||||
products: list[Product] | None = None,
|
products: list[Product] | None = None,
|
||||||
last_used_on_by_product: dict[str, date] | None = None,
|
last_used_on_by_product: dict[str, date] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
profile_ctx = build_user_profile_context(session, reference_date=reference_date)
|
profile_ctx = build_user_profile_context(
|
||||||
|
session,
|
||||||
|
reference_date=reference_date,
|
||||||
|
current_user=current_user,
|
||||||
|
)
|
||||||
snapshot = session.exec(
|
snapshot = session.exec(
|
||||||
select(SkinConditionSnapshot).order_by(
|
select(SkinConditionSnapshot).order_by(
|
||||||
col(SkinConditionSnapshot.snapshot_date).desc()
|
col(SkinConditionSnapshot.snapshot_date).desc()
|
||||||
|
|
@ -1061,7 +1137,7 @@ def _build_shopping_context(
|
||||||
if product_ids
|
if product_ids
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
inv_by_product: dict = {}
|
inv_by_product: dict[UUID, list[ProductInventory]] = {}
|
||||||
for inv in inventory_rows:
|
for inv in inventory_rows:
|
||||||
inv_by_product.setdefault(inv.product_id, []).append(inv)
|
inv_by_product.setdefault(inv.product_id, []).append(inv)
|
||||||
|
|
||||||
|
|
@ -1213,7 +1289,10 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
|
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
|
||||||
def suggest_shopping(session: Session = Depends(get_session)):
|
def suggest_shopping(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
reference_date = date.today()
|
reference_date = date.today()
|
||||||
shopping_products = _get_shopping_products(session)
|
shopping_products = _get_shopping_products(session)
|
||||||
last_used_on_by_product = build_last_used_on_by_product(
|
last_used_on_by_product = build_last_used_on_by_product(
|
||||||
|
|
@ -1223,6 +1302,7 @@ def suggest_shopping(session: Session = Depends(get_session)):
|
||||||
context = _build_shopping_context(
|
context = _build_shopping_context(
|
||||||
session,
|
session,
|
||||||
reference_date=reference_date,
|
reference_date=reference_date,
|
||||||
|
current_user=current_user,
|
||||||
products=shopping_products,
|
products=shopping_products,
|
||||||
last_used_on_by_product=last_used_on_by_product,
|
last_used_on_by_product=last_used_on_by_product,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ def test_household_inventory_update_rules_owner_admin_and_member(session: Sessio
|
||||||
|
|
||||||
assert can_update_inventory(session, inventory.id, owner) is True
|
assert can_update_inventory(session, inventory.id, owner) is True
|
||||||
assert can_update_inventory(session, inventory.id, admin) is True
|
assert can_update_inventory(session, inventory.id, admin) is True
|
||||||
assert can_update_inventory(session, inventory.id, member) is False
|
assert can_update_inventory(session, inventory.id, member) is True
|
||||||
|
|
||||||
|
|
||||||
def test_product_visibility_for_owner_admin_and_household_shared(session: Session):
|
def test_product_visibility_for_owner_admin_and_household_shared(session: Session):
|
||||||
|
|
|
||||||
370
backend/tests/test_products_auth.py
Normal file
370
backend/tests/test_products_auth.py
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from db import get_session
|
||||||
|
from innercontext.api.auth_deps import get_current_user
|
||||||
|
from innercontext.auth import (
|
||||||
|
CurrentHouseholdMembership,
|
||||||
|
CurrentUser,
|
||||||
|
IdentityData,
|
||||||
|
TokenClaims,
|
||||||
|
)
|
||||||
|
from innercontext.models import (
|
||||||
|
DayTime,
|
||||||
|
Household,
|
||||||
|
HouseholdMembership,
|
||||||
|
HouseholdRole,
|
||||||
|
Product,
|
||||||
|
ProductCategory,
|
||||||
|
ProductInventory,
|
||||||
|
Role,
|
||||||
|
)
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user(
|
||||||
|
user_id: UUID,
|
||||||
|
*,
|
||||||
|
role: Role = Role.MEMBER,
|
||||||
|
household_id: UUID | None = None,
|
||||||
|
) -> CurrentUser:
|
||||||
|
claims = TokenClaims(
|
||||||
|
issuer="https://auth.test",
|
||||||
|
subject=str(user_id),
|
||||||
|
audience=("innercontext-web",),
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||||
|
groups=("innercontext-member",),
|
||||||
|
raw_claims={"iss": "https://auth.test", "sub": str(user_id)},
|
||||||
|
)
|
||||||
|
membership = None
|
||||||
|
if household_id is not None:
|
||||||
|
membership = CurrentHouseholdMembership(
|
||||||
|
household_id=household_id,
|
||||||
|
role=HouseholdRole.MEMBER,
|
||||||
|
)
|
||||||
|
return CurrentUser(
|
||||||
|
user_id=user_id,
|
||||||
|
role=role,
|
||||||
|
identity=IdentityData.from_claims(claims),
|
||||||
|
claims=claims,
|
||||||
|
household_membership=membership,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_membership(session, user_id: UUID, household_id: UUID) -> None:
|
||||||
|
membership = HouseholdMembership(
|
||||||
|
user_id=user_id,
|
||||||
|
household_id=household_id,
|
||||||
|
role=HouseholdRole.MEMBER,
|
||||||
|
)
|
||||||
|
session.add(membership)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_product(session, *, user_id: UUID, short_id: str, name: str) -> Product:
|
||||||
|
product = Product(
|
||||||
|
user_id=user_id,
|
||||||
|
short_id=short_id,
|
||||||
|
name=name,
|
||||||
|
brand="Brand",
|
||||||
|
category=ProductCategory.SERUM,
|
||||||
|
recommended_time=DayTime.BOTH,
|
||||||
|
leave_on=True,
|
||||||
|
)
|
||||||
|
setattr(product, "product_effect_profile", {})
|
||||||
|
session.add(product)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def _create_inventory(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
user_id: UUID,
|
||||||
|
product_id: UUID,
|
||||||
|
is_household_shared: bool,
|
||||||
|
) -> ProductInventory:
|
||||||
|
entry = ProductInventory(
|
||||||
|
user_id=user_id,
|
||||||
|
product_id=product_id,
|
||||||
|
is_household_shared=is_household_shared,
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def auth_client(session):
|
||||||
|
auth_state = {"current_user": _current_user(uuid4(), role=Role.ADMIN)}
|
||||||
|
|
||||||
|
def _session_override():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
def _current_user_override():
|
||||||
|
return auth_state["current_user"]
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = _session_override
|
||||||
|
app.dependency_overrides[get_current_user] = _current_user_override
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client, auth_state
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_endpoints_require_authentication(session):
|
||||||
|
def _session_override():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = _session_override
|
||||||
|
app.dependency_overrides.pop(get_current_user, None)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/products")
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["detail"] == "Missing bearer token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_product_visible_in_summary_marks_is_owned_false(auth_client, session):
|
||||||
|
client, auth_state = auth_client
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
member_id = uuid4()
|
||||||
|
household = Household()
|
||||||
|
session.add(household)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(household)
|
||||||
|
_create_membership(session, owner_id, household.id)
|
||||||
|
_create_membership(session, member_id, household.id)
|
||||||
|
|
||||||
|
shared_product = _create_product(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
short_id="shprd001",
|
||||||
|
name="Shared Product",
|
||||||
|
)
|
||||||
|
_ = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=shared_product.id,
|
||||||
|
is_household_shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_state["current_user"] = _current_user(member_id, household_id=household.id)
|
||||||
|
response = client.get("/products/summary")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()
|
||||||
|
shared_item = next(item for item in items if item["id"] == str(shared_product.id))
|
||||||
|
assert shared_item["is_owned"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_product_visible_filters_private_inventory_rows(auth_client, session):
|
||||||
|
client, auth_state = auth_client
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
member_id = uuid4()
|
||||||
|
household = Household()
|
||||||
|
session.add(household)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(household)
|
||||||
|
_create_membership(session, owner_id, household.id)
|
||||||
|
_create_membership(session, member_id, household.id)
|
||||||
|
|
||||||
|
product = _create_product(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
short_id="shprd002",
|
||||||
|
name="Shared Inventory Product",
|
||||||
|
)
|
||||||
|
shared_row = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=product.id,
|
||||||
|
is_household_shared=True,
|
||||||
|
)
|
||||||
|
_ = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=product.id,
|
||||||
|
is_household_shared=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_state["current_user"] = _current_user(member_id, household_id=household.id)
|
||||||
|
response = client.get(f"/products/{product.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
inventory_ids = {entry["id"] for entry in response.json()["inventory"]}
|
||||||
|
assert str(shared_row.id) in inventory_ids
|
||||||
|
assert len(inventory_ids) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_inventory_update_allows_household_member(auth_client, session):
|
||||||
|
client, auth_state = auth_client
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
member_id = uuid4()
|
||||||
|
household = Household()
|
||||||
|
session.add(household)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(household)
|
||||||
|
_create_membership(session, owner_id, household.id)
|
||||||
|
_create_membership(session, member_id, household.id)
|
||||||
|
|
||||||
|
product = _create_product(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
short_id="shprd003",
|
||||||
|
name="Shared Update Product",
|
||||||
|
)
|
||||||
|
inventory = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=product.id,
|
||||||
|
is_household_shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_state["current_user"] = _current_user(member_id, household_id=household.id)
|
||||||
|
response = client.patch(
|
||||||
|
f"/inventory/{inventory.id}",
|
||||||
|
json={"is_opened": True, "remaining_level": "low"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["is_opened"] is True
|
||||||
|
assert response.json()["remaining_level"] == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_household_member_cannot_edit_shared_product(auth_client, session):
|
||||||
|
client, auth_state = auth_client
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
member_id = uuid4()
|
||||||
|
household = Household()
|
||||||
|
session.add(household)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(household)
|
||||||
|
_create_membership(session, owner_id, household.id)
|
||||||
|
_create_membership(session, member_id, household.id)
|
||||||
|
|
||||||
|
product = _create_product(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
short_id="shprd004",
|
||||||
|
name="Shared No Edit",
|
||||||
|
)
|
||||||
|
_ = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=product.id,
|
||||||
|
is_household_shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_state["current_user"] = _current_user(member_id, household_id=household.id)
|
||||||
|
response = client.patch(f"/products/{product.id}", json={"name": "Intrusion"})
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_household_member_cannot_delete_shared_product(auth_client, session):
|
||||||
|
client, auth_state = auth_client
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
member_id = uuid4()
|
||||||
|
household = Household()
|
||||||
|
session.add(household)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(household)
|
||||||
|
_create_membership(session, owner_id, household.id)
|
||||||
|
_create_membership(session, member_id, household.id)
|
||||||
|
|
||||||
|
product = _create_product(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
short_id="shprd005",
|
||||||
|
name="Shared No Delete",
|
||||||
|
)
|
||||||
|
_ = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=product.id,
|
||||||
|
is_household_shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_state["current_user"] = _current_user(member_id, household_id=household.id)
|
||||||
|
response = client.delete(f"/products/{product.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_household_member_cannot_create_or_delete_inventory_on_shared_product(
|
||||||
|
auth_client, session
|
||||||
|
):
|
||||||
|
client, auth_state = auth_client
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
member_id = uuid4()
|
||||||
|
household = Household()
|
||||||
|
session.add(household)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(household)
|
||||||
|
_create_membership(session, owner_id, household.id)
|
||||||
|
_create_membership(session, member_id, household.id)
|
||||||
|
|
||||||
|
product = _create_product(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
short_id="shprd006",
|
||||||
|
name="Shared Inventory Restrictions",
|
||||||
|
)
|
||||||
|
inventory = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=product.id,
|
||||||
|
is_household_shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_state["current_user"] = _current_user(member_id, household_id=household.id)
|
||||||
|
create_response = client.post(f"/products/{product.id}/inventory", json={})
|
||||||
|
delete_response = client.delete(f"/inventory/{inventory.id}")
|
||||||
|
|
||||||
|
assert create_response.status_code == 404
|
||||||
|
assert delete_response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_household_member_cannot_update_non_shared_inventory(auth_client, session):
|
||||||
|
client, auth_state = auth_client
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
member_id = uuid4()
|
||||||
|
household = Household()
|
||||||
|
session.add(household)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(household)
|
||||||
|
_create_membership(session, owner_id, household.id)
|
||||||
|
_create_membership(session, member_id, household.id)
|
||||||
|
|
||||||
|
product = _create_product(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
short_id="shprd007",
|
||||||
|
name="Private Inventory",
|
||||||
|
)
|
||||||
|
inventory = _create_inventory(
|
||||||
|
session,
|
||||||
|
user_id=owner_id,
|
||||||
|
product_id=product.id,
|
||||||
|
is_household_shared=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_state["current_user"] = _current_user(member_id, household_id=household.id)
|
||||||
|
response = client.patch(f"/inventory/{inventory.id}", json={"is_opened": True})
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
Loading…
Add table
Add a link
Reference in a new issue