mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-23 16:39:27 +00:00
Feature: Allow monitoring access to tasks summary (#12624)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
@@ -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.<br/>: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/`. |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -652,7 +652,7 @@ export class SettingsComponent
|
||||
this.permissionsService.isAdmin() ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.SystemStatus
|
||||
PermissionType.SystemMonitoring
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
const VIEW_ONLY_PERMISSION_TYPES = new Set<PermissionType>([
|
||||
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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user