Updated backend
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user