nautobot-contract-models¶
A Nautobot content plugin that adds first-class models for vendor contracts, invoices, renewal tracking, and PDF attachments, with the relationships needed to answer questions like:
- Which contracts expire in the next 60 days?
- Which devices are covered by an active support contract, and which aren't?
- What did we pay last quarter for circuit X, and is it trending up?
- Show me the signed PDF of the master services agreement we have with Acme.
Inspired by netbox-contract, but re-architected for Nautobot 3.x conventions: PrimaryModel, the Status framework, Tenant, ChangeLog, the Job framework, and the modern NautobotUIViewSet / ObjectDetailContent UI Component Framework.
Status¶
Tested against Nautobot 3.1.1. CalVer versioning (YYYY.M.D) — see pyproject.toml for the version that was current when you cloned.
Models¶
| Model | Role | Lifecycle |
|---|---|---|
ServiceProvider |
The vendor / counterparty | Independent — referenced by Contracts |
Contract |
The master agreement: dates, costs, currency, status | Owned by a ServiceProvider (PROTECT); optional Tenant (SET_NULL) |
Invoice |
One billing line on a Contract | CASCADE on Contract delete |
ContractAssignment |
Generic-FK link between a Contract and any Nautobot object (Device, Circuit, VirtualMachine, …) | CASCADE on Contract delete; PROTECT on target ContentType |
InvoiceAttachment |
A file uploaded against an Invoice (typically the vendor PDF) | CASCADE on Invoice delete |
ContractAttachment |
A file uploaded against a Contract (signed PDF, SOW, renewal letter) | CASCADE on Contract delete |
All six are PrimaryModel subclasses, so they get for free: ChangeLog, custom fields, tags, dynamic groups, REST API, GraphQL, webhooks, notes, contacts, computed fields.
The ContractAssignment model uses Django's ContentType + GenericForeignKey so one model handles all target types — no separate ContractDevice, ContractCircuit, ContractVM tables. Operators can attach a Contract to anything in the Nautobot ORM with a UUID PK.
Install¶
Add to nautobot_config.py:
PLUGINS = ["nautobot_contract_models"]
PLUGINS_CONFIG = {
"nautobot_contract_models": {
# Days-out window for the renewal-alert Job + home-dashboard panel.
# Default: 60. Override via env var, file, etc. as you would any
# PLUGINS_CONFIG entry.
"renewal_window_days": 60,
},
}
Then run migrations:
The 0002_register_statuses data migration creates four Status records (Active, Expired, Cancelled, Pending) and binds them to the Contract and Invoice content types. Idempotent — safe to re-run.
After install¶
The renewal-check Job ships disabled (Nautobot 3.x default for newly-discovered Jobs). To enable it:
- Apps → Jobs, find "Check upcoming renewals" under the Contracts group
- Edit the Job, check Enabled, save
- (Optional) Configure a recurring schedule: Apps → Jobs → Scheduled Jobs → Add
Configuration via PLUGINS_CONFIG¶
| Key | Type | Default | Effect |
|---|---|---|---|
renewal_window_days |
int | 60 |
Window in days for the renewal-alert Job's default + the homepage "Upcoming Renewals" panel |
hide_dlm_contracts_nav |
bool | False |
When True AND nautobot-app-device-lifecycle-mgmt is installed, removes DLM's Contracts sidebar group so operators see one canonical contracts surface (ours). DLM's other features remain intact. See docs/admin/install.md → Coexistence. |
Coexistence with nautobot-app-device-lifecycle¶
Both plugins ship a "Contracts" surface; theirs (ContractLCM) is structurally simpler than ours. Since v2026.5.11 the two coexist without colliding on Django's Status reverse accessor. v2026.5.12 adds:
- A one-way idempotent Migrate ContractLCM → Contract Job that copies every
ContractLCM(and its device M2M, converted to our polymorphicContractAssignment) into our model. - The opt-in
hide_dlm_contracts_navflag (see above).
Full operator runbook: docs/admin/install.md → Coexistence with nautobot-app-device-lifecycle.
REST API¶
The plugin exposes a full REST API under /api/plugins/contracts/. Authentication is the standard Nautobot token; pass via Authorization: Token <key> header.
TOKEN=...
BASE=https://nautobot.example.com/api/plugins/contracts
# List contracts, with count fields populated
curl -H "Authorization: Token $TOKEN" "$BASE/contracts/"
# Same query but with FKs expanded inline (provider, status, tenant)
curl -H "Authorization: Token $TOKEN" "$BASE/contracts/?depth=1"
# Filter by name + currency
curl -H "Authorization: Token $TOKEN" "$BASE/contracts/?currency=USD&name__ic=acme"
# Find contracts expiring before a date
curl -H "Authorization: Token $TOKEN" "$BASE/contracts/?end_date__lte=2026-12-31"
# Create a Contract
curl -H "Authorization: Token $TOKEN" -X POST "$BASE/contracts/" \
-d '{"name":"Acme MSA","provider":"<provider-uuid>","status":"<active-status-uuid>",
"start_date":"2026-01-01","end_date":"2027-01-01","recurring_cost":"1200.00"}' \
-H "Content-Type: application/json"
Six endpoints, all with the standard list/detail/create/edit/delete + filter + bulk actions:
| Path | Model |
|---|---|
/api/plugins/contracts/service-providers/ |
ServiceProvider |
/api/plugins/contracts/contracts/ |
Contract |
/api/plugins/contracts/invoices/ |
Invoice |
/api/plugins/contracts/contract-assignments/ |
ContractAssignment |
/api/plugins/contracts/contract-attachments/ |
ContractAttachment |
/api/plugins/contracts/invoice-attachments/ |
InvoiceAttachment |
Count annotations included in responses: contract_count (on ServiceProvider); invoice_count, assignment_count, attachment_count (on Contract); attachment_count (on Invoice).
GraphQL¶
All six models register in Nautobot's GraphQL schema automatically. Single-call cross-table queries:
{
contracts {
name
end_date
recurring_cost
currency
provider { name account_number }
status { name }
}
service_providers {
name
contracts { name end_date }
}
}
POST to /api/graphql/ with Authorization: Token <key> and Content-Type: application/json. The interactive GraphiQL explorer is at /graphql/.
The renewal-check Job¶
Check upcoming renewals (under the Contracts group):
- Walks active contracts, finds rows whose
end_datefalls withinwindow_days(default fromPLUGINS_CONFIG.renewal_window_days) - Logs a per-contract entry —
WARNINGlevel for contracts expiring within 7 days,INFOotherwise - Returns the count, surfaced in the JobResult UI's "Result" field
- Read-only: doesn't modify contracts
# Run via CLI
nautobot-server runjob "Contracts.RenewalCheckJob"
# Or via the API
curl -H "Authorization: Token $TOKEN" -X POST \
"https://nautobot.example.com/api/extras/jobs/<job-uuid>/run/" \
-d '{"data": {"window_days": 30, "include_expired": false}}' \
-H "Content-Type: application/json"
Each per-contract log entry has the Contract attached as the JobLogEntry's object, so it shows up as a clickable link in the result UI. To route warnings into Slack/email/PagerDuty, configure a webhook on JobLogEntry creation in Apps → Webhooks.
Home dashboard panel¶
A "Contracts" panel appears on Nautobot's home page showing the next 10 contracts within renewal_window_days, ordered soonest first. Each row links to the contract detail page. The panel respects the user's view_contract permission and renders an empty-state message when there are no upcoming renewals.
Cost analytics¶
Contracts have a billing_period field (monthly, quarterly, semiannual, annual, one_time) so cost helpers can normalize across mixed billing cadences. Without it, a $1,200 annual contract and a $1,200 monthly contract are indistinguishable at the schema level — aggregating gives wrong answers.
The nautobot_contract_models.cost module exposes:
| Helper | Returns | Purpose |
|---|---|---|
monthly_cost(contract) |
Decimal |
recurring_cost normalized to a per-month figure |
annual_cost(contract) |
Decimal |
monthly_cost × 12 |
total_contract_value(contract) |
Decimal |
monthly × term_months + one_time_cost |
burn_rate_by_currency() |
dict[str, Decimal] |
sum of monthly_cost across active contracts, grouped by currency |
renewal_cost_in_window(days) |
dict[str, Decimal] |
total contract value for end-dates falling in the window |
spend_by_vendor(limit=10) |
list[(provider, monthly, currency)] |
top vendors by current monthly spend |
Aggregations always group by Contract.currency — we do not do FX conversion in v1.
Two home dashboard panels surface the data: Cost Summary (current monthly burn per currency, annualized, top 5 vendors) and Renewal Forecast (renewal cost in 30/90/365-day windows).
The Monthly cost report Job (under the Contracts group) logs the same numbers to JobLogEntry. Schedule it weekly to get a cost trend in JobResult history without standing up a separate time-series store.
⚠️ Migration note for upgrading installs: migration 0007_contract_billing_period defaults every existing contract to billing_period='monthly'. If you have annual / quarterly contracts already in the database, edit them after upgrade — otherwise the burn-rate panels will over-count by 12x (annual) or 3x (quarterly).
Bulk CSV import¶
Migrating from a spreadsheet of existing contracts? Use the standard Nautobot
import flow at Contracts → Contracts → Import (or visit
/plugins/contracts/contracts/import/). Two tabs: paste CSV body, or upload
a file. The page auto-generates a field-reference table — required vs
optional, format hints (date format, FK-by-name lookup syntax,
boolean literals).
FK lookups by natural key: provider=Acme Networks resolves the
ServiceProvider by name; status=Active resolves the Status the same way.
UUIDs also work.
A working sample lives at development/sample-data/contracts.csv —
six representative rows covering hardware support, SaaS, a Microsoft EA,
a multi-year warranty, mixed currencies, and every billing-period choice.
See development/sample-data/README.md for format quirks.
Renewal Calendar¶
A dedicated /plugins/contracts/reports/renewal-calendar/ page renders a forward-looking, month-by-month grid of contract renewals (default 12 months, configurable up to 36). Cells encode total renewal value (recurring × term + one-time fees) with an amber saturation scale — pale wash for small months, saturated for the renewal cliff. Click any cell to drill into the contract list filtered to that month + currency.
Design notes:
- Per-currency rows. No FX conversion. USD and EUR contracts appear on separate rows.
- Single-hue scale. Amber lightness ramp; works in light and dark mode (the CSS swaps the lightness curve for dark backgrounds).
- Accessibility. Real
<table>semantics, screen-reader labels per cell,prefers-reduced-motionhonored, focus-visible outlines. - Print-friendly.
@media printstrips colors and adds borders so procurement teams can take it to budget meetings. - Anchored to month boundaries. The window starts at the first of the current month, so partial-month renewals at the left edge aren't dropped.
Linked from the Contracts → Reports → Renewal Calendar nav menu.
Cost History¶
/plugins/contracts/reports/cost-history/ renders three time-series line charts (monthly burn, 90-day renewal forecast, active contract count), one line per currency, over a configurable window (4/12/26/52 weeks). Inline SVG — no JS chart library, prints natively.
Data comes from the CostSnapshot model. Schedule the Capture cost history snapshot Job weekly to feed the trend; on a fresh install the page renders an empty state pointing operators at the Job. The Detect cost anomalies Job (also under Contracts) compares this week's snapshots to a configurable baseline (default 4 weeks ago) and emits a WARNING-level JobLogEntry whenever burn rate or 90-day renewal forecast moves by more than threshold_pct (default 20%) per currency — wire a webhook to JobLogEntry creation to route into Slack/email/a ticket.
Snapshots are exposed via a read-only REST API at /api/plugins/contracts/cost-snapshots/ for external tooling (Grafana, BI dashboards). Filterable by snapshot_date__gte, snapshot_date__lte, and currency. Writes (POST/PATCH/DELETE) return 405 Method Not Allowed — snapshots are write-once historical facts, captured exclusively by the Job.
Notes¶
Every Contract, Invoice, ServiceProvider, ContractAssignment, and Attachment detail page exposes a Notes tab that supports Markdown — useful for renewal reminders, vendor escalation contacts, internal context that doesn't fit in the structured fields. Notes are framework-provided by Nautobot (no plugin code added); they persist across changelog/object updates and are attributed to the user who created them.
File attachments¶
Both Contract and Invoice support multiple file attachments (the upload field accepts any file type — typically PDF for invoices and signed contracts).
Files are stored under Nautobot's MEDIA_ROOT:
invoice_attachments/YYYY/MM/<filename>contract_attachments/YYYY/MM/<filename>
Served at /media/invoice_attachments/... and /media/contract_attachments/.... The nautobot-media Docker volume persists files across container restarts.
⚠️ Production-deploy note: files are NOT included in DB dumps. Production deployments need a separate backup strategy for MEDIA_ROOT (or configure DEFAULT_FILE_STORAGE for S3/cloud storage and back that up via cloud-provider tooling).
UI walkthrough¶
After install, the Nautobot left sidebar gains a "Contracts" tab with four list views: Contracts, Invoices, Service Providers, Assignments. Each list view supports the standard Nautobot conventions:
- Filtering, sorting, column toggling
- Bulk edit / bulk delete
- CSV import / export
- Saved views (per-user filter sets)
Each detail page renders the model's fields, plus per-parent panels for child collections:
- Contract detail → Invoices, Coverage (assignments), Attachments
- Invoice detail → Attachments
- ServiceProvider detail → Contracts
Each child panel has an "Add <child>" button that pre-populates the parent FK, so creating an invoice from a contract's detail page lands on the create form with the contract already selected.
Limitations¶
Honest about what v1 doesn't do:
- Single currency per contract / invoice. Costs are stored as
Decimal(12, 2)plus acurrencyISO 4217 CharField. No FX conversion. Reports across mixed-currency contracts are the operator's problem. - No approval workflows for contract changes. Nautobot's general
ApprovalQueuecovers this case if you need it. - No external-system sync. Contract data lives in Nautobot's own DB. If you need to read contracts from Hudu / ConnectWise / Lansweeper, build a separate SSoT plugin that syncs into these models.
- No per-line-item invoice breakdown. One
Invoicerow = one billing period. Use Nautobot custom fields or notes if you need finer granularity. - No multi-currency rate-tracking. Reporting contract value in a base currency means doing the math at query time.
- File attachments are model-specific —
InvoiceAttachmentandContractAttachmentare sibling models, not a generic GFK. Adding a third attachment type means duplicating the pattern (or refactoring to a GFK model). v1 follows netbox-contract's convention of separate models per parent. - Production media-volume backups are out of scope. See note above.
Development¶
See development/README.md for the full bringup walkthrough, including the four known gotchas (COMPOSE_PROJECT_NAME collision, volume-permission first-boot fix, worker restarts after editing jobs.py, etc.).
License¶
TBD with the operator.
Acknowledgements¶
- Data model inspired by netbox-contract by Marc Lebreuil
- Tooling and dev-stack patterns mirror the operator's nautobot-plugin-ssot-hudu
- Built on Nautobot's App development conventions