Browse Source

Add per-user statistics filtering (#730)

  Admins can now filter the Statistics page by user via a new
  stats:filter_by_user permission. A user dropdown appears in the stats
  header showing all users plus "No User (System)" for prints without
  attribution. The filter applies to all stats widgets, failure analysis,
  and CSV/Excel exports. Backend validates the permission on all 4 stats
  endpoints, returning 403 if the filter is used without authorization.
maziggy 1 month ago
parent
commit
ec0ae162e8

+ 1 - 0
CHANGELOG.md

@@ -9,6 +9,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. The stagger option is also available in the direct Print dialog when multiple printers are selected — prints are automatically queued with staggered start times, so you can close the browser and walk away. Requested by @UVCXanth.
 - **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. The stagger option is also available in the direct Print dialog when multiple printers are selected — prints are automatically queued with staggered start times, so you can close the browser and walk away. Requested by @UVCXanth.
 - **Plate-Clear Confirmation Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — New "Require plate-clear confirmation" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved). Requested by @UVCXanth.
 - **Plate-Clear Confirmation Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — New "Require plate-clear confirmation" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved). Requested by @UVCXanth.
 - **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
 - **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
+- **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports. Requested by @3823u44238.
 
 
 ### Improved
 ### Improved
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.

+ 1 - 0
README.md

@@ -100,6 +100,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
 - Cost analytics & failure analysis
 - Cost analytics & failure analysis
+- Per-user statistics filtering (admin permission gated)
 - CSV/Excel export
 - CSV/Excel export
 
 
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation

+ 38 - 4
backend/app/api/routes/archives.py

@@ -33,6 +33,25 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/archives", tags=["archives"])
 router = APIRouter(prefix="/archives", tags=["archives"])
 
 
 
 
+def _validate_user_filter_permission(current_user: User | None, created_by_id: int | None):
+    """Raise 403 if created_by_id filter is used without stats:filter_by_user permission."""
+    if created_by_id is None or current_user is None:
+        return
+    if current_user.is_admin:
+        return
+    if not current_user.has_permission(Permission.STATS_FILTER_BY_USER.value):
+        raise HTTPException(status_code=403, detail="Permission stats:filter_by_user required")
+
+
+def _apply_user_filter(conditions: list, created_by_id: int | None):
+    """Append created_by_id filter to conditions list if specified."""
+    if created_by_id is not None:
+        if created_by_id == -1:
+            conditions.append(PrintArchive.created_by_id.is_(None))
+        else:
+            conditions.append(PrintArchive.created_by_id == created_by_id)
+
+
 def compute_time_accuracy(archive: PrintArchive) -> dict:
 def compute_time_accuracy(archive: PrintArchive) -> dict:
     """Compute actual print time and accuracy for an archive.
     """Compute actual print time and accuracy for an archive.
 
 
