Ver código fonte

feat(stats): add /archives/slim endpoint and fix dashboard bugs

- Add lightweight GET /archives/slim endpoint with column-level SELECT
  (13 fields vs 46), skipping duplicate detection for ~70-80% payload
  reduction on the stats dashboard
- Add ArchiveSlim Pydantic schema and TypeScript type
- Switch StatsPage and FilamentTrends to use ArchiveSlim
- Fix critical bug in failure_analysis.py: use effective_days for week
  count, separate non-date filters, build fresh week_filter per loop
- Fix busiestDay timezone bug: parse YYYY-MM-DD with split() + local
  Date constructor instead of new Date() which creates UTC midnight
- Fix success streak ordering: sort by completed_at || created_at
  instead of created_at alone
- Add 6 integration tests for /archives/slim endpoint
AneoPsy 2 meses atrás
pai
commit
168c00f47d

+ 73 - 1
backend/app/api/routes/archives.py

@@ -22,7 +22,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 
@@ -154,6 +154,78 @@ async def list_archives(
     return result
     return result
 
 
 
 
+@router.get("/slim", response_model=list[ArchiveSlim])
+async def list_archives_slim(
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
+    limit: int = 10000,
+    offset: int = 0,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Lightweight archive listing for stats/dashboard widgets.
+
+    Returns only the fields needed for client-side aggregation,
+    skipping duplicate detection, file paths, and extra_data.
+    """
+    filters = []
+    if date_from:
+        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at >= dt_from)
+    if date_to:
+        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        filters.append(PrintArchive.created_at <= dt_to)
+
+    query = (
+        select(
+            PrintArchive.printer_id,
+            PrintArchive.print_name,
+            PrintArchive.print_time_seconds,
+            PrintArchive.started_at,
+            PrintArchive.completed_at,
+            PrintArchive.filament_used_grams,
+            PrintArchive.filament_type,
+            PrintArchive.filament_color,
+            PrintArchive.status,
+            PrintArchive.cost,
+            PrintArchive.quantity,
+            PrintArchive.created_at,
+        )
+        .where(*filters)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    result = await db.execute(query)
+    rows = result.all()
+
+    return [
+        {
+            "printer_id": r.printer_id,
+            "print_name": r.print_name,
+            "print_time_seconds": r.print_time_seconds,
+            "actual_time_seconds": (
+                int((r.completed_at - r.started_at).total_seconds())
+                if r.started_at
+                and r.completed_at
+                and r.status == "completed"
+                and (r.completed_at - r.started_at).total_seconds() > 0
+                else None
+            ),
+            "filament_used_grams": r.filament_used_grams,
+            "filament_type": r.filament_type,
+            "filament_color": r.filament_color,
+            "status": r.status,
+            "started_at": r.started_at,
+            "completed_at": r.completed_at,
+            "cost": r.cost,
+            "quantity": r.quantity,
+            "created_at": r.created_at,
+        }
+        for r in rows
+    ]
+
+
 @router.get("/search", response_model=list[ArchiveResponse])
 @router.get("/search", response_model=list[ArchiveResponse])
 async def search_archives(
 async def search_archives(
     q: str = Query(..., min_length=2, description="Search query"),
     q: str = Query(..., min_length=2, description="Search query"),

+ 21 - 0
backend/app/schemas/archive.py

@@ -110,6 +110,27 @@ class ArchiveResponse(BaseModel):
         from_attributes = True
         from_attributes = True
 
 
 
 
+class ArchiveSlim(BaseModel):
+    """Lightweight archive response for stats/dashboard widgets."""
+
+    printer_id: int | None
+    print_name: str | None
+    print_time_seconds: int | None
+    actual_time_seconds: int | None = None
+    filament_used_grams: float | None
+    filament_type: str | None
+    filament_color: str | None
+    status: str
+    started_at: datetime | None
+    completed_at: datetime | None
+    cost: float | None
+    quantity: int = 1
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
 class ArchiveStats(BaseModel):
 class ArchiveStats(BaseModel):
     total_prints: int
     total_prints: int
     successful_prints: int
     successful_prints: int

+ 15 - 8
backend/app/services/failure_analysis.py

@@ -34,8 +34,9 @@ class FailureAnalysisService:
         Returns:
         Returns:
             Dictionary with failure analysis results
             Dictionary with failure analysis results
         """
         """
-        # Build base query
+        # Build base query — separate date vs non-date filters for trend reuse
         base_filter = []
         base_filter = []
+        non_date_filter = []
         if date_from or date_to:
         if date_from or date_to:
             if date_from:
             if date_from:
                 dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
                 dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
@@ -43,14 +44,19 @@ class FailureAnalysisService:
             if date_to:
             if date_to:
                 dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
                 dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
                 base_filter.append(PrintArchive.created_at <= dt_to)
                 base_filter.append(PrintArchive.created_at <= dt_to)
+            # Compute effective span for trend
+            range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
+            range_end = dt_to if date_to else datetime.now(timezone.utc)
+            effective_days = max((range_end - range_start).days, 1)
         else:
         else:
             effective_days = days if days is not None else 30
             effective_days = days if days is not None else 30
             cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
             cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
             base_filter.append(PrintArchive.created_at >= cutoff_date)
             base_filter.append(PrintArchive.created_at >= cutoff_date)
         if printer_id:
         if printer_id:
-            base_filter.append(PrintArchive.printer_id == printer_id)
+            non_date_filter.append(PrintArchive.printer_id == printer_id)
         if project_id:
         if project_id:
-            base_filter.append(PrintArchive.project_id == project_id)
+            non_date_filter.append(PrintArchive.project_id == project_id)
+        base_filter.extend(non_date_filter)
 
 
         # Total counts
         # Total counts
         total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
         total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
@@ -154,15 +160,16 @@ class FailureAnalysisService:
 
 
         # Failure rate trend (by week)
         # Failure rate trend (by week)
         trend_data = []
         trend_data = []
-        for i in range(min(days // 7, 12)):  # Up to 12 weeks
+        num_weeks = min(effective_days // 7, 12)
+        for i in range(num_weeks):
             week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
             week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
             week_start = week_end - timedelta(weeks=1)
             week_start = week_end - timedelta(weeks=1)
 
 
-            week_filter = base_filter.copy()
-            week_filter[0] = and_(
+            week_filter = [
                 PrintArchive.created_at >= week_start,
                 PrintArchive.created_at >= week_start,
                 PrintArchive.created_at < week_end,
                 PrintArchive.created_at < week_end,
-            )
+                *non_date_filter,
+            ]
 
 
             week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
             week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
             week_failed = await self.db.execute(
             week_failed = await self.db.execute(
@@ -187,7 +194,7 @@ class FailureAnalysisService:
         trend_data.reverse()  # Oldest first
         trend_data.reverse()  # Oldest first
 
 
         return {
         return {
-            "period_days": days,
+            "period_days": effective_days,
             "total_prints": total_prints,
             "total_prints": total_prints,
             "failed_prints": failed_prints,
             "failed_prints": failed_prints,
             "failure_rate": round(failure_rate, 1),
             "failure_rate": round(failure_rate, 1),

+ 150 - 0
backend/tests/integration/test_archives_api.py

@@ -227,6 +227,156 @@ class TestArchivesAPI:
         assert "successful_prints" in result
         assert "successful_prints" in result
 
 
 
 
+class TestArchivesSlimAPI:
+    """Integration tests for /api/v1/archives/slim endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_empty(self, async_client: AsyncClient):
+        """Verify empty list when no archives exist."""
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_returns_only_expected_fields(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify response contains only slim fields, not full archive data."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Slim Test",
+            status="completed",
+            filament_type="PLA",
+            filament_color="#FF0000",
+            filament_used_grams=50.0,
+            print_time_seconds=3600,
+            cost=1.50,
+            quantity=2,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        item = data[0]
+
+        # Expected fields present
+        assert item["printer_id"] == printer.id
+        assert item["print_name"] == "Slim Test"
+        assert item["status"] == "completed"
+        assert item["filament_type"] == "PLA"
+        assert item["filament_color"] == "#FF0000"
+        assert item["filament_used_grams"] == 50.0
+        assert item["print_time_seconds"] == 3600
+        assert item["cost"] == 1.50
+        assert item["quantity"] == 2
+        assert "created_at" in item
+
+        # Full archive fields must NOT be present
+        assert "id" not in item
+        assert "filename" not in item
+        assert "file_path" not in item
+        assert "file_size" not in item
+        assert "extra_data" not in item
+        assert "notes" not in item
+        assert "tags" not in item
+        assert "photos" not in item
+        assert "thumbnail_path" not in item
+        assert "content_hash" not in item
+        assert "duplicates" not in item
+        assert "duplicate_count" not in item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_computes_actual_time(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is computed from started_at/completed_at."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
+        completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)  # 2 hours = 7200s
+        await archive_factory(
+            printer.id,
+            status="completed",
+            started_at=started,
+            completed_at=completed,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] == 7200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_actual_time_null_for_failed(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify actual_time_seconds is null for non-completed prints."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="failed",
+            started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["actual_time_seconds"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify date_from and date_to filters work."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            print_name="Old Print",
+            created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
+        )
+        await archive_factory(
+            printer.id,
+            print_name="New Print",
+            created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
+        )
+
+        # Filter to only June 2024
+        response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["print_name"] == "New Print"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify limit and offset work."""
+        printer = await printer_factory()
+        for i in range(5):
+            await archive_factory(printer.id, print_name=f"Print {i}")
+
+        response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
+
+        assert response.status_code == 200
+        assert len(response.json()) == 2
+
+
 class TestArchiveDataIntegrity:
 class TestArchiveDataIntegrity:
     """Tests for archive data integrity."""
     """Tests for archive data integrity."""
 
 

+ 1 - 1
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -147,7 +147,7 @@ describe('StatsPage', () => {
       http.get('/api/v1/printers/', () => {
       http.get('/api/v1/printers/', () => {
         return HttpResponse.json(mockPrinters);
         return HttpResponse.json(mockPrinters);
       }),
       }),
-      http.get('/api/v1/archives/', () => {
+      http.get('/api/v1/archives/slim', () => {
         return HttpResponse.json(mockArchives);
         return HttpResponse.json(mockArchives);
       }),
       }),
       http.get('/api/v1/settings/', () => {
       http.get('/api/v1/settings/', () => {

+ 22 - 0
frontend/src/api/client.ts

@@ -373,6 +373,22 @@ export interface Archive {
   created_by_username: string | null;
   created_by_username: string | null;
 }
 }
 
 
+export interface ArchiveSlim {
+  printer_id: number | null;
+  print_name: string | null;
+  print_time_seconds: number | null;
+  actual_time_seconds: number | null;
+  filament_used_grams: number | null;
+  filament_type: string | null;
+  filament_color: string | null;
+  status: string;
+  started_at: string | null;
+  completed_at: string | null;
+  cost: number | null;
+  quantity: number;
+  created_at: string;
+}
+
 export interface PrintLogEntry {
 export interface PrintLogEntry {
   id: number;
   id: number;
   print_name: string | null;
   print_name: string | null;
@@ -2499,6 +2515,12 @@ export const api = {
     if (dateTo) params.set('date_to', dateTo);
     if (dateTo) params.set('date_to', dateTo);
     return request<Archive[]>(`/archives/?${params}`);
     return request<Archive[]>(`/archives/?${params}`);
   },
   },
+  getArchivesSlim: (dateFrom?: string, dateTo?: string) => {
+    const params = new URLSearchParams();
+    if (dateFrom) params.set('date_from', dateFrom);
+    if (dateTo) params.set('date_to', dateTo);
+    return request<ArchiveSlim[]>(`/archives/slim?${params}`);
+  },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   searchArchives: (query: string, options?: {
   searchArchives: (query: string, options?: {
     printerId?: number;
     printerId?: number;

+ 2 - 2
frontend/src/components/FilamentTrends.tsx

@@ -12,13 +12,13 @@ import {
   Pie,
   Pie,
   Cell,
   Cell,
 } from 'recharts';
 } from 'recharts';
-import type { Archive } from '../api/client';
+import type { ArchiveSlim } from '../api/client';
 import { MetricToggle, type Metric } from './MetricToggle';
 import { MetricToggle, type Metric } from './MetricToggle';
 import { parseUTCDate } from '../utils/date';
 import { parseUTCDate } from '../utils/date';
 import { formatWeight } from '../utils/weight';
 import { formatWeight } from '../utils/weight';
 
 
 interface FilamentTrendsProps {
 interface FilamentTrendsProps {
-  archives: Archive[];
+  archives: ArchiveSlim[];
   currency?: string;
   currency?: string;
 }
 }
 
 

+ 9 - 9
frontend/src/pages/StatsPage.tsx

@@ -32,7 +32,7 @@ import {
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
-import { api, type Archive } from '../api/client';
+import { api, type ArchiveSlim } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
 import { FilamentTrends } from '../components/FilamentTrends';
 import { Dashboard, type DashboardWidget } from '../components/Dashboard';
 import { Dashboard, type DashboardWidget } from '../components/Dashboard';
@@ -368,7 +368,7 @@ function PrinterStatsWidget({
   printerMap,
   printerMap,
 }: {
 }: {
   stats: { prints_by_printer: Record<string, number> } | undefined;
   stats: { prints_by_printer: Record<string, number> } | undefined;
-  archives: Archive[];
+  archives: ArchiveSlim[];
   printerMap: Map<string, string>;
   printerMap: Map<string, string>;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -669,7 +669,7 @@ function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: {
   );
   );
 }
 }
 
 
-function RecordsWidget({ archives, currency }: { archives: Archive[]; currency: string }) {
+function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; currency: string }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const records = useMemo(() => {
   const records = useMemo(() => {
@@ -684,8 +684,8 @@ function RecordsWidget({ archives, currency }: { archives: Archive[]; currency:
     if (archives.length === 0) return result;
     if (archives.length === 0) return result;
 
 
     // Find the archive with the highest value for a given field
     // Find the archive with the highest value for a given field
-    const findMax = (getter: (a: Archive) => number | null | undefined): { archive: Archive | null; value: number } => {
-      let best: Archive | null = null;
+    const findMax = (getter: (a: ArchiveSlim) => number | null | undefined): { archive: ArchiveSlim | null; value: number } => {
+      let best: ArchiveSlim | null = null;
       let bestVal = 0;
       let bestVal = 0;
       archives.forEach(a => {
       archives.forEach(a => {
         const v = getter(a);
         const v = getter(a);
@@ -742,14 +742,14 @@ function RecordsWidget({ archives, currency }: { archives: Archive[]; currency:
         iconColor: 'text-purple-400',
         iconColor: 'text-purple-400',
         label: t('stats.busiestDay'),
         label: t('stats.busiestDay'),
         value: `${busiestCount} ${t('common.prints')}`,
         value: `${busiestCount} ${t('common.prints')}`,
-        detail: new Date(busiestDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }),
+        detail: (() => { const [y, m, d] = busiestDay.split('-').map(Number); return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); })(),
       });
       });
     }
     }
 
 
     // Success streak
     // Success streak
     const sorted = [...archives]
     const sorted = [...archives]
       .filter(a => a.status === 'completed' || a.status === 'failed')
       .filter(a => a.status === 'completed' || a.status === 'failed')
-      .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+      .sort((a, b) => new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime());
     let streak = 0;
     let streak = 0;
     for (const a of sorted) {
     for (const a of sorted) {
       if (a.status === 'completed') streak++;
       if (a.status === 'completed') streak++;
@@ -855,8 +855,8 @@ export function StatsPage() {
   });
   });
 
 
   const { data: archives, refetch: refetchArchives } = useQuery({
   const { data: archives, refetch: refetchArchives } = useQuery({
-    queryKey: ['archives', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
-    queryFn: () => api.getArchives(undefined, undefined, 10000, 0, effectiveDateRange.dateFrom, effectiveDateRange.dateTo),
+    queryKey: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
+    queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo),
   });
   });
 
 
   const { data: settings } = useQuery({
   const { data: settings } = useQuery({