Spaces:
Configuration error
Configuration error
Commit ·
88dcd96
1
Parent(s): 1519172
widget lib changes
Browse files- app/main.py +5 -0
- app/widget_collection/__init__.py +1 -0
- app/widget_collection/model.py +22 -0
- app/widget_collection/router.py +70 -0
- app/widget_collection/schemas.py +22 -0
- app/widget_collection/service.py +62 -0
- scripts/seed_widget_collection.py +66 -0
app/main.py
CHANGED
|
@@ -22,6 +22,7 @@ from app.events.controllers.router import router as events_router
|
|
| 22 |
from app.reports.controllers.router import router as reports_router
|
| 23 |
from app.dashboard.controllers.router import router as dashboard_router
|
| 24 |
from app.kpi_cache.controllers.router import router as kpi_cache_router
|
|
|
|
| 25 |
|
| 26 |
# Init logging
|
| 27 |
setup_logging(level=settings.LOG_LEVEL.strip().upper())
|
|
@@ -45,6 +46,9 @@ async def lifespan(app: FastAPI):
|
|
| 45 |
)
|
| 46 |
await kpi_col.create_index([("merchant_id", 1)], background=True, name="kpi_cache_merchant_id")
|
| 47 |
await kpi_col.create_index([("expires_at", 1)], background=True, name="kpi_cache_expires_at")
|
|
|
|
|
|
|
|
|
|
| 48 |
logger.info("Analytics Microservice started", extra={"event": "service_ready"})
|
| 49 |
yield
|
| 50 |
logger.info("Shutting down Analytics Microservice", extra={"event": "service_stopping"})
|
|
@@ -133,6 +137,7 @@ app.include_router(events_router)
|
|
| 133 |
app.include_router(reports_router)
|
| 134 |
app.include_router(dashboard_router)
|
| 135 |
app.include_router(kpi_cache_router)
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
@app.get("/health", tags=["health"])
|
|
|
|
| 22 |
from app.reports.controllers.router import router as reports_router
|
| 23 |
from app.dashboard.controllers.router import router as dashboard_router
|
| 24 |
from app.kpi_cache.controllers.router import router as kpi_cache_router
|
| 25 |
+
from app.widget_collection.router import router as widget_collection_router
|
| 26 |
|
| 27 |
# Init logging
|
| 28 |
setup_logging(level=settings.LOG_LEVEL.strip().upper())
|
|
|
|
| 46 |
)
|
| 47 |
await kpi_col.create_index([("merchant_id", 1)], background=True, name="kpi_cache_merchant_id")
|
| 48 |
await kpi_col.create_index([("expires_at", 1)], background=True, name="kpi_cache_expires_at")
|
| 49 |
+
# Ensure MongoDB index for analytics_widget_collection
|
| 50 |
+
wc_col = db["analytics_widget_collection"]
|
| 51 |
+
await wc_col.create_index("widget_id", unique=True, background=True, name="widget_id_unique")
|
| 52 |
logger.info("Analytics Microservice started", extra={"event": "service_ready"})
|
| 53 |
yield
|
| 54 |
logger.info("Shutting down Analytics Microservice", extra={"event": "service_stopping"})
|
|
|
|
| 137 |
app.include_router(reports_router)
|
| 138 |
app.include_router(dashboard_router)
|
| 139 |
app.include_router(kpi_cache_router)
|
| 140 |
+
app.include_router(widget_collection_router)
|
| 141 |
|
| 142 |
|
| 143 |
@app.get("/health", tags=["health"])
|
app/widget_collection/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# widget_collection module
|
app/widget_collection/model.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MongoDB document model for analytics_widget_collection."""
|
| 2 |
+
from typing import Any, Dict, Optional
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class DataConfig(BaseModel):
|
| 7 |
+
source: str
|
| 8 |
+
params: Dict[str, Any] = {}
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class WidgetCollectionDocument(BaseModel):
|
| 12 |
+
widget_id: str
|
| 13 |
+
type: str = "kpi"
|
| 14 |
+
title: str
|
| 15 |
+
category: str
|
| 16 |
+
unit: Optional[str] = None
|
| 17 |
+
description: Optional[str] = None
|
| 18 |
+
drill_down_url: Optional[str] = None
|
| 19 |
+
data_config: DataConfig
|
| 20 |
+
|
| 21 |
+
class Config:
|
| 22 |
+
populate_by_name = True
|
app/widget_collection/router.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI router for widget_collection endpoints."""
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
|
| 4 |
+
from app.core.logging import get_logger
|
| 5 |
+
from app.dependencies.auth import get_current_user, TokenUser
|
| 6 |
+
from app.dependencies.kpi_permissions import require_dashboard_view
|
| 7 |
+
from app.widget_collection.schemas import ListWidgetCollectionRequest, UpdateWidgetRolesRequest
|
| 8 |
+
from app.widget_collection.service import WidgetCollectionService
|
| 9 |
+
|
| 10 |
+
logger = get_logger(__name__)
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/widget-collection", tags=["Widget Collection"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.post("/list")
|
| 16 |
+
async def list_widgets(
|
| 17 |
+
payload: ListWidgetCollectionRequest,
|
| 18 |
+
current_user: TokenUser = Depends(require_dashboard_view),
|
| 19 |
+
):
|
| 20 |
+
"""
|
| 21 |
+
List widget collection documents with optional projection_list.
|
| 22 |
+
Requires: permissions.dashboard.view
|
| 23 |
+
"""
|
| 24 |
+
filters: dict = {}
|
| 25 |
+
if payload.filters:
|
| 26 |
+
if payload.filters.category:
|
| 27 |
+
filters["category"] = payload.filters.category
|
| 28 |
+
if payload.filters.type:
|
| 29 |
+
filters["type"] = payload.filters.type
|
| 30 |
+
|
| 31 |
+
docs = await WidgetCollectionService.list_widgets(
|
| 32 |
+
filters=filters,
|
| 33 |
+
skip=payload.skip,
|
| 34 |
+
limit=payload.limit,
|
| 35 |
+
projection_list=payload.projection_list,
|
| 36 |
+
)
|
| 37 |
+
return {"success": True, "data": docs, "count": len(docs)}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/{widget_id}")
|
| 41 |
+
async def get_widget(
|
| 42 |
+
widget_id: str,
|
| 43 |
+
current_user: TokenUser = Depends(require_dashboard_view),
|
| 44 |
+
):
|
| 45 |
+
"""Fetch a single widget config document by widget_id."""
|
| 46 |
+
doc = await WidgetCollectionService.get_widget(widget_id)
|
| 47 |
+
if not doc:
|
| 48 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
| 49 |
+
return {"success": True, "data": doc}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.put("/{widget_id}")
|
| 53 |
+
async def update_widget(
|
| 54 |
+
widget_id: str,
|
| 55 |
+
payload: UpdateWidgetRolesRequest,
|
| 56 |
+
current_user: TokenUser = Depends(require_dashboard_view),
|
| 57 |
+
):
|
| 58 |
+
"""Update data_config for a widget."""
|
| 59 |
+
update_data: dict = {}
|
| 60 |
+
if payload.data_config is not None:
|
| 61 |
+
update_data["data_config"] = payload.data_config
|
| 62 |
+
|
| 63 |
+
if not update_data:
|
| 64 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Nothing to update")
|
| 65 |
+
|
| 66 |
+
updated = await WidgetCollectionService.update_widget(widget_id, update_data)
|
| 67 |
+
if not updated:
|
| 68 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
| 69 |
+
|
| 70 |
+
return {"success": True, "message": "Widget updated"}
|
app/widget_collection/schemas.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request/response schemas for widget_collection endpoints."""
|
| 2 |
+
from typing import Any, Dict, List, Optional
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class WidgetCollectionFilters(BaseModel):
|
| 7 |
+
category: Optional[str] = None
|
| 8 |
+
type: Optional[str] = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ListWidgetCollectionRequest(BaseModel):
|
| 12 |
+
filters: Optional[WidgetCollectionFilters] = None
|
| 13 |
+
skip: int = Field(0, ge=0)
|
| 14 |
+
limit: int = Field(100, ge=1, le=500)
|
| 15 |
+
projection_list: Optional[List[str]] = Field(
|
| 16 |
+
None,
|
| 17 |
+
description="List of fields to include in response"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class UpdateWidgetRolesRequest(BaseModel):
|
| 22 |
+
data_config: Optional[Dict[str, Any]] = None
|
app/widget_collection/service.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CRUD service for analytics_widget_collection MongoDB collection."""
|
| 2 |
+
from typing import Any, Dict, List, Optional
|
| 3 |
+
|
| 4 |
+
from app.nosql import get_database
|
| 5 |
+
from app.core.logging import get_logger
|
| 6 |
+
|
| 7 |
+
logger = get_logger(__name__)
|
| 8 |
+
|
| 9 |
+
COLLECTION = "analytics_widget_collection"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class WidgetCollectionService:
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def list_widgets(
|
| 16 |
+
filters: Dict[str, Any],
|
| 17 |
+
skip: int = 0,
|
| 18 |
+
limit: int = 100,
|
| 19 |
+
projection_list: Optional[List[str]] = None,
|
| 20 |
+
) -> List[Any]:
|
| 21 |
+
db = get_database()
|
| 22 |
+
col = db[COLLECTION]
|
| 23 |
+
|
| 24 |
+
projection: Optional[Dict[str, int]] = None
|
| 25 |
+
if projection_list:
|
| 26 |
+
projection = {field: 1 for field in projection_list}
|
| 27 |
+
projection["_id"] = 0
|
| 28 |
+
|
| 29 |
+
cursor = col.find(filters, projection).skip(skip).limit(limit)
|
| 30 |
+
docs = await cursor.to_list(length=limit)
|
| 31 |
+
|
| 32 |
+
if projection_list:
|
| 33 |
+
return docs
|
| 34 |
+
|
| 35 |
+
# Strip MongoDB _id for clean output
|
| 36 |
+
for doc in docs:
|
| 37 |
+
doc.pop("_id", None)
|
| 38 |
+
return docs
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
async def get_widget(widget_id: str) -> Optional[Dict[str, Any]]:
|
| 42 |
+
db = get_database()
|
| 43 |
+
col = db[COLLECTION]
|
| 44 |
+
doc = await col.find_one({"widget_id": widget_id}, {"_id": 0})
|
| 45 |
+
return doc
|
| 46 |
+
|
| 47 |
+
@staticmethod
|
| 48 |
+
async def update_widget(widget_id: str, update_data: Dict[str, Any]) -> bool:
|
| 49 |
+
db = get_database()
|
| 50 |
+
col = db[COLLECTION]
|
| 51 |
+
result = await col.update_one({"widget_id": widget_id}, {"$set": update_data})
|
| 52 |
+
return result.matched_count > 0
|
| 53 |
+
|
| 54 |
+
@staticmethod
|
| 55 |
+
async def upsert_widget(doc: Dict[str, Any]) -> None:
|
| 56 |
+
db = get_database()
|
| 57 |
+
col = db[COLLECTION]
|
| 58 |
+
await col.update_one(
|
| 59 |
+
{"widget_id": doc["widget_id"]},
|
| 60 |
+
{"$set": doc},
|
| 61 |
+
upsert=True,
|
| 62 |
+
)
|
scripts/seed_widget_collection.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Seed script — populates analytics_widget_collection in MongoDB.
|
| 3 |
+
|
| 4 |
+
Derives all documents from KPI_WIDGET_REGISTRY (single source of truth).
|
| 5 |
+
Run from the analytics-ms root:
|
| 6 |
+
python -m scripts.seed_widget_collection
|
| 7 |
+
"""
|
| 8 |
+
import asyncio
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 13 |
+
|
| 14 |
+
from app.core.config import settings # noqa: E402
|
| 15 |
+
from app.nosql import connect_to_mongo, close_mongo_connection, get_database # noqa: E402
|
| 16 |
+
from app.kpi_cache.constants import KPI_WIDGET_REGISTRY # noqa: E402
|
| 17 |
+
|
| 18 |
+
COLLECTION = "dashboard_widgets"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _build_document(widget_id: str, meta: dict) -> dict:
|
| 22 |
+
return {
|
| 23 |
+
"widget_id": widget_id,
|
| 24 |
+
"type": "kpi",
|
| 25 |
+
"title": meta.get("title", widget_id),
|
| 26 |
+
"category": meta.get("category", ""),
|
| 27 |
+
"unit": meta.get("unit"),
|
| 28 |
+
"description": meta.get("description"),
|
| 29 |
+
"drill_down_url": meta.get("drill_down_url"),
|
| 30 |
+
"data_config": {
|
| 31 |
+
"source": "merchant_kpi_stats",
|
| 32 |
+
"params": {
|
| 33 |
+
"widget": widget_id,
|
| 34 |
+
"period_window": "mtd",
|
| 35 |
+
"unit": meta.get("unit", "count"),
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def seed():
|
| 42 |
+
await connect_to_mongo()
|
| 43 |
+
db = get_database()
|
| 44 |
+
col = db[COLLECTION]
|
| 45 |
+
|
| 46 |
+
# Ensure unique index on widget_id
|
| 47 |
+
await col.create_index("widget_id", unique=True, background=True, name="widget_id_unique")
|
| 48 |
+
|
| 49 |
+
docs = [_build_document(wid, meta) for wid, meta in KPI_WIDGET_REGISTRY.items()]
|
| 50 |
+
|
| 51 |
+
upserted = 0
|
| 52 |
+
for doc in docs:
|
| 53 |
+
result = await col.update_one(
|
| 54 |
+
{"widget_id": doc["widget_id"]},
|
| 55 |
+
{"$set": doc},
|
| 56 |
+
upsert=True,
|
| 57 |
+
)
|
| 58 |
+
if result.upserted_id or result.modified_count:
|
| 59 |
+
upserted += 1
|
| 60 |
+
|
| 61 |
+
print(f"Seeded {upserted}/{len(docs)} widget collection documents into '{COLLECTION}'.")
|
| 62 |
+
await close_mongo_connection()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
asyncio.run(seed())
|