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 fastapi import APIRouter, Depends, HTTPException
from pydantic import field_validator from pydantic import field_validator
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, col, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import LabResult, MedicationEntry, MedicationUsage from innercontext.models import LabResult, MedicationEntry, MedicationUsage
from innercontext.models.enums import MedicationKind, ResultFlag 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 # Medication routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -143,7 +137,7 @@ def list_medications(
if kind is not None: if kind is not None:
stmt = stmt.where(MedicationEntry.kind == kind) stmt = stmt.where(MedicationEntry.kind == kind)
if product_name is not None: 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() return session.exec(stmt).all()

View file

@ -4,19 +4,13 @@ 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.utils import get_or_404
from innercontext.models import ProductInventory from innercontext.models import ProductInventory
from innercontext.api.products import InventoryUpdate from innercontext.api.products import InventoryUpdate
router = APIRouter() 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) @router.get("/{inventory_id}", response_model=ProductInventory)
def get_inventory(inventory_id: UUID, session: Session = Depends(get_session)): def get_inventory(inventory_id: UUID, session: Session = Depends(get_session)):
return get_or_404(session, ProductInventory, inventory_id) 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 sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import ( from innercontext.models import (
Product, Product,
ProductCategory, ProductCategory,
@ -83,7 +84,6 @@ class ProductCreate(SQLModel):
is_tool: bool = False is_tool: bool = False
needle_length_mm: Optional[float] = None needle_length_mm: Optional[float] = None
personal_rating: Optional[int] = None
personal_tolerance_notes: Optional[str] = None personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = None personal_repurchase_intent: Optional[bool] = None
@ -137,7 +137,6 @@ class ProductUpdate(SQLModel):
is_tool: Optional[bool] = None is_tool: Optional[bool] = None
needle_length_mm: Optional[float] = None needle_length_mm: Optional[float] = None
personal_rating: Optional[int] = None
personal_tolerance_notes: Optional[str] = None personal_tolerance_notes: Optional[str] = None
personal_repurchase_intent: Optional[bool] = 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 # Product routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import GroomingSchedule, Routine, RoutineStep from innercontext.models import GroomingSchedule, Routine, RoutineStep
from innercontext.models.enums import GroomingAction, PartOfDay 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 # Routine routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, SQLModel, select from sqlmodel import Session, SQLModel, select
from db import get_session from db import get_session
from innercontext.api.utils import get_or_404
from innercontext.models import SkinConditionSnapshot from innercontext.models import SkinConditionSnapshot
from innercontext.models.enums import ( from innercontext.models.enums import (
BarrierState, 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 # 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 datetime import date, datetime
from typing import ClassVar, Optional from typing import Any, ClassVar, Optional, cast
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import field_validator, model_validator from pydantic import field_validator, model_validator
@ -160,7 +160,6 @@ class Product(SQLModel, table=True):
is_tool: bool = Field(default=False) is_tool: bool = Field(default=False)
needle_length_mm: float | None = Field(default=None, gt=0) 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_tolerance_notes: str | None = None
personal_repurchase_intent: bool | None = None personal_repurchase_intent: bool | None = None
@ -181,7 +180,7 @@ class Product(SQLModel, table=True):
@classmethod @classmethod
def coerce_effect_profile(cls, v: object) -> object: def coerce_effect_profile(cls, v: object) -> object:
if isinstance(v, dict): if isinstance(v, dict):
return ProductEffectProfile(**v) return ProductEffectProfile(**cast(dict[str, Any], v))
return v return v
@model_validator(mode="after") @model_validator(mode="after")
@ -324,8 +323,6 @@ class Product(SQLModel, table=True):
if self.usage_notes: if self.usage_notes:
ctx["usage_notes"] = 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: if self.personal_tolerance_notes:
ctx["personal_tolerance_notes"] = self.personal_tolerance_notes ctx["personal_tolerance_notes"] = self.personal_tolerance_notes
if self.personal_repurchase_intent is not None: 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 = FastAPI(title="innercontext API", lifespan=lifespan, redirect_slashes=False)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware, # ty: ignore[invalid-argument-type]
allow_origins=["*"], allow_origins=["*"],
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View file

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

View file

@ -149,7 +149,6 @@ export interface Product {
is_medication: boolean; is_medication: boolean;
is_tool: boolean; is_tool: boolean;
needle_length_mm?: number; needle_length_mm?: number;
personal_rating?: number;
personal_tolerance_notes?: string; personal_tolerance_notes?: string;
personal_repurchase_intent?: boolean; personal_repurchase_intent?: boolean;
created_at: string; created_at: string;

View file

@ -67,7 +67,6 @@
<TableHead>Category</TableHead> <TableHead>Category</TableHead>
<TableHead>Targets</TableHead> <TableHead>Targets</TableHead>
<TableHead>Time</TableHead> <TableHead>Time</TableHead>
<TableHead>Rating</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -93,17 +92,10 @@
</div> </div>
</TableCell> </TableCell>
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell> <TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
<TableCell>
{#if product.personal_rating}
{product.personal_rating}/10
{:else}
<span class="text-muted-foreground"></span>
{/if}
</TableCell>
</TableRow> </TableRow>
{:else} {:else}
<TableRow> <TableRow>
<TableCell colspan={6} class="text-center text-muted-foreground py-8"> <TableCell colspan={5} class="text-center text-muted-foreground py-8">
No products found. No products found.
</TableCell> </TableCell>
</TableRow> </TableRow>

View file

@ -19,8 +19,7 @@ export const actions: Actions = {
if (value !== '') body[key] = value; if (value !== '') body[key] = value;
} }
if ('leave_on' in body) body.leave_on = body.leave_on === 'true'; if ('leave_on' in body) body.leave_on = body.leave_on === 'true';
if ('personal_rating' in body) body.personal_rating = Number(body.personal_rating); try {
try {
const product = await updateProduct(params.id, body); const product = await updateProduct(params.id, body);
return { success: true, product }; return { success: true, product };
} catch (e) { } catch (e) {

View file

@ -48,9 +48,7 @@
{/if} {/if}
</span> </span>
<span class="text-muted-foreground">Rating</span> </div>
<span>{product.personal_rating != null ? `${product.personal_rating}/10` : '—'}</span>
</div>
{#if product.targets.length} {#if product.targets.length}
<div> <div>
<span class="text-muted-foreground">Targets: </span> <span class="text-muted-foreground">Targets: </span>
@ -80,17 +78,6 @@
<CardHeader><CardTitle>Quick edit</CardTitle></CardHeader> <CardHeader><CardTitle>Quick edit</CardTitle></CardHeader>
<CardContent> <CardContent>
<form method="POST" action="?/update" use:enhance class="grid grid-cols-2 gap-4"> <form method="POST" action="?/update" use:enhance class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label for="personal_rating">Personal rating (1-10)</Label>
<Input
id="personal_rating"
name="personal_rating"
type="number"
min="1"
max="10"
value={product.personal_rating ?? ''}
/>
</div>
<div class="space-y-1"> <div class="space-y-1">
<Label for="personal_tolerance_notes">Tolerance notes</Label> <Label for="personal_tolerance_notes">Tolerance notes</Label>
<Input <Input

View file

@ -109,9 +109,6 @@ export const actions: Actions = {
const max_frequency_per_week = parseOptionalInt(form.get('max_frequency_per_week') as string | null); const max_frequency_per_week = parseOptionalInt(form.get('max_frequency_per_week') as string | null);
if (max_frequency_per_week !== undefined) payload.max_frequency_per_week = max_frequency_per_week; if (max_frequency_per_week !== undefined) payload.max_frequency_per_week = max_frequency_per_week;
const personal_rating = parseOptionalInt(form.get('personal_rating') as string | null);
if (personal_rating !== undefined) payload.personal_rating = personal_rating;
const needle_length_mm = parseOptionalFloat(form.get('needle_length_mm') as string | null); const needle_length_mm = parseOptionalFloat(form.get('needle_length_mm') as string | null);
if (needle_length_mm !== undefined) payload.needle_length_mm = needle_length_mm; if (needle_length_mm !== undefined) payload.needle_length_mm = needle_length_mm;

View file

@ -366,11 +366,6 @@
<CardHeader><CardTitle>Personal notes</CardTitle></CardHeader> <CardHeader><CardTitle>Personal notes</CardTitle></CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="personal_rating">Personal rating (110)</Label>
<Input id="personal_rating" name="personal_rating" type="number" min="1" max="10" placeholder="e.g. 8" />
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Repurchase intent</Label> <Label>Repurchase intent</Label>
<input type="hidden" name="personal_repurchase_intent" value={personalRepurchaseIntent} /> <input type="hidden" name="personal_repurchase_intent" value={personalRepurchaseIntent} />