diff --git a/django_forms_workflows/tasks.py b/django_forms_workflows/tasks.py index 382f20e..5e07390 100644 --- a/django_forms_workflows/tasks.py +++ b/django_forms_workflows/tasks.py @@ -24,6 +24,7 @@ import requests from django.conf import settings from django.core.mail import EmailMultiAlternatives +from django.db.models import Prefetch from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone @@ -37,6 +38,7 @@ PendingNotification, WebhookDeliveryLog, WebhookEndpoint, + WorkflowDefinition, WorkflowStage, ) @@ -803,8 +805,17 @@ def check_approval_deadlines() -> str: (no user context available). """ now = timezone.now() - pending = ApprovalTask.objects.select_related("submission__form_definition").filter( - status="pending" + pending = ( + ApprovalTask.objects.select_related("submission__form_definition") + .prefetch_related( + Prefetch( + "submission__form_definition__workflows", + # The compatibility accessor uses .first(), which orders by PK. + # Prefetch in that order so it can reuse the relation cache. + queryset=WorkflowDefinition.objects.order_by("pk"), + ) + ) + .filter(status="pending") ) expired_count = 0 diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 3776149..bf5c1da 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -9,6 +9,7 @@ from django_forms_workflows import tasks as tasks_module from django_forms_workflows.models import ( ApprovalTask, + FormDefinition, FormSubmission, NotificationLog, NotificationRule, @@ -2254,3 +2255,47 @@ def test_bcc_passthrough(self, db, mailoutbox): log = NotificationLog.objects.get(recipient_email="primary@example.com") assert log.cc_emails == "cc@example.com" assert log.bcc_emails == "secret@example.com" + + +@pytest.mark.parametrize("task_count", [1, 8]) +def test_deadline_scan_has_constant_query_count( + task_count, user, approver_user, django_assert_num_queries +): + # Distinct forms prevent a relation cache from hiding per-task lookups. + for index in range(task_count): + form = FormDefinition.objects.create(name=f"Form {index}", slug=f"form-{index}") + WorkflowDefinition.objects.create(form_definition=form) + # Preserve the first-workflow compatibility policy for sibling workflows. + WorkflowDefinition.objects.create( + form_definition=form, approval_deadline_days=1 + ) + submission = FormSubmission.objects.create( + form_definition=form, + submitter=user, + form_data={}, + status="pending_approval", + ) + ApprovalTask.objects.create( + submission=submission, + step_name="Review", + assigned_to=approver_user, + status="pending", + ) + # A form without a workflow must still be skipped, not drop or crash the scan. + orphan_form = FormDefinition.objects.create(name="No workflow", slug="no-workflow") + orphan = FormSubmission.objects.create( + form_definition=orphan_form, + submitter=user, + form_data={}, + status="pending_approval", + ) + ApprovalTask.objects.create( + submission=orphan, + step_name="Review", + assigned_to=approver_user, + status="pending", + ) + with django_assert_num_queries(2): + result = check_approval_deadlines() + assert result == "expired=0, reminders=0, auto_approved=0" + assert ApprovalTask.objects.filter(status="pending").count() == task_count + 1