""" routes_inventory.py ─────────────────── Inventory Pipeline API Endpoints: POST /inventory/import-batches — create a new import batch GET /inventory/import-batches — list import batches GET /inventory/import-batches/{batch_id} — get batch status POST /inventory/properties — create a single property GET /inventory/properties — list properties GET /inventory/properties/{property_id} — get a property PATCH /inventory/properties/{property_id} — update a property DELETE /inventory/properties/{property_id} — archive a property POST /inventory/properties/{property_id}/media — attach media to a property GET /inventory/properties/{property_id}/media — list media for a property DELETE /inventory/media/{media_asset_id} — remove a media asset """ from __future__ import annotations import json import logging from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from backend.auth.dependencies import get_current_user logger = logging.getLogger("velocity.inventory") router = APIRouter() # ── Helpers ─────────────────────────────────────────────────────────────────── def _pool(request: Request): pool = request.app.state.db_pool if pool is None: raise HTTPException(status_code=503, detail="Database unavailable.") return pool def _tenant_scope(user) -> str: return user.tenant_id # ── Pydantic Models ─────────────────────────────────────────────────────────── VALID_SOURCE_TYPES = {"csv", "json", "api_push", "manual"} VALID_PROPERTY_STATUSES = {"active", "archived", "draft", "under_review"} VALID_MEDIA_TYPES = {"image", "video", "floorplan", "brochure", "360", "vr"} class ImportBatchCreate(BaseModel): source_type: str source_file_ref: Optional[str] = None total_rows: int = 0 class PropertyCreate(BaseModel): batch_id: Optional[str] = None source_id: Optional[str] = None project_name: str developer_name: str location: dict = Field(default_factory=dict) # {city, district, lat, lng} property_type: str price_bands: list[dict] = Field(default_factory=list) unit_mix: list[dict] = Field(default_factory=list) amenities: list[str] = Field(default_factory=list) status: str = "draft" validation_state: dict = Field(default_factory=dict) class PropertyUpdate(BaseModel): project_name: Optional[str] = None developer_name: Optional[str] = None location: Optional[dict] = None property_type: Optional[str] = None price_bands: Optional[list[dict]] = None unit_mix: Optional[list[dict]] = None amenities: Optional[list[str]] = None status: Optional[str] = None validation_state: Optional[dict] = None class MediaAssetCreate(BaseModel): media_type: str url: str thumbnail_url: Optional[str] = None sort_order: int = 0 metadata: dict = Field(default_factory=dict) # ── Import Batches ──────────────────────────────────────────────────────────── @router.post("/import-batches", status_code=status.HTTP_201_CREATED, summary="Create an inventory import batch") async def create_import_batch( request: Request, body: ImportBatchCreate, user=Depends(get_current_user), ): if body.source_type not in VALID_SOURCE_TYPES: raise HTTPException(400, f"Invalid source_type. Valid: {sorted(VALID_SOURCE_TYPES)}") pool = _pool(request) async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO inventory_import_batches (tenant_id, source_type, submitted_by, total_rows, source_file_ref) VALUES ($1, $2, $3, $4, $5) RETURNING batch_id, status, created_at """, _tenant_scope(user), body.source_type, user.user_id, body.total_rows, body.source_file_ref, ) return dict(row) @router.get("/import-batches", summary="List import batches") async def list_import_batches( request: Request, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), user=Depends(get_current_user), ): pool = _pool(request) async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT batch_id, source_type, submitted_by, status, total_rows, accepted_rows, rejected_rows, created_at, completed_at FROM inventory_import_batches WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 """, _tenant_scope(user), limit, offset, ) total = await conn.fetchval( "SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", _tenant_scope(user), ) return {"total": total, "limit": limit, "offset": offset, "batches": [dict(r) for r in rows]} @router.get("/import-batches/{batch_id}", summary="Get import batch status") async def get_import_batch( batch_id: str, request: Request, user=Depends(get_current_user), ): pool = _pool(request) async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2 """, batch_id, _tenant_scope(user), ) if not row: raise HTTPException(404, "Batch not found") return dict(row) # ── Properties ──────────────────────────────────────────────────────────────── @router.post("/properties", status_code=status.HTTP_201_CREATED, summary="Create a property") async def create_property( request: Request, body: PropertyCreate, user=Depends(get_current_user), ): if body.status not in VALID_PROPERTY_STATUSES: raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}") pool = _pool(request) async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO inventory_properties ( tenant_id, batch_id, source_id, project_name, developer_name, location, property_type, price_bands, unit_mix, amenities, status, validation_state ) VALUES ( $1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9::jsonb, $10, $11, $12::jsonb ) RETURNING property_id, created_at """, _tenant_scope(user), body.batch_id, body.source_id, body.project_name, body.developer_name, json.dumps(body.location), body.property_type, json.dumps(body.price_bands), json.dumps(body.unit_mix), body.amenities, body.status, json.dumps(body.validation_state), ) return {"property_id": str(row["property_id"]), "created_at": str(row["created_at"])} @router.get("/properties", summary="List inventory properties") async def list_properties( request: Request, status_filter: Optional[str] = Query(None, alias="status"), property_type: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), user=Depends(get_current_user), ): pool = _pool(request) async with pool.acquire() as conn: where_clause = "WHERE tenant_id = $1" params: list[Any] = [_tenant_scope(user)] idx = 2 if status_filter: where_clause += f" AND status = ${idx}" params.append(status_filter) idx += 1 if property_type: where_clause += f" AND property_type = ${idx}" params.append(property_type) idx += 1 rows = await conn.fetch( f""" SELECT property_id, project_name, developer_name, property_type, location, price_bands, unit_mix, status, ingested_at, created_at FROM inventory_properties {where_clause} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1} """, *params, limit, offset, ) total = await conn.fetchval( f"SELECT COUNT(*) FROM inventory_properties {where_clause}", *params, ) return {"total": total, "limit": limit, "offset": offset, "properties": [dict(r) for r in rows]} @router.get("/properties/{property_id}", summary="Get a property") async def get_property( property_id: str, request: Request, user=Depends(get_current_user), ): pool = _pool(request) async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2", property_id, _tenant_scope(user), ) if not row: raise HTTPException(404, "Property not found") return dict(row) @router.patch("/properties/{property_id}", summary="Update a property") async def update_property( property_id: str, request: Request, body: PropertyUpdate, user=Depends(get_current_user), ): updates: list[str] = [] values: list[Any] = [] idx = 1 def _add(col: str, val: Any, cast: str = ""): nonlocal idx updates.append(f"{col} = ${idx}{cast}") values.append(val) idx += 1 if body.project_name is not None: _add("project_name", body.project_name) if body.developer_name is not None: _add("developer_name", body.developer_name) if body.location is not None: _add("location", json.dumps(body.location), "::jsonb") if body.property_type is not None: _add("property_type", body.property_type) if body.price_bands is not None: _add("price_bands", json.dumps(body.price_bands), "::jsonb") if body.unit_mix is not None: _add("unit_mix", json.dumps(body.unit_mix), "::jsonb") if body.amenities is not None: _add("amenities", body.amenities) if body.status is not None: if body.status not in VALID_PROPERTY_STATUSES: raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_PROPERTY_STATUSES)}") _add("status", body.status) if body.validation_state is not None: _add("validation_state", json.dumps(body.validation_state), "::jsonb") if not updates: raise HTTPException(400, "No fields to update") _add("updated_at", datetime.now(timezone.utc)) values.extend([property_id, _tenant_scope(user)]) pool = _pool(request) async with pool.acquire() as conn: result = await conn.execute( f""" UPDATE inventory_properties SET {', '.join(updates)} WHERE property_id=${idx} AND tenant_id=${idx+1} """, *values, ) if result == "UPDATE 0": raise HTTPException(404, "Property not found") return {"status": "updated"} @router.delete("/properties/{property_id}", summary="Archive a property") async def archive_property( property_id: str, request: Request, user=Depends(get_current_user), ): pool = _pool(request) async with pool.acquire() as conn: result = await conn.execute( """ UPDATE inventory_properties SET status='archived', updated_at=NOW() WHERE property_id=$1 AND tenant_id=$2 """, property_id, _tenant_scope(user), ) if result == "UPDATE 0": raise HTTPException(404, "Property not found") return {"status": "archived"} # ── Media Assets ────────────────────────────────────────────────────────────── @router.post("/properties/{property_id}/media", status_code=status.HTTP_201_CREATED, summary="Attach media to a property") async def add_media( property_id: str, request: Request, body: MediaAssetCreate, user=Depends(get_current_user), ): if body.media_type not in VALID_MEDIA_TYPES: raise HTTPException(400, f"Invalid media_type. Valid: {sorted(VALID_MEDIA_TYPES)}") pool = _pool(request) async with pool.acquire() as conn: # Verify property belongs to tenant exists = await conn.fetchval( "SELECT 1 FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2", property_id, _tenant_scope(user), ) if not exists: raise HTTPException(404, "Property not found") row = await conn.fetchrow( """ INSERT INTO inventory_media_assets (property_id, tenant_id, media_type, url, thumbnail_url, sort_order, metadata, uploaded_by) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8) RETURNING media_asset_id, created_at """, property_id, _tenant_scope(user), body.media_type, body.url, body.thumbnail_url, body.sort_order, json.dumps(body.metadata), user.user_id, ) return {"media_asset_id": str(row["media_asset_id"]), "created_at": str(row["created_at"])} @router.get("/properties/{property_id}/media", summary="List media for a property") async def list_media( property_id: str, request: Request, user=Depends(get_current_user), ): pool = _pool(request) async with pool.acquire() as conn: rows = await conn.fetch( """ SELECT media_asset_id, media_type, url, thumbnail_url, sort_order, metadata, created_at FROM inventory_media_assets WHERE property_id=$1 AND tenant_id=$2 ORDER BY sort_order ASC, created_at ASC """, property_id, _tenant_scope(user), ) return {"media": [dict(r) for r in rows]} @router.delete("/media/{media_asset_id}", summary="Remove a media asset") async def delete_media( media_asset_id: str, request: Request, user=Depends(get_current_user), ): pool = _pool(request) async with pool.acquire() as conn: result = await conn.execute( "DELETE FROM inventory_media_assets WHERE media_asset_id=$1 AND tenant_id=$2", media_asset_id, _tenant_scope(user), ) if result == "DELETE 0": raise HTTPException(404, "Media asset not found") return {"status": "deleted"}