First-class Contract, Invoice, and ServiceProvider models for Nautobot.
Project description
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
pip install nautobot-contract-models
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:
nautobot-server migrate nautobot_contract_models
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 |
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
cd development/
cp .env.example .env
$EDITOR .env
make build && make up
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
Project details
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 nautobot_contract_models-2026.5.11.tar.gz.
File metadata
- Download URL: nautobot_contract_models-2026.5.11.tar.gz
- Upload date:
- Size: 1.3 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
214a0f7f68fa85aebf803ce44bfb87120fec5b424751cb3bfad6524f27085a1c
|
|
| MD5 |
5f79d15cc4bf016481238bb6202e95d0
|
|
| BLAKE2b-256 |
cf2d79d49c8c77d4a906c5f64ffef705b612154af3255d4f2cc9ab5cc14aaaa5
|
File details
Details for the file nautobot_contract_models-2026.5.11-py3-none-any.whl.
File metadata
- Download URL: nautobot_contract_models-2026.5.11-py3-none-any.whl
- Upload date:
- Size: 1.4 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4032e36404a6826f7469addf044c69a9d6fae29a37821e727625f26ce97f83a4
|
|
| MD5 |
76e6c582a7f4ede78dbdda23fdf0c6b2
|
|
| BLAKE2b-256 |
e2ec058e8ed1083e69a8d4d9d6785b822a799cf9cc6fe52f17e89d6e64123ade
|