forked from sagnik/Project_Velocity
Merge Conflicts (#41)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#41
This commit is contained in:
172
backend/main.py
172
backend/main.py
@@ -11,13 +11,12 @@ import os
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from dotenv import load_dotenv
|
||||
@@ -62,12 +61,14 @@ from backend.api.routes_mobile_edge import router as mobile_edge_router
|
||||
from backend.api.routes_inventory import router as inventory_router
|
||||
from backend.api.routes_admin_surface import router as admin_surface_router
|
||||
from backend.api.routes_oracle_templates import router as oracle_templates_router
|
||||
from backend.api.routes_observability import router as observability_router
|
||||
from backend.api.routes_crm_imports import router as crm_imports_router
|
||||
from backend.api.routes_runtime_llm import router as runtime_llm_router
|
||||
from backend.auth.dependencies import (
|
||||
create_access_token, verify_password, get_current_user, UserPrincipal
|
||||
)
|
||||
from backend.auth.routes import router as auth_router
|
||||
from backend.auth.user_directory import ensure_user_directory_schema
|
||||
from backend.db.pool import create_pool, close_pool
|
||||
from backend.migrations.runner import apply_migrations
|
||||
from backend.observability import RequestObservabilityMiddleware
|
||||
from backend.oracle.router_v1 import router as oracle_v1_router
|
||||
from backend.routers.cctv import router as cctv_router
|
||||
from backend.routers.scenes import router as scenes_router
|
||||
@@ -86,6 +87,11 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
app.state.db_pool = await create_pool()
|
||||
logger.info("asyncpg pool created")
|
||||
async with app.state.db_pool.acquire() as conn:
|
||||
applied = await apply_migrations(conn)
|
||||
if applied:
|
||||
logger.info("Applied backend migrations: %s", ", ".join(applied))
|
||||
await ensure_user_directory_schema(app)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create DB pool: %s", exc)
|
||||
app.state.db_pool = None
|
||||
@@ -118,6 +124,7 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(RequestObservabilityMiddleware)
|
||||
|
||||
# ── Static asset serving (Vault files) ───────────────────────────────────────
|
||||
|
||||
@@ -125,11 +132,6 @@ ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
|
||||
if os.path.isdir(ASSET_DIR):
|
||||
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
|
||||
|
||||
|
||||
def _sanitize_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
|
||||
return cleaned or "upload"
|
||||
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
|
||||
@@ -146,6 +148,7 @@ app.include_router(vault_router, prefix="/api/vault", tags=["Vault"])
|
||||
app.include_router(mobile_edge_router, prefix="/api/mobile-edge", tags=["Mobile Edge"])
|
||||
app.include_router(inventory_router, prefix="/api/inventory", tags=["Inventory"])
|
||||
app.include_router(admin_surface_router, prefix="/api/admin-surface", tags=["Admin Surface"])
|
||||
app.include_router(observability_router, prefix="/api", tags=["Observability"])
|
||||
app.include_router(crm_imports_router, prefix="/api", tags=["CRM Canonical"])
|
||||
app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime LLM"])
|
||||
|
||||
@@ -153,144 +156,6 @@ app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime
|
||||
from backend.routers.vault import router as public_vault_router
|
||||
app.include_router(public_vault_router, prefix="/vault", tags=["Vault Public"])
|
||||
|
||||
# ── Auth endpoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
@app.post("/api/auth/login", tags=["Auth"])
|
||||
async def login(body: LoginRequest):
|
||||
"""
|
||||
Authenticate a user and return a JWT.
|
||||
Credentials are verified against the users_and_roles table.
|
||||
"""
|
||||
from backend.db.pool import get_pool
|
||||
from fastapi import Request
|
||||
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id::text, role, password_hash FROM users_and_roles WHERE email = $1 AND is_active = TRUE",
|
||||
body.email,
|
||||
)
|
||||
|
||||
if not row or not verify_password(body.password, row["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password.",
|
||||
)
|
||||
|
||||
token = create_access_token(user_id=row["id"], role=row["role"])
|
||||
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
|
||||
|
||||
|
||||
@app.get("/api/auth/me", tags=["Auth"])
|
||||
async def me(user: UserPrincipal = Depends(get_current_user)):
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT full_name, email, avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
user.user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"role": user.role,
|
||||
"full_name": row["full_name"] if row else None,
|
||||
"email": row["email"] if row else None,
|
||||
"avatar_url": row["avatar_url"] if row else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/auth/users", tags=["Auth"])
|
||||
async def list_auth_users(_: UserPrincipal = Depends(get_current_user)):
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
id::text AS user_id,
|
||||
role,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY
|
||||
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
|
||||
"""
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": row["user_id"],
|
||||
"role": row["role"],
|
||||
"full_name": row["full_name"],
|
||||
"email": row["email"],
|
||||
"avatar_url": row["avatar_url"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@app.post("/api/auth/profile/avatar", tags=["Auth"])
|
||||
async def upload_profile_avatar(
|
||||
file: UploadFile = File(...),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||
if file.content_type not in allowed:
|
||||
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
|
||||
|
||||
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
|
||||
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||
extension = ".png"
|
||||
|
||||
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
|
||||
avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_{int(datetime.now(UTC).timestamp())}{extension}"
|
||||
destination = avatar_dir / filename
|
||||
contents = await file.read()
|
||||
destination.write_bytes(contents)
|
||||
|
||||
avatar_url = f"/assets/profile_avatars/{filename}"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE users_and_roles
|
||||
SET avatar_url = $2
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
user.user_id,
|
||||
avatar_url,
|
||||
)
|
||||
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
|
||||
|
||||
class _CatalystManager:
|
||||
@@ -359,7 +224,7 @@ async def crm_ws(ws: WebSocket) -> None:
|
||||
{
|
||||
"type": "crm_presence",
|
||||
"connected_clients": len(_crm_mgr.active),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -376,7 +241,7 @@ async def broadcast_live_event(event_type, message, campaign_name=None, value=No
|
||||
"message": message,
|
||||
"campaignName": campaign_name,
|
||||
"value": value,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await _catalyst_mgr.broadcast(payload)
|
||||
|
||||
@@ -387,7 +252,7 @@ app.state.broadcast_live_event = broadcast_live_event
|
||||
async def broadcast_crm_event(payload: dict) -> None:
|
||||
enriched = {
|
||||
**payload,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await _crm_mgr.broadcast(enriched)
|
||||
|
||||
@@ -406,6 +271,5 @@ async def health() -> dict:
|
||||
"service": "velocity-backend",
|
||||
"version": "2.0.0",
|
||||
"db_pool": "connected" if db_ok else "unavailable",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user