Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/app/api/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ async def capabilities() -> dict[str, Any]:
},
"features": {
"external_tagging": True,
"external_suggestions": False,
"external_pairings": False,
"external_suggestions": True,
"external_pairings": True,
},
"version": "1.0.0",
}
Expand Down
80 changes: 79 additions & 1 deletion backend/app/api/outfits.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
)
from app.models.user import User
from app.schemas.item import DEFAULT_WASH_INTERVALS
from app.schemas.outfit import MAX_AUTHORING_TEXT_LENGTH, OutfitAttributeFields
from app.services.ai_service import AIDisabledError
from app.services.external_outfit_service import ExternalOutfitService
from app.services.item_service import ItemService
from app.services.learning_service import LearningService
from app.services.outfit_service import OutfitListFilters, OutfitService
Expand Down Expand Up @@ -188,6 +190,10 @@ class OutfitResponse(BaseModel):
source: str
reasoning: str | None = None
style_notes: str | None = None
season: str | None = None
formality: str | None = None
palette: list[str] | None = None
notes: str | None = None
highlights: list[str] | None = None
weather: dict | None = None
items: list[OutfitItemResponse]
Expand Down Expand Up @@ -404,6 +410,10 @@ def outfit_to_response(
source=outfit.source.value,
reasoning=outfit.reasoning,
style_notes=outfit.style_notes,
season=outfit.season,
formality=outfit.formality,
palette=outfit.palette,
notes=outfit.notes,
highlights=highlights,
weather=outfit.weather_data,
items=items,
Expand Down Expand Up @@ -488,6 +498,70 @@ async def suggest_outfit(
return outfit_to_response(outfit, wore_instead_map, is_starter_suggestion=is_starter)


class SuggestionCreateRequest(OutfitAttributeFields):
model_config = ConfigDict(extra="forbid")

items: list[UUID] = Field(min_length=1, max_length=20)
occasion: str = Field(max_length=50)
name: Annotated[str | None, Field(max_length=100)] = None
scheduled_for: date | None = Field(
default=None, description="Defaults to the user's current date"
)
reasoning: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None
style_notes: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None

@field_validator("occasion")
@classmethod
def validate_occasion(cls, v: str) -> str:
v = v.strip().lower()
if v not in VALID_OCCASIONS:
raise ValueError(
f"Invalid occasion '{v}'. Must be one of: {', '.join(sorted(VALID_OCCASIONS))}"
)
return v


@router.post("/suggestions", response_model=OutfitResponse, status_code=status.HTTP_201_CREATED)
async def create_external_suggestion(
request: SuggestionCreateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> OutfitResponse:
"""Persist an externally-authored suggestion; available regardless of the AI flags."""
await rate_limit_by_user(
str(current_user.id), "external_suggestion", max_requests=20, window_seconds=60
)

service = ExternalOutfitService(db)
try:
outfit = await service.create_suggestion(
user=current_user,
item_ids=request.items,
occasion=request.occasion,
name=request.name,
scheduled_for=request.scheduled_for,
reasoning=request.reasoning,
style_notes=request.style_notes,
season=request.season,
formality=request.formality,
palette=request.palette,
notes=request.notes,
)
except ItemOwnershipError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error_code": "OUTFIT_ITEM_OWNERSHIP",
"message": "One or more items do not belong to you",
},
) from None

await db.commit()

full = await service.get_full_outfit(outfit.id)
return outfit_to_response(full)


@router.get("", response_model=OutfitListResponse)
async def list_outfits(
db: Annotated[AsyncSession, Depends(get_db)],
Expand Down Expand Up @@ -1052,7 +1126,7 @@ def _check_studio_kill_switch() -> None:
)


class StudioCreateRequest(BaseModel):
class StudioCreateRequest(OutfitAttributeFields):
model_config = ConfigDict(extra="forbid")

items: list[UUID] = Field(min_length=1, max_length=20)
Expand Down Expand Up @@ -1129,6 +1203,10 @@ async def create_studio_outfit(
scheduled_for=request.scheduled_for,
mark_worn=request.mark_worn,
source_item_id=request.source_item_id,
season=request.season,
formality=request.formality,
palette=request.palette,
notes=request.notes,
)
except ItemOwnershipError:
raise HTTPException(
Expand Down
85 changes: 82 additions & 3 deletions backend/app/api/pairings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, computed_field
from pydantic import BaseModel, ConfigDict, Field, computed_field
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.models.outfit import Outfit, OutfitSource
from app.models.outfit import Outfit
from app.models.user import User
from app.schemas.outfit import MAX_AUTHORING_TEXT_LENGTH, OutfitAttributeFields
from app.services.ai_service import AIDisabledError
from app.services.external_outfit_service import ExternalOutfitService
from app.services.pairing_service import (
PAIRING_SOURCE_CLAUSE,
AIGenerationError,
InsufficientItemsError,
PairingService,
)
from app.services.studio_service import ItemOwnershipError
from app.utils.auth import get_current_user
from app.utils.rate_limit import rate_limit_by_user
from app.utils.signed_urls import sign_image_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -101,6 +106,10 @@ class PairingResponse(BaseModel):
source: str
reasoning: str | None = None
style_notes: str | None = None
season: str | None = None
formality: str | None = None
palette: list[str] | None = None
notes: str | None = None
highlights: list[str] | None = None
source_item: SourceItemResponse | None = None
items: list[PairingItemResponse]
Expand Down Expand Up @@ -202,6 +211,10 @@ def pairing_to_response(outfit: Outfit) -> PairingResponse:
source=outfit.source.value,
reasoning=outfit.reasoning,
style_notes=outfit.style_notes,
season=outfit.season,
formality=outfit.formality,
palette=outfit.palette,
notes=outfit.notes,
highlights=highlights,
source_item=source_item_response,
items=items,
Expand Down Expand Up @@ -306,6 +319,72 @@ async def list_item_pairings(
)


class PairingCreateRequest(OutfitAttributeFields):
model_config = ConfigDict(extra="forbid")

items: list[UUID] = Field(
min_length=1, max_length=20, description="Partner items to pair with the source item"
)
scheduled_for: date | None = Field(
default=None, description="Defaults to the user's current date"
)
reasoning: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None
style_notes: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None


@router.post("/item/{item_id}", response_model=PairingResponse, status_code=status.HTTP_201_CREATED)
async def create_external_pairing(
item_id: UUID,
request: PairingCreateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> PairingResponse:
"""Persist an externally-authored pairing; available regardless of the AI flags."""
await rate_limit_by_user(
str(current_user.id), "external_pairing", max_requests=20, window_seconds=60
)

source_item = await PairingService(db).get_source_item(current_user.id, item_id)
if not source_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Source item not found or not available",
)

service = ExternalOutfitService(db)
try:
outfit = await service.create_pairing(
user=current_user,
source_item_id=item_id,
item_ids=request.items,
scheduled_for=request.scheduled_for,
reasoning=request.reasoning,
style_notes=request.style_notes,
season=request.season,
formality=request.formality,
palette=request.palette,
notes=request.notes,
)
except ItemOwnershipError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error_code": "OUTFIT_ITEM_OWNERSHIP",
"message": "One or more items do not belong to you",
},
) from None
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None

