django-workflows
Reusable Django service helpers for common account flows:
- Registration and verification-code email flow
- Forgot-password code flow
- Reset-code validation and lockout handling
- Transport-neutral email utility with recipient validation
Installation
Install the private distribution from the configured package source:
pip install BmorricalDjangoWorkflows==2.1.8
In requirements.txt, pin it directly:
BmorricalDjangoWorkflows==2.1.8
Note: distribution name is BmorricalDjangoWorkflows, while imports remain under django_workflows.
Consumption policy:
- Pin an exact reviewed version in every deployed host.
- Do not deploy from an editable install, sibling path, floating branch, or unpinned VCS reference.
- Keep private-index credentials in developer, CI, and deployment environments rather than dependency files.
- Rebuild and test each host after changing the package pin.
The current tag workflow uploads Python artifacts to public PyPI. That conflicts with a private-distribution policy. Do not push a release tag containing private-only work until the workflow and consuming environments are configured for an approved private Python package source.
Ledger Application
django_workflows.ledger is a concrete reusable Django application for tenant-scoped funds, vendors, line items, budgets, transactions, documents, reports, and history APIs.
The package owns the Ledger models, initial migration, serializers, services, permissions, default views, URL patterns, and admin registrations. The host owns tenant resolution, per-tenant module enablement, audit context and database triggers, structured logging, storage configuration, authentication, and deployment infrastructure.
Required host settings:
LEDGER_TENANT_MODEL = "tenant.Tenant"
LEDGER = {
"ENABLED": True,
"DATABASE_ALIAS": "default",
"TENANT_ID_RESOLVER": "myproject.ledger_adapters.user_tenant_id",
"TENANT_ENABLED_CHECKER": "myproject.ledger_adapters.tenant_has_ledger_enabled",
"AUDIT_CONTEXT": "myproject.audit.edit_history_context",
"EVENT_LOGGER": "myproject.ledger_adapters.log_ledger_event",
"HISTORY_MODEL": "edit_history.EditHistory",
"ACCESS_GROUP_NAMES": ("SUPER_USER", "LEDGER_ADMIN", "LEDGER_AUDITOR"),
"EDIT_GROUP_NAMES": ("SUPER_USER", "LEDGER_ADMIN"),
}
Install the app and mount its stable default routes:
INSTALLED_APPS = [
# Host tenant and history applications must also be installed.
"django_workflows.ledger.apps.LedgerConfig",
]
urlpatterns = [
path("api/", include("django_workflows.ledger.urls")),
]
The tenant resolver receives the authenticated user and returns one tenant ID. Domain services receive that tenant ID explicitly. The tenant enablement checker receives a tenant ID and returns a boolean.
The default audit adapter is intentionally a no-op so the package can be imported in isolated tooling. A host requiring complete auditability must configure AUDIT_CONTEXT and install database triggers for every Ledger table; service hooks alone do not capture direct ORM, admin, or script writes.
The package owns Ledger domain migrations. The ledger app label, model names, and existing database table names are compatibility contracts. A host may own later trigger migrations that depend on package migrations because those triggers reference host audit tables. Moving an existing local Ledger app into this package without rebuilding also requires an explicit migration-history takeover plan; sharing the app label alone does not make every in-place upgrade safe.
Ledger stores a recurring fiscal-year start month and day on each tenant's LedgerConfiguration. April 1 remains the compatibility default, while valid dates such as January 1 and May 17 are supported. The configured boundary drives default fiscal years, transaction-to-line-item validation, budgets, commissioners reports, and dashboard periods. Fiscal years are named for the calendar year in which they start; for example, a May 17 FY 2026 runs from May 17, 2026 through May 16, 2027.
Dashboard dateStart and dateEnd filters scope income, expenses, adjustments, and recent transactions. Balance remains cumulative through dateEnd; omitting both dates produces All Time results. Hosts should present the fiscal calendar as tenant-scoped Ledger configuration. Currency, locale, terminology, transaction workflow, and storage retention remain product configuration still to be defined.
Receivables
Receivables are the customer-facing half of Ledger: customers, recorded work
time, recurring services, invoices, payments, and supporting documents. They
live in the ledger app and share its tenant resolution, permissions, audit
context, and archive semantics.
Cash boundary
Ledger is a single-entry cash ledger, and receivables do not change that:
- Issuing an invoice records an obligation and writes no
LedgerTransaction. - Recording a payment writes one income
LedgerTransactionand links it to the payment, so cash keeps exactly one source of truth. - Outstanding and overdue amounts are reported from open invoices through
build_receivables_summary, deliberately separate from the cash balance.
This is a controlled cash ledger with a receivables register beside it, not accrual accounting. There is still no chart of accounts, no journal balancing, and no period close.
Enablement
Receivables are controlled per tenant by LedgerConfiguration.receivables_enabled
and default to enabled, including for tenants that have no configuration row
yet. Disable it per tenant where a customer list does not apply. The gate covers
the receivables APIs and the generic history, archive, and restore routes for
receivables resources.
upsert_receivables_configuration intentionally does not require the feature to
be enabled, so an administrator can switch it back on.
Time
Work time is stored in whole minutes, matching the platform rule that decimal
hours are a presentation value rather than a calculation input. Serializers
expose an hours field for display; minutes remains the stored unit.
Retainers
A customer's retainer_hours are prepaid hours consumed before billing starts.
An invoice credits min(retainer_hours, hourly_billed_hours) * hourly_rate and
never drives a total below zero. Both retainer_hours_available and
retainer_hours_applied are stored on the invoice, so retainer consumption stays
an answerable question rather than being lost in a floored total.
Because the retainer is expressed in hours, it is consumed against hourly work only. Flat-priced entries and recurring services bill on top of it.
Invoice numbering
Numbering is tenant configuration: invoice_number_prefix,
invoice_number_next, and invoice_number_padding shape the suggestion returned
by suggest_invoice_number. Callers stay free to supply any number they want. A
supplied number that matches the configured pattern advances the sequence; one
that does not leaves it alone, which keeps free-form and legacy numbers workable.
Numbers are unique per tenant.
Invoice lifecycle
draft -> issued -> partially_paid -> paid, with void available from any
open state.
- Only a draft accepts new lines.
- Issuing assigns the number, snapshots the customer name and hourly rate, and derives the due date from the customer's payment terms.
- Voiding requires a reason and is refused once payments exist; reverse the payments first so the cash ledger stays correct.
- Voiding releases the invoice's billable entries so the work can be rebilled.
Endpoints
GET /api/ledger/receivables/configuration/
POST /api/ledger/receivables/configuration/
GET /api/ledger/receivables/summary/
GET /api/ledger/customers/
POST /api/ledger/customers/upsert/
GET /api/ledger/customers/<id>/statement/
GET /api/ledger/recurring-services/
POST /api/ledger/recurring-services/upsert/
GET /api/ledger/billable-entries/
POST /api/ledger/billable-entries/upsert/
GET /api/ledger/invoices/
POST /api/ledger/invoices/create/
GET /api/ledger/invoices/next-number/
POST /api/ledger/invoices/<id>/lines/
POST /api/ledger/invoices/<id>/issue/
POST /api/ledger/invoices/<id>/void/
POST /api/ledger/invoices/<id>/payments/
POST /api/ledger/invoices/<id>/documents/
GET /api/ledger/invoice-documents/<id>/download/
The generic ledger/<resource>/<id>/ detail, archive, restore, and history
routes accept customers, recurring-services, billable-entries, invoices,
invoice-lines, invoice-payments, and invoice-documents.
Host responsibilities
The host still owns database audit triggers for the new tables. A deployment is
incomplete if ledger__customers, ledger__recurring_services,
ledger__billable_entries, ledger__invoices, ledger__invoice_lines,
ledger__invoice_payments, and ledger__invoice_documents exist without their
insert, update, and delete triggers.
Documents
Invoice documents accept PDF, PNG, JPEG, TIFF, and DOCX up to 20 MB. Each upload records a sanitized filename, content type, size, SHA-256 digest, and uploader id. Re-uploading identical bytes to the same invoice returns the existing record rather than storing a second copy, so a double submission cannot leave orphaned duplicates in storage. Recorded content type and size are metadata, not a security boundary; scanning, retention, and purge remain host responsibilities.
Not included
There is no invoice PDF renderer, no email delivery, no customer payment portal, no tax or multi-currency handling, no late fees, and no importer. Statement presentation is a host concern.
Host Project Requirements
This package expects the host Django project to provide:
- A Django user model available through
django.contrib.auth.get_user_model() - A user meta model referenced by
WORKFLOWS["USER_META_MODEL"] - A user manager
create_user(...)method that acceptsusername, because this package explicitly setsusername=email
The user meta model should include at least:
userrelationverify_codefieldverify_timefieldattemptsfield
Optional (used if present):
force_password_reset
Django Settings
Add a WORKFLOWS dict in your Django settings.
WORKFLOWS = {
# Required in most projects unless your model path matches the default.
"USER_META_MODEL": "users.models_user_meta.UserMeta",
# Optional settings with defaults shown.
"BCC_RECIPIENTS": os.getenv("BCC_RECIPIENTS", ""),
"COMPANY_NAME": "My Company",
"EMAIL_TEMPLATE_RENDERER": "project.utils.emails.render_branded_email",
"ENABLE_EMAIL_DELIVERY": False,
"FORGOT_PASSWORD_URL": "https://app.example.com/forgot-password",
"RESET_CODE_TTL_MINUTES": 10,
"MAX_VERIFY_ATTEMPTS": 3,
}
Notes:
USER_META_MODELmust be a dotted import path such asmyapp.models.UserMeta.EMAIL_TEMPLATE_RENDERERcan point at a callable that receivesbody_htmland an optionaltitlekeyword and returns branded HTML for package-owned auth emails.BCC_RECIPIENTSis read at send time when provided inWORKFLOWS, avoiding import-order dependencies in host settings.FORGOT_PASSWORD_URLis used in the admin-created account email to link users into the host app's forgot-password flow.- Host apps will usually source
FORGOT_PASSWORD_URLfrom an environment variable in their own settings module. - If
ENABLE_EMAIL_DELIVERYis true, configure the email transport environment variables used by the mail service.
Example host-project wiring:
import os
WORKFLOWS = {
"USER_META_MODEL": "users.models_user_meta.UserMeta",
"EMAIL_TEMPLATE_RENDERER": "project.utils.emails.render_branded_email",
"FORGOT_PASSWORD_URL": os.getenv("FORGOT_PASSWORD_URL", ""),
}
Email Delivery Environment Variables
When email delivery is enabled, set:
ENABLE_EMAIL_DELIVERY=trueEMAIL_DELIVERY_PROVIDER=mailgun|mailpit|smtp|disabledEMAIL_FROM="My Company <no-reply@example.com>"
For the mailgun provider, also set:
EMAIL_API_KEYEMAIL_DOMAIN
Optional:
BCC_RECIPIENTSas a comma-, semicolon-, or newline-separated list. A host can instead provide it asWORKFLOWS["BCC_RECIPIENTS"]for runtime configuration.EMAIL_REPLY_TOas a single email or comma-separated list for reply handlingEMAIL_LOG_RESPONSE_TEXT=trueto log response bodies at debug level
Example:
ENABLE_EMAIL_DELIVERY=true
EMAIL_DELIVERY_PROVIDER=mailgun
EMAIL_FROM="My Company <no-reply@example.com>"
EMAIL_DOMAIN="mg.example.com"
EMAIL_API_KEY="key-example"
BCC_RECIPIENTS="audit@example.com,ops@example.com"
EMAIL_REPLY_TO="support@example.com"
If BCC_RECIPIENTS is not set, no static BCC recipients are added.
Email Delivery Logs
Email delivery emits structured fields that JSON log formatters can expose to Datadog:
event_name=email.recipients_preparedincludesemail.provider, recipient counts, maskedemail.bcc_recipients, andemail.mailgun_domainfor Mailgun sends.event_name=email.provider_responseincludesoutcome,email.response_code,email.message_id, andemail.mailgun_domain.- A non-2xx provider response is logged at warning level with
outcome=rejected; a 2xx response usesoutcome=accepted.
Recipient local parts are masked in logs. The Mailgun message ID can be used to find the corresponding accepted, delivered, suppressed, or failed event in Mailgun without logging message content or full recipient addresses.
Old To New Env Mapping
If a consuming app previously used the Mailgun-specific names, update them as follows:
MAILGUN_COMPANY_NAME->EMAIL_FROMENABLE_MAILGUN->ENABLE_EMAIL_DELIVERYMAILGUN_LOG_RESPONSE_TEXT->EMAIL_LOG_RESPONSE_TEXTMAILGUN_API_KEY->EMAIL_API_KEYMAILGUN_DOMAIN->EMAIL_DOMAINMAILGUN_BCC_RECIPIENTS->BCC_RECIPIENTS- no old equivalent ->
EMAIL_REPLY_TO
Built-In Defaults
If a consuming app does not define one of these values in .env, the package now defaults to:
ENABLE_EMAIL_DELIVERY=FalseEMAIL_API_KEY=""EMAIL_DOMAIN=""EMAIL_DELIVERY_PROVIDER=""DEVELOPMENT_MODE=FalseDEFAULT_FROM_EMAIL=""BCC_RECIPIENTS=""EMAIL_REPLY_TO=""EMAIL_LOG_RESPONSE_TEXT=False
Additional behavior:
EMAIL_FROMfalls back toDEFAULT_FROM_EMAILwhenEMAIL_FROMis unset.- If
EMAIL_DELIVERY_PROVIDERis blank, the package resolves the transport from the other flags: - If
ENABLE_EMAIL_DELIVERY=true, it defaults to themailguntransport. - If
DEVELOPMENT_MODE=true, it defaults to thesmtptransport. - Otherwise delivery remains disabled.
Practical implication:
- Production apps using Mailgun should set
ENABLE_EMAIL_DELIVERY,EMAIL_FROM,EMAIL_API_KEY, andEMAIL_DOMAIN. - If replies should go to a real inbox, also set
EMAIL_REPLY_TO. - Local apps using Mailpit should usually set
EMAIL_DELIVERY_PROVIDER=mailpit,EMAIL_FROM, and the Django SMTP settings shown below. - Apps that do not want this package to send email can omit everything and leave delivery disabled.
Example for a branded sender with a monitored reply inbox:
EMAIL_FROM="Bourbonnais Township Highway Department <no-reply@mail.bthwy.org>"
EMAIL_REPLY_TO="office@bthwy.org"
This keeps the authenticated sender in the From header while directing user replies to the EMAIL_REPLY_TO inbox.
Django Email Backend Settings
If a consuming app uses EMAIL_DELIVERY_PROVIDER=smtp or EMAIL_DELIVERY_PROVIDER=mailpit, it should configure Django's email backend in settings.py.
import os
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL")
EMAIL_HOST = os.getenv("EMAIL_HOST", "localhost")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "25"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False") == "True"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "False") == "True"
Notes:
- This is only needed for the
smtpandmailpitproviders. mailpitcommonly listens on port1025, so setEMAIL_PORTaccordingly in local development.- If you set
EMAIL_FROM, that value is preferred by this package. Otherwise it falls back toDEFAULT_FROM_EMAIL.
Example Usage
from django_workflows.users.services.auth_flow import (
register_user_and_send_verification_email,
send_forgot_password_code,
verify_reset_code,
change_password,
)
result = register_user_and_send_verification_email(
email="person@example.com",
first_name="First",
last_name="Last",
password="example-password",
)
forgot = send_forgot_password_code("person@example.com")
verify = verify_reset_code(email="person@example.com", code="AB12CD")
changed = change_password(
email="person@example.com",
password="new-password",
password_verify="new-password",
)
Direct Email Service Usage
Attachment tuple shape:
(filename, file_bytes_or_file_object, mimetype)
Example attachment entry:
("report.pdf", pdf_bytes, "application/pdf")
from django_workflows.services.email import send_email
response = send_email(
to=["primary@example.com", "secondary@example.com"],
subject="Welcome",
html="<p>Thanks for joining.</p>",
cc="manager@example.com",
bcc=["audit@example.com"],
reply_to="support@example.com",
attachments=[("report.pdf", pdf_bytes, "application/pdf")],
)
# Optional: explicit runtime override for enablement
send_email(
to="user@example.com",
subject="Dry run",
html="<p>This will not send.</p>",
enabled=False,
)
Local Development
Run tests:
make test
Run tests with coverage:
make test-coverage
CI runs tests on push and pull requests using .github/workflows/tests.yml.
Troubleshooting
ImproperlyConfigured for USER_META_MODEL
Error example:
WORKFLOWS['USER_META_MODEL'] must be a dotted path like 'myapp.models.UserMeta'.
Fix:
- Set
WORKFLOWS["USER_META_MODEL"]to a valid dotted import path. - Verify the target model is importable by Django at runtime.
UserMeta field errors
If you see attribute errors around verification state, confirm your user meta model provides:
verify_codeverify_timeattempts
Email delivery not sending
Check:
ENABLE_EMAIL_DELIVERY=trueEMAIL_DELIVERY_PROVIDEREMAIL_FROMorDEFAULT_FROM_EMAILEMAIL_API_KEYandEMAIL_DOMAINwhen using the Mailgun provider
No static BCC recipients applied
This is expected unless you set BCC_RECIPIENTS.
Release
Releases are maintainer-owned. Before releasing:
- Confirm the intended distribution source and artifact visibility.
- Run the complete package test suite from a clean environment.
- Update
VERSIONthrough the reviewed release process. - Review the release commit and tag before pushing.
- Confirm the built wheel and source distribution install from the approved package source.
- Pin the exact version in each consuming host and rebuild it.
- Run host migration, trigger, API, and integration checks.
The included release script creates and pushes a version tag:
./release.sh <version>
At present, that tag invokes .github/workflows/publish.yml, which uploads to public PyPI. Replace or reconfigure that workflow before using the script for a private release. The private index endpoint and credentials must be supplied by the release environment; they must not be committed to this repository.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file bmorricaldjangoworkflows-2.1.16.tar.gz.
File metadata
- Download URL: bmorricaldjangoworkflows-2.1.16.tar.gz
- Upload date:
- Size: 159.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6721bf13e31d1476f0b34f79d0477f1f114a90366a553122853e2c055b6caff1
|
|
| MD5 |
12cc0a4f582ed195be6e39bf3c3fa615
|
|
| BLAKE2b-256 |
b7da35c1adcc14c375d230fd231c751b1b0bda83ecb3ca390faf7ededa1c5f96
|
File details
Details for the file bmorricaldjangoworkflows-2.1.16-py3-none-any.whl.
File metadata
- Download URL: bmorricaldjangoworkflows-2.1.16-py3-none-any.whl
- Upload date:
- Size: 176.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13a4088e1c6733080f651a5abf80144b704d6e5cc8aa2f5f6f5220612db33b9a
|
|
| MD5 |
a1fb9c6642eeda71f5c9bbea8f7a9a1b
|
|
| BLAKE2b-256 |
c7c27a5eb8f3ec1704ca348c224ed80989e2ebd8139e44e01d8fa4b1a5afebde
|