Updated backend

This commit is contained in:
sriram
2026-08-12 16:37:28 +05:30
parent eb3567df62
commit ac8cfacbc9
21 changed files with 1496 additions and 155 deletions

144
app/api/deps.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Request-scoped authentication dependencies.
Guards are attached per route, not as middleware matching on paths. Two
reasons that matters here:
* A path-matching middleware silently stops guarding a route the moment
somebody renames it. A ``Depends`` on the route function cannot drift out
of sync with the route it protects.
* FastAPI reflects these into the OpenAPI schema, so ``/docs`` shows which
operations need a credential instead of implying everything is open.
The guard therefore holds regardless of which host the request arrives on -
through the frontend's nginx on ``{$DOMAIN}``, or directly on ``api.{$DOMAIN}``.
Usage::
@router.post("/thing", dependencies=[Depends(require_admin)])
def create_thing(): ...
@router.post("/other", dependencies=[Depends(require_permission("add_product"))])
def other_thing(): ...
@router.post("/who", ...)
def who(principal: Principal = Depends(get_principal)): ...
"""
from __future__ import annotations
from typing import Callable, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from app.infrastructure.security import (
AuthError,
Principal,
anonymous_principal,
decode_access_token,
principal_for_api_key,
)
from app.infrastructure.settings import AUTH_ENABLED
# auto_error=False on both: with two accepted credential types, letting either
# scheme raise on its own would reject a request that carried the *other* one.
# get_principal decides, once it has seen both.
_bearer_scheme = HTTPBearer(auto_error=False, description="Access token from POST /api/auth/login")
_api_key_scheme = APIKeyHeader(
name="X-API-Key",
auto_error=False,
description="Static key for machine consumers (see API_KEYS)",
)
_UNAUTHENTICATED = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated. Send a bearer token from POST /api/auth/login, or an X-API-Key header.",
headers={"WWW-Authenticate": "Bearer"},
)
def get_principal(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
api_key: Optional[str] = Depends(_api_key_scheme),
) -> Principal:
"""Resolve the caller, or raise 401. Use this to require *any* valid credential."""
if not AUTH_ENABLED:
return anonymous_principal()
if credentials is not None and credentials.credentials:
try:
return decode_access_token(credentials.credentials)
except AuthError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
headers={"WWW-Authenticate": "Bearer"},
) from exc
if api_key:
try:
return principal_for_api_key(api_key)
except AuthError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
) from exc
raise _UNAUTHENTICATED
def get_optional_principal(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
api_key: Optional[str] = Depends(_api_key_scheme),
) -> Optional[Principal]:
"""
Resolve the caller if they presented a valid credential, else None.
For endpoints that are public but behave differently when signed in. A
credential that is present but *invalid* still raises - failing open there
would mean a typo'd token silently downgrades to anonymous access.
"""
if not AUTH_ENABLED:
return anonymous_principal()
if credentials is None and not api_key:
return None
return get_principal(credentials, api_key)
def require_role(*roles: str) -> Callable[[Principal], Principal]:
"""Require the caller to hold one of ``roles``."""
allowed = frozenset(roles)
def _dependency(principal: Principal = Depends(get_principal)) -> Principal:
if principal.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"This operation requires the {' or '.join(sorted(allowed))} role; "
f"you are signed in as '{principal.role}'."
),
)
return principal
return _dependency
def require_permission(permission: str) -> Callable[[Principal], Principal]:
"""
Require a specific permission. ``admin`` passes every check - see
``Principal.has_permission``.
"""
def _dependency(principal: Principal = Depends(get_principal)) -> Principal:
if not principal.has_permission(permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"This operation requires the '{permission}' permission.",
)
return principal
return _dependency
# The two guards used most often, named so route decorators stay readable.
require_admin = require_role("admin")
require_authenticated = get_principal

View File

@@ -6,8 +6,9 @@ import logging
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, Field
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from app.api.deps import require_permission
from app.infrastructure.settings import S3_BUCKET
from app.services.vector_store import list_available_brands, count_products_by_brand, _connect
from app.services.s3_service import s3_service
@@ -55,7 +56,7 @@ def get_project_details() -> dict:
}
@router.post("/upload-dataset")
@router.post("/upload-dataset", dependencies=[Depends(require_permission("upload_train_test"))])
async def upload_training_dataset(file: UploadFile = File(...)) -> dict:
"""Admin endpoint: Upload Excel or CSV file to train/test decision records."""
if not file.filename:
@@ -94,7 +95,7 @@ async def upload_training_dataset(file: UploadFile = File(...)) -> dict:
}
@router.post("/allocate-discounts")
@router.post("/allocate-discounts", dependencies=[Depends(require_permission("allocate_discounts"))])
def allocate_discounts_by_stock(payload: BulkDiscountAllocationRequest) -> dict:
"""Admin endpoint: Dynamically allocate discounts on products based on remaining stock levels.
Helpful for store clearance, revenue optimization, and inventory decision making."""

View File

@@ -1,120 +1,259 @@
"""Authentication router for role-based access control (Admin, User, Store)."""
"""
Authentication router - issues and inspects access tokens.
This replaces an earlier version that returned a role profile without issuing
anything, accepted an empty password, and granted `admin` to any username that
asked for the role. It decided which buttons the UI drew; it protected nothing.
Now the token this returns is the credential every write endpoint checks (see
app/api/deps.py), so the rules hold for curl and partner scripts too, not just
for the React app.
Accounts come from the environment - two of them, admin and user, configured as
PBKDF2 digests. That is deliberately not a user database: this project has no
user table, no registration flow and no password reset, and inventing one here
would be a bigger change than the problem calls for. Machine consumers get
API_KEYS instead. If per-user accounts become a real requirement, this module
is the seam to replace.
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import threading
import time
from typing import Dict, List, Tuple
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, status
from app.api.deps import get_principal
from app.infrastructure.security import (
ROLE_PERMISSIONS,
Principal,
create_access_token,
verify_password,
)
from app.infrastructure.settings import (
AUTH_ADMIN_PASSWORD_HASH,
AUTH_ADMIN_USERNAME,
AUTH_ENABLED,
AUTH_LOCKOUT_SECONDS,
AUTH_MAX_LOGIN_ATTEMPTS,
AUTH_USER_PASSWORD_HASH,
AUTH_USER_USERNAME,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
role: Optional[str] = None # Optional override if using role selector
username: str = Field(min_length=1, max_length=150)
password: str = Field(min_length=1, max_length=1024)
class UserProfile(BaseModel):
username: str
role: str # 'admin', 'user', or 'store'
role: str
display_name: str
email: str
permissions: List[str] = Field(default_factory=list)
# Predefined user credentials for system roles (Admin and User)
PREDEFINED_USERS: Dict[str, Dict[str, Any]] = {
"admin": {
"passwords": ["admin12345", "admin123"],
"role": "admin",
"display_name": "System Administrator",
"email": "admin@nutritionintel.com",
},
"user": {
"passwords": ["user123", "store123"],
"role": "user",
"display_name": "Product & Store Manager",
"email": "user@nutritionintel.com",
},
}
ROLE_PERMISSIONS: Dict[str, List[str]] = {
"admin": ["view_catalog", "view_project_details", "upload_train_test", "allocate_discounts", "manage_analytics", "manage_nutrition"],
"user": ["add_product", "upload_batch_products", "update_db_and_json", "fetch_images", "upload_store_inventory", "view_store_analytics", "view_nutrition_insights", "optimize_profits"],
}
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = Field(description="Token lifetime in seconds")
user: UserProfile
@router.post("/login", response_model=UserProfile)
def login(payload: LoginRequest) -> UserProfile:
"""Authenticate user with username and password (Admin or User)."""
un = payload.username.lower().strip()
pwd = payload.password.strip().lower()
target_role = (payload.role or "").lower().strip()
# A syntactically valid hash of an unguessable value. Never matches any real
# password; it exists only so the unknown-username path in login() does the
# same PBKDF2 work as the known one, keeping the two indistinguishable by timing.
_DUMMY_HASH = (
"pbkdf2_sha256$600000$YWJjZGVmZ2hpamtsbW5vcA==$"
"S1cVFrGD4pDkGqSjbEbaVSTONzGhCT9BOaWPQ2vwvvA="
)
# Check predefined usernames
if un in PREDEFINED_USERS:
user_info = PREDEFINED_USERS[un]
if pwd in user_info["passwords"] or pwd == "":
role = user_info["role"]
return UserProfile(
username=un,
role=role,
display_name=user_info["display_name"],
email=user_info["email"],
permissions=ROLE_PERMISSIONS.get(role, []),
def _accounts() -> Dict[str, dict]:
"""
The configured accounts, read per call so a settings reload is picked up.
Usernames are compared case-insensitively (matching what the login form
sends), but the password is not touched - the previous version lowercased
it before comparing, which silently shrank the effective keyspace.
"""
return {
AUTH_ADMIN_USERNAME.lower(): {
"password_hash": AUTH_ADMIN_PASSWORD_HASH,
"role": "admin",
"display_name": "System Administrator",
"email": "admin@nutritionintel.com",
},
AUTH_USER_USERNAME.lower(): {
"password_hash": AUTH_USER_PASSWORD_HASH,
"role": "user",
"display_name": "Product & Store Manager",
"email": "user@nutritionintel.com",
},
}
# ---------------------------------------------------------------------------
# Failed-login throttle
# ---------------------------------------------------------------------------
# In-process and per-worker: with several uvicorn workers a determined attacker
# gets AUTH_MAX_LOGIN_ATTEMPTS per worker, not overall. That is a real limit,
# not a rounding error - but it still turns an unbounded password oracle into a
# rate-limited one without adding Redis to the deployment. Move this to a shared
# store if you ever run many workers.
_failures: Dict[Tuple[str, str], Tuple[int, float]] = {}
_failures_lock = threading.Lock()
def _throttle_key(username: str, request: Request) -> Tuple[str, str]:
# request.client.host is the real client IP because uvicorn runs with
# --proxy-headers behind nginx/Caddy (see backend/Dockerfile); without that
# every request would appear to come from the proxy and share one bucket.
client = request.client.host if request.client else "unknown"
return (username, client)
def _check_not_locked(key: Tuple[str, str]) -> None:
with _failures_lock:
entry = _failures.get(key)
if entry is None:
return
count, first_seen = entry
if time.time() - first_seen > AUTH_LOCKOUT_SECONDS:
del _failures[key]
return
if count >= AUTH_MAX_LOGIN_ATTEMPTS:
retry_after = int(AUTH_LOCKOUT_SECONDS - (time.time() - first_seen))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many failed sign-in attempts. Try again in {retry_after}s.",
headers={"Retry-After": str(max(retry_after, 1))},
)
# Support role-based direct login (e.g. username 'Admin', 'User', 'Store')
if target_role in PREDEFINED_USERS or target_role == "store":
matched_key = "user" if target_role in ("user", "store") else target_role
user_info = PREDEFINED_USERS.get(matched_key, PREDEFINED_USERS["user"])
if pwd in user_info["passwords"] or pwd == "":
role = user_info["role"]
return UserProfile(
username=matched_key,
role=role,
display_name=user_info["display_name"],
email=user_info["email"],
permissions=ROLE_PERMISSIONS.get(role, []),
)
# Fallback for custom username
if un:
role = "admin" if target_role == "admin" else "user"
return UserProfile(
username=un,
role=role,
display_name=un.title(),
email=f"{un}@nutritionintel.com",
permissions=ROLE_PERMISSIONS.get(role, []),
def _record_failure(key: Tuple[str, str]) -> None:
now = time.time()
with _failures_lock:
count, first_seen = _failures.get(key, (0, now))
if now - first_seen > AUTH_LOCKOUT_SECONDS:
count, first_seen = 0, now
_failures[key] = (count + 1, first_seen)
def _clear_failures(key: Tuple[str, str]) -> None:
with _failures_lock:
_failures.pop(key, None)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request) -> LoginResponse:
"""Exchange a username and password for an access token."""
if not AUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Authentication is disabled on this server (AUTH_ENABLED=false), so no "
"token can be issued. Every endpoint is open; sign-in is not required."
),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials. Passwords: Admin (Admin12345), User (User123).",
username = payload.username.strip().lower()
key = _throttle_key(username, request)
_check_not_locked(key)
account = _accounts().get(username)
# Verify against a dummy hash when the username is unknown so a bad
# username and a bad password take the same time. Otherwise the response
# latency alone enumerates valid usernames.
stored_hash = account["password_hash"] if account else _DUMMY_HASH
password_ok = verify_password(payload.password, stored_hash)
if account is None or not password_ok:
_record_failure(key)
logger.warning("Failed sign-in for %r from %s", username, key[1])
# One message for both failure modes, for the same reason.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password.",
)
_clear_failures(key)
role = account["role"]
permissions = ROLE_PERMISSIONS.get(role, [])
token, expires_in = create_access_token(username, role, permissions)
logger.info("Issued token for %r (role=%s)", username, role)
return LoginResponse(
access_token=token,
expires_in=expires_in,
user=UserProfile(
username=username,
role=role,
display_name=account["display_name"],
email=account["email"],
permissions=permissions,
),
)
@router.get("/me", response_model=UserProfile)
def me(principal: Principal = Depends(get_principal)) -> UserProfile:
"""
Who the presented credential belongs to. 401 if it is missing or expired.
The frontend calls this on boot to check a restored session before showing
the app, so an expired token lands on the login page rather than on a
dashboard whose every request then fails.
"""
account = _accounts().get(principal.username, {})
return UserProfile(
username=principal.username,
role=principal.role,
display_name=account.get("display_name", principal.username.title()),
email=account.get("email", f"{principal.username}@nutritionintel.com"),
permissions=principal.permissions,
)
@router.get("/roles")
def list_roles() -> dict:
"""Return available roles (Admin and User)."""
"""
The available roles and what each may do.
Note there are no demo credentials here any more. The passwords are set per
deployment via AUTH_ADMIN_PASSWORD_HASH / AUTH_USER_PASSWORD_HASH; this
endpoint used to publish working ones to anyone who asked.
"""
return {
"roles": [
{
"id": "admin",
"name": "Admin",
"description": "Full access: Catalog brand cards, existing project details, upload Excel/CSV train/test models, allocate discounts based on stock remaining, analytics & nutrition.",
"demo_username": "Admin",
"demo_password": "Admin12345",
"description": (
"Full access: catalog brand cards, project details, Excel/CSV "
"train/test uploads, discount allocation, analytics and nutrition. "
"Implicitly holds every permission."
),
"permissions": ROLE_PERMISSIONS["admin"],
},
{
"id": "user",
"name": "User",
"description": "Combined User & Store role: Upload single or batch CSV/Excel product entries with auto image & DB/JSON sync, store inventory management, profit analytics & nutrition.",
"demo_username": "User",
"demo_password": "User123",
"description": (
"Combined user and store role: single or batch product uploads with "
"image and DB/JSON sync, store inventory, profit analytics, nutrition."
),
"permissions": ROLE_PERMISSIONS["user"],
},
]
}

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from app.api.background import run_in_background
from app.api.deps import require_admin
from app.api.job_store import job_store
from app.api.schemas import CatalogGenerateRequest, CatalogJobOut
from app.core.ingestion import ingest_brand
@@ -24,7 +25,12 @@ async def _run_job(job_id: str, brand: str, max_products: int) -> None:
job_store.update(job_id, "failed", detail=str(e))
@router.post("/catalog/generate", response_model=CatalogJobOut, status_code=202)
@router.post(
"/catalog/generate",
response_model=CatalogJobOut,
status_code=202,
dependencies=[Depends(require_admin)],
)
def generate_catalog(payload: CatalogGenerateRequest) -> CatalogJobOut:
"""Kick off brand catalog ingestion (discovery -> images -> embeddings ->
pgvector) as a background daemon thread and return immediately with a job id.

View File

@@ -1,14 +1,22 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.api.deps import require_authenticated
from app.api.schemas import ChatRequest, ChatResponseOut, SourceProductOut
from app.services.rag_service import answer_query
router = APIRouter(tags=["chat"])
@router.post("/chat", response_model=ChatResponseOut)
# Any signed-in role may chat, but anonymous callers may not: this is the only
# endpoint that spends Ollama time, and an unmetered LLM on a public host is
# what exhausts an 8GB VPS first.
@router.post(
"/chat",
response_model=ChatResponseOut,
dependencies=[Depends(require_authenticated)],
)
def chat(payload: ChatRequest) -> ChatResponseOut:
"""Conversational RAG endpoint: retrieves the most relevant products
from pgvector, then asks the local Ollama model to answer the

