Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 101 additions & 7 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,44 @@
from datetime import datetime, timezone
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession

from app.dependencies.async_db import get_async_db
from app.dependencies.auth import get_current_seller_async
from app.models.alert import (
Alert,
AlertActionType,
AlertDeliveryStatus,
AlertUrgencyLevel,
)
from app.models.recommendation import RecommendationAction, RecommendationConfidence
from app.models.seller import Seller
from app.repositories.alert_repository import AlertRepository
from app.repositories.competitor_repository import CompetitorRepository
from app.repositories.query_utils import PaginationParams, SortDirection
from app.repositories.recommendation_repository import RecommendationRepository
from app.schemas.dashboard.activity_schema import ActivityItem
from app.schemas.dashboard.alert_schema import AlertListResponse, AlertSortField
from app.schemas.dashboard.competitor_schema import CompetitorListResponse, CompetitorSortField
from app.schemas.dashboard.market_trend_schema import MarketTrendPoint
from app.schemas.dashboard.opportunity_schema import OpportunityListResponse, OpportunitySortField
from app.schemas.dashboard.opportunity_metrics_schema import OpportunityMetricRecord
from app.schemas.dashboard.price_trend_schema import CompetitorPriceTrendPoint
from app.schemas.dashboard.recommendation_schema import (
RecommendationListResponse,
RecommendationSortField,
)
from app.schemas.dashboard.summary_schema import DashboardSummaryResponse
from app.services.dashboard.activity_service import ActivityDashboardService
from app.services.dashboard.alert_service import AlertDashboardService
from app.services.dashboard.competitor_service import CompetitorDashboardService
from app.services.dashboard.dashboard_service import DashboardService
from app.services.dashboard.market_trend_service import MarketTrendDashboardService
from app.services.dashboard.opportunity_metrics_service import OpportunityMetricsService
from app.services.dashboard.opportunity_service import OpportunityDashboardService
from app.services.dashboard.price_trend_service import CompetitorPriceTrendService
from app.services.dashboard.recommendation_service import RecommendationDashboardService

router = APIRouter(prefix="/dashboard", tags=["dashboard"])
Expand All @@ -50,10 +60,12 @@ class AlertResolveResponse(BaseModel):
async def get_dashboard_summary(
seller_id: UUID | None = Query(None, description="Filter by seller"),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> DashboardSummaryResponse:
start = time.monotonic()
service = DashboardService(db, logger=logger)
response = await service.get_summary(seller_id=str(seller_id) if seller_id else None)
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
response = await service.get_summary(seller_id=scoped_seller_id)
_log_endpoint("dashboard_summary", start, seller_id=seller_id)
return response

Expand All @@ -75,14 +87,16 @@ async def list_competitors(
page: int | None = Query(None, ge=1),
page_size: int | None = Query(None, ge=1, le=200),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> CompetitorListResponse:
start = time.monotonic()
pagination = PaginationParams.from_request(
limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200
)
service = CompetitorDashboardService(CompetitorRepository(db), logger=logger)
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
response = await service.list_competitors(
seller_id=str(seller_id) if seller_id else None,
seller_id=scoped_seller_id,
product_id=str(product_id) if product_id else None,
stock_status=stock_status,
search=search,
Expand Down Expand Up @@ -114,14 +128,16 @@ async def list_recommendations(
page: int | None = Query(None, ge=1),
page_size: int | None = Query(None, ge=1, le=200),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> RecommendationListResponse:
start = time.monotonic()
pagination = PaginationParams.from_request(
limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200
)
service = RecommendationDashboardService(RecommendationRepository(db), logger=logger)
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
response = await service.list_recommendations(
seller_id=str(seller_id) if seller_id else None,
seller_id=scoped_seller_id,
product_id=str(product_id) if product_id else None,
action_type=action_type.value if action_type else None,
confidence=confidence.value if confidence else None,
Expand Down Expand Up @@ -159,14 +175,16 @@ async def list_alerts(
page: int | None = Query(None, ge=1),
page_size: int | None = Query(None, ge=1, le=200),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> AlertListResponse:
start = time.monotonic()
pagination = PaginationParams.from_request(
limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200
)
service = AlertDashboardService(AlertRepository(db), logger=logger)
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
response = await service.list_alerts(
seller_id=str(seller_id) if seller_id else None,
seller_id=scoped_seller_id,
product_id=str(product_id) if product_id else None,
urgency_level=urgency_level.value if urgency_level else None,
action_type=action_type.value if action_type else None,
Expand Down Expand Up @@ -197,14 +215,16 @@ async def list_market_opportunities(
page: int | None = Query(None, ge=1),
page_size: int | None = Query(None, ge=1, le=200),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> OpportunityListResponse:
start = time.monotonic()
pagination = PaginationParams.from_request(
limit=limit, offset=offset, page=page, page_size=page_size, max_page_size=200
)
service = OpportunityDashboardService(RecommendationRepository(db), logger=logger)
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
response = await service.list_opportunities(
seller_id=str(seller_id) if seller_id else None,
seller_id=scoped_seller_id,
min_revenue=min_revenue,
date_from=date_from,
date_to=date_to,
Expand All @@ -220,17 +240,91 @@ async def list_market_opportunities(
async def resolve_alert(
alert_id: UUID,
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> AlertResolveResponse:
alert = await db.get(Alert, alert_id)
if not alert:
if not alert or str(alert.seller_id) != str(current_seller.id):
raise HTTPException(status_code=404, detail="Alert not found.")
if alert.read_at is None:
alert.read_at = datetime.now(timezone.utc)
await db.commit()
return AlertResolveResponse(id=str(alert.id), status="resolved")


@router.get("/activity", response_model=list[ActivityItem])
async def get_activity_feed(
seller_id: UUID | None = Query(None, description="Filter by seller"),
limit: int = Query(12, ge=1, le=50),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> list[ActivityItem]:
start = time.monotonic()
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
service = ActivityDashboardService(db, logger=logger)
response = await service.get_activity_feed(seller_id=scoped_seller_id, limit=limit)
_log_endpoint("dashboard_activity", start, seller_id=seller_id)
return response


@router.get("/market-trends", response_model=list[MarketTrendPoint])
async def get_market_trends(
seller_id: UUID | None = Query(None, description="Filter by seller"),
days: int = Query(14, ge=1, le=90),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> list[MarketTrendPoint]:
start = time.monotonic()
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
service = MarketTrendDashboardService(db, logger=logger)
response = await service.get_market_trends(seller_id=scoped_seller_id, days=days)
_log_endpoint("dashboard_market_trends", start, seller_id=seller_id)
return response


@router.get("/opportunity-metrics", response_model=list[OpportunityMetricRecord])
async def get_opportunity_metrics(
seller_id: UUID | None = Query(None, description="Filter by seller"),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> list[OpportunityMetricRecord]:
start = time.monotonic()
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
service = OpportunityMetricsService(db, logger=logger)
response = await service.get_metrics(seller_id=scoped_seller_id)
_log_endpoint("dashboard_opportunity_metrics", start, seller_id=seller_id)
return response


@router.get("/competitor-price-trend", response_model=list[CompetitorPriceTrendPoint])
async def get_competitor_price_trend(
seller_id: UUID | None = Query(None, description="Filter by seller"),
product_id: UUID | None = Query(None, description="Filter by product"),
days: int = Query(14, ge=1, le=90),
db: AsyncSession = Depends(get_async_db),
current_seller: Seller = Depends(get_current_seller_async),
) -> list[CompetitorPriceTrendPoint]:
start = time.monotonic()
scoped_seller_id = _require_seller_scope(current_seller, seller_id)
service = CompetitorPriceTrendService(db, logger=logger)
response = await service.get_price_trend(
seller_id=scoped_seller_id,
product_id=str(product_id) if product_id else None,
days=days,
)
_log_endpoint("dashboard_competitor_price_trend", start, seller_id=seller_id)
return response


def _log_endpoint(name: str, start: float, **fields: Any) -> None:
duration_ms = int((time.monotonic() - start) * 1000)
payload = " ".join(f"{key}={value}" for key, value in fields.items() if value is not None)
logger.info("%s duration_ms=%s %s", name, duration_ms, payload)


def _require_seller_scope(current_seller: Seller, seller_id: UUID | None) -> str:
if seller_id and seller_id != current_seller.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Seller scope is not authorized.",
)
return str(current_seller.id)
2 changes: 2 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from dotenv import load_dotenv


print("DATABASE_URL =", os.getenv("DATABASE_URL"))

def _split_csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]

Expand Down
8 changes: 8 additions & 0 deletions backend/app/schemas/dashboard/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from __future__ import annotations

from app.schemas.dashboard.alert_schema import AlertListResponse, AlertRecord
from app.schemas.dashboard.activity_schema import ActivityItem
from app.schemas.dashboard.competitor_schema import CompetitorListResponse, CompetitorRecord
from app.schemas.dashboard.error_schema import DashboardErrorResponse, ErrorDetail
from app.schemas.dashboard.market_trend_schema import MarketTrendPoint
from app.schemas.dashboard.opportunity_schema import OpportunityListResponse, OpportunityRecord
from app.schemas.dashboard.opportunity_metrics_schema import OpportunityMetricRecord
from app.schemas.dashboard.pagination_schema import PaginatedResponse, PaginationMeta
from app.schemas.dashboard.price_trend_schema import CompetitorPriceTrendPoint
from app.schemas.dashboard.recommendation_schema import (
RecommendationListResponse,
RecommendationRecord,
Expand All @@ -14,14 +18,18 @@
__all__ = [
"AlertListResponse",
"AlertRecord",
"ActivityItem",
"CompetitorListResponse",
"CompetitorRecord",
"DashboardErrorResponse",
"ErrorDetail",
"MarketTrendPoint",
"OpportunityListResponse",
"OpportunityRecord",
"OpportunityMetricRecord",
"PaginatedResponse",
"PaginationMeta",
"CompetitorPriceTrendPoint",
"RecommendationListResponse",
"RecommendationRecord",
"DashboardSummaryResponse",
Expand Down
43 changes: 43 additions & 0 deletions backend/app/schemas/dashboard/activity_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

from datetime import datetime
from enum import Enum

from pydantic import BaseModel, ConfigDict, Field


class ActivityCategory(str, Enum):
recommendation = "recommendation"
alert = "alert"
competitor = "competitor"
system = "system"


class ActivitySeverity(str, Enum):
info = "info"
warning = "warning"
critical = "critical"


class ActivityItem(BaseModel):
id: str
title: str
description: str
timestamp: datetime
category: ActivityCategory
severity: ActivitySeverity

model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"id": "rec-9f3a4f02",
"title": "Recommendation generated",
"description": "Lower price recommendation for Dove Body Lotion 200ml.",
"timestamp": "2026-05-26T22:05:00Z",
"category": "recommendation",
"severity": "info",
}
]
}
)
27 changes: 27 additions & 0 deletions backend/app/schemas/dashboard/market_trend_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field


class MarketTrendPoint(BaseModel):
timestamp: datetime
demand_index: float = Field(..., ge=0, le=100)
urgency_index: float = Field(..., ge=0, le=100)
opportunity_index: float = Field(..., ge=0, le=100)
price_index: float = Field(..., ge=-100, le=100)

model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"timestamp": "2026-05-26T00:00:00Z",
"demand_index": 62.5,
"urgency_index": 58.1,
"opportunity_index": 71.4,
"price_index": -3.2,
}
]
}
)
25 changes: 25 additions & 0 deletions backend/app/schemas/dashboard/opportunity_metrics_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from pydantic import BaseModel, ConfigDict


class OpportunityMetricRecord(BaseModel):
id: str
label: str
value: float | str
delta: float | str | None
trend: str

model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"id": "revenue-opportunity",
"label": "Revenue opportunity",
"value": 4200.0,
"delta": 650.0,
"trend": "up",
}
]
}
)
25 changes: 25 additions & 0 deletions backend/app/schemas/dashboard/price_trend_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field


class CompetitorPriceTrendPoint(BaseModel):
timestamp: datetime
competitor_price: float = Field(..., ge=0)
seller_price: float = Field(..., ge=0)
market_average: float = Field(..., ge=0)

model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"timestamp": "2026-05-26T00:00:00Z",
"competitor_price": 410.0,
"seller_price": 450.0,
"market_average": 432.5,
}
]
}
)
Loading
Loading