From 0bc9227d6f96f85163b579fd0c276e7c85566831 Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:59:02 -0700 Subject: [PATCH] refactor(tasks): simplify task tracking code after redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract COMPLETE_STATUSES as a class constant on PaperlessTask, eliminating the repeated status tuple across models.py, views.py (3×), and filters.py - Extract _CELERY_STATE_TO_STATUS as a module-level constant instead of rebuilding the dict on every task_postrun - Extract _V9_TYPE_TO_TRIGGER_SOURCE and _RUNNABLE_TASKS as class constants on TasksViewSet instead of rebuilding on every request - Extract _TRIGGER_SOURCE_TO_V9_TYPE as a class constant on TaskSerializerV9 instead of rebuilding per serialized object - Extract _get_consume_args helper to deduplicate identical arg extraction logic in _extract_input_data, _determine_trigger_source, and _extract_owner_id - Move inline imports (re, traceback) and Avg to module level - Fix _DOCUMENT_SOURCE_TO_TRIGGER type annotation key type to DocumentSource instead of Any - Remove redundant truthiness checks in SystemStatusView branches already guarded by an is-None check Co-Authored-By: Claude Sonnet 4.6 --- src/documents/filters.py | 9 +--- src/documents/models.py | 17 ++++--- src/documents/serialisers.py | 12 ++--- src/documents/signals/handlers.py | 38 +++++++++------ src/documents/views.py | 78 ++++++++++--------------------- 5 files changed, 63 insertions(+), 91 deletions(-) diff --git a/src/documents/filters.py b/src/documents/filters.py index 751f0aa0b..ba1dc11c6 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -906,14 +906,9 @@ class PaperlessTaskFilterSet(FilterSet): fields = ["task_type", "trigger_source", "status", "acknowledged", "owner"] def filter_is_complete(self, queryset, name, value): - complete = [ - PaperlessTask.Status.SUCCESS, - PaperlessTask.Status.FAILURE, - PaperlessTask.Status.REVOKED, - ] if value: - return queryset.filter(status__in=complete) - return queryset.exclude(status__in=complete) + return queryset.filter(status__in=PaperlessTask.COMPLETE_STATUSES) + return queryset.exclude(status__in=PaperlessTask.COMPLETE_STATUSES) class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter): diff --git a/src/documents/models.py b/src/documents/models.py index 83b310394..8a082c76e 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -687,16 +687,19 @@ class PaperlessTask(ModelWithOwner): MAIL_FETCH = "mail_fetch", _("Mail Fetch") LLM_INDEX = "llm_index", _("LLM Index") + COMPLETE_STATUSES = ( + Status.SUCCESS, + Status.FAILURE, + Status.REVOKED, + ) + class TriggerSource(models.TextChoices): SCHEDULED = "scheduled", _("Scheduled") # Celery beat WEB_UI = "web_ui", _("Web UI") # Document uploaded via web API_UPLOAD = "api_upload", _("API Upload") # Document uploaded via API FOLDER_CONSUME = "folder_consume", _("Folder Consume") # Consume folder EMAIL_CONSUME = "email_consume", _("Email Consume") # Email attachment - SYSTEM = ( - "system", - _("System"), - ) # Auto-triggered by system (self-heal, config side-effect) + SYSTEM = "system", _("System") # Auto-triggered (self-heal, config side-effect) MANUAL = "manual", _("Manual") # User explicitly ran via /api/tasks/run/ # Identification @@ -811,11 +814,7 @@ class PaperlessTask(ModelWithOwner): @property def is_complete(self) -> bool: - return self.status in ( - self.Status.SUCCESS, - self.Status.FAILURE, - self.Status.REVOKED, - ) + return self.status in self.COMPLETE_STATUSES @property def related_document_ids(self) -> list[int]: diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index ddb9602d9..353fb9700 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -2529,13 +2529,13 @@ class TaskSerializerV9(serializers.ModelSerializer): return None return obj.input_data.get("filename") + _TRIGGER_SOURCE_TO_V9_TYPE = { + PaperlessTask.TriggerSource.SCHEDULED: "SCHEDULED_TASK", + PaperlessTask.TriggerSource.SYSTEM: "AUTO_TASK", + } + def get_type(self, obj: PaperlessTask) -> str: - # Old type values: AUTO_TASK, SCHEDULED_TASK, MANUAL_TASK - source_to_old_type = { - PaperlessTask.TriggerSource.SCHEDULED: "SCHEDULED_TASK", - PaperlessTask.TriggerSource.SYSTEM: "AUTO_TASK", - } - return source_to_old_type.get(obj.trigger_source, "MANUAL_TASK") + return self._TRIGGER_SOURCE_TO_V9_TYPE.get(obj.trigger_source, "MANUAL_TASK") def get_related_document(self, obj: PaperlessTask) -> int | None: ids = obj.related_document_ids diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 3494439d6..be9ea6e42 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -2,7 +2,9 @@ from __future__ import annotations import hashlib import logging +import re as _re import shutil +import traceback as _tb from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -1009,7 +1011,13 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = { "paperless_mail.tasks.process_mail_accounts": PaperlessTask.TaskType.MAIL_FETCH, } -_DOCUMENT_SOURCE_TO_TRIGGER: dict[Any, PaperlessTask.TriggerSource] = { +_CELERY_STATE_TO_STATUS: dict[str, PaperlessTask.Status] = { + "SUCCESS": PaperlessTask.Status.SUCCESS, + "FAILURE": PaperlessTask.Status.FAILURE, + "REVOKED": PaperlessTask.Status.REVOKED, +} + +_DOCUMENT_SOURCE_TO_TRIGGER: dict[DocumentSource, PaperlessTask.TriggerSource] = { DocumentSource.ConsumeFolder: PaperlessTask.TriggerSource.FOLDER_CONSUME, DocumentSource.ApiUpload: PaperlessTask.TriggerSource.API_UPLOAD, DocumentSource.MailFetch: PaperlessTask.TriggerSource.EMAIL_CONSUME, @@ -1017,14 +1025,23 @@ _DOCUMENT_SOURCE_TO_TRIGGER: dict[Any, PaperlessTask.TriggerSource] = { } +def _get_consume_args( + args: tuple, + task_kwargs: dict, +) -> tuple[Any | None, Any | None]: + """Extract (input_doc, overrides) from consume_file task arguments.""" + input_doc = args[0] if args else task_kwargs.get("input_doc") + overrides = args[1] if len(args) >= 2 else task_kwargs.get("overrides") + return input_doc, overrides + + def _extract_input_data( task_type: PaperlessTask.TaskType, args: tuple, task_kwargs: dict, ) -> dict: if task_type == PaperlessTask.TaskType.CONSUME_FILE: - input_doc = args[0] if args else task_kwargs.get("input_doc") - overrides = args[1] if len(args) >= 2 else task_kwargs.get("overrides") + input_doc, overrides = _get_consume_args(args, task_kwargs) if input_doc is None: return {} data: dict = { @@ -1066,7 +1083,7 @@ def _determine_trigger_source( return PaperlessTask.TriggerSource.SYSTEM if task_type == PaperlessTask.TaskType.CONSUME_FILE: - input_doc = args[0] if args else task_kwargs.get("input_doc") + input_doc, _ = _get_consume_args(args, task_kwargs) if input_doc is not None: return _DOCUMENT_SOURCE_TO_TRIGGER.get( input_doc.source, @@ -1083,15 +1100,13 @@ def _extract_owner_id( ) -> int | None: if task_type != PaperlessTask.TaskType.CONSUME_FILE: return None - overrides = args[1] if len(args) >= 2 else task_kwargs.get("overrides") + _, overrides = _get_consume_args(args, task_kwargs) if overrides and hasattr(overrides, "owner_id"): return overrides.owner_id return None def _parse_legacy_result(result: str) -> dict | None: - import re as _re - if match := _re.search(r"New document id (\d+) created", result): return {"document_id": int(match.group(1))} if match := _re.search(r"It is a duplicate of .* \(#(\d+)\)", result): @@ -1187,12 +1202,7 @@ def task_postrun_handler( try: close_old_connections() - status_map = { - "SUCCESS": PaperlessTask.Status.SUCCESS, - "FAILURE": PaperlessTask.Status.FAILURE, - "REVOKED": PaperlessTask.Status.REVOKED, - } - new_status = status_map.get(state, PaperlessTask.Status.FAILURE) + new_status = _CELERY_STATE_TO_STATUS.get(state, PaperlessTask.Status.FAILURE) result_data: dict | None = None result_message: str | None = None @@ -1252,8 +1262,6 @@ def task_failure_handler( "error_message": str(exception) if exception else "Unknown error", } if traceback: - import traceback as _tb - tb_str = "".join(_tb.format_tb(traceback)) result_data["traceback"] = tb_str[:5000] diff --git a/src/documents/views.py b/src/documents/views.py index 9d236ab0d..61c95a4dd 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -29,6 +29,7 @@ from django.core.cache import cache from django.db import connections from django.db.migrations.loader import MigrationLoader from django.db.migrations.recorder import MigrationRecorder +from django.db.models import Avg from django.db.models import Case from django.db.models import Count from django.db.models import F @@ -3787,6 +3788,20 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): ] ordering = ["-date_created"] + # v9 backwards compat: maps old "type" query param values to new TriggerSource + _V9_TYPE_TO_TRIGGER_SOURCE = { + "AUTO_TASK": PaperlessTask.TriggerSource.SYSTEM, + "SCHEDULED_TASK": PaperlessTask.TriggerSource.SCHEDULED, + "MANUAL_TASK": PaperlessTask.TriggerSource.MANUAL, + } + + _RUNNABLE_TASKS = { + PaperlessTask.TaskType.INDEX_OPTIMIZE: (index_optimize, {}), + PaperlessTask.TaskType.TRAIN_CLASSIFIER: (train_classifier, {}), + PaperlessTask.TaskType.SANITY_CHECK: (sanity_check, {"raise_on_error": False}), + PaperlessTask.TaskType.LLM_INDEX: (llmindex_index, {"rebuild": False}), + } + def get_serializer_class(self): # v9: use backwards-compatible serializer with old field names if self.request.version and int(self.request.version) < 10: @@ -3802,13 +3817,7 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): queryset = queryset.filter(task_type=task_name) task_type_old = self.request.query_params.get("type") if task_type_old is not None: - # Old type values: AUTO_TASK -> SYSTEM, SCHEDULED_TASK -> SCHEDULED, MANUAL_TASK -> MANUAL - old_to_new = { - "AUTO_TASK": PaperlessTask.TriggerSource.SYSTEM, - "SCHEDULED_TASK": PaperlessTask.TriggerSource.SCHEDULED, - "MANUAL_TASK": PaperlessTask.TriggerSource.MANUAL, - } - new_source = old_to_new.get(task_type_old) + new_source = self._V9_TYPE_TO_TRIGGER_SOURCE.get(task_type_old) if new_source: queryset = queryset.filter(trigger_source=new_source) # v10+: direct task_id param for backwards compat @@ -3841,11 +3850,7 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): self.get_queryset() .filter( acknowledged=False, - status__in=[ - PaperlessTask.Status.SUCCESS, - PaperlessTask.Status.FAILURE, - PaperlessTask.Status.REVOKED, - ], + status__in=PaperlessTask.COMPLETE_STATUSES, ) .update(acknowledged=True) ) @@ -3854,11 +3859,6 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): @action(methods=["get"], detail=False) def summary(self, request): """Aggregated task statistics per task_type over the last N days (default 30).""" - from django.db.models import Avg - from django.db.models import Count - from django.db.models import Max - from django.db.models import Q - days = int(request.query_params.get("days", 30)) cutoff = timezone.now() - timedelta(days=days) queryset = self.get_queryset().filter(date_created__gte=cutoff) @@ -3909,27 +3909,14 @@ class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]): serializer.is_valid(raise_exception=True) task_type = serializer.validated_data.get("task_type") - task_func_map = { - PaperlessTask.TaskType.INDEX_OPTIMIZE: (index_optimize, {}), - PaperlessTask.TaskType.TRAIN_CLASSIFIER: (train_classifier, {}), - PaperlessTask.TaskType.SANITY_CHECK: ( - sanity_check, - {"raise_on_error": False}, - ), - PaperlessTask.TaskType.LLM_INDEX: ( - llmindex_index, - {"rebuild": False}, - ), - } - - if task_type not in task_func_map: + if task_type not in self._RUNNABLE_TASKS: return Response( {"error": f"Task type '{task_type}' cannot be manually triggered"}, status=status.HTTP_400_BAD_REQUEST, ) try: - task_func, task_kwargs = task_func_map[task_type] + task_func, task_kwargs = self._RUNNABLE_TASKS[task_type] async_result = task_func.apply_async( kwargs=task_kwargs, headers={"trigger_source": "manual"}, @@ -4569,11 +4556,7 @@ class SystemStatusView(PassUserMixin): last_trained_task = ( PaperlessTask.objects.filter( task_type=PaperlessTask.TaskType.TRAIN_CLASSIFIER, - status__in=[ - PaperlessTask.Status.SUCCESS, - PaperlessTask.Status.FAILURE, - PaperlessTask.Status.REVOKED, - ], # ignore running tasks + status__in=PaperlessTask.COMPLETE_STATUSES, # ignore running tasks ) .order_by("-date_done") .first() @@ -4583,10 +4566,7 @@ class SystemStatusView(PassUserMixin): if last_trained_task is None: classifier_status = "WARNING" classifier_error = "No classifier training tasks found" - elif ( - last_trained_task - and last_trained_task.status != PaperlessTask.Status.SUCCESS - ): + elif last_trained_task.status != PaperlessTask.Status.SUCCESS: classifier_status = "ERROR" classifier_error = last_trained_task.result_message classifier_last_trained = ( @@ -4596,11 +4576,7 @@ class SystemStatusView(PassUserMixin): last_sanity_check = ( PaperlessTask.objects.filter( task_type=PaperlessTask.TaskType.SANITY_CHECK, - status__in=[ - PaperlessTask.Status.SUCCESS, - PaperlessTask.Status.FAILURE, - PaperlessTask.Status.REVOKED, - ], # ignore running tasks + status__in=PaperlessTask.COMPLETE_STATUSES, # ignore running tasks ) .order_by("-date_done") .first() @@ -4610,10 +4586,7 @@ class SystemStatusView(PassUserMixin): if last_sanity_check is None: sanity_check_status = "WARNING" sanity_check_error = "No sanity check tasks found" - elif ( - last_sanity_check - and last_sanity_check.status != PaperlessTask.Status.SUCCESS - ): + elif last_sanity_check.status != PaperlessTask.Status.SUCCESS: sanity_check_status = "ERROR" sanity_check_error = last_sanity_check.result_message sanity_check_last_run = ( @@ -4638,10 +4611,7 @@ class SystemStatusView(PassUserMixin): if last_llmindex_update is None: llmindex_status = "WARNING" llmindex_error = "No LLM index update tasks found" - elif ( - last_llmindex_update - and last_llmindex_update.status == PaperlessTask.Status.FAILURE - ): + elif last_llmindex_update.status == PaperlessTask.Status.FAILURE: llmindex_status = "ERROR" llmindex_error = last_llmindex_update.result_message llmindex_last_modified = (