@@ -247,16 +266,18 @@ async def list_archives(
 async def list_archives_slim(
 async def list_archives_slim(
     date_from: date | None = Query(None),
     date_from: date | None = Query(None),
     date_to: date | None = Query(None),
     date_to: date | None = Query(None),
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     limit: int = Query(default=10000, le=50000),
     limit: int = Query(default=10000, le=50000),
     offset: int = 0,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Lightweight archive listing for stats/dashboard widgets.
     """Lightweight archive listing for stats/dashboard widgets.
 
 
     Returns only the fields needed for client-side aggregation,
     Returns only the fields needed for client-side aggregation,
     skipping duplicate detection, file paths, and extra_data.
     skipping duplicate detection, file paths, and extra_data.
     """
     """
+    _validate_user_filter_permission(current_user, created_by_id)
     filters = []
     filters = []
     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)
@@ -264,6 +285,7 @@ async def list_archives_slim(
     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)
         filters.append(PrintArchive.created_at <= dt_to)
         filters.append(PrintArchive.created_at <= dt_to)
+    _apply_user_filter(filters, created_by_id)
 
 
     query = (
     query = (
         select(
         select(
@@ -448,8 +470,9 @@ async def analyze_failures(
     date_to: date | None = Query(None),
     date_to: date | None = Query(None),
     printer_id: int | None = None,
     printer_id: int | None = None,
     project_id: int | None = None,
     project_id: int | None = None,
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
     """Analyze failure patterns across prints.
     """Analyze failure patterns across prints.
 
 
@@ -460,6 +483,8 @@ async def analyze_failures(
     - Recent failures
     - Recent failures
     - Weekly trend
     - Weekly trend
     """
     """
+    _validate_user_filter_permission(current_user, created_by_id)
+
     from backend.app.services.failure_analysis import FailureAnalysisService
     from backend.app.services.failure_analysis import FailureAnalysisService
 
 
     service = FailureAnalysisService(db)
     service = FailureAnalysisService(db)
@@ -469,6 +494,7 @@ async def analyze_failures(
         date_to=date_to,
         date_to=date_to,
         printer_id=printer_id,
         printer_id=printer_id,
         project_id=project_id,
         project_id=project_id,
+        created_by_id=created_by_id,
     )
     )
 
 
 
 
@@ -579,10 +605,13 @@ async def export_stats(
     days: int = 30,
     days: int = 30,
     printer_id: int | None = None,
     printer_id: int | None = None,
     project_id: int | None = None,
     project_id: int | None = None,
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
 ):
     """Export statistics summary to CSV or Excel format."""
     """Export statistics summary to CSV or Excel format."""
+    _validate_user_filter_permission(current_user, created_by_id)
+
     from fastapi.responses import StreamingResponse
     from fastapi.responses import StreamingResponse
 
 
     from backend.app.services.export import ExportService
     from backend.app.services.export import ExportService
@@ -597,6 +626,7 @@ async def export_stats(
             days=days,
             days=days,
             printer_id=printer_id,
             printer_id=printer_id,
             project_id=project_id,
             project_id=project_id,
+            created_by_id=created_by_id,
         )
         )
     except ImportError as e:
     except ImportError as e:
         raise HTTPException(500, str(e))
         raise HTTPException(500, str(e))
@@ -612,10 +642,13 @@ async def export_stats(
 async def get_archive_stats(
 async def get_archive_stats(
     date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
     date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
     date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
     date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
+    created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
 ):
     """Get statistics across all archives."""
     """Get statistics across all archives."""
+    _validate_user_filter_permission(current_user, created_by_id)
+
     # Build date filter conditions
     # Build date filter conditions
     base_conditions = []
     base_conditions = []
     if date_from:
     if date_from:
@@ -624,6 +657,7 @@ async def get_archive_stats(
     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_conditions.append(PrintArchive.created_at <= dt_to)
         base_conditions.append(PrintArchive.created_at <= dt_to)
+    _apply_user_filter(base_conditions, created_by_id)
 
 
     # Total counts
     # Total counts
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
     total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))

+ 2 - 0
backend/app/core/permissions.py

@@ -120,6 +120,7 @@ class Permission(StrEnum):
 
 
     # Stats/Metrics
     # Stats/Metrics
     STATS_READ = "stats:read"
     STATS_READ = "stats:read"
+    STATS_FILTER_BY_USER = "stats:filter_by_user"
 
 
     # System Info
     # System Info
     SYSTEM_READ = "system:read"
     SYSTEM_READ = "system:read"
@@ -264,6 +265,7 @@ PERMISSION_CATEGORIES = {
     "Stats & History": [
     "Stats & History": [
         Permission.AMS_HISTORY_READ,
         Permission.AMS_HISTORY_READ,
         Permission.STATS_READ,
         Permission.STATS_READ,
+        Permission.STATS_FILTER_BY_USER,
     ],
     ],
     "System": [
     "System": [
         Permission.SYSTEM_READ,
         Permission.SYSTEM_READ,

+ 3 - 0
backend/app/services/export.py

@@ -158,6 +158,7 @@ class ExportService:
         days: int = 30,
         days: int = 30,
         printer_id: int | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
         project_id: int | None = None,
+        created_by_id: int | None = None,
     ) -> tuple[bytes, str, str]:
     ) -> tuple[bytes, str, str]:
         """Export statistics summary to CSV or Excel format.
         """Export statistics summary to CSV or Excel format.
 
 
@@ -166,6 +167,7 @@ class ExportService:
             days: Number of days to include in stats
             days: Number of days to include in stats
             printer_id: Filter by printer
             printer_id: Filter by printer
             project_id: Filter by project
             project_id: Filter by project
+            created_by_id: Filter by user who created the print (-1 for no user)
 
 
         Returns:
         Returns:
             Tuple of (file_bytes, filename, content_type)
             Tuple of (file_bytes, filename, content_type)
@@ -178,6 +180,7 @@ class ExportService:
             days=days,
             days=days,
             printer_id=printer_id,
             printer_id=printer_id,
             project_id=project_id,
             project_id=project_id,
+            created_by_id=created_by_id,
         )
         )
 
 
         # Build stats rows
         # Build stats rows

+ 6 - 0
backend/app/services/failure_analysis.py

@@ -21,6 +21,7 @@ class FailureAnalysisService:
         date_to: date | None = None,
         date_to: date | None = None,
         printer_id: int | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
         project_id: int | None = None,
+        created_by_id: int | None = None,
     ) -> dict:
     ) -> dict:
         """Analyze failure patterns across archives.
         """Analyze failure patterns across archives.
 
 
@@ -56,6 +57,11 @@ class FailureAnalysisService:
             non_date_filter.append(PrintArchive.printer_id == printer_id)
             non_date_filter.append(PrintArchive.printer_id == printer_id)
         if project_id:
         if project_id:
             non_date_filter.append(PrintArchive.project_id == project_id)
             non_date_filter.append(PrintArchive.project_id == project_id)
+        if created_by_id is not None:
+            if created_by_id == -1:
+                non_date_filter.append(PrintArchive.created_by_id.is_(None))
+            else:
+                non_date_filter.append(PrintArchive.created_by_id == created_by_id)
         base_filter.extend(non_date_filter)
         base_filter.extend(non_date_filter)
 
 
         # Total counts
         # Total counts

+ 128 - 0
backend/tests/unit/test_permissions_stats_filter.py

@@ -0,0 +1,128 @@
+"""Tests for the stats:filter_by_user permission and user filter helpers."""
+
+from unittest.mock import MagicMock
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS, PERMISSION_CATEGORIES, Permission
+
+
+class TestStatsFilterByUserPermission:
+    """Test the stats:filter_by_user permission is properly defined."""
+
+    def test_permission_enum_exists(self):
+        """The STATS_FILTER_BY_USER permission should exist in the enum."""
+        assert hasattr(Permission, "STATS_FILTER_BY_USER")
+        assert Permission.STATS_FILTER_BY_USER.value == "stats:filter_by_user"
+
+    def test_permission_in_all_permissions(self):
+        """The permission should be in ALL_PERMISSIONS list."""
+        assert "stats:filter_by_user" in ALL_PERMISSIONS
+
+    def test_permission_in_administrators_group(self):
+        """Administrators should have the permission (via ALL_PERMISSIONS)."""
+        admin_perms = DEFAULT_GROUPS["Administrators"]["permissions"]
+        assert "stats:filter_by_user" in admin_perms
+
+    def test_permission_not_in_operators_group(self):
+        """Operators should NOT have the permission."""
+        operator_perms = DEFAULT_GROUPS["Operators"]["permissions"]
+        assert "stats:filter_by_user" not in operator_perms
+
+    def test_permission_not_in_viewers_group(self):
+        """Viewers should NOT have the permission."""
+        viewer_perms = DEFAULT_GROUPS["Viewers"]["permissions"]
+        assert "stats:filter_by_user" not in viewer_perms
+
+    def test_permission_in_stats_category(self):
+        """The permission should be in the Stats & History category."""
+        stats_category = PERMISSION_CATEGORIES["Stats & History"]
+        assert Permission.STATS_FILTER_BY_USER in stats_category
+
+
+class TestValidateUserFilterPermission:
+    """Test the _validate_user_filter_permission helper."""
+
+    @pytest.fixture
+    def validate(self):
+        from backend.app.api.routes.archives import _validate_user_filter_permission
+
+        return _validate_user_filter_permission
+
+    def test_no_filter_no_check(self, validate):
+        """When created_by_id is None, no permission check is done."""
+        validate(None, None)  # Should not raise
+
+    def test_no_user_no_check(self, validate):
+        """When current_user is None (auth disabled), no permission check is done."""
+        validate(None, 5)  # Should not raise
+
+    def test_admin_always_allowed(self, validate):
+        """Admin users should always be allowed to filter."""
+        user = MagicMock()
+        user.is_admin = True
+        validate(user, 5)  # Should not raise
+
+    def test_user_with_permission_allowed(self, validate):
+        """Users with stats:filter_by_user permission should be allowed."""
+        user = MagicMock()
+        user.is_admin = False
+        user.has_permission.return_value = True
+        validate(user, 5)  # Should not raise
+        user.has_permission.assert_called_once_with("stats:filter_by_user")
+
+    def test_user_without_permission_denied(self, validate):
+        """Users without the permission should get 403."""
+        user = MagicMock()
+        user.is_admin = False
+        user.has_permission.return_value = False
+        with pytest.raises(HTTPException) as exc_info:
+            validate(user, 5)
+        assert exc_info.value.status_code == 403
+
+    def test_sentinel_minus_one_also_checked(self, validate):
+        """The sentinel value -1 (no user) also requires permission."""
+        user = MagicMock()
+        user.is_admin = False
+        user.has_permission.return_value = False
+        with pytest.raises(HTTPException):
+            validate(user, -1)
+
+
+class TestApplyUserFilter:
+    """Test the _apply_user_filter helper."""
+
+    @pytest.fixture
+    def apply_filter(self):
+        from backend.app.api.routes.archives import _apply_user_filter
+
+        return _apply_user_filter
+
+    def test_none_does_nothing(self, apply_filter):
+        """When created_by_id is None, conditions list should not change."""
+        conditions = []
+        apply_filter(conditions, None)
+        assert len(conditions) == 0
+
+    def test_positive_id_adds_filter(self, apply_filter):
+        """A positive user ID should add an equality filter."""
+        conditions = []
+        apply_filter(conditions, 5)
+        assert len(conditions) == 1
+        # Check it's a SQLAlchemy comparison expression
+        assert str(conditions[0]) == "print_archives.created_by_id = :created_by_id_1"
+
+    def test_minus_one_adds_is_null(self, apply_filter):
+        """The sentinel value -1 should add an IS NULL filter."""
+        conditions = []
+        apply_filter(conditions, -1)
+        assert len(conditions) == 1
+        assert "IS NULL" in str(conditions[0]).upper()
+
+    def test_appends_to_existing_conditions(self, apply_filter):
+        """Filter should be appended to existing conditions."""
+        conditions = ["existing"]
+        apply_filter(conditions, 5)
+        assert len(conditions) == 2
+        assert conditions[0] == "existing"

+ 14 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -397,4 +397,18 @@ describe('StatsPage', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('user filter', () => {
+    it('does not show user filter dropdown when auth is disabled', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Quick Stats')).toBeInTheDocument();
+      });
+
+      // Auth is disabled in our test setup (default), so user filter should not appear
+      // The filter requires authEnabled && hasPermission('stats:filter_by_user')
+      expect(screen.queryByText('All Users')).not.toBeInTheDocument();
+    });
+  });
 });
 });

+ 9 - 4
frontend/src/api/client.ts

@@ -2096,7 +2096,7 @@ export type Permission =
   | 'discovery:scan'
   | 'discovery:scan'
   | 'firmware:read' | 'firmware:update'
   | 'firmware:read' | 'firmware:update'
   | 'ams_history:read'
   | 'ams_history:read'
-  | 'stats:read'
+  | 'stats:read' | 'stats:filter_by_user'
   | 'system:read'
   | 'system:read'
   | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
   | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
   | 'github:backup' | 'github:restore'
   | 'github:backup' | 'github:restore'
@@ -2614,10 +2614,11 @@ 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) => {
+  getArchivesSlim: (dateFrom?: string, dateTo?: string, createdById?: number) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (dateFrom) params.set('date_from', dateFrom);
     if (dateFrom) params.set('date_from', dateFrom);
     if (dateTo) params.set('date_to', dateTo);
     if (dateTo) params.set('date_to', dateTo);
+    if (createdById !== undefined) params.set('created_by_id', String(createdById));
     const qs = params.toString();
     const qs = params.toString();
     return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
     return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
   },
   },
@@ -2660,10 +2661,11 @@ export const api = {
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
     request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
   deleteArchive: (id: number) =>
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
-  getArchiveStats: (options?: { dateFrom?: string; dateTo?: string }) => {
+  getArchiveStats: (options?: { dateFrom?: string; dateTo?: string; createdById?: number }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateTo) params.set('date_to', options.dateTo);
     if (options?.dateTo) params.set('date_to', options.dateTo);
+    if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
     const qs = params.toString();
     const qs = params.toString();
     return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);
     return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);
   },
   },
@@ -2680,13 +2682,14 @@ export const api = {
     }),
     }),
   recalculateCosts: () =>
   recalculateCosts: () =>
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
-  getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number }) => {
+  getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number; createdById?: number }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (options?.days) params.set('days', String(options.days));
     if (options?.days) params.set('days', String(options.days));
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateTo) params.set('date_to', options.dateTo);
     if (options?.dateTo) params.set('date_to', options.dateTo);
     if (options?.printerId) params.set('printer_id', String(options.printerId));
     if (options?.printerId) params.set('printer_id', String(options.printerId));
     if (options?.projectId) params.set('project_id', String(options.projectId));
     if (options?.projectId) params.set('project_id', String(options.projectId));
+    if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
     const qs = params.toString();
     const qs = params.toString();
     return request<FailureAnalysis>(`/archives/analysis/failures${qs ? `?${qs}` : ''}`);
     return request<FailureAnalysis>(`/archives/analysis/failures${qs ? `?${qs}` : ''}`);
   },
   },
@@ -2739,12 +2742,14 @@ export const api = {
     days?: number;
     days?: number;
     printerId?: number;
     printerId?: number;
     projectId?: number;
     projectId?: number;
+    createdById?: number;
   }): Promise<{ blob: Blob; filename: string }> => {
   }): Promise<{ blob: Blob; filename: string }> => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (options?.format) params.set('format', options.format);
     if (options?.format) params.set('format', options.format);
     if (options?.days) params.set('days', String(options.days));
     if (options?.days) params.set('days', String(options.days));
     if (options?.printerId) params.set('printer_id', String(options.printerId));
     if (options?.printerId) params.set('printer_id', String(options.printerId));
     if (options?.projectId) params.set('project_id', String(options.projectId));
     if (options?.projectId) params.set('project_id', String(options.projectId));
+    if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
 
 
     const headers: Record<string, string> = {};
     const headers: Record<string, string> = {};
     if (authToken) {
     if (authToken) {

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -1123,6 +1123,9 @@ export default {
       from: 'Von',
       from: 'Von',
       to: 'Bis',
       to: 'Bis',
     },
     },
+    allUsers: 'Alle Benutzer',
+    noUser: 'Kein Benutzer (System)',
+    filterByUser: 'Nach Benutzer filtern',
   },
   },
 
 
   // Maintenance page
   // Maintenance page

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -1123,6 +1123,10 @@ export default {
       from: 'From',
       from: 'From',
       to: 'To',
       to: 'To',
     },
     },
+    // User filter
+    allUsers: 'All Users',
+    noUser: 'No User (System)',
+    filterByUser: 'Filter by User',
   },
   },
 
 
   // Maintenance page
   // Maintenance page

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -1123,6 +1123,9 @@ export default {
       from: 'Du',
       from: 'Du',
       to: 'Au',
       to: 'Au',
     },
     },
+    allUsers: 'Tous les utilisateurs',
+    noUser: 'Aucun utilisateur (Système)',
+    filterByUser: 'Filtrer par utilisateur',
   },
   },
 
 
   // Maintenance page
   // Maintenance page

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -1123,6 +1123,9 @@ export default {
       from: 'Da',
       from: 'Da',
       to: 'A',
       to: 'A',
     },
     },
+    allUsers: 'Tutti gli utenti',
+    noUser: 'Nessun utente (Sistema)',
+    filterByUser: 'Filtra per utente',
   },
   },
 
 
   // Maintenance page
   // Maintenance page

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -1122,6 +1122,9 @@ export default {
       from: '開始',
       from: '開始',
       to: '終了',
       to: '終了',
     },
     },
+    allUsers: '全ユーザー',
+    noUser: 'ユーザーなし(システム)',
+    filterByUser: 'ユーザーでフィルター',
   },
   },
 
 
   // Maintenance page
   // Maintenance page

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1123,6 +1123,9 @@ export default {
       from: 'De',
       from: 'De',
       to: 'Até',
       to: 'Até',
     },
     },
+    allUsers: 'Todos os Usuários',
+    noUser: 'Sem Usuário (Sistema)',
+    filterByUser: 'Filtrar por Usuário',
   },
   },
 
 
   // Maintenance page
   // Maintenance page

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1123,6 +1123,9 @@ export default {
       from: '从',
       from: '从',
       to: '到',
       to: '到',
     },
     },
+    allUsers: '所有用户',
+    noUser: '无用户(系统)',
+    filterByUser: '按用户筛选',
   },
   },
 
 
   // Maintenance page
   // Maintenance page

+ 95 - 11
frontend/src/pages/StatsPage.tsx

@@ -19,6 +19,7 @@ import {
   Calculator,
   Calculator,
   Calendar,
   Calendar,
   ChevronDown,
   ChevronDown,
+  Users,
 } from 'lucide-react';
 } from 'lucide-react';
 import {
 import {
   BarChart,
   BarChart,
@@ -706,17 +707,19 @@ function FilamentTrendsWidget({
   return <FilamentTrends archives={archives} currency={currency} dateFrom={dateFrom} dateTo={dateTo} />;
   return <FilamentTrends archives={archives} currency={currency} dateFrom={dateFrom} dateTo={dateTo} />;
 }
 }
 
 
-function FailureAnalysisWidget({ size = 1, dateFrom, dateTo }: {
+function FailureAnalysisWidget({ size = 1, dateFrom, dateTo, createdById }: {
   size?: 1 | 2 | 4;
   size?: 1 | 2 | 4;
   dateFrom?: string;
   dateFrom?: string;
   dateTo?: string;
   dateTo?: string;
+  createdById?: number;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const hasDateRange = !!(dateFrom || dateTo);
   const hasDateRange = !!(dateFrom || dateTo);
   const { data: analysis, isLoading } = useQuery({
   const { data: analysis, isLoading } = useQuery({
-    queryKey: ['failureAnalysis', dateFrom, dateTo],
+    queryKey: ['failureAnalysis', dateFrom, dateTo, createdById ?? 'all'],
     queryFn: () => api.getFailureAnalysis({
     queryFn: () => api.getFailureAnalysis({
       ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),
       ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),
+      createdById,
     }),
     }),
   });
   });
 
 
@@ -923,12 +926,15 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
 export function StatsPage() {
 export function StatsPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, authEnabled } = useAuth();
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [dashboardKey, setDashboardKey] = useState(0);
   const [dashboardKey, setDashboardKey] = useState(0);
   const [hiddenCount, setHiddenCount] = useState(0);
   const [hiddenCount, setHiddenCount] = useState(0);
   const [isRecalculating, setIsRecalculating] = useState(false);
   const [isRecalculating, setIsRecalculating] = useState(false);
+  const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
+  const [showUserPicker, setShowUserPicker] = useState(false);
+  const canFilterByUser = authEnabled && hasPermission('stats:filter_by_user');
   const [timeframe, setTimeframe] = useState<TimeframeState>(() => {
   const [timeframe, setTimeframe] = useState<TimeframeState>(() => {
     try {
     try {
       const saved = localStorage.getItem('bambusy-stats-timeframe');
       const saved = localStorage.getItem('bambusy-stats-timeframe');
@@ -977,11 +983,15 @@ export function StatsPage() {
     };
     };
   }, [dashboardKey]);
   }, [dashboardKey]);
 
 
-  const { data: stats, isLoading, refetch: refetchStats } = useQuery({
-    queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
+  // Only pass createdById when a user is actually selected (not "All Users")
+  const createdByIdParam = selectedUserId !== null ? selectedUserId : undefined;
+
+  const { data: stats, isLoading, isFetching: isStatsFetching, refetch: refetchStats } = useQuery({
+    queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam ?? 'all'],
     queryFn: () => api.getArchiveStats({
     queryFn: () => api.getArchiveStats({
       dateFrom: effectiveDateRange.dateFrom,
       dateFrom: effectiveDateRange.dateFrom,
       dateTo: effectiveDateRange.dateTo,
       dateTo: effectiveDateRange.dateTo,
+      createdById: createdByIdParam,
     }),
     }),
   });
   });
 
 
@@ -990,9 +1000,9 @@ export function StatsPage() {
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
-  const { data: archives, refetch: refetchArchives } = useQuery({
-    queryKey: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo],
-    queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo),
+  const { data: archives, isFetching: isArchivesFetching, refetch: refetchArchives } = useQuery({
+    queryKey: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam ?? 'all'],
+    queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam),
   });
   });
 
 
   const { data: settings } = useQuery({
   const { data: settings } = useQuery({
@@ -1000,11 +1010,23 @@ export function StatsPage() {
     queryFn: api.getSettings,
     queryFn: api.getSettings,
   });
   });
 
 
+  const { data: users } = useQuery({
+    queryKey: ['users'],
+    queryFn: api.getUsers,
+    enabled: canFilterByUser,
+  });
+
+  const selectedUserLabel = useMemo(() => {
+    if (selectedUserId === null) return t('stats.allUsers', 'All Users');
+    if (selectedUserId === -1) return t('stats.noUser', 'No User (System)');
+    return users?.find(u => u.id === selectedUserId)?.username ?? '?';
+  }, [selectedUserId, users, t]);
+
   const handleExport = async (format: 'csv' | 'xlsx') => {
   const handleExport = async (format: 'csv' | 'xlsx') => {
     setShowExportMenu(false);
     setShowExportMenu(false);
     setIsExporting(true);
     setIsExporting(true);
     try {
     try {
-      const { blob, filename } = await api.exportStats({ format, days: 90 });
+      const { blob, filename } = await api.exportStats({ format, days: 90, createdById: createdByIdParam });
       const url = URL.createObjectURL(blob);
       const url = URL.createObjectURL(blob);
       const a = document.createElement('a');
       const a = document.createElement('a');
       a.href = url;
       a.href = url;
@@ -1032,6 +1054,8 @@ export function StatsPage() {
     }
     }
   };
   };
 
 
+  const isRefetching = (isStatsFetching || isArchivesFetching) && !isLoading;
+
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const currency = getCurrencySymbol(settings?.currency || 'USD');
   const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
   const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
   const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);
   const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);
@@ -1069,7 +1093,7 @@ export function StatsPage() {
     {
     {
       id: 'failure-analysis',
       id: 'failure-analysis',
       title: t('stats.failureAnalysis'),
       title: t('stats.failureAnalysis'),
-      component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
+      component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} createdById={createdByIdParam} />,
       defaultSize: 1,
       defaultSize: 1,
     },
     },
     {
     {
@@ -1102,7 +1126,10 @@ export function StatsPage() {
     <div className="p-4 md:p-8">
     <div className="p-4 md:p-8">
       <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
       <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
         <div>
         <div>
-          <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
+          <div className="flex items-center gap-2">
+            <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
+            {isRefetching && <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />}
+          </div>
           <p className="text-bambu-gray">{t('stats.subtitle')}</p>
           <p className="text-bambu-gray">{t('stats.subtitle')}</p>
         </div>
         </div>
         <div className="flex items-center gap-2 flex-wrap">
         <div className="flex items-center gap-2 flex-wrap">
@@ -1180,6 +1207,63 @@ export function StatsPage() {
               </div>
               </div>
             )}
             )}
           </div>
           </div>
+          {/* User Filter */}
+          {canFilterByUser && users && users.length > 0 && (
+            <div className="relative">
+              <Button
+                variant="secondary"
+                onClick={() => setShowUserPicker(!showUserPicker)}
+              >
+                <Users className="w-4 h-4" />
+                {selectedUserLabel}
+                <ChevronDown className="w-3 h-3" />
+              </Button>
+              {showUserPicker && (
+                <>
+                  <div
+                    className="fixed inset-0 z-10"
+                    onClick={() => setShowUserPicker(false)}
+                  />
+                  <div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 p-2 max-h-64 overflow-y-auto">
+                    <button
+                      className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
+                        selectedUserId === null
+                          ? 'bg-bambu-green text-white'
+                          : 'text-white hover:bg-bambu-dark-tertiary'
+                      }`}
+                      onClick={() => { setSelectedUserId(null); setShowUserPicker(false); }}
+                    >
+                      {t('stats.allUsers', 'All Users')}
+                    </button>
+                    <button
+                      className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
+                        selectedUserId === -1
+                          ? 'bg-bambu-green text-white'
+                          : 'text-white hover:bg-bambu-dark-tertiary'
+                      }`}
+                      onClick={() => { setSelectedUserId(-1); setShowUserPicker(false); }}
+                    >
+                      {t('stats.noUser', 'No User (System)')}
+                    </button>
+                    <div className="border-t border-bambu-dark-tertiary my-1" />
+                    {users.map(u => (
+                      <button
+                        key={u.id}
+                        className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${
+                          selectedUserId === u.id
+                            ? 'bg-bambu-green text-white'
+                            : 'text-white hover:bg-bambu-dark-tertiary'
+                        }`}
+                        onClick={() => { setSelectedUserId(u.id); setShowUserPicker(false); }}
+                      >
+                        {u.username}
+                      </button>
+                    ))}
+                  </div>
+                </>
+              )}
+            </div>
+          )}
           {/* Timeframe Selector */}
           {/* Timeframe Selector */}
           <div className="relative">
           <div className="relative">
             <Button
             <Button

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Dk9OaHEO.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BmTXGa-5.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dk9OaHEO.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-B4zcncds.css">
     <link rel="stylesheet" crossorigin href="/assets/index-B4zcncds.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff