feat/#24 WebOS Completion (#25)
#24 WebOS Completion Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
399
backend/api/routes_inventory.py
Normal file
399
backend/api/routes_inventory.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
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 UTC, datetime
|
||||
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
|
||||
|
||||
|
||||
# ── 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
|
||||
""",
|
||||
user.role, 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
|
||||
""",
|
||||
user.role, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", user.role,
|
||||
)
|
||||
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, user.role,
|
||||
)
|
||||
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
|
||||
""",
|
||||
user.role, 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] = [user.role]
|
||||
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, user.role,
|
||||
)
|
||||
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(UTC))
|
||||
values.extend([property_id, user.role])
|
||||
|
||||
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, user.role,
|
||||
)
|
||||
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, user.role,
|
||||
)
|
||||
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, user.role, 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, user.role,
|
||||
)
|
||||
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, user.role,
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(404, "Media asset not found")
|
||||
return {"status": "deleted"}
|
||||
Reference in New Issue
Block a user