View File

@@ -2,10 +2,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.background import run_in_background
from app.api.deps import require_admin
from app.api.nutrition_job_store import nutrition_job_store
from app.services import nutrition_enrichment_service
@@ -54,7 +55,7 @@ def _run_train_job(job_id: str) -> None:
nutrition_job_store.update(job_id, status="failed", detail=str(e))
@router.post("/enrich", status_code=202)
@router.post("/enrich", status_code=202, dependencies=[Depends(require_admin)])
def enrich_nutrition(payload: EnrichRequest) -> dict:
"""Retrieves verified nutrition data for every product in the
catalog (Open Food Facts), computes transparent scores/insights, and
@@ -69,7 +70,7 @@ def enrich_nutrition(payload: EnrichRequest) -> dict:
return {"job_id": job.job_id, "status": job.status}
@router.post("/train", status_code=202)
@router.post("/train", status_code=202, dependencies=[Depends(require_admin)])
def train_nutrition_models() -> dict:
"""Trains the nutrition-similarity (KNN/cosine) and nutrition-based
clustering (KMeans) models over the currently enriched catalog.

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from app.api.background import run_in_background
from app.api.deps import require_admin
from app.api.store_job_store import store_job_store
from app.api.store_schemas import SeedRequest, TrainRequest
from app.services import ml_training_service, store_seed_service
@@ -33,7 +34,7 @@ def _run_train_job(job_id: str, models) -> None:
store_job_store.update(job_id, "failed", detail=str(e))
@router.post("/seed", status_code=202)
@router.post("/seed", status_code=202, dependencies=[Depends(require_admin)])
def seed_store_intelligence(payload: SeedRequest) -> dict:
"""Provisions the 5 stores + simulates order history (Features 1, 2, 8).
Requires at least one brand already ingested via the existing
@@ -47,7 +48,7 @@ def seed_store_intelligence(payload: SeedRequest) -> dict:
return {"job_id": job.job_id, "status": job.status}
@router.post("/train", status_code=202)
@router.post("/train", status_code=202, dependencies=[Depends(require_admin)])
def train_models(payload: TrainRequest) -> dict:
"""Trains every ML model (or a subset) against the seeded data.
Equivalent to running `python scripts/train_ml_models.py`."""

View File

@@ -7,9 +7,10 @@ import threading
from pathlib import Path
from typing import Any, Dict
from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends
from pydantic import BaseModel
from app.api.deps import require_admin
from app.services.vector_store import count_products_all_brands, list_available_brands, _connect
from app.services.store_db import list_stores
from app.services.ollama_service import _ensure_client
@@ -80,7 +81,7 @@ def get_system_status() -> SystemStatusOut:
)
@router.post("/system/init")
@router.post("/system/init", dependencies=[Depends(require_admin)])
def initialize_system(background_tasks: BackgroundTasks) -> Dict[str, Any]:
"""Trigger background auto-initialization of sample catalog and store data."""
background_tasks.add_task(_run_background_auto_seed)

View File

@@ -7,9 +7,10 @@ import pandas as pd
from typing import Any, Dict, List, Optional
from datetime import datetime
from fastapi import APIRouter, File, HTTPException, UploadFile, Response
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response
from fastapi.responses import PlainTextResponse
from app.api.deps import require_permission
from app.services.vector_store import _connect
from app.services import store_db, nutrition_db
@@ -72,8 +73,10 @@ def _get_int(row: dict, keys: List[str], default: int = 0) -> int:
# ---------------------------------------------------------------------------
# Stores Inventory Excel / CSV Upload
# ---------------------------------------------------------------------------
@router.post("/stores")
@router.post("/stores/upload")
# Both aliases carry the guard. A dependency on only one decorator would leave
# the other path an unauthenticated route to the same function.
@router.post("/stores", dependencies=[Depends(require_permission("upload_store_inventory"))])
@router.post("/stores/upload", dependencies=[Depends(require_permission("upload_store_inventory"))])
async def upload_stores_file(file: UploadFile = File(...)) -> Dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
@@ -181,8 +184,8 @@ async def upload_stores_file(file: UploadFile = File(...)) -> Dict[str, Any]:
# ---------------------------------------------------------------------------
# Sales / Analytics Excel / CSV Upload
# ---------------------------------------------------------------------------
@router.post("/analytics")
@router.post("/analytics/upload")
@router.post("/analytics", dependencies=[Depends(require_permission("manage_analytics"))])
@router.post("/analytics/upload", dependencies=[Depends(require_permission("manage_analytics"))])
async def upload_analytics_file(file: UploadFile = File(...)) -> Dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
@@ -278,8 +281,8 @@ async def upload_analytics_file(file: UploadFile = File(...)) -> Dict[str, Any]:
# ---------------------------------------------------------------------------
# Nutrition Intelligence Excel / CSV Upload
# ---------------------------------------------------------------------------
@router.post("/nutrition")
@router.post("/nutrition/upload")
@router.post("/nutrition", dependencies=[Depends(require_permission("manage_nutrition"))])
@router.post("/nutrition/upload", dependencies=[Depends(require_permission("manage_nutrition"))])
async def upload_nutrition_file(file: UploadFile = File(...)) -> Dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")

View File

@@ -6,7 +6,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, File, UploadFile
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
from app.api.deps import require_permission
from app.services.vector_store import (
upsert_brand_products,
@@ -218,7 +220,7 @@ def _update_json_catalog_file(brand: str, product_dict: Dict[str, Any]) -> None:
logger.info("✅ Updated JSON seed file '%s' (total products: %d)", file_path.name, data["total_products"])
@router.post("/add", status_code=201)
@router.post("/add", status_code=201, dependencies=[Depends(require_permission("add_product"))])
def add_new_product(payload: AddProductRequest) -> dict:
"""User role endpoint: Add a single new product record (e.g. Lion Dates 450g).
Automatically enriches details, fetches images, updates PostgreSQL DB,
@@ -235,7 +237,11 @@ def add_new_product(payload: AddProductRequest) -> dict:
raise HTTPException(status_code=500, detail=f"Failed to add product: {e}")
@router.post("/batch-add", status_code=201)
@router.post(
"/batch-add",
status_code=201,
dependencies=[Depends(require_permission("upload_batch_products"))],
)
def batch_add_products(payload: BatchAddProductsRequest) -> dict:
"""User role endpoint: Batch upload multiple product records at once."""
added = []
@@ -256,7 +262,11 @@ def batch_add_products(payload: BatchAddProductsRequest) -> dict:
}
@router.post("/upload-file", status_code=201)
@router.post(
"/upload-file",
status_code=201,
dependencies=[Depends(require_permission("upload_batch_products"))],
)
async def upload_products_file(file: UploadFile = File(...)) -> dict:
"""User role endpoint: Upload CSV or Excel file containing products to enrich and sync."""
filename = file.filename or ""