diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 2de7f52..d972e2a 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -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", } diff --git a/backend/app/api/outfits.py b/backend/app/api/outfits.py index d45d4d2..7316a23 100644 --- a/backend/app/api/outfits.py +++ b/backend/app/api/outfits.py @@ -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 @@ -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] @@ -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, @@ -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)], @@ -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) @@ -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( diff --git a/backend/app/api/pairings.py b/backend/app/api/pairings.py index 8a3aac2..cfd6637 100644 --- a/backend/app/api/pairings.py +++ b/backend/app/api/pairings.py @@ -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__) @@ -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] @@ -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, @@ -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, @@ -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, ) ) diff --git a/backend/app/models/outfit.py b/backend/app/models/outfit.py index f345232..1141417 100644 --- a/backend/app/models/outfit.py +++ b/backend/app/models/outfit.py @@ -39,6 +39,7 @@ class OutfitSource(enum.StrEnum): on_demand = "on_demand" manual = "manual" pairing = "pairing" + external = "external" class Outfit(Base): @@ -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), diff --git a/backend/app/schemas/outfit.py b/backend/app/schemas/outfit.py new file mode 100644 index 0000000..3d931ba --- /dev/null +++ b/backend/app/schemas/outfit.py @@ -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 diff --git a/backend/app/services/external_outfit_service.py b/backend/app/services/external_outfit_service.py new file mode 100644 index 0000000..f655d99 --- /dev/null +++ b/backend/app/services/external_outfit_service.py @@ -0,0 +1,149 @@ +"""Write surface for externally-authored outfits (suggestions and pairings). + +Authored rows are regular outfits with source='external'; no separate table. +""" + +from datetime import date +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.outfit import ( + FamilyOutfitRating, + Outfit, + OutfitItem, + OutfitSource, + OutfitStatus, +) +from app.models.user import User +from app.services.studio_service import validate_item_ownership +from app.utils.timezone import get_user_today + + +class ExternalOutfitService: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_full_outfit(self, outfit_id: UUID) -> Outfit: + result = await self.db.execute( + select(Outfit) + .where(Outfit.id == outfit_id) + .options( + selectinload(Outfit.items).selectinload(OutfitItem.item), + selectinload(Outfit.feedback), + selectinload(Outfit.source_item), + selectinload(Outfit.family_ratings).selectinload(FamilyOutfitRating.user), + ) + ) + return result.scalar_one() + + async def _persist_outfit( + self, + user: User, + *, + ordered_item_ids: list[UUID], + occasion: str, + name: str | None = None, + scheduled_for: date | None, + source_item_id: UUID | None = None, + reasoning: str | None, + style_notes: str | None, + season: str | None, + formality: str | None, + palette: list[str] | None, + notes: str | None, + ) -> Outfit: + await validate_item_ownership(self.db, user.id, ordered_item_ids) + + outfit = Outfit( + user_id=user.id, + occasion=occasion, + scheduled_for=scheduled_for or get_user_today(user), + source=OutfitSource.external, + status=OutfitStatus.pending, + name=name, + source_item_id=source_item_id, + reasoning=reasoning, + style_notes=style_notes, + season=season, + formality=formality, + palette=palette, + notes=notes, + ) + self.db.add(outfit) + await self.db.flush() + + for position, item_id in enumerate(ordered_item_ids): + self.db.add(OutfitItem(outfit_id=outfit.id, item_id=item_id, position=position)) + + await self.db.flush() + return outfit + + async def create_suggestion( + self, + user: User, + *, + item_ids: list[UUID], + occasion: str, + name: str | None = None, + scheduled_for: date | None = None, + 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, + ) -> Outfit: + """Persist an authored suggestion; item positions follow the request order.""" + return await self._persist_outfit( + user, + ordered_item_ids=list(dict.fromkeys(item_ids)), + occasion=occasion, + name=name, + scheduled_for=scheduled_for, + reasoning=reasoning, + style_notes=style_notes, + season=season, + formality=formality, + palette=palette, + notes=notes, + ) + + async def create_pairing( + self, + user: User, + *, + source_item_id: UUID, + item_ids: list[UUID], + scheduled_for: date | None = None, + 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, + ) -> Outfit: + """Persist an authored pairing; the source item leads when absent from item_ids.""" + ordered = ( + list(dict.fromkeys(item_ids)) + if source_item_id in item_ids + else list(dict.fromkeys([source_item_id, *item_ids])) + ) + if len(ordered) < 2: + raise ValueError("A pairing needs at least one partner item") + + return await self._persist_outfit( + user, + ordered_item_ids=ordered, + occasion="pairing", + scheduled_for=scheduled_for, + source_item_id=source_item_id, + reasoning=reasoning, + style_notes=style_notes, + season=season, + formality=formality, + palette=palette, + notes=notes, + ) diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index efda986..ae4e97a 100644 --- a/backend/app/services/pairing_service.py +++ b/backend/app/services/pairing_service.py @@ -3,7 +3,7 @@ import re from uuid import UUID -from sqlalchemy import and_, select +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -19,6 +19,14 @@ PAIRING_PROMPT_TEMPLATE = load_prompt("item_pairing") +# A pairing is an internally-generated row, or an externally-authored one still +# anchored to its source item (external rows without one are suggestions). +# Internal rows stay pairings even after their source item is deleted (SET NULL). +PAIRING_SOURCE_CLAUSE = or_( + Outfit.source == OutfitSource.pairing, + and_(Outfit.source == OutfitSource.external, Outfit.source_item_id.is_not(None)), +) + class PairingService: def __init__(self, db: AsyncSession): @@ -317,7 +325,7 @@ async def get_pairings_for_item( base_query = select(Outfit).where( and_( Outfit.user_id == user_id, - Outfit.source == OutfitSource.pairing, + PAIRING_SOURCE_CLAUSE, Outfit.source_item_id == source_item_id, ) ) @@ -327,7 +335,7 @@ async def get_pairings_for_item( select(Outfit.id).where( and_( Outfit.user_id == user_id, - Outfit.source == OutfitSource.pairing, + PAIRING_SOURCE_CLAUSE, Outfit.source_item_id == source_item_id, ) ) @@ -362,7 +370,7 @@ async def get_all_pairings( # Base conditions conditions = [ Outfit.user_id == user_id, - Outfit.source == OutfitSource.pairing, + PAIRING_SOURCE_CLAUSE, ] # Filter by source item type if specified diff --git a/backend/app/services/studio_service.py b/backend/app/services/studio_service.py index 47c45c9..6231e36 100644 --- a/backend/app/services/studio_service.py +++ b/backend/app/services/studio_service.py @@ -33,6 +33,28 @@ class OutfitNotTemplateError(Exception): pass +async def validate_item_ownership( + db: AsyncSession, user_id: UUID, item_ids: list[UUID] +) -> list[ClothingItem]: + if not item_ids: + raise ValueError("items required") + + result = await db.execute( + select(ClothingItem).where( + and_( + ClothingItem.id.in_(item_ids), + ClothingItem.user_id == user_id, + ClothingItem.status == ItemStatus.ready, + ) + ) + ) + items = list(result.scalars().all()) + unique_requested = set(item_ids) + if len(items) != len(unique_requested): + raise ItemOwnershipError("one or more items do not belong to the caller") + return items + + class StudioService: CLONE_SOFT_IDEMPOTENCY_SECONDS = 5 @@ -40,27 +62,6 @@ def __init__(self, db: AsyncSession): self.db = db self.learning = LearningService(db) - async def _validate_item_ownership( - self, user_id: UUID, item_ids: list[UUID] - ) -> list[ClothingItem]: - if not item_ids: - raise ValueError("items required") - - result = await self.db.execute( - select(ClothingItem).where( - and_( - ClothingItem.id.in_(item_ids), - ClothingItem.user_id == user_id, - ClothingItem.status == ItemStatus.ready, - ) - ) - ) - items = list(result.scalars().all()) - unique_requested = set(item_ids) - if len(items) != len(unique_requested): - raise ItemOwnershipError("one or more items do not belong to the caller") - return items - def _order_items_canonically(self, items: list[ClothingItem]) -> list[ClothingItem]: type_map = {item.id: (item.type or "") for item in items} ordered_ids = canonical_item_order([i.id for i in items], type_map) @@ -140,8 +141,12 @@ async def create_from_scratch( scheduled_for: date | None, mark_worn: bool, source_item_id: UUID | None, + season: str | None = None, + formality: str | None = None, + palette: list[str] | None = None, + notes: str | None = None, ) -> Outfit: - items = await self._validate_item_ownership(user.id, item_ids) + items = await validate_item_ownership(self.db, user.id, item_ids) ordered = self._order_items_canonically(items) effective_worn = scheduled_for if mark_worn else None @@ -154,6 +159,10 @@ async def create_from_scratch( status=OutfitStatus.pending, name=name, source_item_id=source_item_id, + season=season, + formality=formality, + palette=palette, + notes=notes, ) self.db.add(outfit) await self.db.flush() @@ -203,7 +212,7 @@ async def create_wore_instead( if existing_replacement is not None: return existing_replacement - items = await self._validate_item_ownership(user.id, item_ids) + items = await validate_item_ownership(self.db, user.id, item_ids) ordered = self._order_items_canonically(items) effective_date = scheduled_for or original.scheduled_for @@ -382,7 +391,7 @@ async def patch_outfit( if outfit.feedback is not None and outfit.feedback.worn_at is not None: raise OutfitWornImmutableError("cannot modify items on a worn outfit") - new_items = await self._validate_item_ownership(user.id, items) + new_items = await validate_item_ownership(self.db, user.id, items) ordered = self._order_items_canonically(new_items) old_item_ids = [oi.item_id for oi in outfit.items] diff --git a/backend/migrations/versions/e5f6a7b8c9d0_add_external_outfit_authoring.py b/backend/migrations/versions/e5f6a7b8c9d0_add_external_outfit_authoring.py new file mode 100644 index 0000000..7a3ce2a --- /dev/null +++ b/backend/migrations/versions/e5f6a7b8c9d0_add_external_outfit_authoring.py @@ -0,0 +1,46 @@ +"""Add the external outfit source and authoring attribute columns. + +'external' names the write path: outfits authored through the external +authoring API rather than generated internally, composed in the studio, or +derived from a pairing request. The backend can attest to the write path, so +the value carries the same server-derived semantics as the tagging origins. + +The four attribute columns (season, formality, palette, notes) let an author +record the outfit qualities the internal AI keeps implicit in its reasoning. +All nullable; existing rows and the internal generation paths are unaffected. + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-07-10 17:40:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers, used by Alembic. +revision: str = "e5f6a7b8c9d0" +down_revision: str | None = "d4e5f6a7b8c9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TYPE outfit_source ADD VALUE IF NOT EXISTS 'external'") + + op.add_column("outfits", sa.Column("season", sa.String(length=20), nullable=True)) + op.add_column("outfits", sa.Column("formality", sa.String(length=50), nullable=True)) + op.add_column("outfits", sa.Column("palette", JSONB, nullable=True)) + op.add_column("outfits", sa.Column("notes", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("outfits", "notes") + op.drop_column("outfits", "palette") + op.drop_column("outfits", "formality") + op.drop_column("outfits", "season") + + # Note: Cannot remove enum value in PostgreSQL, so 'external' will remain diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index f1188fe..659c5d9 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -101,8 +101,8 @@ async def test_capabilities_default_on(client: AsyncClient): assert data["ai"] == {"vision": True, "text": True} assert data["features"] == { "external_tagging": True, - "external_suggestions": False, - "external_pairings": False, + "external_suggestions": True, + "external_pairings": True, } assert data["version"] == "1.0.0" diff --git a/backend/tests/test_external_authoring.py b/backend/tests/test_external_authoring.py new file mode 100644 index 0000000..5b0ba6a --- /dev/null +++ b/backend/tests/test_external_authoring.py @@ -0,0 +1,487 @@ +"""External outfit authoring: the agent-facing write surface for suggestions +and pairings (POST /outfits/suggestions, POST /pairings/item/{item_id}). + +Covers persistence as Outfit(source=external), ownership enforcement, the +authoring attributes (season/formality/palette/notes), pairing list/delete +inclusion, and availability with internal AI off. +""" + +from datetime import date +from uuid import uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import Settings +from app.models.item import ClothingItem, ItemStatus +from app.models.outfit import Outfit, OutfitItem, OutfitSource, OutfitStatus +from app.models.user import User + + +def _make_item(user_id, item_type="shirt", **kwargs) -> ClothingItem: + return ClothingItem( + user_id=user_id, + type=item_type, + image_path=f"test/{uuid4()}.jpg", + status=ItemStatus.ready, + **kwargs, + ) + + +async def _make_wardrobe( + db_session: AsyncSession, user: User, types: list[str] +) -> list[ClothingItem]: + items = [_make_item(user.id, item_type=t) for t in types] + db_session.add_all(items) + await db_session.commit() + return items + + +async def _make_foreign_item(db_session: AsyncSession) -> ClothingItem: + unique_id = str(uuid4())[:8] + other = User( + id=uuid4(), + external_id=f"other-user-{unique_id}", + email=f"other-{unique_id}@example.com", + display_name="Other User", + timezone="UTC", + is_active=True, + onboarding_completed=False, + ) + db_session.add(other) + await db_session.flush() + item = _make_item(other.id) + db_session.add(item) + await db_session.commit() + return item + + +# --- POST /outfits/suggestions ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_suggestion_persists_external_outfit( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans, sneakers = await _make_wardrobe( + db_session, test_user, ["shirt", "jeans", "sneakers"] + ) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={ + "items": [str(sneakers.id), str(shirt.id), str(jeans.id)], + "occasion": "casual", + "name": "Weekend look", + "reasoning": "Light layers for a mild day", + "style_notes": "Roll the sleeves", + "season": "Summer", + "formality": "casual", + "palette": ["Navy", " white "], + "notes": "Pairs well with the canvas tote", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["source"] == "external" + assert body["status"] == "pending" + assert body["occasion"] == "casual" + assert body["name"] == "Weekend look" + assert body["reasoning"] == "Light layers for a mild day" + assert body["style_notes"] == "Roll the sleeves" + assert body["season"] == "summer" + assert body["formality"] == "casual" + assert body["palette"] == ["navy", "white"] + assert body["notes"] == "Pairs well with the canvas tote" + assert body["scheduled_for"] is not None + # Positions follow the request order + assert [i["id"] for i in body["items"]] == [str(sneakers.id), str(shirt.id), str(jeans.id)] + + +@pytest.mark.asyncio +async def test_create_suggestion_defaults_attributes_to_null( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "office", "palette": []}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["season"] is None + assert body["formality"] is None + # palette=[] collapses to null + assert body["palette"] is None + assert body["notes"] is None + assert body["name"] is None + + +@pytest.mark.asyncio +async def test_create_suggestion_accepts_explicit_scheduled_for( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={ + "items": [str(shirt.id), str(jeans.id)], + "occasion": "casual", + "scheduled_for": "2026-08-01", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["scheduled_for"] == "2026-08-01" + + +@pytest.mark.asyncio +async def test_create_suggestion_rejects_foreign_item( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + foreign = await _make_foreign_item(db_session) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(foreign.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error_code"] == "OUTFIT_ITEM_OWNERSHIP" + + +@pytest.mark.asyncio +async def test_create_suggestion_rejects_invalid_occasion( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id)], "occasion": "space-walk"}, + headers=auth_headers, + ) + assert resp.status_code == 422, resp.text + + +@pytest.mark.asyncio +async def test_create_suggestion_rejects_unknown_fields( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id)], "occasion": "casual", "source": "manual"}, + headers=auth_headers, + ) + assert resp.status_code == 422, resp.text + + +@pytest.mark.asyncio +async def test_create_suggestion_validates_attribute_bounds( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + too_many_colors = await client.post( + "/api/v1/outfits/suggestions", + json={ + "items": [str(shirt.id)], + "occasion": "casual", + "palette": [f"color-{i}" for i in range(11)], + }, + headers=auth_headers, + ) + assert too_many_colors.status_code == 422, too_many_colors.text + + oversized_notes = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id)], "occasion": "casual", "notes": "x" * 2001}, + headers=auth_headers, + ) + assert oversized_notes.status_code == 422, oversized_notes.text + + +@pytest.mark.asyncio +async def test_create_suggestion_available_with_ai_off( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession, monkeypatch +): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["source"] == "external" + + +@pytest.mark.asyncio +async def test_suggestion_listed_under_external_source_filter( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + outfit_id = created.json()["id"] + + listed = await client.get("/api/v1/outfits?source=external", headers=auth_headers) + assert listed.status_code == 200, listed.text + assert outfit_id in [o["id"] for o in listed.json()["outfits"]] + + +@pytest.mark.asyncio +async def test_suggestion_not_listed_as_pairing( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + "/api/v1/outfits/suggestions", + json={"items": [str(shirt.id), str(jeans.id)], "occasion": "casual"}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + + pairings = await client.get("/api/v1/pairings", headers=auth_headers) + assert pairings.status_code == 200, pairings.text + assert created.json()["id"] not in [p["id"] for p in pairings.json()["pairings"]] + + +# --- POST /pairings/item/{item_id} ------------------------------------------ + + +@pytest.mark.asyncio +async def test_create_pairing_persists_external_pairing( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans, jacket = await _make_wardrobe(db_session, test_user, ["shirt", "jeans", "jacket"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={ + "items": [str(jeans.id), str(jacket.id)], + "reasoning": "Denim anchors the shirt", + "style_notes": "Keep the jacket open", + "season": "fall", + "formality": "smart-casual", + "palette": ["blue", "grey"], + "notes": "Good transitional-weather pick", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["source"] == "external" + assert body["occasion"] == "pairing" + assert body["source_item"]["id"] == str(shirt.id) + assert body["season"] == "fall" + assert body["formality"] == "smart-casual" + assert body["palette"] == ["blue", "grey"] + assert body["notes"] == "Good transitional-weather pick" + # The source item leads when left out of the partner list + assert [i["id"] for i in body["items"]] == [str(shirt.id), str(jeans.id), str(jacket.id)] + + +@pytest.mark.asyncio +async def test_create_pairing_respects_explicit_source_position( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans, jacket = await _make_wardrobe(db_session, test_user, ["shirt", "jeans", "jacket"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id), str(shirt.id), str(jacket.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert [i["id"] for i in body["items"]] == [str(jeans.id), str(shirt.id), str(jacket.id)] + + +@pytest.mark.asyncio +async def test_create_pairing_unknown_source_item_404( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (jeans,) = await _make_wardrobe(db_session, test_user, ["jeans"]) + + resp = await client.post( + f"/api/v1/pairings/item/{uuid4()}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 404, resp.text + + +@pytest.mark.asyncio +async def test_create_pairing_rejects_foreign_partner( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + foreign = await _make_foreign_item(db_session) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(foreign.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error_code"] == "OUTFIT_ITEM_OWNERSHIP" + + +@pytest.mark.asyncio +async def test_create_pairing_requires_a_partner( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + (shirt,) = await _make_wardrobe(db_session, test_user, ["shirt"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(shirt.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 400, resp.text + + +@pytest.mark.asyncio +async def test_create_pairing_available_with_ai_off( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession, monkeypatch +): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["source"] == "external" + + +# --- Pairing list/delete inclusion ------------------------------------------ + + +@pytest.mark.asyncio +async def test_external_pairing_listed_alongside_generated( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + pairing_id = created.json()["id"] + + all_pairings = await client.get("/api/v1/pairings", headers=auth_headers) + assert all_pairings.status_code == 200, all_pairings.text + assert pairing_id in [p["id"] for p in all_pairings.json()["pairings"]] + + item_pairings = await client.get(f"/api/v1/pairings/item/{shirt.id}", headers=auth_headers) + assert item_pairings.status_code == 200, item_pairings.text + assert pairing_id in [p["id"] for p in item_pairings.json()["pairings"]] + + +@pytest.mark.asyncio +async def test_generated_pairing_listing_unchanged( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + outfit = Outfit( + user_id=test_user.id, + occasion="pairing", + scheduled_for=date.today(), + status=OutfitStatus.pending, + source=OutfitSource.pairing, + source_item_id=shirt.id, + reasoning="Generated pairing", + ) + outfit.items.append(OutfitItem(item_id=shirt.id, position=0)) + outfit.items.append(OutfitItem(item_id=jeans.id, position=1)) + db_session.add(outfit) + await db_session.commit() + + listed = await client.get("/api/v1/pairings", headers=auth_headers) + assert listed.status_code == 200, listed.text + entries = [p for p in listed.json()["pairings"] if p["id"] == str(outfit.id)] + assert len(entries) == 1 + # Default-on regression: internally-generated rows report the attributes as null + assert entries[0]["season"] is None + assert entries[0]["formality"] is None + assert entries[0]["palette"] is None + assert entries[0]["notes"] is None + + +@pytest.mark.asyncio +async def test_delete_external_pairing( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + created = await client.post( + f"/api/v1/pairings/item/{shirt.id}", + json={"items": [str(jeans.id)]}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + pairing_id = created.json()["id"] + + deleted = await client.delete(f"/api/v1/pairings/{pairing_id}", headers=auth_headers) + assert deleted.status_code == 204, deleted.text + + listed = await client.get("/api/v1/pairings", headers=auth_headers) + assert pairing_id not in [p["id"] for p in listed.json()["pairings"]] + + +# --- POST /outfits/studio ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_studio_accepts_authoring_attributes( + client: AsyncClient, test_user, auth_headers, db_session: AsyncSession +): + shirt, jeans = await _make_wardrobe(db_session, test_user, ["shirt", "jeans"]) + + resp = await client.post( + "/api/v1/outfits/studio", + json={ + "items": [str(shirt.id), str(jeans.id)], + "occasion": "casual", + "season": "spring", + "formality": "casual", + "palette": ["green"], + "notes": "Studio compose with attributes", + }, + headers=auth_headers, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["source"] == "manual" + assert body["season"] == "spring" + assert body["formality"] == "casual" + assert body["palette"] == ["green"] + assert body["notes"] == "Studio compose with attributes" diff --git a/backend/tests/test_studio_service.py b/backend/tests/test_studio_service.py index 833b27c..513570b 100644 --- a/backend/tests/test_studio_service.py +++ b/backend/tests/test_studio_service.py @@ -79,6 +79,59 @@ async def test_create_from_scratch(db_session, studio_user, wardrobe_items): assert outfit.feedback.accepted is True +@pytest.mark.asyncio +async def test_create_from_scratch_with_authoring_attributes( + db_session, studio_user, wardrobe_items +): + service = StudioService(db_session) + shirt, jeans = wardrobe_items[0], wardrobe_items[1] + + outfit = await service.create_from_scratch( + user=studio_user, + item_ids=[shirt.id, jeans.id], + occasion="casual", + name=None, + scheduled_for=None, + mark_worn=False, + source_item_id=None, + season="summer", + formality="smart-casual", + palette=["blue", "white"], + notes="Composed for the lake trip", + ) + await db_session.commit() + + assert outfit.source == OutfitSource.manual + assert outfit.season == "summer" + assert outfit.formality == "smart-casual" + assert outfit.palette == ["blue", "white"] + assert outfit.notes == "Composed for the lake trip" + + +@pytest.mark.asyncio +async def test_create_from_scratch_leaves_authoring_attributes_unset( + db_session, studio_user, wardrobe_items +): + service = StudioService(db_session) + shirt, jeans = wardrobe_items[0], wardrobe_items[1] + + outfit = await service.create_from_scratch( + user=studio_user, + item_ids=[shirt.id, jeans.id], + occasion="casual", + name=None, + scheduled_for=None, + mark_worn=False, + source_item_id=None, + ) + await db_session.commit() + + assert outfit.season is None + assert outfit.formality is None + assert outfit.palette is None + assert outfit.notes is None + + @pytest.mark.asyncio async def test_create_from_scratch_mark_worn(db_session, studio_user, wardrobe_items): service = StudioService(db_session) diff --git a/frontend/app/dashboard/family/feed/page.tsx b/frontend/app/dashboard/family/feed/page.tsx index c07ab06..65841bf 100644 --- a/frontend/app/dashboard/family/feed/page.tsx +++ b/frontend/app/dashboard/family/feed/page.tsx @@ -12,6 +12,7 @@ import { Calendar, Zap, Edit3, + Bot, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -58,6 +59,11 @@ function SourceBadge({ source }: { source: OutfitSource }) { label: t('feed.sourceBadges.pairing'), className: 'bg-violet-500/10 text-violet-600 border-violet-500/20', }, + external: { + icon: Bot, + label: t('feed.sourceBadges.external'), + className: 'bg-teal-500/10 text-teal-600 border-teal-500/20', + }, }; const { icon: Icon, label, className } = config[source]; diff --git a/frontend/components/outfit-calendar.tsx b/frontend/components/outfit-calendar.tsx index c58204b..79f7bc5 100644 --- a/frontend/components/outfit-calendar.tsx +++ b/frontend/components/outfit-calendar.tsx @@ -111,7 +111,8 @@ export function OutfitCalendar({ const dateKey = format(day, 'yyyy-MM-dd'); const sources = outfitsByDate.get(dateKey); const hasScheduled = sources?.has('scheduled'); - const hasOnDemand = sources?.has('on_demand') || sources?.has('manual'); + const hasOnDemand = + sources?.has('on_demand') || sources?.has('manual') || sources?.has('external'); const isSelected = selectedDate && isSameDay(day, selectedDate); const isCurrentMonth = isSameMonth(day, currentMonth); const isDayToday = isToday(day); diff --git a/frontend/components/outfit-history-card.tsx b/frontend/components/outfit-history-card.tsx index 66be445..af84f6b 100644 --- a/frontend/components/outfit-history-card.tsx +++ b/frontend/components/outfit-history-card.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Calendar, Zap, Edit3, ThumbsUp, ThumbsDown, Clock, Eye, Star, ArrowRight, Shirt, Users, ExternalLink } from 'lucide-react'; +import { Calendar, Zap, Edit3, ThumbsUp, ThumbsDown, Clock, Eye, Star, ArrowRight, Shirt, Users, ExternalLink, Bot } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -76,6 +76,11 @@ function SourceBadge({ source }: { source: OutfitSource }) { label: t('sourceBadges.pairing'), className: 'bg-violet-500/10 text-violet-600 border-violet-500/20', }, + external: { + icon: Bot, + label: t('sourceBadges.external'), + className: 'bg-teal-500/10 text-teal-600 border-teal-500/20', + }, }; const { icon: Icon, label, className } = config[source]; diff --git a/frontend/components/outfits/outfit-card.tsx b/frontend/components/outfits/outfit-card.tsx index fb9a5c1..0676536 100644 --- a/frontend/components/outfits/outfit-card.tsx +++ b/frontend/components/outfits/outfit-card.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; import { formatDistanceToNow, parseISO } from 'date-fns'; import { BookmarkCheck, + Bot, Layers, RefreshCw, Shirt, @@ -63,6 +64,13 @@ function getSourceBadge(outfit: Outfit, t: any): { className: 'bg-amber-100 text-amber-700 border-amber-200', }; } + if (outfit.source === 'external') { + return { + label: t('external'), + icon: , + className: 'bg-teal-100 text-teal-700 border-teal-200', + }; + } return { label: t('ai'), icon: , diff --git a/frontend/components/pairing-card.tsx b/frontend/components/pairing-card.tsx index d29aaf6..c4f20fc 100644 --- a/frontend/components/pairing-card.tsx +++ b/frontend/components/pairing-card.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Trash2, Star, Sparkles } from 'lucide-react'; +import { Trash2, Star, Sparkles, Bot } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -54,10 +54,21 @@ export function PairingCard({ pairing, onFeedback, onPreview }: PairingCardProps {/* Header with source badge */} - - - {t('badge')} - + + + + {t('badge')} + + {pairing.source === 'external' && ( + + + {t('externalBadge')} + + )} +