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:
parent
5e3c0c97e5
commit
6333c6678a
15 changed files with 30 additions and 81 deletions
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
13
backend/innercontext/api/utils.py
Normal file
13
backend/innercontext/api/utils.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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=["*"],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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 };
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,6 @@
|
||||||
—
|
—
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted-foreground">Rating</span>
|
|
||||||
<span>{product.personal_rating != null ? `${product.personal_rating}/10` : '—'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{#if product.targets.length}
|
{#if product.targets.length}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (1–10)</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} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue