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
31 changes: 20 additions & 11 deletions app/features/analytics/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@

from datetime import date

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

from app.core.config import get_settings
from app.core.database import get_db
from app.core.exceptions import BadRequestError
from app.core.logging import get_logger
from app.features.analytics.schemas import (
DrilldownDimension,
Expand Down Expand Up @@ -40,23 +41,23 @@ def validate_date_range(start_date: date, end_date: date) -> None:
end_date: End of analysis period.

Raises:
HTTPException: If date range is invalid.
BadRequestError: If date range is invalid. Surfaces as an RFC 7807
``application/problem+json`` 400 via the registered handler — a
raw ``HTTPException`` would bypass the problem-details envelope.
"""
settings = get_settings()

if end_date < start_date:
raise HTTPException(
status_code=400,
detail=f"end_date ({end_date}) must be >= start_date ({start_date})",
raise BadRequestError(
message=f"end_date ({end_date}) must be >= start_date ({start_date})",
)

days_diff = (end_date - start_date).days
max_days = settings.analytics_max_date_range_days

if days_diff > max_days:
raise HTTPException(
status_code=400,
detail=f"Date range ({days_diff} days) exceeds maximum allowed ({max_days} days)",
raise BadRequestError(
message=f"Date range ({days_diff} days) exceeds maximum allowed ({max_days} days)",
)


Expand Down Expand Up @@ -108,10 +109,12 @@ async def get_kpis(
),
store_id: int | None = Query(
None,
ge=1,
description="Filter by store ID. Use GET /dimensions/stores to find valid IDs.",
),
product_id: int | None = Query(
None,
ge=1,
description="Filter by product ID. Use GET /dimensions/products to find valid IDs.",
),
category: str | None = Query(
Expand All @@ -134,7 +137,7 @@ async def get_kpis(
Aggregated KPI metrics.

Raises:
HTTPException: If date range is invalid.
BadRequestError: If date range is invalid (RFC 7807 400).
"""
# Validate date range before processing
validate_date_range(start_date, end_date)
Expand Down Expand Up @@ -206,10 +209,12 @@ async def get_drilldowns(
),
store_id: int | None = Query(
None,
ge=1,
description="Filter by store ID. Use GET /dimensions/stores to find valid IDs.",
),
product_id: int | None = Query(
None,
ge=1,
description="Filter by product ID. Use GET /dimensions/products to find valid IDs.",
),
max_items: int = Query(
Expand All @@ -235,7 +240,7 @@ async def get_drilldowns(
Drilldown analysis with ranked items.

Raises:
HTTPException: If date range is invalid.
BadRequestError: If date range is invalid (RFC 7807 400).
"""
# Validate date range before processing
validate_date_range(start_date, end_date)
Expand Down Expand Up @@ -301,10 +306,12 @@ async def get_timeseries(
),
store_id: int | None = Query(
None,
ge=1,
description="Filter by store ID. Use GET /dimensions/stores to find valid IDs.",
),
product_id: int | None = Query(
None,
ge=1,
description="Filter by product ID. Use GET /dimensions/products to find valid IDs.",
),
category: str | None = Query(
Expand All @@ -328,7 +335,7 @@ async def get_timeseries(
Time series response with points in ascending period order.

Raises:
HTTPException: If date range is invalid.
BadRequestError: If date range is invalid (RFC 7807 400).
"""
# Validate date range before processing
validate_date_range(start_date, end_date)
Expand Down Expand Up @@ -380,10 +387,12 @@ async def get_timeseries(
async def get_inventory_status(
store_id: int | None = Query(
None,
ge=1,
description="Filter by store ID. Use GET /dimensions/stores to find valid IDs.",
),
product_id: int | None = Query(
None,
ge=1,
description="Filter by product ID. Use GET /dimensions/products to find valid IDs.",
),
db: AsyncSession = Depends(get_db),
Expand Down
30 changes: 23 additions & 7 deletions app/features/analytics/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from app.core.config import get_settings
Expand Down Expand Up @@ -122,11 +122,25 @@ async def db_session() -> AsyncGenerator[AsyncSession, None]:
try:
yield session
finally:
# Clean up test data (delete in FK-safe order). InventorySnapshotDaily
# FK-references store/product/calendar, so it must be cleared before
# the Store/Product/Calendar deletes below.
await session.execute(delete(InventorySnapshotDaily))
await session.execute(delete(SalesDaily))
# Clean up test data (delete in FK-safe order). Scope the fact-table
# deletes to TEST-prefixed stores/products so a shared dev or
# integration dataset is never wiped. InventorySnapshotDaily /
# SalesDaily FK-reference store/product/calendar, so they must be
# cleared before the Store/Product/Calendar deletes below.
test_store_ids = select(Store.id).where(Store.code.like("TEST-%"))
test_product_ids = select(Product.id).where(Product.sku.like("TEST-%"))
await session.execute(
delete(InventorySnapshotDaily).where(
InventorySnapshotDaily.store_id.in_(test_store_ids)
| InventorySnapshotDaily.product_id.in_(test_product_ids)
)
)
await session.execute(
delete(SalesDaily).where(
SalesDaily.store_id.in_(test_store_ids)
| SalesDaily.product_id.in_(test_product_ids)
)
)
await session.execute(delete(Product).where(Product.sku.like("TEST-%")))
await session.execute(delete(Store).where(Store.code.like("TEST-%")))
await session.execute(
Expand Down Expand Up @@ -154,7 +168,9 @@ async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
) as ac:
yield ac

app.dependency_overrides.clear()
# Remove only this fixture's override — clear() would also drop overrides
# installed by other fixtures sharing the app instance.
app.dependency_overrides.pop(get_db, None)


@pytest.fixture
Expand Down
41 changes: 28 additions & 13 deletions app/features/config/tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,34 @@ async def test_get_effective_config_masks_secrets(self):
async def test_get_effective_config_maps_agent_limits(self):
"""The agent session-limit fields are sourced from the Settings singleton."""
settings = get_settings()
settings.agent_max_tool_calls = 7
settings.agent_timeout_seconds = 99
settings.agent_retry_attempts = 2
settings.agent_session_ttl_minutes = 45
settings.agent_require_approval = ["create_alias"]

config = await service.get_effective_config(_mock_db())

assert config.agent_max_tool_calls == 7
assert config.agent_timeout_seconds == 99
assert config.agent_retry_attempts == 2
assert config.agent_session_ttl_minutes == 45
assert config.agent_require_approval == ["create_alias"]
# get_settings() returns a cached singleton — snapshot every field this
# test mutates and restore it in a finally block so the mutation never
# leaks into another test.
fields = (
"agent_max_tool_calls",
"agent_timeout_seconds",
"agent_retry_attempts",
"agent_session_ttl_minutes",
"agent_require_approval",
)
original = {field: getattr(settings, field) for field in fields}
try:
settings.agent_max_tool_calls = 7
settings.agent_timeout_seconds = 99
settings.agent_retry_attempts = 2
settings.agent_session_ttl_minutes = 45
settings.agent_require_approval = ["create_alias"]

config = await service.get_effective_config(_mock_db())

assert config.agent_max_tool_calls == 7
assert config.agent_timeout_seconds == 99
assert config.agent_retry_attempts == 2
assert config.agent_session_ttl_minutes == 45
assert config.agent_require_approval == ["create_alias"]
finally:
for field, value in original.items():
setattr(settings, field, value)


# =============================================================================
Expand Down
12 changes: 8 additions & 4 deletions app/features/dimensions/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,11 @@ async def list_stores(
else:
order_by = Store.code.asc()

# Apply pagination
# Apply pagination. Append the unique `code` as a tie-breaker so rows
# with equal sort values keep a stable order across pages (offset
# pagination over a non-unique sort key is otherwise non-deterministic).
offset = (page - 1) * page_size
stmt = stmt.order_by(order_by).offset(offset).limit(page_size)
stmt = stmt.order_by(order_by, Store.code.asc()).offset(offset).limit(page_size)

# Execute query
result = await db.execute(stmt)
Expand Down Expand Up @@ -233,9 +235,11 @@ async def list_products(
else:
order_by = Product.sku.asc()

# Apply pagination
# Apply pagination. Append the unique `sku` as a tie-breaker so rows
# with equal sort values keep a stable order across pages (offset
# pagination over a non-unique sort key is otherwise non-deterministic).
offset = (page - 1) * page_size
stmt = stmt.order_by(order_by).offset(offset).limit(page_size)
stmt = stmt.order_by(order_by, Product.sku.asc()).offset(offset).limit(page_size)

# Execute query
result = await db.execute(stmt)
Expand Down
19 changes: 15 additions & 4 deletions app/features/dimensions/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from app.core.config import get_settings
Expand Down Expand Up @@ -66,8 +66,17 @@ async def db_session() -> AsyncGenerator[AsyncSession, None]:
try:
yield session
finally:
# Clean up test data (delete in FK-safe order).
await session.execute(delete(SalesDaily))
# Clean up test data (delete in FK-safe order). Scope the SalesDaily
# delete to TEST-prefixed stores/products so a shared dev or
# integration dataset is never wiped.
test_store_ids = select(Store.id).where(Store.code.like("TEST-%"))
test_product_ids = select(Product.id).where(Product.sku.like("TEST-%"))
await session.execute(
delete(SalesDaily).where(
SalesDaily.store_id.in_(test_store_ids)
| SalesDaily.product_id.in_(test_product_ids)
)
)
await session.execute(delete(Product).where(Product.sku.like("TEST-%")))
await session.execute(delete(Store).where(Store.code.like("TEST-%")))
await session.execute(
Expand Down Expand Up @@ -95,7 +104,9 @@ async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
) as ac:
yield ac

app.dependency_overrides.clear()
# Remove only this fixture's override — clear() would also drop overrides
# installed by other fixtures sharing the app instance.
app.dependency_overrides.pop(get_db, None)


@pytest.fixture
Expand Down
11 changes: 9 additions & 2 deletions app/features/jobs/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,16 @@ async def list_jobs(
else:
order_by = Job.created_at.desc()

# Apply pagination
# Apply pagination. Append created_at then the unique `job_id` as
# tie-breakers so rows with equal sort values keep a stable order
# across pages (offset pagination over a non-unique sort key is
# otherwise non-deterministic).
offset = (page - 1) * page_size
stmt = stmt.order_by(order_by).offset(offset).limit(page_size)
stmt = (
stmt.order_by(order_by, Job.created_at.desc(), Job.job_id.asc())
.offset(offset)
.limit(page_size)
)

# Execute query
result = await db.execute(stmt)
Expand Down
4 changes: 3 additions & 1 deletion app/features/jobs/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
) as ac:
yield ac

app.dependency_overrides.clear()
# Remove only this fixture's override — clear() would also drop overrides
# installed by other fixtures sharing the app instance.
app.dependency_overrides.pop(get_db, None)


@pytest.fixture
Expand Down
6 changes: 4 additions & 2 deletions app/features/registry/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,11 @@ async def list_runs(
else:
order_by = ModelRun.created_at.desc()

# Apply pagination
# Apply pagination. Append the unique `run_id` as a tie-breaker so rows
# with equal sort values keep a stable order across pages (offset
# pagination over a non-unique sort key is otherwise non-deterministic).
offset = (page - 1) * page_size
stmt = stmt.order_by(order_by).offset(offset).limit(page_size)
stmt = stmt.order_by(order_by, ModelRun.run_id.asc()).offset(offset).limit(page_size)

result = await db.execute(stmt)
runs = result.scalars().all()
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/charts/backtest-folds-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export function BacktestFoldsChart({
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className={`h-[${height}px] w-full`}>
{/* Height is passed via inline style — a `h-[${height}px]` class is a
dynamic string Tailwind cannot statically discover, so the JIT
compiler drops it at build time. */}
<ChartContainer config={chartConfig} className="w-full" style={{ height: `${height}px` }}>
<BarChart data={formattedData} accessibilityLayer>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="foldLabel" tickLine={false} axisLine={false} />
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/charts/revenue-bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export function RevenueBarChart({
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className={`h-[${height}px] w-full`}>
{/* Height is passed via inline style — a `h-[${height}px]` class is a
dynamic string Tailwind cannot statically discover, so the JIT
compiler drops it at build time. */}
<ChartContainer config={chartConfig} className="w-full" style={{ height: `${height}px` }}>
<BarChart data={data} accessibilityLayer>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tickLine={false} axisLine={false} />
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/charts/time-series-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ export function TimeSeriesChart({
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className={`h-[${height}px] w-full`}>
{/* Height is passed via inline style — a `h-[${height}px]` class is a
dynamic string Tailwind cannot statically discover, so the JIT
compiler drops it at build time. */}
<ChartContainer config={chartConfig} className="w-full" style={{ height: `${height}px` }}>
<ComposedChart data={data} accessibilityLayer>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
Expand Down
Loading