From 0c25c2dac5f0757ba072ff9731799d2dafb4d013 Mon Sep 17 00:00:00 2001
From: Trenton H <797416+stumpylog@users.noreply.github.com>
Date: Wed, 22 Apr 2026 13:48:54 -0700
Subject: [PATCH] Feature: Allow monitoring access to tasks summary (#12624)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
docs/usage.md | 2 +-
.../admin/settings/settings.component.spec.ts | 4 +-
.../admin/settings/settings.component.ts | 2 +-
.../permissions-select.component.spec.ts | 10 +-
.../permissions-select.component.ts | 2 +-
.../app/services/permissions.service.spec.ts | 4 +-
.../src/app/services/permissions.service.ts | 2 +-
src/documents/permissions.py | 2 +-
src/documents/tests/test_api_schema.py | 8 ++
src/documents/tests/test_api_status.py | 2 +-
src/documents/tests/test_api_tasks.py | 94 +++++++++++++++++++
src/documents/views.py | 18 +++-
..._alter_applicationconfiguration_options.py | 2 +-
src/paperless/models.py | 2 +-
14 files changed, 133 insertions(+), 21 deletions(-)
diff --git a/docs/usage.md b/docs/usage.md
index 98eceb22a..3c5f6222a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -414,7 +414,7 @@ still have "object-level" permissions.
| SavedView | Add, edit, delete or view Saved Views. |
| ShareLink | Add, delete or view Share Links. |
| StoragePath | Add, edit, delete or view Storage Paths. |
-| SystemStatus | View the system status dialog and corresponding API endpoint. Admin users also retain system status access. |
+| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
| Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.
:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts
index 546231339..854444e98 100644
--- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts
+++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts
@@ -337,7 +337,7 @@ describe('SettingsComponent', () => {
.mockImplementation(
(action, type) =>
action === PermissionAction.View &&
- type === PermissionType.SystemStatus
+ type === PermissionType.SystemMonitoring
)
completeSetup()
expect(component['systemStatus']).toEqual(status) // private
@@ -359,7 +359,7 @@ describe('SettingsComponent', () => {
.mockImplementation(
(action, type) =>
action === PermissionAction.View &&
- type === PermissionType.SystemStatus
+ type === PermissionType.SystemMonitoring
)
completeSetup()
component.showSystemStatus()
diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts
index 09a2df92b..8d6805bbc 100644
--- a/src-ui/src/app/components/admin/settings/settings.component.ts
+++ b/src-ui/src/app/components/admin/settings/settings.component.ts
@@ -652,7 +652,7 @@ export class SettingsComponent
this.permissionsService.isAdmin() ||
this.permissionsService.currentUserCan(
PermissionAction.View,
- PermissionType.SystemStatus
+ PermissionType.SystemMonitoring
)
)
}
diff --git a/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts b/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts
index 4ad0bbd54..7f2313a9b 100644
--- a/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts
+++ b/src-ui/src/app/components/common/permissions-select/permissions-select.component.spec.ts
@@ -133,28 +133,28 @@ describe('PermissionsSelectComponent', () => {
expect(viewInput.nativeElement.disabled).toBeFalsy()
})
- it('should treat system status as view-only', () => {
+ it('should treat system monitoring as view-only', () => {
component.ngOnInit()
fixture.detectChanges()
expect(
component.isActionSupported(
- PermissionType.SystemStatus,
+ PermissionType.SystemMonitoring,
PermissionAction.View
)
).toBeTruthy()
expect(
component.isActionSupported(
- PermissionType.SystemStatus,
+ PermissionType.SystemMonitoring,
PermissionAction.Change
)
).toBeFalsy()
const changeInput = fixture.debugElement.query(
- By.css('input#SystemStatus_Change')
+ By.css('input#SystemMonitoring_Change')
)
const viewInput = fixture.debugElement.query(
- By.css('input#SystemStatus_View')
+ By.css('input#SystemMonitoring_View')
)
expect(changeInput.nativeElement.disabled).toBeTruthy()
diff --git a/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts b/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts
index 2c39e597a..2032e84e0 100644
--- a/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts
+++ b/src-ui/src/app/components/common/permissions-select/permissions-select.component.ts
@@ -261,7 +261,7 @@ export class PermissionsSelectComponent
// Global statistics and system status only support view
if (
type === PermissionType.GlobalStatistics ||
- type === PermissionType.SystemStatus
+ type === PermissionType.SystemMonitoring
) {
return action === PermissionAction.View
}
diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts
index 0249cb425..1beb0204a 100644
--- a/src-ui/src/app/services/permissions.service.spec.ts
+++ b/src-ui/src/app/services/permissions.service.spec.ts
@@ -8,7 +8,7 @@ import {
const VIEW_ONLY_PERMISSION_TYPES = new Set([
PermissionType.GlobalStatistics,
- PermissionType.SystemStatus,
+ PermissionType.SystemMonitoring,
])
describe('PermissionsService', () => {
@@ -270,7 +270,7 @@ describe('PermissionsService', () => {
'delete_applicationconfiguration',
'view_applicationconfiguration',
'view_global_statistics',
- 'view_system_status',
+ 'view_system_monitoring',
],
{
username: 'testuser',
diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts
index cb045934d..0627dca63 100644
--- a/src-ui/src/app/services/permissions.service.ts
+++ b/src-ui/src/app/services/permissions.service.ts
@@ -30,7 +30,7 @@ export enum PermissionType {
Workflow = '%s_workflow',
ProcessedMail = '%s_processedmail',
GlobalStatistics = '%s_global_statistics',
- SystemStatus = '%s_system_status',
+ SystemMonitoring = '%s_system_monitoring',
}
@Injectable({
diff --git a/src/documents/permissions.py b/src/documents/permissions.py
index 648b0ded1..99e1425de 100644
--- a/src/documents/permissions.py
+++ b/src/documents/permissions.py
@@ -72,7 +72,7 @@ def has_system_status_permission(user: User | None) -> bool:
return (
getattr(user, "is_superuser", False)
or getattr(user, "is_staff", False)
- or user.has_perm("paperless.view_system_status")
+ or user.has_perm("paperless.view_system_monitoring")
)
diff --git a/src/documents/tests/test_api_schema.py b/src/documents/tests/test_api_schema.py
index c53cbb401..876722be0 100644
--- a/src/documents/tests/test_api_schema.py
+++ b/src/documents/tests/test_api_schema.py
@@ -100,6 +100,14 @@ class TestTasksSummarySchema:
"summary items must have 'total_count' (TaskSummarySerializer)"
)
+ def test_summary_days_parameter_constraints(self, api_schema: SchemaGenerator):
+ op = api_schema["paths"]["/api/tasks/summary/"]["get"]
+ params = {p["name"]: p for p in op.get("parameters", [])}
+ assert "days" in params, "days query parameter must be declared"
+ schema = params["days"]["schema"]
+ assert schema.get("minimum") == 1, "days must have minimum: 1"
+ assert schema.get("maximum") == 365, "days must have maximum: 365"
+
class TestTasksActiveSchema:
"""tasks_active_retrieve: response must be an array of TaskSerializerV10."""
diff --git a/src/documents/tests/test_api_status.py b/src/documents/tests/test_api_status.py
index ef94f8b33..bb0039e67 100644
--- a/src/documents/tests/test_api_status.py
+++ b/src/documents/tests/test_api_status.py
@@ -102,7 +102,7 @@ class TestSystemStatus(APITestCase):
user = User.objects.create_user(username="status_user")
user.user_permissions.add(
- Permission.objects.get(codename="view_system_status"),
+ Permission.objects.get(codename="view_system_monitoring"),
)
self.client.force_login(user)
diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py
index aee080900..b85622463 100644
--- a/src/documents/tests/test_api_tasks.py
+++ b/src/documents/tests/test_api_tasks.py
@@ -578,6 +578,100 @@ class TestSummary:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "days" in response.data
+ def test_days_capped_at_365(self, admin_client: APIClient) -> None:
+ """?days= above 365 is silently clamped to 365 so tasks older than a year are excluded."""
+ old_task = PaperlessTaskFactory(task_type=PaperlessTask.TaskType.CONSUME_FILE)
+ PaperlessTask.objects.filter(pk=old_task.pk).update(
+ date_created=timezone.now() - timedelta(days=400),
+ )
+
+ response = admin_client.get(ENDPOINT + "summary/", {"days": 10000})
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data) == 0
+
+
+@pytest.mark.django_db()
+class TestSummaryPermissions:
+ def test_monitoring_user_can_access_summary(
+ self,
+ user_client: APIClient,
+ regular_user,
+ ) -> None:
+ """A user with view_system_monitoring but no document permissions can access summary/."""
+ regular_user.user_permissions.add(
+ Permission.objects.get(codename="view_system_monitoring"),
+ )
+
+ response = user_client.get(ENDPOINT + "summary/")
+
+ assert response.status_code == status.HTTP_200_OK
+
+ def test_monitoring_user_sees_all_tasks(
+ self,
+ user_client: APIClient,
+ regular_user,
+ admin_user,
+ ) -> None:
+ """Monitoring user sees aggregate data for all tasks, not just unowned ones."""
+ regular_user.user_permissions.add(
+ Permission.objects.get(codename="view_system_monitoring"),
+ )
+ PaperlessTaskFactory(
+ owner=admin_user,
+ task_type=PaperlessTask.TaskType.CONSUME_FILE,
+ status=PaperlessTask.Status.SUCCESS,
+ )
+
+ response = user_client.get(ENDPOINT + "summary/")
+
+ assert response.status_code == status.HTTP_200_OK
+ total = sum(item["total_count"] for item in response.data)
+ assert total == 1
+
+ def test_regular_user_summary_scoped_to_own_and_unowned_tasks(
+ self,
+ user_client: APIClient,
+ regular_user: User,
+ admin_user: User,
+ ) -> None:
+ """A regular user with view_paperlesstask but not view_system_monitoring sees only
+ their own tasks and unowned tasks in the summary, not other users' tasks."""
+ regular_user.user_permissions.add(
+ Permission.objects.get(codename="view_paperlesstask"),
+ )
+
+ PaperlessTaskFactory(
+ owner=regular_user,
+ task_type=PaperlessTask.TaskType.CONSUME_FILE,
+ status=PaperlessTask.Status.SUCCESS,
+ )
+ PaperlessTaskFactory(
+ owner=None,
+ task_type=PaperlessTask.TaskType.CONSUME_FILE,
+ status=PaperlessTask.Status.SUCCESS,
+ )
+ PaperlessTaskFactory( # other user's task — must not appear
+ owner=admin_user,
+ task_type=PaperlessTask.TaskType.CONSUME_FILE,
+ status=PaperlessTask.Status.SUCCESS,
+ )
+
+ response = user_client.get(ENDPOINT + "summary/")
+
+ assert response.status_code == status.HTTP_200_OK
+ total = sum(item["total_count"] for item in response.data)
+ assert total == 2
+
+ def test_unauthenticated_cannot_access_summary(
+ self,
+ rest_api_client: APIClient,
+ ) -> None:
+ """Unauthenticated requests to summary/ return 401."""
+ response = rest_api_client.get(ENDPOINT + "summary/")
+
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
@pytest.mark.django_db()
class TestActive:
diff --git a/src/documents/views.py b/src/documents/views.py
index 789d7a659..217550634 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -3844,10 +3844,10 @@ class RemoteVersionView(GenericAPIView[Any]):
parameters=[
OpenApiParameter(
name="days",
- type=int,
+ type={"type": "integer", "minimum": 1, "maximum": 365, "default": 30},
location=OpenApiParameter.QUERY,
required=False,
- description="Number of days to include in aggregation (default 30)",
+ description="Number of days to include in aggregation (default 30, min 1, max 365)",
),
],
),
@@ -3949,18 +3949,28 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]):
count = tasks.update(acknowledged=True)
return Response({"result": count})
+ def get_permissions(self):
+ if self.action == "summary" and has_system_status_permission(
+ getattr(self.request, "user", None),
+ ):
+ return [IsAuthenticated()]
+ return super().get_permissions()
+
@action(methods=["get"], detail=False)
def summary(self, request):
"""Aggregated task statistics per task_type over the last N days (default 30)."""
try:
- days = max(1, int(request.query_params.get("days", 30)))
+ days = min(365, max(1, int(request.query_params.get("days", 30))))
except (TypeError, ValueError):
return Response(
{"days": "Must be a positive integer."},
status=status.HTTP_400_BAD_REQUEST,
)
cutoff = timezone.now() - timedelta(days=days)
- queryset = self.get_queryset().filter(date_created__gte=cutoff)
+ if has_system_status_permission(request.user):
+ queryset = PaperlessTask.objects.filter(date_created__gte=cutoff)
+ else:
+ queryset = self.get_queryset().filter(date_created__gte=cutoff)
data = queryset.values("task_type").annotate(
total_count=Count("id"),
diff --git a/src/paperless/migrations/0009_alter_applicationconfiguration_options.py b/src/paperless/migrations/0009_alter_applicationconfiguration_options.py
index ae2b5b88c..4a374f23d 100644
--- a/src/paperless/migrations/0009_alter_applicationconfiguration_options.py
+++ b/src/paperless/migrations/0009_alter_applicationconfiguration_options.py
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
options={
"permissions": [
("view_global_statistics", "Can view global object counts"),
- ("view_system_status", "Can view system status information"),
+ ("view_system_monitoring", "Can view system status information"),
],
"verbose_name": "paperless application settings",
},
diff --git a/src/paperless/models.py b/src/paperless/models.py
index 6a38d28ac..07fe9207a 100644
--- a/src/paperless/models.py
+++ b/src/paperless/models.py
@@ -343,7 +343,7 @@ class ApplicationConfiguration(AbstractSingletonModel):
verbose_name = _("paperless application settings")
permissions = [
("view_global_statistics", "Can view global object counts"),
- ("view_system_status", "Can view system status information"),
+ ("view_system_monitoring", "Can view system status information"),
]
def __str__(self) -> str: # pragma: no cover