refactor: remove personal_rating, DRY get_or_404, fix ty errors

- Drop Product.personal_rating from model, API schemas, and all frontend
  views (list table, detail view, quick-edit form, new-product form)
- Extract get_or_404 into backend/innercontext/api/utils.py; remove five
  duplicate copies from individual API modules
- Fix all ty type errors: generic get_or_404 with TypeVar, cast() in
  coerce_effect_profile validator, col() for ilike on SQLModel column,
  dict[str, Any] annotation in test helper, ty: ignore for CORSMiddleware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-27 11:20:13 +01:00
parent 5e3c0c97e5
commit 6333c6678a
15 changed files with 30 additions and 81 deletions

View file

@ -5,9 +5,10 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from pydantic import field_validator
from sqlmodel import Session, SQLModel, select
from sqlmodel import Session, SQLModel, col, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import LabResult, MedicationEntry, MedicationUsage
from innercontext.models.enums import MedicationKind, ResultFlag
@ -121,13 +122,6 @@ class LabResultUpdate(SQLModel):
# ---------------------------------------------------------------------------
def get_or_404(session: Session, model, record_id) -> object:
obj = session.get(model, record_id)
if obj is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
# ---------------------------------------------------------------------------
# Medication routes
# ---------------------------------------------------------------------------
@ -143,7 +137,7 @@ def list_medications(
if kind is not None:
stmt = stmt.where(MedicationEntry.kind == kind)
if product_name is not None:
stmt = stmt.where(MedicationEntry.product_name.ilike(f"%{product_name}%"))
stmt = stmt.where(col(MedicationEntry.product_name).ilike(f"%{product_name}%"))
return session.exec(stmt).all()

View file

@ -4,19 +4,13 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import ProductInventory
from innercontext.api.products import InventoryUpdate
router = APIRouter()
def get_or_404(session: Session, model, record_id) -> object:
obj = session.get(model, record_id)
if obj is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
@router.get("/{inventory_id}", response_model=ProductInventory)
def get_inventory(inventory_id: UUID, session: Session = Depends(get_session)):
return get_or_404(session, ProductInventory, inventory_id)

View file

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import (
Product,
ProductCategory,
@ -83,7 +84,6 @@ class ProductCreate(SQLModel):
is_tool: bool = False
needle_length_mm: Optional[float] = None
personal_rating: Optional[int] = None
personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None
@ -137,7 +137,6 @@ class ProductUpdate(SQLModel):
is_tool: Optional[bool] = None
needle_length_mm: Optional[float] = None
personal_rating: Optional[int] = None
personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None
@ -165,13 +164,6 @@ class InventoryUpdate(SQLModel):
# ---------------------------------------------------------------------------
def get_or_404(session: Session, model, record_id) -> object:
obj = session.get(model, record_id)
if obj is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
# ---------------------------------------------------------------------------
# Product routes
# ---------------------------------------------------------------------------

View file

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import GroomingSchedule, Routine, RoutineStep
from innercontext.models.enums import GroomingAction, PartOfDay
@ -64,13 +65,6 @@ class GroomingScheduleUpdate(SQLModel):
# ---------------------------------------------------------------------------
def get_or_404(session: Session, model, record_id) -> object:
obj = session.get(model, record_id)
if obj is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
# ---------------------------------------------------------------------------
# Routine routes
# ---------------------------------------------------------------------------

View file

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, SQLModel, select
from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import SkinConditionSnapshot
from innercontext.models.enums import (
BarrierState,
@ -66,13 +67,6 @@ class SnapshotUpdate(SQLModel):
# ---------------------------------------------------------------------------
def get_or_404(session: Session, model, record_id) -> object:
obj = session.get(model, record_id)
if obj is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,13 @@
from typing import TypeVar
from fastapi import HTTPException
from sqlmodel import Session
_T = TypeVar("_T")
def get_or_404(session: Session, model: type[_T], record_id: object) -> _T:
obj = session.get(model, record_id)
if obj is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj

View file

@ -1,5 +1,5 @@
from datetime import date, datetime
from typing import ClassVar, Optional
from typing import Any, ClassVar, Optional, cast
from uuid import UUID, uuid4
from pydantic import field_validator, model_validator
@ -160,7 +160,6 @@ class Product(SQLModel, table=True):
is_tool: bool = Field(default=False)
needle_length_mm: float | None = Field(default=None, gt=0)
personal_rating: int | None = Field(default=None, ge=1, le=10)
personal_tolerance_notes: str | None = None
personal_repurchase_intent: bool | None = None
@ -181,7 +180,7 @@ class Product(SQLModel, table=True):
@classmethod
def coerce_effect_profile(cls, v: object) -> object:
if isinstance(v, dict):
return ProductEffectProfile(**v)
return ProductEffectProfile(**cast(dict[str, Any], v))
return v
@model_validator(mode="after")
@ -324,8 +323,6 @@ class Product(SQLModel, table=True):
if self.usage_notes:
ctx["usage_notes"] = self.usage_notes
if self.personal_rating is not None:
ctx["personal_rating"] = self.personal_rating
if self.personal_tolerance_notes:
ctx["personal_tolerance_notes"] = self.personal_tolerance_notes
if self.personal_repurchase_intent is not None:

View file

@ -20,7 +20,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="innercontext API", lifespan=lifespan, redirect_slashes=False)
app.add_middleware(
CORSMiddleware,
CORSMiddleware, # ty: ignore[invalid-argument-type]
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],

View file

@ -1,6 +1,8 @@
"""Unit tests for Product.to_llm_context() — no database required."""
from uuid import uuid4
from typing import Any
import pytest
from innercontext.models import Product
@ -18,8 +20,8 @@ from innercontext.models.product import (
)
def _make(**kwargs):
defaults = dict(
def _make(**kwargs: Any) -> Product:
defaults: dict[str, Any] = dict(
id=uuid4(),
name="Test",
brand="B",