await db.commit()

full = await service.get_full_outfit(outfit.id)
return pairing_to_response(full)


@router.delete("/{pairing_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_pairing(
pairing_id: UUID,
Expand All @@ -316,7 +395,7 @@ async def delete_pairing(
and_(
Outfit.id == pairing_id,
Outfit.user_id == current_user.id,
Outfit.source == OutfitSource.pairing,
PAIRING_SOURCE_CLAUSE,
)
)

Expand Down
7 changes: 7 additions & 0 deletions backend/app/models/outfit.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class OutfitSource(enum.StrEnum):
on_demand = "on_demand"
manual = "manual"
pairing = "pairing"
external = "external"


class Outfit(Base):
Expand All @@ -59,6 +60,12 @@ class Outfit(Base):
style_notes: Mapped[str | None] = mapped_column(Text)
ai_raw_response: Mapped[dict | None] = mapped_column(JSONB)

# Authoring attributes
season: Mapped[str | None] = mapped_column(String(20), nullable=True)
formality: Mapped[str | None] = mapped_column(String(50), nullable=True)
palette: Mapped[list | None] = mapped_column(JSONB, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

# Status
status: Mapped[OutfitStatus] = mapped_column(
Enum(OutfitStatus, name="outfit_status", create_type=False),
Expand Down
40 changes: 40 additions & 0 deletions backend/app/schemas/outfit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Annotated

from pydantic import BaseModel, Field, field_validator

MAX_AUTHORING_TEXT_LENGTH = 2000


class OutfitAttributeFields(BaseModel):
"""Optional descriptive outfit attributes shared by the authoring and studio
request schemas. Free-form, but canonically match the item tag vocabulary
(season: spring/summer/fall/winter/all-season; formality: very-casual
through very-formal).
"""

season: Annotated[str | None, Field(max_length=20)] = None
formality: Annotated[str | None, Field(max_length=50)] = None
palette: list[str] | None = Field(
default=None,
max_length=10,
description="Dominant outfit colors, most prominent first",
)
notes: Annotated[str | None, Field(max_length=MAX_AUTHORING_TEXT_LENGTH)] = None

@field_validator("season", "formality")
@classmethod
def normalize_label(cls, v: str | None) -> str | None:
if v is None:
return None
return v.strip().lower() or None

@field_validator("palette")
@classmethod
def validate_palette(cls, v: list[str] | None) -> list[str] | None:
if v is None:
return None
colors = [c.strip().lower() for c in v]
if any(not c or len(c) > 50 for c in colors):
raise ValueError("Palette colors must be 1-50 characters")
# [] collapses to None so "no palette" has a single representation
return colors or None
Loading
Loading