Scheduled reports

Scheduled reports run an independent copy of a validated row or summary report and email the complete bounded result to an explicitly confirmed list of recipients. The feature is available from the Scheduled reports link in the QBE builder or through Django Admin’s Django QBE 2 navigation.

A schedule belongs to the staff user who created it. Only that owner can view, edit, pause, resume or delete it; superusers do not bypass this ownership rule. The definition is copied when the schedule is created. Changing or deleting the source saved query does not change the schedule. Use Edit definition and explicitly update the snapshot to change what future runs execute.

Warning

Email recipients receive report data as an attachment. They do not gain application access, and BCC hides recipient addresses from one another, but recipients can keep or forward the file. Confirm that every address is authorized to receive the report. Use the optional domain allowlist for an additional deployment-level restriction.

Create a schedule

  1. Build a row report or grouped summary and choose Preview report.

  2. Choose Schedule this report from the results page.

  3. Set a name, daily or weekly frequency, local time, IANA timezone, recipients and CSV or Excel format. Excel requires django-qbe2[excel].

  4. Confirm that the listed recipients may receive the report, then save.

Addresses are entered one per line, normalized and deduplicated. The default maximum is 10. The schedule screen shows its next run, last status and recent sanitized run history. Recipients are not displayed in one another’s messages.

Migration and storage

After upgrading the package and adding django_qbe2 to INSTALLED_APPS, apply its migrations:

python manage.py migrate

The package adds ScheduledReport and ScheduledReportRun tables to the default database. User foreign keys use AUTH_USER_MODEL. Schedules store a bounded definition, recurrence, recipients and output format. Run history stores only occurrence timestamps, status, attempts, a short lease and a sanitized message. Report rows, SQL results and attachment contents are never persisted.

Run the scheduler

The package does not start background threads or require Celery. Invoke this bounded management command periodically through cron, a platform scheduler or your task system:

python manage.py qbe2_run_scheduled_reports --limit 25

For example, a cron entry can poll once per minute:

* * * * * cd /srv/myproject && /srv/myproject/.venv/bin/python manage.py qbe2_run_scheduled_reports --limit 25

The database lease and unique schedule/occurrence record prevent two workers from claiming the same occurrence. SQLite and PostgreSQL are supported. A worker that exits without recording a result can be reclaimed after its 15-minute lease expires. Failed runs retry after 5 and then 10 minutes, with at most three attempts. A permission loss disables the schedule and requires owner review.

If the command is down for several periods, it sends only the most recent due occurrence and moves to the next future occurrence; it does not send a burst of missed reports. Pause stops new claims; an already claimed run can finish. Resume chooses the next future local occurrence.

Delivery uses an at-least-once boundary: email delivery and saving the success record cannot be one atomic transaction. If the process stops after the mail server accepts a message but before the database records success, a retry may send that same occurrence again. Keep the command supervised and monitor run history for failed deliveries.

Email configuration

Django QBE 2 sends a plain-text EmailMessage through the project’s default Django mailer. Configure that mailer for your deployment. For Django 6.0, use the version’s email settings, for example:

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.example.com"
EMAIL_PORT = 587
EMAIL_HOST_USER = os.environ["SMTP_USERNAME"]
EMAIL_HOST_PASSWORD = os.environ["SMTP_PASSWORD"]
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = "reports@example.com"

Django 6.1 introduces MAILERS; configure the default mailer there instead of relying on the deprecated EMAIL_* settings:

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": "smtp.example.com",
            "port": 587,
            "username": os.environ["SMTP_USERNAME"],
            "password": os.environ["SMTP_PASSWORD"],
            "use_tls": True,
            "timeout": 10,
        },
    },
}
DEFAULT_FROM_EMAIL = "reports@example.com"

For a safe local run, configure Django’s console email backend; it prints the message without contacting an external service. Django’s test runner uses the in-memory backend automatically. QBE does not require an SMTP or email-provider package of its own. See Django’s email documentation and MAILERS settings.

Recurrence and timezone behavior

Schedules run daily or weekly at minute precision in the saved IANA timezone. The database stores the next occurrence in UTC. During a daylight-saving gap, the occurrence moves to the first valid local minute after the gap. During a repeated hour, it uses the first occurrence. These rules keep the local schedule predictable while avoiding skipped or duplicate runs at a clock transition.

Authorization and output limits

Each occurrence loads the owner again, rebuilds the authorized report catalog, validates the stored definition and executes the report’s current get_queryset(request) policy. Related report permissions and row policies are checked through the same report engine. A superuser running the management command never replaces the schedule owner. If the report, required fields, owner’s active/staff state or required permissions are no longer valid, the run fails without sending an attachment and disables the schedule for review.

Reports that need middleware-added request context, such as a tenant, can provide it on their Report class:

class OrderReport(Report):
    model = Order
    fields = ("number", "total")

    def get_queryset(self, request):
        return Order.objects.filter(tenant=request.tenant)

    def get_scheduled_request(self, *, owner, schedule):
        request = super().get_scheduled_request(owner=owner, schedule=schedule)
        request.tenant = tenant_for_schedule_owner(owner)
        return request

The hook must return an HttpRequest whose user is the schedule owner. QBE restores the fresh database-loaded owner after calling the hook. If a report cannot reconstruct its required context, its policy should fail closed. The schedule timezone is available as request.timezone.

Each export must be complete. Existing limits cap report rows, summary source rows, summary output rows and related values. A schedule fails without an attachment if any applicable limit is exceeded. Attachments are capped at 10 MiB by default, configurable with QBE2_MAX_SCHEDULED_ATTACHMENT_BYTES. Recipient count defaults to 10 and can be changed with QBE2_MAX_SCHEDULED_RECIPIENTS. Optionally set QBE2_SCHEDULED_EMAIL_DOMAIN_ALLOWLIST to a list or tuple of accepted domains. See Settings and HTTP routes for the complete settings table.

Troubleshooting

  • No email was sent: inspect the schedule’s last-run status and the host project’s configured mailer; run the management command manually to see its batch summary.

  • The schedule was disabled: restore the owner’s active staff status, report-level view permissions and any related-report permissions, then review the definition and resume it.

  • The report exceeded a limit: narrow its conditions, reduce selected related values or groups, or adjust the relevant positive integer QBE limit. The package will not send a partial attachment.

  • A recipient domain is rejected: check QBE2_SCHEDULED_EMAIL_DOMAIN_ALLOWLIST in the settings module used by the command process.

  • No run is due: check the stored timezone and next-run time; the command only processes due schedules and should be invoked periodically.