refactor: split table models into Base/Table/Public for proper FastAPI serialization
Add ProductBase, ProductPublic, ProductWithInventory and SkinConditionSnapshotBase, SkinConditionSnapshotPublic. Table models now inherit from their Base counterpart and override JSON fields with sa_column. All field_serializer hacks removed; FastAPI response models use the non-table Public classes so Pydantic coerces raw DB dicts → typed models cleanly. ProductCreate and SnapshotCreate now simply inherit their respective Base classes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
479be25112
commit
c09acc7c81
15 changed files with 225 additions and 198 deletions
|
|
@ -3,7 +3,7 @@ from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from sqlmodel import Session, SQLModel, col, select
|
from sqlmodel import Session, SQLModel, col, select
|
||||||
|
|
||||||
|
|
@ -72,8 +72,11 @@ class LabResultCreate(SQLModel):
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_test_code_format(cls, v: str) -> str:
|
def check_test_code_format(cls, v: str) -> str:
|
||||||
if not re.fullmatch(r"\d+-\d", v):
|
if not re.fullmatch(r"\d+-\d", v):
|
||||||
raise ValueError("test_code must match LOINC format: digits-digit (e.g. 718-7)")
|
raise ValueError(
|
||||||
|
"test_code must match LOINC format: digits-digit (e.g. 718-7)"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
test_name_original: Optional[str] = None
|
test_name_original: Optional[str] = None
|
||||||
test_name_loinc: Optional[str] = None
|
test_name_loinc: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -142,9 +145,7 @@ def list_medications(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/medications", response_model=MedicationEntry, status_code=201)
|
@router.post("/medications", response_model=MedicationEntry, status_code=201)
|
||||||
def create_medication(
|
def create_medication(data: MedicationCreate, session: Session = Depends(get_session)):
|
||||||
data: MedicationCreate, session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
entry = MedicationEntry(record_id=uuid4(), **data.model_dump())
|
entry = MedicationEntry(record_id=uuid4(), **data.model_dump())
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -274,9 +275,7 @@ def list_lab_results(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lab-results", response_model=LabResult, status_code=201)
|
@router.post("/lab-results", response_model=LabResult, status_code=201)
|
||||||
def create_lab_result(
|
def create_lab_result(data: LabResultCreate, session: Session = Depends(get_session)):
|
||||||
data: LabResultCreate, session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
result = LabResult(record_id=uuid4(), **data.model_dump())
|
result = LabResult(record_id=uuid4(), **data.model_dump())
|
||||||
session.add(result)
|
session.add(result)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from db import get_session
|
from db import get_session
|
||||||
|
from innercontext.api.products import InventoryUpdate
|
||||||
from innercontext.api.utils import get_or_404
|
from innercontext.api.utils import get_or_404
|
||||||
from innercontext.models import ProductInventory
|
from innercontext.models import ProductInventory
|
||||||
from innercontext.api.products import InventoryUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,33 @@ from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, 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.api.utils import get_or_404
|
||||||
from innercontext.models import (
|
from innercontext.models import (
|
||||||
Product,
|
Product,
|
||||||
|
ProductBase,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
ProductInventory,
|
ProductInventory,
|
||||||
|
ProductPublic,
|
||||||
|
ProductWithInventory,
|
||||||
SkinConcern,
|
SkinConcern,
|
||||||
)
|
)
|
||||||
|
from innercontext.models.enums import (
|
||||||
|
AbsorptionSpeed,
|
||||||
|
DayTime,
|
||||||
|
PriceTier,
|
||||||
|
SkinType,
|
||||||
|
TextureType,
|
||||||
|
)
|
||||||
from innercontext.models.product import (
|
from innercontext.models.product import (
|
||||||
ActiveIngredient,
|
ActiveIngredient,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInteraction,
|
ProductInteraction,
|
||||||
)
|
)
|
||||||
from innercontext.models.enums import (
|
|
||||||
AbsorptionSpeed,
|
|
||||||
DayTime,
|
|
||||||
PriceTier,
|
|
||||||
TextureType,
|
|
||||||
SkinType,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -35,57 +38,8 @@ router = APIRouter()
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(SQLModel):
|
class ProductCreate(ProductBase):
|
||||||
name: str
|
pass
|
||||||
brand: str
|
|
||||||
line_name: Optional[str] = None
|
|
||||||
sku: Optional[str] = None
|
|
||||||
url: Optional[str] = None
|
|
||||||
barcode: Optional[str] = None
|
|
||||||
|
|
||||||
category: ProductCategory
|
|
||||||
recommended_time: DayTime
|
|
||||||
|
|
||||||
texture: Optional[TextureType] = None
|
|
||||||
absorption_speed: Optional[AbsorptionSpeed] = None
|
|
||||||
leave_on: bool
|
|
||||||
|
|
||||||
price_tier: Optional[PriceTier] = None
|
|
||||||
size_ml: Optional[float] = None
|
|
||||||
pao_months: Optional[int] = None
|
|
||||||
|
|
||||||
inci: list[str] = []
|
|
||||||
actives: Optional[list[ActiveIngredient]] = None
|
|
||||||
|
|
||||||
recommended_for: list[SkinType] = []
|
|
||||||
|
|
||||||
targets: list[SkinConcern] = []
|
|
||||||
contraindications: list[str] = []
|
|
||||||
usage_notes: Optional[str] = None
|
|
||||||
|
|
||||||
fragrance_free: Optional[bool] = None
|
|
||||||
essential_oils_free: Optional[bool] = None
|
|
||||||
alcohol_denat_free: Optional[bool] = None
|
|
||||||
pregnancy_safe: Optional[bool] = None
|
|
||||||
|
|
||||||
product_effect_profile: ProductEffectProfile = ProductEffectProfile()
|
|
||||||
|
|
||||||
ph_min: Optional[float] = None
|
|
||||||
ph_max: Optional[float] = None
|
|
||||||
|
|
||||||
incompatible_with: Optional[list[ProductInteraction]] = None
|
|
||||||
synergizes_with: Optional[list[str]] = None
|
|
||||||
context_rules: Optional[ProductContext] = None
|
|
||||||
|
|
||||||
min_interval_hours: Optional[int] = None
|
|
||||||
max_frequency_per_week: Optional[int] = None
|
|
||||||
|
|
||||||
is_medication: bool = False
|
|
||||||
is_tool: bool = False
|
|
||||||
needle_length_mm: Optional[float] = None
|
|
||||||
|
|
||||||
personal_tolerance_notes: Optional[str] = None
|
|
||||||
personal_repurchase_intent: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProductUpdate(SQLModel):
|
class ProductUpdate(SQLModel):
|
||||||
|
|
@ -159,17 +113,12 @@ class InventoryUpdate(SQLModel):
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Product routes
|
# Product routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[Product])
|
@router.get("", response_model=list[ProductPublic])
|
||||||
def list_products(
|
def list_products(
|
||||||
category: Optional[ProductCategory] = None,
|
category: Optional[ProductCategory] = None,
|
||||||
brand: Optional[str] = None,
|
brand: Optional[str] = None,
|
||||||
|
|
@ -205,7 +154,7 @@ def list_products(
|
||||||
return products
|
return products
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=Product, 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)):
|
||||||
product = Product(
|
product = Product(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
|
|
@ -217,16 +166,18 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
|
||||||
return product
|
return product
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}")
|
@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)):
|
||||||
product = get_or_404(session, Product, product_id)
|
product = get_or_404(session, Product, product_id)
|
||||||
inventory = session.exec(select(ProductInventory).where(ProductInventory.product_id == product_id)).all()
|
inventory = session.exec(
|
||||||
data = product.model_dump(mode="json")
|
select(ProductInventory).where(ProductInventory.product_id == product_id)
|
||||||
data["inventory"] = [item.model_dump(mode="json") for item in inventory]
|
).all()
|
||||||
return data
|
result = ProductWithInventory.model_validate(product, from_attributes=True)
|
||||||
|
result.inventory = list(inventory)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{product_id}", response_model=Product)
|
@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)
|
||||||
):
|
):
|
||||||
|
|
@ -252,9 +203,7 @@ 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(
|
def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)):
|
||||||
product_id: UUID, session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
get_or_404(session, Product, product_id)
|
get_or_404(session, Product, product_id)
|
||||||
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
|
stmt = select(ProductInventory).where(ProductInventory.product_id == product_id)
|
||||||
return session.exec(stmt).all()
|
return session.exec(stmt).all()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlmodel import Session, SQLModel, select
|
from sqlmodel import Session, SQLModel, select
|
||||||
|
|
||||||
from db import get_session
|
from db import get_session
|
||||||
|
|
@ -105,7 +105,9 @@ def list_grooming_schedule(session: Session = Depends(get_session)):
|
||||||
@router.get("/{routine_id}")
|
@router.get("/{routine_id}")
|
||||||
def get_routine(routine_id: UUID, session: Session = Depends(get_session)):
|
def get_routine(routine_id: UUID, session: Session = Depends(get_session)):
|
||||||
routine = get_or_404(session, Routine, routine_id)
|
routine = get_or_404(session, Routine, routine_id)
|
||||||
steps = session.exec(select(RoutineStep).where(RoutineStep.routine_id == routine_id)).all()
|
steps = session.exec(
|
||||||
|
select(RoutineStep).where(RoutineStep.routine_id == routine_id)
|
||||||
|
).all()
|
||||||
data = routine.model_dump(mode="json")
|
data = routine.model_dump(mode="json")
|
||||||
data["steps"] = [step.model_dump(mode="json") for step in steps]
|
data["steps"] = [step.model_dump(mode="json") for step in steps]
|
||||||
return data
|
return data
|
||||||
|
|
@ -206,9 +208,7 @@ def update_grooming_schedule(
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/grooming-schedule/{entry_id}", status_code=204)
|
@router.delete("/grooming-schedule/{entry_id}", status_code=204)
|
||||||
def delete_grooming_schedule(
|
def delete_grooming_schedule(entry_id: UUID, session: Session = Depends(get_session)):
|
||||||
entry_id: UUID, session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
entry = get_or_404(session, GroomingSchedule, entry_id)
|
entry = get_or_404(session, GroomingSchedule, entry_id)
|
||||||
session.delete(entry)
|
session.delete(entry)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,16 @@ from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
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.api.utils import get_or_404
|
||||||
from innercontext.models import SkinConditionSnapshot
|
from innercontext.models import (
|
||||||
|
SkinConditionSnapshot,
|
||||||
|
SkinConditionSnapshotBase,
|
||||||
|
SkinConditionSnapshotPublic,
|
||||||
|
)
|
||||||
from innercontext.models.enums import (
|
from innercontext.models.enums import (
|
||||||
BarrierState,
|
BarrierState,
|
||||||
OverallSkinState,
|
OverallSkinState,
|
||||||
|
|
@ -24,23 +28,8 @@ router = APIRouter()
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class SnapshotCreate(SQLModel):
|
class SnapshotCreate(SkinConditionSnapshotBase):
|
||||||
snapshot_date: date
|
pass
|
||||||
overall_state: Optional[OverallSkinState] = None
|
|
||||||
trend: Optional[SkinTrend] = None
|
|
||||||
skin_type: Optional[SkinType] = None
|
|
||||||
|
|
||||||
hydration_level: Optional[int] = None
|
|
||||||
sebum_tzone: Optional[int] = None
|
|
||||||
sebum_cheeks: Optional[int] = None
|
|
||||||
sensitivity_level: Optional[int] = None
|
|
||||||
|
|
||||||
barrier_state: Optional[BarrierState] = None
|
|
||||||
|
|
||||||
active_concerns: list[SkinConcern] = []
|
|
||||||
risks: list[str] = []
|
|
||||||
priorities: list[str] = []
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SnapshotUpdate(SQLModel):
|
class SnapshotUpdate(SQLModel):
|
||||||
|
|
@ -62,17 +51,12 @@ class SnapshotUpdate(SQLModel):
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SkinConditionSnapshot])
|
@router.get("", response_model=list[SkinConditionSnapshotPublic])
|
||||||
def list_snapshots(
|
def list_snapshots(
|
||||||
from_date: Optional[date] = None,
|
from_date: Optional[date] = None,
|
||||||
to_date: Optional[date] = None,
|
to_date: Optional[date] = None,
|
||||||
|
|
@ -89,10 +73,8 @@ def list_snapshots(
|
||||||
return session.exec(stmt).all()
|
return session.exec(stmt).all()
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SkinConditionSnapshot, status_code=201)
|
@router.post("", response_model=SkinConditionSnapshotPublic, status_code=201)
|
||||||
def create_snapshot(
|
def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session)):
|
||||||
data: SnapshotCreate, session: Session = Depends(get_session)
|
|
||||||
):
|
|
||||||
snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump())
|
snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump())
|
||||||
session.add(snapshot)
|
session.add(snapshot)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -100,12 +82,12 @@ def create_snapshot(
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{snapshot_id}", response_model=SkinConditionSnapshot)
|
@router.get("/{snapshot_id}", response_model=SkinConditionSnapshotPublic)
|
||||||
def get_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)):
|
def get_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)):
|
||||||
return get_or_404(session, SkinConditionSnapshot, snapshot_id)
|
return get_or_404(session, SkinConditionSnapshot, snapshot_id)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{snapshot_id}", response_model=SkinConditionSnapshot)
|
@router.patch("/{snapshot_id}", response_model=SkinConditionSnapshotPublic)
|
||||||
def update_snapshot(
|
def update_snapshot(
|
||||||
snapshot_id: UUID,
|
snapshot_id: UUID,
|
||||||
data: SnapshotUpdate,
|
data: SnapshotUpdate,
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,20 @@ from .health import LabResult, MedicationEntry, MedicationUsage
|
||||||
from .product import (
|
from .product import (
|
||||||
ActiveIngredient,
|
ActiveIngredient,
|
||||||
Product,
|
Product,
|
||||||
|
ProductBase,
|
||||||
ProductContext,
|
ProductContext,
|
||||||
ProductEffectProfile,
|
ProductEffectProfile,
|
||||||
ProductInteraction,
|
ProductInteraction,
|
||||||
ProductInventory,
|
ProductInventory,
|
||||||
|
ProductPublic,
|
||||||
|
ProductWithInventory,
|
||||||
)
|
)
|
||||||
from .routine import GroomingSchedule, Routine, RoutineStep
|
from .routine import GroomingSchedule, Routine, RoutineStep
|
||||||
from .skincare import SkinConditionSnapshot
|
from .skincare import (
|
||||||
|
SkinConditionSnapshot,
|
||||||
|
SkinConditionSnapshotBase,
|
||||||
|
SkinConditionSnapshotPublic,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# domain
|
# domain
|
||||||
|
|
@ -64,14 +71,19 @@ __all__ = [
|
||||||
# product
|
# product
|
||||||
"ActiveIngredient",
|
"ActiveIngredient",
|
||||||
"Product",
|
"Product",
|
||||||
|
"ProductBase",
|
||||||
"ProductContext",
|
"ProductContext",
|
||||||
"ProductEffectProfile",
|
"ProductEffectProfile",
|
||||||
"ProductInteraction",
|
"ProductInteraction",
|
||||||
"ProductInventory",
|
"ProductInventory",
|
||||||
|
"ProductPublic",
|
||||||
|
"ProductWithInventory",
|
||||||
# routine
|
# routine
|
||||||
"GroomingSchedule",
|
"GroomingSchedule",
|
||||||
"Routine",
|
"Routine",
|
||||||
"RoutineStep",
|
"RoutineStep",
|
||||||
# skincare
|
# skincare
|
||||||
"SkinConditionSnapshot",
|
"SkinConditionSnapshot",
|
||||||
|
"SkinConditionSnapshotBase",
|
||||||
|
"SkinConditionSnapshotPublic",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared / Product
|
# Shared / Product
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ from .enums import (
|
||||||
TextureType,
|
TextureType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Value objects
|
# Value objects
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -83,16 +82,11 @@ def _ev(v: object) -> str:
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Table models
|
# Base model (pure Python types, no sa_column, no id/timestamps)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class Product(SQLModel, table=True):
|
class ProductBase(SQLModel):
|
||||||
__tablename__ = "products"
|
|
||||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
brand: str
|
brand: str
|
||||||
line_name: str | None = Field(default=None, max_length=128)
|
line_name: str | None = Field(default=None, max_length=128)
|
||||||
|
|
@ -107,10 +101,61 @@ class Product(SQLModel, table=True):
|
||||||
absorption_speed: AbsorptionSpeed | None = None
|
absorption_speed: AbsorptionSpeed | None = None
|
||||||
leave_on: bool
|
leave_on: bool
|
||||||
|
|
||||||
price_tier: PriceTier | None = Field(default=None, index=True)
|
price_tier: PriceTier | None = None
|
||||||
size_ml: float | None = Field(default=None, gt=0)
|
size_ml: float | None = Field(default=None, gt=0)
|
||||||
pao_months: int | None = Field(default=None, ge=1, le=60)
|
pao_months: int | None = Field(default=None, ge=1, le=60)
|
||||||
|
|
||||||
|
inci: list[str] = Field(default_factory=list)
|
||||||
|
actives: list[ActiveIngredient] | None = None
|
||||||
|
|
||||||
|
recommended_for: list[SkinType] = Field(default_factory=list)
|
||||||
|
|
||||||
|
targets: list[SkinConcern] = Field(default_factory=list)
|
||||||
|
contraindications: list[str] = Field(default_factory=list)
|
||||||
|
usage_notes: str | None = None
|
||||||
|
|
||||||
|
fragrance_free: bool | None = None
|
||||||
|
essential_oils_free: bool | None = None
|
||||||
|
alcohol_denat_free: bool | None = None
|
||||||
|
pregnancy_safe: bool | None = None
|
||||||
|
|
||||||
|
product_effect_profile: ProductEffectProfile = Field(
|
||||||
|
default_factory=ProductEffectProfile
|
||||||
|
)
|
||||||
|
|
||||||
|
ph_min: float | None = Field(default=None, ge=0, le=14)
|
||||||
|
ph_max: float | None = Field(default=None, ge=0, le=14)
|
||||||
|
|
||||||
|
incompatible_with: list[ProductInteraction] | None = None
|
||||||
|
synergizes_with: list[str] | None = None
|
||||||
|
context_rules: ProductContext | None = None
|
||||||
|
|
||||||
|
min_interval_hours: int | None = Field(default=None, ge=0)
|
||||||
|
max_frequency_per_week: int | None = Field(default=None, ge=1, le=14)
|
||||||
|
|
||||||
|
is_medication: bool = Field(default=False)
|
||||||
|
is_tool: bool = Field(default=False)
|
||||||
|
needle_length_mm: float | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
personal_tolerance_notes: str | None = None
|
||||||
|
personal_repurchase_intent: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Table models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Product(ProductBase, table=True):
|
||||||
|
__tablename__ = "products"
|
||||||
|
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
|
||||||
|
# Override: add index for table context
|
||||||
|
price_tier: PriceTier | None = Field(default=None, index=True)
|
||||||
|
|
||||||
|
# Override 9 JSON fields with sa_column (only in table model)
|
||||||
inci: list[str] = Field(
|
inci: list[str] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
)
|
)
|
||||||
|
|
@ -128,21 +173,12 @@ class Product(SQLModel, table=True):
|
||||||
contraindications: list[str] = Field(
|
contraindications: list[str] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
)
|
)
|
||||||
usage_notes: str | None = None
|
|
||||||
|
|
||||||
fragrance_free: bool | None = None
|
|
||||||
essential_oils_free: bool | None = None
|
|
||||||
alcohol_denat_free: bool | None = None
|
|
||||||
pregnancy_safe: bool | None = None
|
|
||||||
|
|
||||||
product_effect_profile: ProductEffectProfile = Field(
|
product_effect_profile: ProductEffectProfile = Field(
|
||||||
default_factory=ProductEffectProfile,
|
default_factory=ProductEffectProfile,
|
||||||
sa_column=Column(JSON, nullable=False),
|
sa_column=Column(JSON, nullable=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
ph_min: float | None = Field(default=None, ge=0, le=14)
|
|
||||||
ph_max: float | None = Field(default=None, ge=0, le=14)
|
|
||||||
|
|
||||||
incompatible_with: list[ProductInteraction] | None = Field(
|
incompatible_with: list[ProductInteraction] | None = Field(
|
||||||
default=None, sa_column=Column(JSON, nullable=True)
|
default=None, sa_column=Column(JSON, nullable=True)
|
||||||
)
|
)
|
||||||
|
|
@ -153,16 +189,6 @@ class Product(SQLModel, table=True):
|
||||||
default=None, sa_column=Column(JSON, nullable=True)
|
default=None, sa_column=Column(JSON, nullable=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
min_interval_hours: int | None = Field(default=None, ge=0)
|
|
||||||
max_frequency_per_week: int | None = Field(default=None, ge=1, le=14)
|
|
||||||
|
|
||||||
is_medication: bool = Field(default=False)
|
|
||||||
is_tool: bool = Field(default=False)
|
|
||||||
needle_length_mm: float | None = Field(default=None, gt=0)
|
|
||||||
|
|
||||||
personal_tolerance_notes: str | None = None
|
|
||||||
personal_repurchase_intent: bool | None = None
|
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=utc_now,
|
default_factory=utc_now,
|
||||||
|
|
@ -264,7 +290,6 @@ class Product(SQLModel, table=True):
|
||||||
ctx["ph_max"] = self.ph_max
|
ctx["ph_max"] = self.ph_max
|
||||||
|
|
||||||
ep = self.product_effect_profile
|
ep = self.product_effect_profile
|
||||||
if ep is not None:
|
|
||||||
if isinstance(ep, dict):
|
if isinstance(ep, dict):
|
||||||
nonzero = {k: v for k, v in ep.items() if v >= 2}
|
nonzero = {k: v for k, v in ep.items() if v >= 2}
|
||||||
else:
|
else:
|
||||||
|
|
@ -307,7 +332,12 @@ class Product(SQLModel, table=True):
|
||||||
ctx["max_frequency_per_week"] = self.max_frequency_per_week
|
ctx["max_frequency_per_week"] = self.max_frequency_per_week
|
||||||
|
|
||||||
safety = {}
|
safety = {}
|
||||||
for flag in ("fragrance_free", "essential_oils_free", "alcohol_denat_free", "pregnancy_safe"):
|
for flag in (
|
||||||
|
"fragrance_free",
|
||||||
|
"essential_oils_free",
|
||||||
|
"alcohol_denat_free",
|
||||||
|
"pregnancy_safe",
|
||||||
|
):
|
||||||
val = getattr(self, flag)
|
val = getattr(self, flag)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
safety[flag] = val
|
safety[flag] = val
|
||||||
|
|
@ -358,3 +388,18 @@ class ProductInventory(SQLModel, table=True):
|
||||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||||
|
|
||||||
product: Optional["Product"] = Relationship(back_populates="inventory")
|
product: Optional["Product"] = Relationship(back_populates="inventory")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public response models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProductPublic(ProductBase):
|
||||||
|
id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProductWithInventory(ProductPublic):
|
||||||
|
inventory: list[ProductInventory] = []
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,13 @@ from .base import utc_now
|
||||||
from .domain import Domain
|
from .domain import Domain
|
||||||
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTrend, SkinType
|
from .enums import BarrierState, OverallSkinState, SkinConcern, SkinTrend, SkinType
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base model (pure Python types, no sa_column, no id/created_at)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class SkinConditionSnapshot(SQLModel, table=True):
|
|
||||||
"""
|
|
||||||
Tygodniowy snapshot kondycji skóry wypełniany przez LLM na podstawie zdjęć
|
|
||||||
i kontekstu rutyny. Wszystkie metryki numeryczne w skali 1–5.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "skin_condition_snapshots"
|
class SkinConditionSnapshotBase(SQLModel):
|
||||||
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
snapshot_date: date
|
||||||
__table_args__ = (UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
snapshot_date: date = Field(index=True)
|
|
||||||
|
|
||||||
overall_state: OverallSkinState | None = None
|
overall_state: OverallSkinState | None = None
|
||||||
trend: SkinTrend | None = None
|
trend: SkinTrend | None = None
|
||||||
|
|
@ -38,17 +32,53 @@ class SkinConditionSnapshot(SQLModel, table=True):
|
||||||
barrier_state: BarrierState | None = None
|
barrier_state: BarrierState | None = None
|
||||||
|
|
||||||
# Aktywne troski — podzbiór SkinConcern widoczny na zdjęciach lub wynikający z rutyny
|
# Aktywne troski — podzbiór SkinConcern widoczny na zdjęciach lub wynikający z rutyny
|
||||||
|
active_concerns: list[SkinConcern] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Wolny tekst — LLM wypełnia na podstawie analizy
|
||||||
|
risks: list[str] = Field(default_factory=list)
|
||||||
|
priorities: list[str] = Field(default_factory=list)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Table model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SkinConditionSnapshot(SkinConditionSnapshotBase, table=True):
|
||||||
|
"""
|
||||||
|
Tygodniowy snapshot kondycji skóry wypełniany przez LLM na podstawie zdjęć
|
||||||
|
i kontekstu rutyny. Wszystkie metryki numeryczne w skali 1–5.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "skin_condition_snapshots"
|
||||||
|
__domains__: ClassVar[frozenset[Domain]] = frozenset({Domain.SKINCARE})
|
||||||
|
__table_args__ = (UniqueConstraint("snapshot_date", name="uq_skin_snapshot_date"),)
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
|
||||||
|
# Override: add index for table context
|
||||||
|
snapshot_date: date = Field(index=True)
|
||||||
|
|
||||||
|
# Override 3 JSON fields with sa_column (only in table model)
|
||||||
active_concerns: list[SkinConcern] = Field(
|
active_concerns: list[SkinConcern] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wolny tekst — LLM wypełnia na podstawie analizy
|
|
||||||
risks: list[str] = Field(
|
risks: list[str] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
)
|
)
|
||||||
priorities: list[str] = Field(
|
priorities: list[str] = Field(
|
||||||
default_factory=list, sa_column=Column(JSON, nullable=False)
|
default_factory=list, sa_column=Column(JSON, nullable=False)
|
||||||
)
|
)
|
||||||
notes: str | None = None
|
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
created_at: datetime = Field(default_factory=utc_now, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public response model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SkinConditionSnapshotPublic(SkinConditionSnapshotBase):
|
||||||
|
id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv() # load .env before db.py reads DATABASE_URL
|
load_dotenv() # load .env before db.py reads DATABASE_URL
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI # noqa: E402
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||||
|
|
||||||
from db import create_db_and_tables
|
from db import create_db_and_tables # noqa: E402
|
||||||
from innercontext.api import health, inventory, products, routines, skincare
|
from innercontext.api import health, inventory, products, routines, skincare # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Medications
|
# Medications
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -32,9 +31,7 @@ def test_list_filter_kind(client):
|
||||||
client.post(
|
client.post(
|
||||||
"/health/medications", json={"kind": "prescription", "product_name": "A"}
|
"/health/medications", json={"kind": "prescription", "product_name": "A"}
|
||||||
)
|
)
|
||||||
client.post(
|
client.post("/health/medications", json={"kind": "supplement", "product_name": "B"})
|
||||||
"/health/medications", json={"kind": "supplement", "product_name": "B"}
|
|
||||||
)
|
|
||||||
r = client.get("/health/medications?kind=supplement")
|
r = client.get("/health/medications?kind=supplement")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
@ -46,9 +43,7 @@ def test_list_filter_product_name(client):
|
||||||
client.post(
|
client.post(
|
||||||
"/health/medications", json={"kind": "otc", "product_name": "Epiduo Forte"}
|
"/health/medications", json={"kind": "otc", "product_name": "Epiduo Forte"}
|
||||||
)
|
)
|
||||||
client.post(
|
client.post("/health/medications", json={"kind": "otc", "product_name": "Panoxyl"})
|
||||||
"/health/medications", json={"kind": "otc", "product_name": "Panoxyl"}
|
|
||||||
)
|
|
||||||
r = client.get("/health/medications?product_name=epid")
|
r = client.get("/health/medications?product_name=epid")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
@ -113,7 +108,11 @@ def test_create_usage(client, created_medication):
|
||||||
mid = created_medication["record_id"]
|
mid = created_medication["record_id"]
|
||||||
r = client.post(
|
r = client.post(
|
||||||
f"/health/medications/{mid}/usages",
|
f"/health/medications/{mid}/usages",
|
||||||
json={"valid_from": "2026-01-01T08:00:00", "dose_value": 1.0, "dose_unit": "pea"},
|
json={
|
||||||
|
"valid_from": "2026-01-01T08:00:00",
|
||||||
|
"dose_value": 1.0,
|
||||||
|
"dose_unit": "pea",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
@ -156,7 +155,9 @@ def test_update_usage(client, created_medication):
|
||||||
)
|
)
|
||||||
uid = r.json()["record_id"]
|
uid = r.json()["record_id"]
|
||||||
|
|
||||||
r2 = client.patch(f"/health/usages/{uid}", json={"dose_value": 2.5, "dose_unit": "mg"})
|
r2 = client.patch(
|
||||||
|
f"/health/usages/{uid}", json={"dose_value": 2.5, "dose_unit": "mg"}
|
||||||
|
)
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r2.json()["dose_value"] == 2.5
|
assert r2.json()["dose_value"] == 2.5
|
||||||
assert r2.json()["dose_unit"] == "mg"
|
assert r2.json()["dose_unit"] == "mg"
|
||||||
|
|
@ -247,8 +248,14 @@ def test_list_filter_flag(client):
|
||||||
|
|
||||||
|
|
||||||
def test_list_filter_date_range(client):
|
def test_list_filter_date_range(client):
|
||||||
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "collected_at": "2026-01-01T00:00:00"})
|
client.post(
|
||||||
client.post("/health/lab-results", json={**LAB_RESULT_DATA, "collected_at": "2026-06-01T00:00:00"})
|
"/health/lab-results",
|
||||||
|
json={**LAB_RESULT_DATA, "collected_at": "2026-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/health/lab-results",
|
||||||
|
json={**LAB_RESULT_DATA, "collected_at": "2026-06-01T00:00:00"},
|
||||||
|
)
|
||||||
r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00")
|
r = client.get("/health/lab-results?from_date=2026-05-01T00:00:00")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
@ -271,7 +278,9 @@ def test_get_lab_result_not_found(client):
|
||||||
def test_update_lab_result(client):
|
def test_update_lab_result(client):
|
||||||
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
|
r = client.post("/health/lab-results", json=LAB_RESULT_DATA)
|
||||||
rid = r.json()["record_id"]
|
rid = r.json()["record_id"]
|
||||||
r2 = client.patch(f"/health/lab-results/{rid}", json={"notes": "Recheck in 3 months"})
|
r2 = client.patch(
|
||||||
|
f"/health/lab-results/{rid}", json={"notes": "Recheck in 3 months"}
|
||||||
|
)
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r2.json()["notes"] == "Recheck in 3 months"
|
assert r2.json()["notes"] == "Recheck in 3 months"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
"""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 typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
import pytest
|
|
||||||
|
|
||||||
from innercontext.models import Product
|
from innercontext.models import Product
|
||||||
from innercontext.models.enums import (
|
from innercontext.models.enums import (
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ def test_list_filter_category(client, client_and_data=None):
|
||||||
"recommended_time": "both",
|
"recommended_time": "both",
|
||||||
"leave_on": True,
|
"leave_on": True,
|
||||||
}
|
}
|
||||||
r1 = client.post("/products", json={**base, "name": "Moist", "category": "moisturizer"})
|
r1 = client.post(
|
||||||
|
"/products", json={**base, "name": "Moist", "category": "moisturizer"}
|
||||||
|
)
|
||||||
r2 = client.post("/products", json={**base, "name": "Ser", "category": "serum"})
|
r2 = client.post("/products", json={**base, "name": "Ser", "category": "serum"})
|
||||||
assert r1.status_code == 201
|
assert r1.status_code == 201
|
||||||
assert r2.status_code == 201
|
assert r2.status_code == 201
|
||||||
|
|
@ -96,7 +98,12 @@ def test_list_filter_is_medication(client):
|
||||||
# is_medication=True requires usage_notes (model validator)
|
# is_medication=True requires usage_notes (model validator)
|
||||||
client.post(
|
client.post(
|
||||||
"/products",
|
"/products",
|
||||||
json={**base, "name": "Med", "is_medication": True, "usage_notes": "Apply pea-sized amount"},
|
json={
|
||||||
|
**base,
|
||||||
|
"name": "Med",
|
||||||
|
"is_medication": True,
|
||||||
|
"usage_notes": "Apply pea-sized amount",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
r = client.get("/products?is_medication=true")
|
r = client.get("/products?is_medication=true")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routines
|
# Routines
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,7 @@ def test_update_snapshot_concerns(client):
|
||||||
|
|
||||||
|
|
||||||
def test_update_snapshot_not_found(client):
|
def test_update_snapshot_not_found(client):
|
||||||
r = client.patch(
|
r = client.patch(f"/skincare/{uuid.uuid4()}", json={"overall_state": "good"})
|
||||||
f"/skincare/{uuid.uuid4()}", json={"overall_state": "good"}
|
|
||||||
)
|
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue