Compare commits

...

156 Commits

Author SHA1 Message Date
Arthur
1fb6507cc1 #14329 Improve diffs for custom_fields
CI / build (20.x, 3.13) (push) Failing after 16s
CI / build (20.x, 3.12) (push) Failing after 18s
CI / build (20.x, 3.14) (push) Failing after 14s
2026-03-17 09:44:01 -07:00
Arthur
ca021e808b #14329 Improve diffs for custom_fields
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.14) (push) Failing after 14s
2026-03-17 09:14:41 -07:00
Arthur
45b53ee036 #14329 Improve diffs for custom_fields 2026-03-17 09:03:57 -07:00
Arthur
992630d670 #14329 Improve diffs for custom_fields 2026-03-17 08:44:18 -07:00
Arthur
c8cd5fd6cd #14329 Improve diffs for custom_fields 2026-03-16 17:14:26 -07:00
bctiemann
2f5543933e Merge pull request #21670 from netbox-community/15513-add-bulk-create-for-prefixes
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 1m5s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m12s
CodeQL / Analyze (python) (push) Failing after 1m13s
Closes #15513: Add bulk creation support for IP prefixes
2026-03-13 18:25:13 -04:00
Martin Hauser
1fc43026d0 Closes #20698: Expose total_vlan_ids on VLAN groups (#21574)
Fixes #20698
2026-03-13 15:10:56 -05:00
Martin Hauser
5804b53bb1 fix(utilities): Add atomic group in expandable field regex pattern
CI / build (20.x, 3.13) (push) Failing after 16s
CI / build (20.x, 3.14) (push) Failing after 15s
CI / build (20.x, 3.12) (push) Failing after 19s
Replace non-capturing group with atomic group in expansion bracket regex
to prevent excessive backtracking. Add missing 'object' key to bulk view
context for template compatibility.
2026-03-13 15:50:27 +01:00
Martin Hauser
775d6aa936 feat(ipam): Add HTMX support to prefix bulk add form
CI / build (20.x, 3.13) (push) Failing after 24s
CI / build (20.x, 3.12) (push) Failing after 27s
CI / build (20.x, 3.14) (push) Failing after 23s
Enable dynamic form updates in the prefix bulk add view by introducing
HTMX partial rendering. Inherit from PrefixForm to support scope and
VLAN fields, and add htmx_template_name for efficient field updates.
2026-03-13 15:10:46 +01:00
Martin Hauser
639a739b5b feat(ipam): Add bulk creation support for prefixes
Implement bulk prefix creation using network patterns
(e.g., 10.[0-2].0/2). Refactor bulk creation views to support reusable
context and templates. Rename IPAddressBulkCreateForm to
IPNetworkBulkCreateForm for IPv4/IPv6 support.
2026-03-13 15:10:18 +01:00
bctiemann
b01d92c98b Fixes: #19953 - ConfigTemplate debug rendering mode (#21652)
CI / build (20.x, 3.12) (push) Failing after 22s
CI / build (20.x, 3.13) (push) Failing after 23s
CI / build (20.x, 3.14) (push) Failing after 56s
CodeQL / Analyze (actions) (push) Failing after 1m30s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m31s
CodeQL / Analyze (python) (push) Failing after 1m23s
Add debug field to ConfigTemplate and (if True) render template errors
with a full traceback.
2026-03-13 08:19:45 +01:00
bctiemann
02165a28a0 Closes #20151: Add support for cable bundles (#21636)
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 1m25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m29s
CodeQL / Analyze (python) (push) Failing after 1m28s
2026-03-11 11:43:40 -05:00
Jason Novinger
80cc7e0d91 Closes #21157: Add public models to export template context
Move shared get_context() logic from ConfigTemplate into
RenderTemplateMixin so ExportTemplate also gets access to all
public model classes. This enables export templates to perform
cross-model lookups (e.g. resolving parent Prefix from IPAddress).
2026-03-11 12:26:17 -04:00
Martin Hauser
e2665ef211 Closes #20961: Introduce RackGroup for physical rack placement (#21624)
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 1m11s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m18s
CodeQL / Analyze (python) (push) Failing after 1m21s
Fixes #20961
2026-03-10 10:19:12 -05:00
bctiemann
c384cec453 Closes #21331: Emit deprecation warning on use of querystring template tag (#21476) 2026-03-10 10:10:40 -05:00
Arthur Hanson
e3d9fe622d Fix #17654: Add Role to ASN (#21582)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jason Novinger <jnovinger@gmail.com>
Closes #21571: Bump minimatch and markdown-it to resolve security alerts (#21573)
2026-03-10 10:00:28 -05:00
bctiemann
719effb548 Fixes: #20123 - Add replicate_components and adopt_components write_only fields to ModuleSerializer (#21600)
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 1m10s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m15s
CodeQL / Analyze (python) (push) Failing after 1m17s
2026-03-09 11:11:40 -07:00
Jeremy Stretch
6659bb3abe Closes #21363: Implement cursor-based pagination for the REST API (#21594)
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.14) (push) Failing after 15s
CodeQL / Analyze (actions) (push) Failing after 10m25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 10m40s
CodeQL / Analyze (python) (push) Failing after 10m40s
2026-03-06 17:13:08 -08:00
bctiemann
0a5f40338d Merge pull request #21584 from netbox-community/21409-introduce-an-option-to-retain-the-original-create-and-latest
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 13s
CodeQL / Analyze (actions) (push) Failing after 13s
CodeQL / Analyze (javascript-typescript) (push) Failing after 12s
CodeQL / Analyze (python) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 24s
Closes #21409: Add option to retain create & last update changelog records when pruning
2026-03-06 09:26:58 -05:00
Martin Hauser
fd6e0e9784 feat(core): Retain create & last update changelog records
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.13) (push) Failing after 14s
CI / build (20.x, 3.14) (push) Failing after 13s
Introduce a new configuration parameter,
`CHANGELOG_RETAIN_CREATE_LAST_UPDATE`, to retain each object's create
record and most recent update record when pruning expired changelog
entries (per `CHANGELOG_RETENTION`).
Update documentation, templates, and forms to reflect this change.

Fixes #21409
2026-03-05 22:05:07 +01:00
Jeremy Stretch
2a176df28a Merge branch 'main' into feature
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 12s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 12s
CodeQL / Analyze (javascript-typescript) (push) Failing after 13s
CodeQL / Analyze (python) (push) Failing after 12s
2026-03-05 12:39:09 -05:00
bctiemann
cd5d88ff8a Merge pull request #21522 from netbox-community/21356-etags
Closes #21356: Implement ETag support for REST API
2026-03-05 12:06:11 -05:00
bctiemann
6e3fd9d4b2 Merge pull request #21581 from netbox-community/20916-jobs-log-stack-trace
Closes #20916: Record a stack trace in the job log for unhandled exceptions
2026-03-05 11:52:41 -05:00
bctiemann
53ae164c75 Fixes: #20984 - Django 6.0 (#21583) 2026-03-05 08:36:47 -08:00
Jeremy Stretch
fa5f9430fc Fixes #20468: Fix range lookups for numeric GraphQL filters (#21589)
CodeQL / Analyze (actions) (push) Failing after 6s
CI / build (20.x, 3.12) (push) Failing after 21s
CodeQL / Analyze (javascript-typescript) (push) Failing after 4s
CI / build (20.x, 3.13) (push) Failing after 19s
CI / build (20.x, 3.14) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 4s
* Fixes #20468: Fix range lookups for numeric GraphQL filters

* Update netbox/netbox/tests/test_graphql.py

---------

Co-authored-by: Martin Hauser <mhauser@netboxlabs.com>
2026-03-05 17:10:49 +01:00
Jeremy Stretch
351066c73f Limit auto-review workflow to GitHub org members (#21570) 2026-03-05 08:06:43 -08:00
bctiemann
e6db3f75ea Merge pull request #21588 from netbox-community/19867-preserve-per_page-param
Fixes #19867: Retain the `per_page` URL parameter after editing an object
2026-03-05 09:56:32 -05:00
Jeremy Stretch
04244e188f #20923: Migrate DCIM view templates (#21372)
* Permit passing template_name to Panel instance

* Define UI layout for ModuleType view

* Define UI layout for DeviceRole view

* Define UI layout for Platform view

* Define UI layout for Module view

* Misc cleanup

* Linkify module bay
2026-03-05 08:43:46 -05:00
Jeremy Stretch
eaad5cc26f Fixes #19867: Retain the per_page URL parameter after editing an object 2026-03-05 08:26:47 -05:00
Jeremy Stretch
c40640af81 Omit the system filepath north of the installation root 2026-03-04 13:47:54 -05:00
Jeremy Stretch
3c6596de8f Closes #20916: Record a stack trace in the job log for unhandled exceptions 2026-03-04 13:39:08 -05:00
Jeremy Stretch
b3de0b9bee Enforce IF-Match for DELETE requests as well
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.13) (push) Failing after 20s
CI / build (20.x, 3.14) (push) Failing after 17s
2026-03-04 10:49:09 -05:00
Jeremy Stretch
ec0fe62df5 Include the current ETag in the 412 response 2026-03-04 10:44:37 -05:00
Jeremy Stretch
d3a0566ee3 Address TOCTOU race condition 2026-03-04 10:38:12 -05:00
Jason Novinger
a1d82e45a0 Closes #21571: Bump minimatch and markdown-it to resolve security alerts (#21573)
CI / build (20.x, 3.12) (push) Failing after 12s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 1m4s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m4s
CodeQL / Analyze (python) (push) Failing after 1m5s
Add yarn resolutions to force patched versions of two transitive
dependencies flagged by dependabot:

- minimatch 3.1.2 → 3.1.5 (GHSA-7r86-cg39-jmmj, high severity ReDoS)
- markdown-it 14.1.0 → 14.1.1 (CVE-2026-2327, medium severity ReDoS)
2026-03-04 16:08:02 +01:00
Jeremy Stretch
694e3765dd Use weak ETags 2026-03-04 10:04:30 -05:00
Jeremy Stretch
303199dc8f Closes #21356: Implement ETag support for REST API 2026-03-04 09:57:59 -05:00
github-actions
e4f7f080b3 Update source translation strings
CodeQL / Analyze (javascript-typescript) (push) Failing after 57s
CodeQL / Analyze (actions) (push) Failing after 1m0s
CodeQL / Analyze (python) (push) Failing after 58s
2026-03-04 05:17:48 +00:00
bctiemann
6eafffb497 Closes: #21304 - Add stronger deprecation warning on use of housekeeping management command (#21483)
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 1m12s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m15s
CodeQL / Analyze (python) (push) Failing after 1m15s
* Add stronger deprecation warning on use of housekeeping management command

* Add stronger deprecation warning on use of housekeeping management command

* Rework deprecation warning to use FutureWarning (not DeprecationWarning as that is ignored in non-dev environments).
2026-03-03 16:12:39 -05:00
Jeremy Stretch
53ea48efa9 Merge branch 'main' into feature 2026-03-03 15:40:46 -05:00
bctiemann
983ba4fda8 Merge pull request #21562 from netbox-community/release-v4.5.4
CI / build (20.x, 3.12) (push) Failing after 41s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 42s
CodeQL / Analyze (javascript-typescript) (push) Failing after 45s
CodeQL / Analyze (python) (push) Failing after 48s
Release v4.5.4
2026-03-03 15:07:18 -05:00
Jeremy Stretch
54462595a6 Release v4.5.4 2026-03-03 12:46:15 -05:00
Jeremy Stretch
8ab752b9ad Closes #21451: Upgrade tom-select to v2.5.2 (#21563) 2026-03-03 18:35:36 +01:00
Jeremy Stretch
b11cc31f9d Closes #21559: Add CLAUDE.md
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 58s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m0s
CodeQL / Analyze (python) (push) Failing after 59s
2026-03-03 12:01:33 -05:00
Martin Hauser
3f02309538 fix(ipam): Avoid allocating IPv6 subnet-router anycast address (#21547)
Ensure available IP selection for IPv6 non-pool prefixes excludes the
subnet-router anycast address (RFC 4291), so allocation starts at ::1
for typical prefixes (e.g. /64).
Add tests for IPv4/IPv6 pools and special cases (/31-/32, /127-/128).

Fixes #21347
2026-03-03 08:26:44 -08:00
Martin Hauser
53345f194a refactor(graphql): Replace FilterLookup[str] with StrFilterLookup
Replace usages of FilterLookup[str] with StrFilterLookup in GraphQL
filter definitions to align with strawberry-graphql-django v0.75.1.
This silences upstream warnings and helps avoid DuplicatedTypeName
errors.

Fixes #21450
2026-03-03 11:17:13 -05:00
Jeremy Stretch
139557b8dd Fixes #21524: Fix IndexError when serializing stale cable paths (#21525) 2026-03-03 16:37:45 +01:00
bctiemann
fcf02bd8bb Merge pull request #21453 from netbox-community/21429-cable-create-add-another-does-not-carry-over-termination
Fixes #21429: Add Cable cloning and fix "Create & Add Another" to preserve Termination Types
2026-03-03 09:44:35 -05:00
Martin Hauser
7d6989ff34 Closes #21477: Add cached relation filters to GraphQL for Cable (#21506) 2026-03-03 08:01:45 -06:00
Arthur Hanson
3b0b95c265 Closes #21550: Call snapshot() before saving related objects (#21551)
Add missing pre-change `snapshot()` calls in views/forms before updating
and saving related objects (device bays, virtual chassis members, and
bulk-import primary MAC/IP assignments), so changelog entries include
pre-change data.
2026-03-03 14:01:04 +01:00
github-actions
cdc2fb2f06 Update source translation strings
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m12s
CodeQL / Analyze (actions) (push) Failing after 1m14s
CodeQL / Analyze (python) (push) Failing after 1m11s
2026-03-03 05:20:47 +00:00
Jeremy Stretch
7ec656bc7c Introduce GitHub actions for Claude Code review (#21545)
CI / build (20.x, 3.12) (push) Failing after 1m45s
CI / build (20.x, 3.13) (push) Failing after 20s
CI / build (20.x, 3.14) (push) Failing after 15m6s
CodeQL / Analyze (actions) (push) Failing after 15m21s
CodeQL / Analyze (javascript-typescript) (push) Failing after 15m25s
CodeQL / Analyze (python) (push) Failing after 15m20s
2026-03-02 10:39:23 -06:00
Rob Duffy
06bbae0f84 Fixes #21527: UI Bug with Displaying Primary IP Address with NAT IP on a Device 2026-03-02 08:57:52 -05:00
Arthur Hanson
8ff9fd26d1 Closes #20787: Address warnings from generation of OpenAPI schema (#21521) 2026-03-02 14:38:39 +01:00
github-actions
a0e23ac3c9 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 57s
CodeQL / Analyze (javascript-typescript) (push) Failing after 56s
CodeQL / Analyze (python) (push) Failing after 55s
2026-02-28 05:11:26 +00:00
Jeremy Stretch
071d4a63aa Fixes #21518: Ensure proper display of decimal custom fields with a zero value (#21523)
CodeQL / Analyze (actions) (push) Failing after 15m27s
CI / build (20.x, 3.12) (push) Failing after 15m41s
CI / build (20.x, 3.13) (push) Failing after 15m40s
CI / build (20.x, 3.14) (push) Failing after 15m39s
CodeQL / Analyze (javascript-typescript) (push) Failing after 51s
CodeQL / Analyze (python) (push) Failing after 59s
2026-02-27 09:13:53 -08:00
github-actions
7db2739465 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 23s
CodeQL / Analyze (javascript-typescript) (push) Failing after 31s
CodeQL / Analyze (python) (push) Failing after 29s
2026-02-26 05:25:45 +00:00
Jeremy Stretch
1a404f5c0f Merge branch 'main' into feature
CI / build (20.x, 3.12) (push) Failing after 19s
CI / build (20.x, 3.13) (push) Failing after 19s
CI / build (20.x, 3.14) (push) Failing after 13s
CodeQL / Analyze (actions) (push) Failing after 1m2s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m9s
CodeQL / Analyze (python) (push) Failing after 1m10s
2026-02-25 17:07:26 -05:00
Dave Bevan
74326edc20 Add new Ethernet types for 10GE and 40GE
CI / build (20.x, 3.12) (push) Failing after 1m8s
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 19s
CodeQL / Analyze (javascript-typescript) (push) Failing after 29s
CodeQL / Analyze (python) (push) Failing after 30s
Closes #21394
2026-02-25 16:34:00 -05:00
Grische
2ef21f7097 Fixes: #21456 - Improve config_context rendering with GraphQL (#21495) 2026-02-25 16:17:04 -05:00
Kartik
3adcdc34c3 clarify E501 enforcement 2026-02-25 15:33:25 -05:00
Martin Hauser
f33109e485 fix(dcim): Rename facility to facility_id in panel attrs (#21482)
Corrects field mismatch by aligning the attribute name with the
data model. This change ensures consistency in attribute mappings
and improves clarity in the codebase.

Fixes #21481
2026-02-25 12:20:51 -08:00
github-actions
d10453883f Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 34s
CodeQL / Analyze (javascript-typescript) (push) Failing after 39s
CodeQL / Analyze (python) (push) Failing after 41s
2026-02-21 05:16:36 +00:00
bctiemann
6dbd8f6170 Merge pull request #21507 from netbox-community/21497-pin-ruff-in-ci-to-avoid-surprise-breakages
CI / build (20.x, 3.12) (push) Failing after 18s
CI / build (20.x, 3.13) (push) Failing after 23s
CI / build (20.x, 3.14) (push) Failing after 33s
CodeQL / Analyze (actions) (push) Failing after 33s
CodeQL / Analyze (javascript-typescript) (push) Failing after 32s
CodeQL / Analyze (python) (push) Failing after 29s
Fixes #21497: Pin Ruff 0.15.2 and run CI via ruff-action
2026-02-20 16:59:46 -05:00
Jason Novinger
715f9d150c Closes #21385: Add contact assignment support to virtual circuits
Adds ContactsMixin to VirtualCircuit model and GraphQL type, and includes
'contacts' in table fields. Verified: UI Contacts tab, REST API POST (201),
GraphQL contacts query.
2026-02-20 16:59:37 -05:00
Martin Hauser
f4567ba099 chore(ci): Pin Ruff 0.15.2 and run via ruff-action
Pin Ruff to v0.15.2 in CI and pre-commit to avoid breakages from
upstream releases. Run Ruff via astral-sh/ruff-action (pinned by SHA)
instead of installing Ruff via pip.
Document where Ruff is pinned and keep the release checklist/style guide
in sync.

Fixes #21472
Fixes #21497
2026-02-20 20:38:11 +01:00
bctiemann
3320e07b70 Closes #21284: Add deprecation note to webhooks documentation (#21491)
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 41s
CodeQL / Analyze (actions) (push) Failing after 1m16s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m24s
CodeQL / Analyze (python) (push) Failing after 1m25s
* Add searchable deprecation comments on request_id and username fields in EventContext

* Add deprecation note in webhooks documentation

* Expand deprecation note/warning

* Add version number to deprecation warning

* Add deprecation warning to two other places
2026-02-20 19:52:42 +01:00
Jeremy Stretch
d5e8f7dafa Closes #21459: Avoid prefetching data for hidden table columns (#21460)
CI / build (20.x, 3.12) (push) Failing after 49s
CI / build (20.x, 3.13) (push) Failing after 16s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 19s
CodeQL / Analyze (javascript-typescript) (push) Failing after 29s
CodeQL / Analyze (python) (push) Failing after 32s
2026-02-20 10:36:46 -08:00
Jeremy Stretch
32e2a17c88 Merge pull request #21503 from netbox-community/21500-enable-linting-for-all-ordering-ruf022
Closes #21500: Enable RUF022 to enforce sorted `__all__` lists
2026-02-20 11:52:00 -05:00
Martin Hauser
3beef34355 chore(ruff): Sort __all__ definitions across modules
Apply consistent alphabetical ordering to `__all__` lists in the
circuits module. Enhances readability and alignment with established
linting guidelines.
2026-02-20 15:36:01 +01:00
Martin Hauser
85d6242962 chore(ruff): Add RUF022 and tweak formatting in ruff.toml
Enable `RUF022` to enforce sorted `__all__` lists. Adjust comment
alignment and whitespace for improved readability and consistency
in ruff.toml configuration.
2026-02-20 15:34:58 +01:00
bctiemann
bb1a44d35b Merge pull request #21479 from netbox-community/21478-graphql-union-type-resolution-for-connected-endpoints
Fixes #21478: Fix GraphQL connected endpoint type resolution for Console Ports
2026-02-20 09:27:36 -05:00
bctiemann
ae6f1f9ae3 Merge pull request #21496 from netbox-community/20923-convert-virtualization-views-to-new-ui-layout
Closes #20923: Migrate Virtualization object views to declarative layouts
2026-02-20 09:26:56 -05:00
Arthur Hanson
915ac90119 20911 Fix sorting in dropdown (#21101)
* Fix TomSelect dropdown ordering

* cleanup

* cleanup

* cleanup

* use correct node version

* change ordering field, remove front-end changes

* rebuild tree after rename

* add migration

* fix migration

* fix migration

* fix migration

* fix migration

* fix migration

* cleanup

* use bulk_update and rebuild

* use bulk_update and rebuild

* cleanup

* fix csv import

* Review feedback

* Review feedback

* fix dropdown sorting

* fix ordering

* review feedback

* review feedback
2026-02-20 09:03:47 -05:00
Martin Hauser
cc47afc401 refactor(virtualization): Port to declarative layout
Add declarative layout panels for Cluster, Cluster Group, Cluster Type,
Virtual Disk, and VM Interface, including addressing, VLAN assignment,
and FHRP group handling.

Expand the declarative layout primitives:
- add GFK attribute rendering support
- add panel for rendering context-provided tables
- update templates to support new panels/attrs

Closes #20923
2026-02-20 14:58:20 +01:00
Martin Hauser
20fee95a9a Closes #21499: Restore deterministic Ruff linting (match Ruff 0.15.1 preview defaults)
Explicitly set `select` rules to maintain compatibility with
Ruff 0.15.1. Ensures deterministic linting behavior despite changes in
Ruff 0.15.2 defaults.
See https://github.com/astral-sh/ruff/releases/tag/0.15.2 for more
details.
2026-02-20 14:40:45 +01:00
github-actions
d2002c64b4 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 41s
CodeQL / Analyze (javascript-typescript) (push) Failing after 48s
CodeQL / Analyze (python) (push) Failing after 51s
2026-02-20 05:23:09 +00:00
Martin Hauser
1b295f1d69 Closes #21473: Enable UP rules and modernize string formatting (#21488)
CI / build (20.x, 3.12) (push) Failing after 55s
CI / build (20.x, 3.13) (push) Failing after 17s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 50s
CodeQL / Analyze (python) (push) Failing after 48s
2026-02-19 10:25:08 -06:00
Martin Hauser
2c200a4fd3 Closes #21369: Add lazy loading and decoding options for ImageAttr (#21444)
Introduces `load_lazy` and `decoding` parameters to `ImageAttr` for
enhanced image handling. Lazy loading improves page performance, while
configurable decoding options provide greater flexibility. Updates the
template to conditionally include these attributes in rendered HTML.

Fixes #21369
2026-02-19 09:22:16 -06:00
bctiemann
fb71cafb51 Closes: #21284: Mark request_id and username fields in EventContext as deprecated (#21485)
Add searchable deprecation comments on request_id and username fields
in EventContext.
2026-02-19 14:03:47 +01:00
github-actions
f373adb636 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 45s
CodeQL / Analyze (javascript-typescript) (push) Failing after 52s
CodeQL / Analyze (python) (push) Failing after 54s
2026-02-19 05:26:47 +00:00
Martin Hauser
e84b062393 fix(dcim): Correct type check for ConsolePort in GraphQL mixin
Fixes a typo in the `resolve_type` method where `ConsolePortType` was
mistakenly referenced instead of `ConsolePort`. Ensures the correct
GraphQL type is returned for ConsolePort instances.

Fixes #21478
2026-02-18 23:19:36 +01:00
Martin Hauser
ef52ac4203 chore(ruff): Enable RET rules and add explicit fallbacks
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 30s
CodeQL / Analyze (javascript-typescript) (push) Failing after 46s
CodeQL / Analyze (python) (push) Failing after 46s
Adopt Ruff `RET` to improve return-flow consistency across the codebase.
Simplify control flow by removing redundant `else` blocks after
`return`, and add explicit `return None` (or equivalent) fallbacks
where appropriate to preserve existing behavior.

Fixes #21411
2026-02-18 16:49:36 -05:00
bctiemann
b22e490847 Fixes: #20490 - Add filtering of Script objects based on object permissions with custom constraints (#21212) 2026-02-18 15:37:40 -05:00
Martin Hauser
945e7ade0a Fixes #21407: Enable I (isort) and stabilize import ordering (#21458)
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 9s
CI / build (20.x, 3.14) (push) Failing after 9s
CodeQL / Analyze (actions) (push) Failing after 37s
CodeQL / Analyze (javascript-typescript) (push) Failing after 48s
CodeQL / Analyze (python) (push) Failing after 49s
- Adopt Ruff `I` (isort) rules for consistent import sorting
- Add two `# isort: split` boundaries to keep required imports pinned
  in `__init__.py` modules
2026-02-18 10:41:51 -06:00
github-actions
7300104cea Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 36s
CodeQL / Analyze (javascript-typescript) (push) Failing after 41s
CodeQL / Analyze (python) (push) Failing after 42s
2026-02-18 05:28:02 +00:00
bctiemann
2900429769 Merge pull request #21441 from netbox-community/21410-tighten-up-ruff-configuration-defaults
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 1m3s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m14s
CodeQL / Analyze (python) (push) Failing after 1m15s
Fixes #21410: Expand Ruff exclusions and standardize formatting settings
2026-02-17 13:14:11 -05:00
Martin Hauser
278c82dd88 chore(ruff): Expand configuration for linting and formatting
Update `ruff.toml` with additional exclusions, linting rules, and
formatting preferences. Includes support for respecting `.gitignore`
and a consistent coding style.

Fixes #21410
2026-02-17 18:31:15 +01:00
Martin Hauser
951d856c3c feat(dcim): Add Cable cloning with Termination mapping
Introduce `clone()` method for the Cable model to enable cloning
its attributes, including termination type and parent selectors.
Updates mappings to align with CableForm workflows, supporting
"Clone" and "Create & Add Another" actions.

Fixes #21429
2026-02-17 18:30:36 +01:00
Jeremy Stretch
c029782cf5 Release v4.5.3 2026-02-17 10:37:44 -05:00
Martin Hauser
bdd23f3d17 fix(extras): Handle username fallback for job events
Fallback to the associated user when username is missing from job
lifecycle event contexts. Add a regression test to ensure JOB_COMPLETED
webhooks are enqueued without a request context.

Fixes #21371
2026-02-17 08:15:58 -05:00
github-actions
af6e18b7d4 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 54s
CodeQL / Analyze (javascript-typescript) (push) Failing after 59s
CodeQL / Analyze (python) (push) Failing after 1m1s
2026-02-17 05:26:34 +00:00
Jeremy Stretch
816c5d4bea Fixes #21412: Defer monkey-patching until after settings have been loaded (#21415)
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 9s
CodeQL / Analyze (actions) (push) Failing after 1m0s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m10s
CodeQL / Analyze (python) (push) Failing after 1m9s
2026-02-16 18:17:50 +01:00
Martin Hauser
f4c3c90bab perf(filters): Avoid ContentType join in ContentTypeFilter
Resolve the ContentType via get_by_natural_key() and filter by the
FK value to prevent an unnecessary join to django_content_type.

Fixes #21420
2026-02-16 12:06:31 -05:00
Martin Hauser
862593f2dd fix(circuits): Persist CircuitType owner field
CircuitTypeForm rendered `owner` twice and did not persist ownership
because the displayed fields didn't match the fields processed by the
form. Remove `owner` from the fieldset and include it in `Meta.fields`
to keep rendering and form processing in sync.

Fixes #21397
2026-02-16 08:54:34 -05:00
Martin Hauser
f4c27fd494 fix(ipam): Use bulk_update in VLANGroup VID range migration
Replace per-row `save()` calls with `bulk_update` when populating
VLANGroup VLAN ID ranges during migration.

This avoids triggering post_save handlers (e.g. search cache/indexing)
on existing VLANGroup records and updates only the relevant fields,
improving both reliability and performance on larger databases.

Fixes #21375
2026-02-16 08:53:16 -05:00
Martin Hauser
ae736ef407 fix(dcim): Render device height as rack units via floatformat
Use `TemplatedAttr` for device height and render using Django's
`floatformat` filter so 0.0 is displayed as `0U` (and whole-U values
omit the decimal).

Fixes #21267
2026-02-16 08:37:50 -05:00
github-actions
d95b1186fb Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 31s
CodeQL / Analyze (javascript-typescript) (push) Failing after 36s
CodeQL / Analyze (python) (push) Failing after 38s
2026-02-14 05:18:04 +00:00
Jason Novinger
d6b9d30086 Fixes #20442: Mark template-accessible methods with alters_data=True (#21431)
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 40s
CodeQL / Analyze (javascript-typescript) (push) Failing after 46s
CodeQL / Analyze (python) (push) Failing after 44s
Add alters_data=True to methods that modify database or filesystem state
and are accessible from Jinja2 sandbox template contexts:

- UserConfig.set(), clear(): Persist preference changes when commit=True
- ManagedFile.sync_data(): Writes files to scripts/reports storage
- ScriptModule.sync_classes(), sync_data(): Creates/deletes Script objects
- Job.start(), terminate(): Updates job status, creates notifications

Methods intentionally not protected:
- DataFile.refresh_from_disk(): Only modifies instance attributes in memory
- Overridden save()/delete(): Django's AltersData mixin auto-propagates
- Properties like Script.python_class: Not callable in template context

Ref: #20356 for exploit details demonstrating the vulnerability
2026-02-13 10:44:18 -08:00
Martin Hauser
9be5aa188c chore(ruff): Update target Python version to 3.12 (#21405)
Set the `target-version` in `ruff.toml` to Python 3.12. Ensures the
linter aligns with the version used in the project's environment.

Fixes #21404
2026-02-13 10:39:09 -08:00
Jason Novinger
f113557e81 Fixes #21127: Clear _path on interfaces when removed from cable
When editing a cable to remove an interface from the B side, the _path
field on the removed interface was not being cleared. This caused the
interface table to display stale connection info via _path.destinations.

Two changes:
- Signal handler now clears _path when termination removed from origins
- CablePath.delete() clears _path on origins (mirrors save() behavior)
2026-02-13 13:36:09 -05:00
Arthur
de812a5a85 21390 skip m2m processing for internal models to avoid extraneous ObjectChange records 2026-02-13 13:27:25 -05:00
Jason Novinger
0b7375136d Closes #21016: Add missing MPTT tree indexes (#21432)
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 41s
CodeQL / Analyze (javascript-typescript) (push) Failing after 47s
CodeQL / Analyze (python) (push) Failing after 49s
Upgrade django-mptt to 0.18.0 and add empty indexes tuple to MPTT model
Meta classes. The empty tuple triggers Django's migration detection for
indexes that django-mptt adds dynamically (see
django-mptt/django-mptt#682). We cannot define the indexes explicitly
because the MPTT fields don't exist when the Meta class is evaluated.

Affected models: Region, SiteGroup, Location, DeviceRole, Platform,
ModuleBay, InventoryItem, InventoryItemTemplate, TenantGroup,
ContactGroup, WirelessLANGroup
2026-02-13 17:00:04 +01:00
Jeremy Stretch
1190adde2b Closes #21419: Improve query efficiency for MultipleChoiceFilter (#21421)
* Pass distinct=False to all ModelMultipleChoiceFilters associated with a ForeignKey field

* Pass distinct=False to all MultipleChoiceFilters associated with a concrete model
2026-02-13 12:31:36 +01:00
Arthur Hanson
2330874a8c Fixes #21277: Record pre-change snapshot when adding devices to cluster in UI (#21424) 2026-02-13 04:41:41 -06:00
Jeremy Stretch
dc738c7102 Closes #21257: Introduce & adopt MultiValueContentTypeFilter (#21417) 2026-02-13 04:24:36 -06:00
Jeremy Stretch
76fd3e3c61 Fixes #21196: q filter should match on primary IP only for IP address values (#21401) 2026-02-13 04:08:01 -06:00
github-actions
4ee64a7731 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 44s
CodeQL / Analyze (javascript-typescript) (push) Failing after 48s
CodeQL / Analyze (python) (push) Failing after 50s
2026-02-13 05:27:16 +00:00
Arthur Hanson
0bb22dee0c Allow REDIS KWARGS to be set in configuration.py (#21377)
CI / build (20.x, 3.12) (push) Failing after 12s
CI / build (20.x, 3.13) (push) Failing after 12s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 44s
CodeQL / Analyze (javascript-typescript) (push) Failing after 57s
CodeQL / Analyze (python) (push) Failing after 1m5s
* Allow REDIS KWARGS to be set in configuration.py

* cleanup

* cleanup

* cleanup

* Update netbox/netbox/settings.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Update netbox/netbox/settings.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* document in REDIS config section

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-02-12 08:35:20 -05:00
Jason Novinger
6c383f293c Fixes #20435: Fix navigation margin issue when scrollbar appears (#21403)
Override Tabler's problematic margin-left: calc(100vw - 100%) rule that
causes a gap between the sidebar and main content when vertical scrollbar
is present on Windows/Linux browsers.

Uses scrollbar-gutter: stable to match the upstream fix in Tabler PR #2548.
2026-02-12 11:30:33 +01:00
github-actions
5bf516c63d Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 35s
CodeQL / Analyze (javascript-typescript) (push) Failing after 28s
CodeQL / Analyze (python) (push) Failing after 33s
2026-02-12 05:28:54 +00:00
Aditya Sharma
7df062d590 Fixes #21358: Prevent exception when sorting by Token column (#21391)
CI / build (20.x, 3.12) (push) Failing after 12s
CI / build (20.x, 3.13) (push) Failing after 12s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 1m8s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m17s
CodeQL / Analyze (python) (push) Failing after 1m19s
Mark the `token` TemplateColumn as non-orderable since it maps to a
Python property rather than a database field, causing a FieldError
when django-tables2 attempts to sort by it.

Add a regression test for TokenTable following the existing pattern
in circuits and vpn test suites.
2026-02-12 00:21:49 +01:00
Aditya Sharma
4b22be03a0 Fixes #21354: Fix Swagger-UI generating wrong URLs when BASE_PATH is set (#21392) 2026-02-11 11:35:13 -08:00
Dylan Lucci
24769ce127 Closes #21266: Add installed device table columns to DeviceBay table (#21348)
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.13) (push) Failing after 21s
CI / build (20.x, 3.14) (push) Failing after 30s
CodeQL / Analyze (actions) (push) Failing after 33s
CodeQL / Analyze (javascript-typescript) (push) Failing after 33s
CodeQL / Analyze (python) (push) Failing after 29s
Expose additional properties of the device installed in each bay as
configurable table columns.

- Rename `role` → `installed_role`
- Rename `device_type` → `installed_device_type`
- Add `installed_description`, `installed_serial`, and
  `installed_asset_tag` columns to `DeviceBayTable`

---------

Co-authored-by: Martin Hauser <mhauser@netboxlabs.com>
2026-02-11 13:55:37 +01:00
github-actions
164e9db98d Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 33s
CodeQL / Analyze (javascript-typescript) (push) Failing after 37s
CodeQL / Analyze (python) (push) Failing after 39s
2026-02-11 05:29:43 +00:00
Martin Hauser
23f1c86e9c Closes #20211: Use thumbnails for ImageAttachment hover previews to improve page load performance (#21386)
CI / build (20.x, 3.12) (push) Failing after 13s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 42s
CodeQL / Analyze (javascript-typescript) (push) Failing after 53s
CodeQL / Analyze (python) (push) Failing after 49s
2026-02-10 11:01:33 -06:00
Martin Hauser
02ffdd9d5d Closes #21268: Add Device Type details panel to Device view (#21368) 2026-02-10 10:37:35 -06:00
Martin Hauser
5013297326 feat(virtualization): Refactor VirtualMachine view to UI layout
Migrate the VirtualMachine detail view to SimpleLayout with standardized
panels for attributes, clusters, and resources. Modularize templates
to improve maintainability and reuse.

Fixes #21337
2026-02-10 10:22:18 -05:00
Daniel Sheppard
289cb4e1bc Simplify find_parent_prefix method
CI / build (20.x, 3.14) (push) Failing after 12s
CI / build (20.x, 3.13) (push) Failing after 14s
CI / build (20.x, 3.12) (push) Failing after 16s
2026-02-10 08:42:21 -06:00
Daniel Sheppard
6e24ce6a1a Fix test failure by casting to IPAddress 2026-02-10 08:29:03 -06:00
github-actions
584e0a9b8c Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 29s
CodeQL / Analyze (javascript-typescript) (push) Failing after 39s
CodeQL / Analyze (python) (push) Failing after 45s
2026-02-10 05:29:34 +00:00
Daniel Sheppard
c6bcfea429 Fine-tune forms, tests, and some model save() functions
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 17s
CI / build (20.x, 3.14) (push) Failing after 17s
2026-02-09 14:57:24 -06:00
Martin Hauser
3ac9d0b8bf Closes #20981: Enhance JSON rendering for Custom Validators and Protection Rules in Config Revision View (#21376)
CI / build (20.x, 3.12) (push) Failing after 18s
CI / build (20.x, 3.13) (push) Failing after 17s
CI / build (20.x, 3.14) (push) Failing after 13s
CodeQL / Analyze (actions) (push) Failing after 29s
CodeQL / Analyze (javascript-typescript) (push) Failing after 37s
CodeQL / Analyze (python) (push) Failing after 38s
* feat(config): Add extra context to ConfigRevisionView

Introduces `get_extra_context` method for `ConfigRevisionView` to
format JSON-based attributes like `CUSTOM_VALIDATORS`,
`DEFAULT_USER_PREFERENCES`, and `PROTECTION_RULES`.
This ensures clearer rendering of configuration data in the UI.

Fixes #20981

* Reduce padding on JSON blocks

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-02-09 09:48:39 -05:00
Daniel Sheppard
d18d7369e6 Add missing migration
CI / build (20.x, 3.13) (push) Failing after 13s
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.14) (push) Failing after 12s
2026-02-08 17:18:49 -06:00
Daniel Sheppard
42e2fd7fb3 Update Triggers and add new functions to triggers to handle certain cases
CI / build (20.x, 3.13) (push) Failing after 14s
CI / build (20.x, 3.14) (push) Failing after 12s
CI / build (20.x, 3.12) (push) Failing after 17s
2026-02-06 15:31:42 -06:00
Daniel Sheppard
cd636168ee Add ignore for migration files to ignore line length requirements as per our developer docs 2026-02-06 15:31:22 -06:00
Daniel Sheppard
b45da7b1e4 Merge branch 'feature' into feature-ip-prefix-link
CI / build (20.x, 3.12) (push) Failing after 12s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 10s
2026-02-06 10:14:22 -06:00
github-actions
b387ea5f58 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 54s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m1s
CodeQL / Analyze (python) (push) Failing after 59s
2026-02-06 05:22:42 +00:00
bctiemann
ba9f6bf359 Fixes: #19129 - Richer display of MAC addresses in InterfaceTable when multiple MACs are present (#21270)
CI / build (20.x, 3.13) (push) Failing after 15s
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.14) (push) Failing after 27s
CodeQL / Analyze (actions) (push) Failing after 30s
CodeQL / Analyze (javascript-typescript) (push) Failing after 25s
CodeQL / Analyze (python) (push) Failing after 25s
* Richer display of MAC addresses in InterfaceTable when multiple MACs are present

* Fix docstring

* Fix docstring

* Use mac_address_display in interface detail page

* Ensure "-" null placeholder still shows up on detail page

* Also include vminterface.html

* Simplify Multiple MAC addresses with additional selectable column for tables in list view and detail view

* Use ManyToManyColumn
2026-02-05 11:16:31 -05:00
Martin Hauser
ee6cbdcefe Fixes #21320: Prevent Rack validation errors when site or optional fields are missing during import (#21321)
CI / build (20.x, 3.12) (push) Failing after 18s
CI / build (20.x, 3.13) (push) Failing after 14s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 24s
CodeQL / Analyze (javascript-typescript) (push) Failing after 30s
CodeQL / Analyze (python) (push) Failing after 27s
2026-02-03 09:32:07 -06:00
Daniel Sheppard
4c35630434 Reorder migrations
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2026-01-15 16:57:28 -06:00
Daniel Sheppard
3cc15ecaf0 Merge branch 'feature' into feature-ip-prefix-link
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2025-12-28 11:41:39 -06:00
Daniel Sheppard
5ada585129 Remove unrelated development path
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
2025-11-25 08:16:46 -06:00
Daniel Sheppard
b03158f1de Add pgtrigger as dependancy
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
2025-11-25 00:06:35 -06:00
Daniel Sheppard
bdde4b7e94 Switch to using triggers
Still outstanding:

* IPAddress and IPRange triggers
* Triggers for VRF changes on Prefix
* Triggers for changing to "container" on Prefix
* Rework logic for saving on all models
2025-11-25 00:01:23 -06:00
Daniel Sheppard
905656f13e Add migration 2025-11-07 09:10:51 -06:00
Daniel Sheppard
42c2dc57f8 Develop triggers for setting parents 2025-11-07 09:02:30 -06:00
Daniel Sheppard
56673f4d88 Signal optimizations 2025-11-05 18:10:38 -06:00
Daniel Sheppard
955c64b68c Re-order migrations 2025-09-14 10:49:44 -05:00
Daniel Sheppard
912e6e4fb1 Merge branch 'feature' of https://github.com/netbox-community/netbox into feature-ip-prefix-link
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
2025-09-11 20:46:47 -05:00
Daniel Sheppard
90d277610c Merge branch 'feature' of https://github.com/netbox-community/netbox into feature-ip-prefix-link 2025-09-03 22:15:38 -05:00
Daniel Sheppard
b1bc933e98 Clean up Prefix TODOs
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-08-08 09:29:12 -05:00
Daniel Sheppard
b54196f595 Merge branch 'feature' into feature-ip-prefix-link
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-08-07 21:33:58 -05:00
Daniel Sheppard
0d31449df8 Optimize prefix assignment. Fix tests
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-07-10 12:59:59 -05:00
Daniel Sheppard
76e85683ac Re-apply de-duplication to IPRangeSerializer 2025-07-09 13:18:28 -05:00
Daniel Sheppard
f844ec5703 Merge branch 'feature' into feature-ip-prefix-link 2025-07-09 13:00:51 -05:00
Daniel Sheppard
7eb3a8d379 Fix some tests 2025-07-09 12:56:05 -05:00
Daniel Sheppard
ade4354ca4 Fix some test errors 2025-07-09 11:12:52 -05:00
Daniel Sheppard
697d5bd876 Slightly DRY the migration 2025-07-09 10:52:24 -05:00
Daniel Sheppard
b19f81cede More work on IP Address/Range and Prefix relationship 2025-07-09 10:36:41 -05:00
Daniel Sheppard
c5e7b21147 Add additional FKs 2025-06-05 09:43:18 -05:00
Daniel Sheppard
c211b624d0 Merge branch 'feature' of https://github.com/netbox-community/netbox into feature-ip-prefix-link
# Conflicts:
#	netbox/ipam/forms/bulk_import.py
2025-05-15 08:40:58 -05:00
Daniel Sheppard
4c8301b3a5 Update migration 2025-05-15 08:38:03 -05:00
Daniel Sheppard
68d0b58293 Update migration 2025-04-10 08:22:23 -05:00
Daniel Sheppard
738ef63527 Update from feature 2025-04-09 10:24:56 -05:00
Daniel Sheppard
747fef0bc2 Work on IP to Prefix ForeignKey relationship 2025-02-24 14:03:18 -06:00
30 changed files with 1237 additions and 101 deletions
+5
View File
@@ -35,6 +35,11 @@ django-mptt==0.17.0
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
django-pglocks
# Manager for managing PostgreSQL triggers
# https://github.com/AmbitionEng/django-pgtrigger/blob/main/CHANGELOG.md
django-pgtrigger
# Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
django-prometheus
+20 -11
View File
@@ -60,18 +60,24 @@ class PrefixSerializer(PrimaryModelSerializer):
vlan = VLANSerializer(nested=True, required=False, allow_null=True)
status = ChoiceField(choices=PrefixStatusChoices, required=False)
role = RoleSerializer(nested=True, required=False, allow_null=True)
children = serializers.IntegerField(read_only=True)
_children = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(read_only=True)
prefix = IPNetworkField()
class Meta:
model = Prefix
fields = [
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope',
'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'children', '_depth',
'id', 'url', 'display_url', 'display', 'family', 'aggregate', 'parent', 'prefix', 'vrf', 'scope_type',
'scope_id', 'scope', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_children', '_depth',
]
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
brief_fields = ('id', 'url', 'display', 'family', 'aggregate', 'parent', 'prefix', 'description', '_depth')
def get_fields(self):
fields = super(PrefixSerializer, self).get_fields()
fields['parent'] = PrefixSerializer(nested=True, read_only=True)
return fields
class PrefixLengthSerializer(serializers.Serializer):
@@ -125,7 +131,9 @@ class AvailablePrefixSerializer(serializers.Serializer):
# IP ranges
#
class IPRangeSerializer(PrimaryModelSerializer):
prefix = PrefixSerializer(nested=True, required=False, allow_null=True)
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
start_address = IPAddressField()
end_address = IPAddressField()
@@ -137,11 +145,11 @@ class IPRangeSerializer(PrimaryModelSerializer):
class Meta:
model = IPRange
fields = [
'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant',
'status', 'role', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'mark_populated', 'mark_utilized',
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'start_address', 'end_address', 'size', 'vrf',
'tenant', 'status', 'role', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'mark_populated', 'mark_utilized',
]
brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'start_address', 'end_address', 'description')
#
@@ -186,6 +194,7 @@ class AvailableIPRequestSerializer(serializers.Serializer):
class IPAddressSerializer(PrimaryModelSerializer):
prefix = PrefixSerializer(nested=True, required=False, allow_null=True)
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
address = IPAddressField()
vrf = VRFSerializer(nested=True, required=False, allow_null=True)
@@ -204,11 +213,11 @@ class IPAddressSerializer(PrimaryModelSerializer):
class Meta:
model = IPAddress
fields = [
'id', 'url', 'display_url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role',
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'address', 'vrf', 'tenant', 'status', 'role',
'assigned_object_type', 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside',
'dns_name', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'address', 'description')
class AvailableIPSerializer(serializers.Serializer):
+40
View File
@@ -340,6 +340,26 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
field_name='prefix',
lookup_expr='net_mask_length__lte'
)
aggregate_id = django_filters.ModelMultipleChoiceFilter(
queryset=Aggregate.objects.all(),
label=_('Aggregate'),
)
aggregate = django_filters.ModelMultipleChoiceFilter(
field_name='aggregate__prefix',
queryset=Aggregate.objects.all(),
to_field_name='prefix',
label=_('Aggregate (Prefix)'),
)
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Prefix.objects.all(),
label=_('Parent Prefix'),
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__prefix',
queryset=Prefix.objects.all(),
to_field_name='prefix',
label=_('Parent Prefix (Prefix)'),
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(),
label=_('VRF'),
@@ -484,6 +504,16 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
method='search_contains',
label=_('Ranges which contain this prefix or IP'),
)
prefix_id = django_filters.ModelMultipleChoiceFilter(
queryset=Prefix.objects.all(),
label=_('Prefix (ID)'),
)
prefix = django_filters.ModelMultipleChoiceFilter(
field_name='prefix__prefix',
queryset=Prefix.objects.all(),
to_field_name='prefix',
label=_('Prefix'),
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(),
label=_('VRF'),
@@ -569,6 +599,16 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
method='search_by_parent',
label=_('Parent prefix'),
)
prefix_id = django_filters.ModelMultipleChoiceFilter(
queryset=Prefix.objects.all(),
label=_('Prefix (ID)'),
)
prefix = django_filters.ModelMultipleChoiceFilter(
field_name='prefix__prefix',
queryset=Prefix.objects.all(),
to_field_name='prefix',
label=_('Prefix (prefix)'),
)
address = MultiValueCharFilter(
method='filter_address',
label=_('Address'),
+23 -3
View File
@@ -168,6 +168,11 @@ class RoleBulkEditForm(OrganizationalModelBulkEditForm):
class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
parent = DynamicModelChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Parent Prefix')
)
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
@@ -221,7 +226,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
model = Prefix
fieldsets = (
FieldSet('tenant', 'status', 'role', 'description'),
FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
FieldSet('parent', 'vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
FieldSet('scope_type', 'scope', name=_('Scope')),
FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
)
@@ -231,6 +236,11 @@ class PrefixBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm):
class IPRangeBulkEditForm(PrimaryModelBulkEditForm):
prefix = DynamicModelChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Prefix')
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -272,6 +282,16 @@ class IPRangeBulkEditForm(PrimaryModelBulkEditForm):
class IPAddressBulkEditForm(PrimaryModelBulkEditForm):
prefix = DynamicModelChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Prefix')
)
prefix = DynamicModelChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Prefix')
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -307,10 +327,10 @@ class IPAddressBulkEditForm(PrimaryModelBulkEditForm):
model = IPAddress
fieldsets = (
FieldSet('status', 'role', 'tenant', 'description'),
FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')),
FieldSet('prefix', 'vrf', 'mask_length', 'dns_name', name=_('Addressing')),
)
nullable_fields = (
'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
'prefix', 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
)
+2 -2
View File
@@ -343,8 +343,8 @@ class IPAddressImportForm(PrimaryModelImportForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'fhrp_group',
'is_primary', 'is_oob', 'dns_name', 'description', 'owner', 'comments', 'tags',
'prefix', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface',
'fhrp_group', 'is_primary', 'is_oob', 'dns_name', 'owner', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
+23 -4
View File
@@ -219,6 +219,12 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
choices=PREFIX_MASK_LENGTH_CHOICES,
label=_('Mask length')
)
aggregate_id = DynamicModelMultipleChoiceField(
queryset=Aggregate.objects.all(),
required=False,
label=_('Aggregate'),
null_option='Global'
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -292,12 +298,20 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet(
'prefix', 'family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')
),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
prefix = DynamicModelMultipleChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Prefix'),
null_option='None'
)
family = forms.ChoiceField(
required=False,
choices=add_blank_choice(IPAddressFamilyChoices),
@@ -342,7 +356,7 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
'prefix', 'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
name=_('Attributes')
),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
@@ -351,7 +365,7 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'prefix_id', 'parent', 'status', 'role')
parent = forms.CharField(
required=False,
widget=forms.TextInput(
@@ -371,6 +385,11 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
choices=IPADDRESS_MASK_LENGTH_CHOICES,
label=_('Mask length')
)
prefix_id = DynamicModelMultipleChoiceField(
queryset=Prefix.objects.all(),
required=False,
label=_('Prefix'),
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
+6 -6
View File
@@ -255,8 +255,8 @@ class IPRangeForm(TenancyForm, PrimaryModelForm):
fieldsets = (
FieldSet(
'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', 'description',
'tags', name=_('IP Range')
'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized',
'description', 'tags', name=_('IP Range')
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
@@ -264,8 +264,8 @@ class IPRangeForm(TenancyForm, PrimaryModelForm):
class Meta:
model = IPRange
fields = [
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_populated',
'mark_utilized', 'description', 'owner', 'comments', 'tags',
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant',
'mark_populated', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
]
@@ -331,8 +331,8 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent',
'nat_inside', 'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
+6
View File
@@ -170,6 +170,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
@strawberry_django.filter_type(models.IPAddress, lookups=True)
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
address: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
vrf_id: ID | None = strawberry_django.filter_field()
@@ -221,6 +222,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
@strawberry_django.filter_type(models.IPRange, lookups=True)
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
start_address: FilterLookup[str] | None = strawberry_django.filter_field()
end_address: FilterLookup[str] | None = strawberry_django.filter_field()
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -275,6 +277,10 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.Prefix, lookups=True)
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
aggregate: Annotated['AggregateFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
parent: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
prefix: FilterLookup[str] | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
vrf_id: ID | None = strawberry_django.filter_field()
+4
View File
@@ -143,6 +143,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
)
class IPAddressType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
address: str
prefix: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
nat_inside: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
@@ -167,6 +168,7 @@ class IPAddressType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
pagination=True
)
class IPRangeType(ContactsMixin, PrimaryObjectType):
prefix: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None
start_address: str
end_address: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
@@ -181,6 +183,8 @@ class IPRangeType(ContactsMixin, PrimaryObjectType):
pagination=True
)
class PrefixType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
aggregate: Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')] | None
parent: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None
prefix: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@@ -0,0 +1,58 @@
# Generated by Django 5.0.9 on 2025-02-20 16:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0086_gfk_indexes'),
]
operations = [
migrations.AddField(
model_name='prefix',
name='parent',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='children',
to='ipam.prefix',
),
),
migrations.AddField(
model_name='ipaddress',
name='prefix',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='ip_addresses',
to='ipam.prefix',
),
),
migrations.AddField(
model_name='iprange',
name='prefix',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='ip_ranges',
to='ipam.prefix',
),
),
migrations.AddField(
model_name='prefix',
name='aggregate',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='prefixes',
to='ipam.aggregate',
),
),
]
@@ -0,0 +1,132 @@
# Generated by Django 5.0.9 on 2025-02-20 16:49
import sys
import time
from django.db import migrations, models
from ipam.choices import PrefixStatusChoices
def draw_progress(count, total, length=20):
if total == 0:
return
progress = count / total
percent = int(progress * 100)
bar = int(progress * length)
sys.stdout.write('\r')
sys.stdout.write(f"[{'=' * bar:{length}s}] {percent}%")
sys.stdout.flush()
def set_prefix(apps, schema_editor, model, attr='address', parent_attr='prefix', parent_model='Prefix'):
start = time.time()
ChildModel = apps.get_model('ipam', model)
ParentModel = apps.get_model('ipam', parent_model)
addresses = ChildModel.objects.all()
total = addresses.count()
if total == 0:
return
print('\r\n')
print(f'Migrating {parent_model}')
print('\r\n')
i = 0
draw_progress(i, total, 50)
for address in addresses:
i += 1
address_attr = getattr(address, attr)
prefixes = ParentModel.objects.filter(
prefix__net_contains_or_equals=str(address_attr.ip),
prefix__net_mask_length__lte=address_attr.prefixlen,
)
setattr(address, parent_attr, prefixes.last())
try:
address.save()
except Exception as e:
print(f'Error at {address}')
raise e
draw_progress(i, total, 50)
end = time.time()
print(f"\r\nElapsed Time: {end - start:.2f}s")
def set_ipaddress_prefix(apps, schema_editor):
set_prefix(apps, schema_editor, 'IPAddress')
def unset_ipaddress_prefix(apps, schema_editor):
IPAddress = apps.get_model('ipam', 'IPAddress')
IPAddress.objects.update(prefix=None)
def set_iprange_prefix(apps, schema_editor):
set_prefix(apps, schema_editor, 'IPRange', 'start_address')
def unset_iprange_prefix(apps, schema_editor):
IPRange = apps.get_model('ipam', 'IPRange')
IPRange.objects.update(prefix=None)
def set_prefix_aggregate(apps, schema_editor):
set_prefix(apps, schema_editor, 'Prefix', 'prefix', 'aggregate', 'Aggregate')
def unset_prefix_aggregate(apps, schema_editor):
Prefix = apps.get_model('ipam', 'Prefix')
Prefix.objects.update(aggregate=None)
def set_prefix_parent(apps, schema_editor):
Prefix = apps.get_model('ipam', 'Prefix')
start = time.time()
addresses = Prefix.objects.all()
i = 0
total = addresses.count()
if total == 0:
return
print('\r\n')
draw_progress(i, total, 50)
for address in addresses:
i += 1
prefixes = Prefix.objects.exclude(pk=address.pk).filter(
models.Q(vrf=address.vrf, prefix__net_contains=str(address.prefix.ip))
| models.Q(
vrf=None,
status=PrefixStatusChoices.STATUS_CONTAINER,
prefix__net_contains=str(address.prefix.ip),
)
)
if not prefixes.exists():
draw_progress(i, total, 50)
continue
address.parent = prefixes.last()
address.save()
draw_progress(i, total, 50)
end = time.time()
print(f"\r\nElapsed Time: {end - start:.2f}s")
def unset_prefix_parent(apps, schema_editor):
Prefix = apps.get_model('ipam', 'Prefix')
Prefix.objects.update(parent=None)
class Migration(migrations.Migration):
dependencies = [
('ipam', '0087_ipaddress_iprange_prefix_parent'),
]
operations = [
migrations.RunPython(set_ipaddress_prefix, unset_ipaddress_prefix),
migrations.RunPython(set_iprange_prefix, unset_iprange_prefix),
migrations.RunPython(set_prefix_aggregate, unset_prefix_aggregate),
migrations.RunPython(set_prefix_parent, unset_prefix_parent),
]
@@ -0,0 +1,57 @@
# Generated by Django 5.2.5 on 2026-02-06 21:30
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0088_ipaddress_iprange_prefix_parent_data'),
]
operations = [
pgtrigger.migrations.AddTrigger(
model_name='prefix',
trigger=pgtrigger.compiler.Trigger(
name='ipam_prefix_delete',
sql=pgtrigger.compiler.UpsertTriggerSql(
func="\n-- Update Child Prefix's with Prefix's PARENT This is a safe assumption based on the fact that the parent would be the\n-- next direct parent for anything else that could contain this prefix\nUPDATE ipam_prefix SET parent_id=OLD.parent_id WHERE parent_id=OLD.id;\nRETURN OLD;\n",
hash='ee3f890009c05a3617428158e7b6f3d77317885d',
operation='DELETE',
pgid='pgtrigger_ipam_prefix_delete_e7810',
table='ipam_prefix',
when='BEFORE',
),
),
),
pgtrigger.migrations.AddTrigger(
model_name='prefix',
trigger=pgtrigger.compiler.Trigger(
name='ipam_prefix_insert',
sql=pgtrigger.compiler.UpsertTriggerSql(
func="\n-- Update the prefix with the new parent if the parent is the most appropriate prefix\nUPDATE ipam_prefix\nSET parent_id=NEW.id\nWHERE\n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nRETURN NEW;\n",
hash='1d71498f09e767183d3b0d29c06c9ac9e2cc000a',
operation='INSERT',
pgid='pgtrigger_ipam_prefix_insert_46c72',
table='ipam_prefix',
when='AFTER',
),
),
),
pgtrigger.migrations.AddTrigger(
model_name='prefix',
trigger=pgtrigger.compiler.Trigger(
name='ipam_prefix_update',
sql=pgtrigger.compiler.UpsertTriggerSql(
func="\n-- When a prefix changes, reassign any child prefixes that no longer\n-- fall within the new prefix range to the parent prefix (or set null if no parent exists)\nUPDATE ipam_prefix\nSET parent_id = OLD.parent_id\nWHERE\n parent_id = NEW.id\n -- IP address no longer contained within the updated prefix\n AND NOT (prefix << NEW.prefix);\n\n-- When a prefix changes, reassign any ip addresses that no longer\n-- fall within the new prefix range to the parent prefix (or set null if no parent exists)\nUPDATE ipam_ipaddress\nSET prefix_id = OLD.parent_id\nWHERE\n prefix_id = NEW.id\n -- IP address no longer contained within the updated prefix\n AND\n NOT (address << NEW.prefix)\n;\n\n-- When a prefix changes, reassign any ip ranges that no longer\n-- fall within the new prefix range to the parent prefix (or set null if no parent exists)\nUPDATE ipam_iprange\nSET prefix_id = OLD.parent_id\nWHERE\n prefix_id = NEW.id\n -- IP address no longer contained within the updated prefix\n AND\n NOT (start_address << NEW.prefix)\n AND\n NOT (end_address << NEW.prefix)\n;\n\n-- When a prefix changes, reassign any ip addresses that are in-scope but\n-- no longer within the same VRF\nUPDATE ipam_ipaddress\n SET prefix_id = OLD.parent_id\n WHERE\n prefix_id = NEW.id\n AND\n address << OLD.prefix\n AND\n (\n NOT address << NEW.prefix\n OR\n (\n vrf_id is NULL\n AND\n NEW.vrf_id IS NOT NULL\n )\n OR\n (\n OLD.vrf_id IS NULL\n AND\n NEW.vrf_id IS NOT NULL\n AND\n NEW.vrf_id != vrf_id\n )\n )\n;\n\n-- When a prefix changes, reassign any ip ranges that are in-scope but\n-- no longer within the same VRF\nUPDATE ipam_iprange\n SET prefix_id = OLD.parent_id\n WHERE\n prefix_id = NEW.id\n AND\n start_address << OLD.prefix\n AND\n end_address << OLD.prefix\n AND\n (\n NOT start_address << NEW.prefix\n OR\n NOT end_address << NEW.prefix\n OR\n (\n vrf_id is NULL\n AND\n NEW.vrf_id IS NOT NULL\n )\n OR\n (\n OLD.vrf_id IS NULL\n AND\n NEW.vrf_id IS NOT NULL\n AND\n NEW.vrf_id != vrf_id\n )\n )\n;\n\n-- Update the prefix with the new parent if the parent is the most appropriate prefix\nUPDATE ipam_prefix\n SET parent_id=NEW.id\n WHERE\n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> prefix AND p.vrf_id = vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nUPDATE ipam_ipaddress\n SET prefix_id = NEW.id\n WHERE\n prefix_id != NEW.id\n AND\n address << NEW.prefix\n AND (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> address AND p.vrf_id = vrf_id\n )\n )\n )\n;\nUPDATE ipam_iprange\n SET prefix_id = NEW.id\n WHERE\n prefix_id != NEW.id\n AND\n start_address << NEW.prefix\n AND\n end_address << NEW.prefix\n AND (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE\n p.prefix >> start_address\n AND\n p.prefix >> end_address\n AND\n p.vrf_id = vrf_id\n )\n )\n )\n;\nRETURN NEW;\n",
hash='7dce524151c88aa9864aad70a24cb5982b05aa28',
operation='UPDATE',
pgid='pgtrigger_ipam_prefix_update_e5fca',
table='ipam_prefix',
when='AFTER',
),
),
),
]
+160 -23
View File
@@ -1,4 +1,5 @@
import netaddr
import pgtrigger
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.indexes import GistIndex
@@ -8,6 +9,7 @@ from django.db.models import F
from django.db.models.functions import Cast
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from netaddr.ip import IPNetwork
from dcim.models.mixins import CachedScopeMixin
from ipam.choices import *
@@ -16,6 +18,8 @@ from ipam.fields import IPNetworkField, IPAddressField
from ipam.lookups import Host
from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet
from ipam.triggers import ipam_prefix_delete_adjust_prefix_parent, ipam_prefix_insert_adjust_prefix_parent, \
ipam_prefix_update_adjust_prefix_parent
from ipam.validators import DNSValidator
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
@@ -185,31 +189,28 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
return min(utilization, 100)
class Role(OrganizationalModel):
"""
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
"Management."
"""
weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'),
default=1000
)
class Meta:
ordering = ('weight', 'name')
verbose_name = _('role')
verbose_name_plural = _('roles')
def __str__(self):
return self.name
class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, PrimaryModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain
areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role.
A Prefix can also be assigned to a VLAN where appropriate.
"""
aggregate = models.ForeignKey(
to='ipam.Aggregate',
on_delete=models.SET_NULL, # This is handled by triggers
related_name='prefixes',
blank=True,
null=True,
verbose_name=_('aggregate')
)
parent = models.ForeignKey(
to='ipam.Prefix',
on_delete=models.DO_NOTHING,
related_name='children',
blank=True,
null=True,
verbose_name=_('Prefix')
)
prefix = IPNetworkField(
verbose_name=_('prefix'),
help_text=_('IPv4 or IPv6 network with mask')
@@ -284,8 +285,32 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
verbose_name_plural = _('prefixes')
indexes = (
models.Index(fields=('scope_type', 'scope_id')),
GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']),
)
GistIndex(
fields=['prefix'],
name='ipam_prefix_gist_idx',
opclasses=['inet_ops'],
),
)
triggers = (
pgtrigger.Trigger(
name='ipam_prefix_delete',
operation=pgtrigger.Delete,
when=pgtrigger.Before,
func=ipam_prefix_delete_adjust_prefix_parent,
),
pgtrigger.Trigger(
name='ipam_prefix_insert',
operation=pgtrigger.Insert,
when=pgtrigger.After,
func=ipam_prefix_insert_adjust_prefix_parent,
),
pgtrigger.Trigger(
name='ipam_prefix_update',
operation=pgtrigger.Update,
when=pgtrigger.After,
func=ipam_prefix_update_adjust_prefix_parent,
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -301,6 +326,8 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
super().clean()
if self.prefix:
if not isinstance(self.prefix, IPNetwork):
self.prefix = IPNetwork(self.prefix)
# /0 masks are not acceptable
if self.prefix.prefixlen == 0:
@@ -322,6 +349,10 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
def save(self, *args, **kwargs):
if not self.pk or not self.parent or (self.prefix != self._prefix) or (self.vrf_id != self._vrf_id):
parent = self.find_parent_prefix(networks=self.prefix, vrf=self.vrf, exclude=self.pk)
self.parent = parent
if isinstance(self.prefix, netaddr.IPNetwork):
# Clear host bits from prefix
@@ -346,11 +377,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
@property
def depth(self):
def depth_count(self):
return self._depth
@property
def children(self):
def children_count(self):
return self._children
def _set_prefix_length(self, value):
@@ -490,11 +521,63 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
return min(utilization, 100)
@classmethod
def find_parent_prefix(cls, networks, vrf=None, exclude=None):
# TODO: Document
if type(networks) in [netaddr.IPAddress, netaddr.IPNetwork, str]:
networks = [networks, ]
network_filter = models.Q()
for network in networks:
network_filter &= models.Q(
prefix__net_contains_or_equals=network
)
prefixes = Prefix.objects.filter(
models.Q(
network_filter,
vrf=vrf
) | models.Q(
network_filter,
vrf=None,
status=PrefixStatusChoices.STATUS_CONTAINER,
)
)
if exclude:
prefixes = prefixes.exclude(pk=exclude)
return prefixes.last()
class Role(OrganizationalModel):
"""
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
"Management."
"""
weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'),
default=1000
)
class Meta:
ordering = ('weight', 'name')
verbose_name = _('role')
verbose_name_plural = _('roles')
def __str__(self):
return self.name
class IPRange(ContactsMixin, PrimaryModel):
"""
A range of IP addresses, defined by start and end addresses.
"""
prefix = models.ForeignKey(
to='ipam.Prefix',
on_delete=models.SET_NULL,
related_name='ip_ranges',
null=True,
blank=True,
verbose_name=_('prefix'),
)
start_address = IPAddressField(
verbose_name=_('start address'),
help_text=_('IPv4 or IPv6 address (with mask)')
@@ -564,6 +647,27 @@ class IPRange(ContactsMixin, PrimaryModel):
super().clean()
if self.start_address and self.end_address:
# If prefix is set, validate suitability
if self.prefix:
# Check that start address and end address are within the prefix range
if self.start_address not in self.prefix.prefix and self.end_address not in self.prefix.prefix:
raise ValidationError({
'start_address': _("Start address must be part of the selected prefix"),
'end_address': _("End address must be part of the selected prefix.")
})
elif self.start_address not in self.prefix.prefix:
raise ValidationError({
'start_address': _("Start address must be part of the selected prefix")
})
elif self.end_address not in self.prefix.prefix:
raise ValidationError({
'end_address': _("End address must be part of the selected prefix.")
})
# Check that VRF matches prefix VRF
if self.vrf != self.prefix.vrf:
raise ValidationError({
'vrf': _("VRF must match the prefix VRF.")
})
# Check that start & end IP versions match
if self.start_address.version != self.end_address.version:
@@ -626,6 +730,12 @@ class IPRange(ContactsMixin, PrimaryModel):
# Record the range's size (number of IP addresses)
self.size = int(self.end_address.ip - self.start_address.ip) + 1
# Set the parent prefix
self.prefix = Prefix.find_parent_prefix(
networks=[self.start_address, self.end_address],
vrf=self.vrf
)
super().save(*args, **kwargs)
@property
@@ -732,6 +842,14 @@ class IPAddress(ContactsMixin, PrimaryModel):
for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress
which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
"""
prefix = models.ForeignKey(
to='ipam.Prefix',
on_delete=models.SET_NULL,
related_name='ip_addresses',
blank=True,
null=True,
verbose_name=_('Prefix')
)
address = IPAddressField(
verbose_name=_('address'),
help_text=_('IPv4 or IPv6 address (with mask)')
@@ -819,6 +937,7 @@ class IPAddress(ContactsMixin, PrimaryModel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._address = self.address
# Denote the original assigned object (if any) for validation in clean()
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
@@ -865,6 +984,16 @@ class IPAddress(ContactsMixin, PrimaryModel):
super().clean()
if self.address:
# If prefix is set, validate suitability
if self.prefix:
if self.address not in self.prefix.prefix:
raise ValidationError({
'prefix': _("IP address must be part of the selected prefix.")
})
if self.vrf != self.prefix.vrf:
raise ValidationError({
'vrf': _("IP address VRF must match the prefix VRF.")
})
# /0 masks are not acceptable
if self.address.prefixlen == 0:
@@ -958,6 +1087,9 @@ class IPAddress(ContactsMixin, PrimaryModel):
# Force dns_name to lowercase
self.dns_name = self.dns_name.lower()
# Set the parent prefix
self.prefix = Prefix.find_parent_prefix(networks=self.address, vrf=self.vrf)
super().save(*args, **kwargs)
def clone(self):
@@ -1012,3 +1144,8 @@ class IPAddress(ContactsMixin, PrimaryModel):
def get_role_color(self):
return IPAddressRoleChoices.colors.get(self.role)
@classmethod
def find_prefix(self, address):
prefixes = Prefix.objects.filter(prefix__net_contains=address.address, vrf=address.vrf)
return prefixes.last()
+7 -3
View File
@@ -53,11 +53,12 @@ class IPAddressIndex(SearchIndex):
model = models.IPAddress
fields = (
('address', 100),
('prefix', 200),
('dns_name', 300),
('description', 500),
('comments', 5000),
)
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description')
@register_search
@@ -66,10 +67,11 @@ class IPRangeIndex(SearchIndex):
fields = (
('start_address', 100),
('end_address', 300),
('prefix', 400),
('description', 500),
('comments', 5000),
)
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description')
@register_search
@@ -77,10 +79,12 @@ class PrefixIndex(SearchIndex):
model = models.Prefix
fields = (
('prefix', 110),
('parent', 200),
('aggregate', 300),
('description', 500),
('comments', 5000),
)
display_attrs = ('scope', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
display_attrs = ('scope', 'aggregate', 'parent', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
@register_search
+25 -9
View File
@@ -152,6 +152,10 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
parent = tables.Column(
verbose_name=_('Parent'),
linkify=True
)
prefix = columns.TemplateColumn(
verbose_name=_('Prefix'),
template_code=PREFIX_LINK_WITH_DEPTH,
@@ -230,9 +234,9 @@ class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = Prefix
fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group',
'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'contacts',
'comments', 'tags', 'created', 'last_updated',
'pk', 'id', 'prefix', 'status', 'parent', 'prefix', 'prefix_flat', 'children', 'vrf', 'utilization',
'tenant', 'tenant_group', 'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized',
'contacts', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role',
@@ -246,8 +250,11 @@ class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
#
# IP ranges
#
class IPRangeTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
prefix = tables.Column(
verbose_name=_('Prefix'),
linkify=True
)
start_address = tables.Column(
verbose_name=_('Start address'),
linkify=True
@@ -284,9 +291,9 @@ class IPRangeTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = IPRange
fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group',
'mark_populated', 'mark_utilized', 'utilization', 'description', 'contacts', 'comments', 'tags',
'created', 'last_updated',
'pk', 'id', 'start_address', 'end_address', 'prefix', 'size', 'vrf', 'status', 'role', 'tenant',
'tenant_group', 'mark_populated', 'mark_utilized', 'utilization', 'description', 'contacts',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -301,10 +308,18 @@ class IPRangeTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
#
class IPAddressTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
prefix = tables.Column(
verbose_name=_('Prefix'),
linkify=True
)
address = tables.TemplateColumn(
template_code=IPADDRESS_LINK,
verbose_name=_('IP Address')
)
prefix = tables.Column(
linkify=True,
verbose_name=_('Prefix')
)
vrf = tables.TemplateColumn(
template_code=VRF_LINK,
verbose_name=_('VRF')
@@ -353,8 +368,9 @@ class IPAddressTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable
class Meta(PrimaryModelTable.Meta):
model = IPAddress
fields = (
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside',
'assigned', 'dns_name', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
'pk', 'id', 'address', 'vrf', 'prefix', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside',
'nat_outside', 'assigned', 'dns_name', 'description', 'comments', 'contacts', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
+14 -6
View File
@@ -16,12 +16,20 @@ PREFIX_COPY_BUTTON = """
PREFIX_LINK_WITH_DEPTH = """
{% load helpers %}
{% if record.depth %}
<div class="record-depth">
{% for i in record.depth|as_range %}
<span>•</span>
{% endfor %}
</div>
{% if record.depth_count %}
{% if object %}
<div class="record-depth">
{% for i in record.depth_count|parent_depth:object|as_range %}
<span>•</span>
{% endfor %}
</div>
{% else %}
<div class="record-depth">
{% for i in record.depth_count|as_range %}
<span>•</span>
{% endfor %}
</div>
{% endif %}
{% endif %}
""" + PREFIX_LINK
+6 -3
View File
@@ -407,7 +407,8 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
class PrefixTest(APIViewTestCases.APIViewTestCase):
model = Prefix
brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url']
# TODO: Alter for parent prefix
brief_fields = ['_depth', 'aggregate', 'description', 'display', 'family', 'id', 'parent', 'prefix', 'url']
create_data = [
{
'prefix': '192.168.4.0/24',
@@ -647,7 +648,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
class IPRangeTest(APIViewTestCases.APIViewTestCase):
model = IPRange
brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url']
# TODO: Alter for parent prefix
brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'prefix', 'start_address', 'url']
create_data = [
{
'start_address': '192.168.4.10/24',
@@ -805,7 +807,8 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
class IPAddressTest(APIViewTestCases.APIViewTestCase):
model = IPAddress
brief_fields = ['address', 'description', 'display', 'family', 'id', 'url']
# TODO: Alter for parent prefix
brief_fields = ['address', 'description', 'display', 'family', 'id', 'prefix', 'url']
create_data = [
{
'address': '192.168.0.4/24',
+6
View File
@@ -901,6 +901,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Test for parent prefix
# TODO: Test for children?
# TODO: Test for aggregate
class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPRange.objects.all()
@@ -1079,6 +1083,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self):
# TODO: Alter for prefix
params = {'parent': ['10.0.1.0/24', '10.0.2.0/24']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'parent': ['10.0.1.0/25']} # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25
@@ -1318,6 +1323,7 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_parent(self):
# TODO: Alter for prefix
params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+369 -21
View File
@@ -39,6 +39,26 @@ class TestAggregate(TestCase):
class TestIPRange(TestCase):
@classmethod
def setUpTestData(cls):
cls.vrf = VRF.objects.create(name='VRF A', rd='1:1')
cls.prefixes = (
# IPv4
Prefix(prefix='192.0.0.0/16'),
Prefix(prefix='192.0.2.0/24'),
Prefix(prefix='192.0.0.0/16', vrf=cls.vrf),
# IPv6
Prefix(prefix='2001:db8::/32'),
Prefix(prefix='2001:db8::/64'),
)
for prefix in cls.prefixes:
prefix.clean()
prefix.save()
def test_overlapping_range(self):
iprange_192_168 = IPRange.objects.create(
@@ -87,6 +107,69 @@ class TestIPRange(TestCase):
)
iprange_4_198_201.clean()
def test_parent_prefix(self):
ranges = (
IPRange(
start_address=IPNetwork('192.0.0.1/24'),
end_address=IPNetwork('192.0.0.254/24'),
prefix=self.prefixes[0]
),
IPRange(
start_address=IPNetwork('192.0.2.1/24'),
end_address=IPNetwork('192.0.2.254/24'),
prefix=self.prefixes[1]
),
IPRange(
start_address=IPNetwork('192.0.2.1/24'),
end_address=IPNetwork('192.0.2.254/24'),
vrf=self.vrf,
prefix=self.prefixes[2]
),
IPRange(
start_address=IPNetwork('2001:db8::/64'),
end_address=IPNetwork('2001:db8::ffff/64'),
prefix=self.prefixes[4]
),
IPRange(
start_address=IPNetwork('2001:db8:2::/64'),
end_address=IPNetwork('2001:db8:2::ffff/64'),
prefix=self.prefixes[3]
),
)
for range in ranges:
range.clean()
range.save()
self.assertEqual(ranges[0].prefix, self.prefixes[0])
self.assertEqual(ranges[1].prefix, self.prefixes[1])
self.assertEqual(ranges[2].prefix, self.prefixes[2])
self.assertEqual(ranges[3].prefix, self.prefixes[4])
def test_parent_prefix_change(self):
range = IPRange(
start_address=IPNetwork('192.0.1.1/24'),
end_address=IPNetwork('192.0.1.254/24'),
prefix=self.prefixes[0]
)
range.clean()
range.save()
prefix = Prefix(prefix='192.0.0.0/17')
prefix.clean()
prefix.save()
range.refresh_from_db()
self.assertEqual(range.prefix, prefix)
# TODO: Prefix Altered
# TODO: Prefix Deleted
# TODO: Prefix falls outside range
# TODO: Prefix VRF does not match range VRF
class TestPrefix(TestCase):
@@ -169,23 +252,16 @@ class TestPrefix(TestCase):
prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER
)
ips = IPAddress.objects.bulk_create((
IPAddress(address=IPNetwork('10.0.0.1/24'), vrf=None),
IPAddress(address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]),
IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
IPAddress(address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.0.1/24'), vrf=None),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
))
child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()}
child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
# Global container should return all children
self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
parent_prefix.vrf = vrfs[0]
parent_prefix.save()
child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()}
# VRF container is limited to its own VRF
self.assertSetEqual(child_ip_pks, {ips[1].pk})
def test_get_available_prefixes(self):
prefixes = Prefix.objects.bulk_create((
@@ -332,6 +408,62 @@ class TestPrefix(TestCase):
duplicate_prefix = Prefix(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean)
def test_parent_container_prefix_change(self):
vrfs = VRF.objects.bulk_create((
VRF(name='VRF 1'),
VRF(name='VRF 2'),
VRF(name='VRF 3'),
))
parent_prefix = Prefix.objects.create(
prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER
)
ips = IPAddress.objects.bulk_create((
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.0.1/24'), vrf=None),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
))
child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
# Global container should return all children
self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
parent_prefix.vrf = vrfs[0]
parent_prefix.save()
parent_prefix.refresh_from_db()
child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
# VRF container is limited to its own VRF
self.assertSetEqual(child_ip_pks, {ips[1].pk})
def test_parent_container_vrf_change(self):
vrfs = VRF.objects.bulk_create((
VRF(name='VRF 1'),
VRF(name='VRF 2'),
VRF(name='VRF 3'),
))
parent_prefix = Prefix.objects.create(
prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER
)
ips = IPAddress.objects.bulk_create((
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.0.1/24'), vrf=None),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
))
child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
# Global container should return all children
self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
parent_prefix.prefix = '10.0.0.0/23'
parent_prefix.save()
parent_prefix.refresh_from_db()
child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk})
class TestPrefixHierarchy(TestCase):
"""
@@ -344,17 +476,21 @@ class TestPrefixHierarchy(TestCase):
prefixes = (
# IPv4
Prefix(prefix='10.0.0.0/8', _depth=0, _children=2),
Prefix(prefix='10.0.0.0/16', _depth=1, _children=1),
Prefix(prefix='10.0.0.0/24', _depth=2, _children=0),
Prefix(prefix='10.0.0.0/8'),
Prefix(prefix='10.0.0.0/16'),
Prefix(prefix='10.0.0.0/24'),
Prefix(prefix='192.168.0.0/16'),
# IPv6
Prefix(prefix='2001:db8::/32', _depth=0, _children=2),
Prefix(prefix='2001:db8::/40', _depth=1, _children=1),
Prefix(prefix='2001:db8::/48', _depth=2, _children=0),
Prefix(prefix='2001:db8::/32'),
Prefix(prefix='2001:db8::/40'),
Prefix(prefix='2001:db8::/48'),
)
Prefix.objects.bulk_create(prefixes)
for prefix in prefixes:
prefix.clean()
prefix.save()
def test_create_prefix4(self):
# Create 10.0.0.0/12
@@ -362,15 +498,19 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 2)
self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/12'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24'))
self.assertEqual(prefixes[3].parent.prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[3]._depth, 3)
self.assertEqual(prefixes[3]._children, 0)
@@ -380,15 +520,19 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 2)
self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/36'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[3].parent.prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[3]._depth, 3)
self.assertEqual(prefixes[3]._children, 0)
@@ -400,12 +544,15 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 2)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/12'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 0)
@@ -417,12 +564,15 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 2)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/36'))
self.assertEqual(prefixes[2]._depth, 2)
self.assertEqual(prefixes[2]._children, 0)
@@ -437,14 +587,17 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
prefixes = Prefix.objects.filter(vrf=vrf)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 0)
@@ -459,14 +612,17 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
prefixes = Prefix.objects.filter(vrf=vrf)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 0)
@@ -476,9 +632,11 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
@@ -488,9 +646,11 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 1)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 0)
@@ -500,15 +660,20 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=4)
self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/8'))
self.assertEqual(prefixes[2]._depth, 1)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24'))
# TODO: How to we resolve the parent for duplicate prefixes
self.assertEqual(prefixes[3].parent.prefix, IPNetwork('10.0.0.0/16'))
self.assertEqual(prefixes[3]._depth, 2)
self.assertEqual(prefixes[3]._children, 0)
@@ -518,20 +683,158 @@ class TestPrefixHierarchy(TestCase):
prefixes = Prefix.objects.filter(prefix__family=6)
self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[0].parent, None)
self.assertEqual(prefixes[0]._depth, 0)
self.assertEqual(prefixes[0]._children, 3)
self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[1]._depth, 1)
self.assertEqual(prefixes[1]._children, 1)
self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/32'))
self.assertEqual(prefixes[2]._depth, 1)
self.assertEqual(prefixes[2]._children, 1)
self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48'))
self.assertEqual(prefixes[3].parent.prefix, IPNetwork('2001:db8::/40'))
self.assertEqual(prefixes[3]._depth, 2)
self.assertEqual(prefixes[3]._children, 0)
class TestTriggers(TestCase):
"""
Test the automatic updating of depth and child count in response to changes made within
the prefix hierarchy.
"""
@classmethod
def setUpTestData(cls):
vrfs = (
VRF(name='VRF A'),
VRF(name='VRF B'),
)
for vrf in vrfs:
vrf.clean()
vrf.save()
cls.prefixes = (
# IPv4
Prefix(prefix='10.0.0.0/8'),
Prefix(prefix='10.0.0.0/16'),
Prefix(prefix='10.0.0.0/22'),
Prefix(prefix='10.0.0.0/23'),
Prefix(prefix='10.0.2.0/23'),
Prefix(prefix='10.0.0.0/24'),
Prefix(prefix='10.0.1.0/24'),
Prefix(prefix='10.0.2.0/24'),
Prefix(prefix='10.0.3.0/24'),
Prefix(prefix='10.1.0.0/16', status='container'),
Prefix(prefix='10.1.0.0/22', vrf=vrfs[0]),
Prefix(prefix='10.1.0.0/23', vrf=vrfs[0]),
Prefix(prefix='10.1.2.0/23', vrf=vrfs[0]),
Prefix(prefix='10.1.0.0/24', vrf=vrfs[0]),
Prefix(prefix='10.1.1.0/24', vrf=vrfs[0]),
Prefix(prefix='10.1.2.0/24', vrf=vrfs[0]),
Prefix(prefix='10.1.3.0/24', vrf=vrfs[0]),
)
for prefix in cls.prefixes:
prefix.clean()
prefix.save()
def test_current_hierarchy(self):
self.assertIsNone(Prefix.objects.get(prefix='10.0.0.0/8').parent)
self.assertEqual(Prefix.objects.get(prefix='10.0.0.0/16').parent, Prefix.objects.get(prefix='10.0.0.0/8'))
self.assertEqual(Prefix.objects.get(prefix='10.0.0.0/22').parent, Prefix.objects.get(prefix='10.0.0.0/16'))
self.assertEqual(Prefix.objects.get(prefix='10.0.0.0/23').parent, Prefix.objects.get(prefix='10.0.0.0/22'))
self.assertEqual(Prefix.objects.get(prefix='10.0.2.0/23').parent, Prefix.objects.get(prefix='10.0.0.0/22'))
self.assertEqual(Prefix.objects.get(prefix='10.0.0.0/24').parent, Prefix.objects.get(prefix='10.0.0.0/23'))
self.assertEqual(Prefix.objects.get(prefix='10.0.1.0/24').parent, Prefix.objects.get(prefix='10.0.0.0/23'))
self.assertEqual(Prefix.objects.get(prefix='10.0.2.0/24').parent, Prefix.objects.get(prefix='10.0.2.0/23'))
self.assertEqual(Prefix.objects.get(prefix='10.0.3.0/24').parent, Prefix.objects.get(prefix='10.0.2.0/23'))
def test_basic_insert(self):
pfx = Prefix.objects.create(prefix='10.0.0.0/21')
self.assertIsNotNone(Prefix.objects.get(prefix='10.0.0.0/22').parent)
self.assertEqual(Prefix.objects.get(prefix='10.0.0.0/22').parent, pfx)
def test_vrf_insert(self):
vrf = VRF.objects.get(name='VRF A')
pfx = Prefix.objects.create(prefix='10.1.0.0/21', vrf=vrf)
parent = Prefix.objects.get(prefix='10.1.0.0/16')
self.assertIsNotNone(Prefix.objects.get(prefix='10.1.0.0/21').parent)
self.assertEqual(Prefix.objects.get(prefix='10.1.0.0/21').parent, parent)
self.assertIsNotNone(Prefix.objects.get(prefix='10.1.0.0/22').parent)
self.assertEqual(Prefix.objects.get(prefix='10.1.0.0/22').parent, pfx)
def test_basic_delete(self):
Prefix.objects.get(prefix='10.0.0.0/23').delete()
parent = Prefix.objects.get(prefix='10.0.0.0/22')
self.assertEqual(Prefix.objects.get(prefix='10.0.0.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.0.1.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.0.2.0/24').parent, Prefix.objects.get(prefix='10.0.2.0/23'))
def test_vrf_delete(self):
Prefix.objects.get(prefix='10.1.0.0/23').delete()
parent = Prefix.objects.get(prefix='10.1.0.0/22')
self.assertEqual(Prefix.objects.get(prefix='10.1.0.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.1.1.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.1.2.0/24').parent, Prefix.objects.get(prefix='10.1.2.0/23'))
def test_basic_update(self):
pfx = Prefix.objects.get(prefix='10.0.0.0/23')
parent = Prefix.objects.get(prefix='10.0.0.0/22')
pfx.prefix = '10.3.0.0/23'
pfx.parent = Prefix.objects.get(prefix='10.0.0.0/8')
pfx.clean()
pfx.save()
self.assertEqual(Prefix.objects.get(prefix='10.0.0.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.0.1.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.0.2.0/24').parent, Prefix.objects.get(prefix='10.0.2.0/23'))
def test_vrf_update(self):
pfx = Prefix.objects.get(prefix='10.1.0.0/23')
parent = Prefix.objects.get(prefix='10.1.0.0/22')
pfx.prefix = '10.3.0.0/23'
pfx.parent = None
pfx.clean()
pfx.save()
self.assertEqual(Prefix.objects.get(prefix='10.1.0.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.1.1.0/24').parent, parent)
self.assertEqual(Prefix.objects.get(prefix='10.1.2.0/24').parent, Prefix.objects.get(prefix='10.1.2.0/23'))
# TODO: Test VRF Changes
class TestIPAddress(TestCase):
"""
Test the automatic updating of depth and child count in response to changes made within
the prefix hierarchy.
"""
@classmethod
def setUpTestData(cls):
cls.vrf = VRF.objects.create(name='VRF A', rd='1:1')
cls.prefixes = (
# IPv4
Prefix(prefix='192.0.0.0/16'),
Prefix(prefix='192.0.2.0/24'),
Prefix(prefix='192.0.0.0/16', vrf=cls.vrf),
# IPv6
Prefix(prefix='2001:db8::/32'),
Prefix(prefix='2001:db8::/64'),
)
for prefix in cls.prefixes:
prefix.clean()
prefix.save()
def test_get_duplicates(self):
ips = IPAddress.objects.bulk_create((
@@ -543,6 +846,44 @@ class TestIPAddress(TestCase):
self.assertSetEqual(set(duplicate_ip_pks), {ips[1].pk, ips[2].pk})
def test_parent_prefix(self):
ips = (
IPAddress(address=IPNetwork('192.0.0.1/24'), prefix=self.prefixes[0]),
IPAddress(address=IPNetwork('192.0.2.1/24'), prefix=self.prefixes[1]),
IPAddress(address=IPNetwork('192.0.2.1/24'), vrf=self.vrf, prefix=self.prefixes[2]),
IPAddress(address=IPNetwork('2001:db8::/64'), prefix=self.prefixes[4]),
IPAddress(address=IPNetwork('2001:db8:2::/64')),
)
for ip in ips:
ip.clean()
ip.save()
self.assertEqual(ips[0].prefix, self.prefixes[0])
self.assertEqual(ips[1].prefix, self.prefixes[1])
self.assertEqual(ips[2].prefix, self.prefixes[2])
self.assertEqual(ips[3].prefix, self.prefixes[4])
self.assertEqual(ips[4].prefix, self.prefixes[3])
def test_parent_prefix_change(self):
ip = IPAddress(address=IPNetwork('192.0.1.1/24'), prefix=self.prefixes[0])
ip.clean()
ip.save()
prefix = Prefix(prefix='192.0.1.0/17')
prefix.clean()
prefix.save()
ip.refresh_from_db()
self.assertEqual(ip.prefix, prefix)
# TODO: Prefix Altered
# TODO: Prefix Deleted
# TODO: Prefix does not contain IP Address
# TODO: Prefix VRF does not match IP Address VRF
#
# Uniqueness enforcement tests
#
@@ -559,13 +900,20 @@ class TestIPAddress(TestCase):
self.assertRaises(ValidationError, duplicate_ip.clean)
def test_duplicate_vrf(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
vrf = VRF.objects.get(rd='1:1')
vrf.enforce_unique = False
vrf.clean()
vrf.save()
IPAddress.objects.create(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
self.assertIsNone(duplicate_ip.clean())
def test_duplicate_vrf_unique(self):
vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
vrf = VRF.objects.get(rd='1:1')
vrf.enforce_unique = True
vrf.clean()
vrf.save()
IPAddress.objects.create(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean)
+19 -7
View File
@@ -421,6 +421,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
# TODO: Alter for prefix
cls.form_data = {
'prefix': IPNetwork('192.0.2.0/24'),
'scope_type': ContentType.objects.get_for_model(Site).pk,
@@ -436,6 +437,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
site = sites[0].pk
# TODO: Alter for prefix
cls.csv_data = (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
@@ -443,6 +445,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
)
# TODO: Alter for prefix
cls.csv_update_data = (
"id,description,status",
f"{prefixes[0].pk},New description 7,{PrefixStatusChoices.STATUS_RESERVED}",
@@ -450,6 +453,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
f"{prefixes[2].pk},New description 9,{PrefixStatusChoices.STATUS_RESERVED}",
)
# TODO: Alter for prefix
cls.bulk_edit_data = {
'vrf': vrfs[1].pk,
'tenant': None,
@@ -477,9 +481,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def test_prefix_ipranges(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_ranges = (
IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
IPRange(prefix=prefix, start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
IPRange(prefix=prefix, start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
IPRange(prefix=prefix, start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
)
IPRange.objects.bulk_create(ip_ranges)
self.assertEqual(prefix.get_child_ranges().count(), 3)
@@ -491,12 +495,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def test_prefix_ipaddresses(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.1/16')),
IPAddress(address=IPNetwork('192.168.0.2/16')),
IPAddress(address=IPNetwork('192.168.0.3/16')),
IPAddress(prefix=prefix, address=IPNetwork('192.168.0.1/16')),
IPAddress(prefix=prefix, address=IPNetwork('192.168.0.2/16')),
IPAddress(prefix=prefix, address=IPNetwork('192.168.0.3/16')),
)
IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(prefix.get_child_ips().count(), 3)
self.assertEqual(prefix.ip_addresses.all().count(), 3)
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
@@ -670,6 +674,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
# TODO: Alter for prefix
cls.form_data = {
'start_address': IPNetwork('192.0.5.10/24'),
'end_address': IPNetwork('192.0.5.100/24'),
@@ -683,6 +688,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
# TODO: Alter for prefix
cls.csv_data = (
"vrf,start_address,end_address,status",
"VRF 1,10.1.0.1/16,10.1.9.254/16,active",
@@ -690,6 +696,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VRF 1,10.3.0.1/16,10.3.9.254/16,active",
)
# TODO: Alter for prefix
cls.csv_update_data = (
"id,description,status",
f"{ip_ranges[0].pk},New description 7,{IPRangeStatusChoices.STATUS_RESERVED}",
@@ -697,6 +704,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
f"{ip_ranges[2].pk},New description 9,{IPRangeStatusChoices.STATUS_RESERVED}",
)
# TODO: Alter for prefix
cls.bulk_edit_data = {
'vrf': vrfs[1].pk,
'tenant': None,
@@ -763,6 +771,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
),
)
FHRPGroup.objects.bulk_create(fhrp_groups)
# TODO: Alter for prefix
cls.form_data = {
'vrf': vrfs[1].pk,
'address': IPNetwork('192.0.2.99/24'),
@@ -775,6 +784,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
# TODO: Alter for prefix
cls.csv_data = (
"vrf,address,status,fhrp_group",
"VRF 1,192.0.2.4/24,active,FHRP Group 1",
@@ -782,6 +792,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VRF 1,192.0.2.6/24,active,FHRP Group 3",
)
# TODO: Alter for prefix
cls.csv_update_data = (
"id,description,status",
f"{ipaddresses[0].pk},New description 7,{IPAddressStatusChoices.STATUS_RESERVED}",
@@ -789,6 +800,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
f"{ipaddresses[2].pk},New description 9,{IPAddressStatusChoices.STATUS_RESERVED}",
)
# TODO: Alter for prefix
cls.bulk_edit_data = {
'vrf': vrfs[1].pk,
'tenant': None,
+220
View File
@@ -0,0 +1,220 @@
ipam_prefix_delete_adjust_prefix_parent = """
-- Update Child Prefix's with Prefix's PARENT This is a safe assumption based on the fact that the parent would be the
-- next direct parent for anything else that could contain this prefix
UPDATE ipam_prefix SET parent_id=OLD.parent_id WHERE parent_id=OLD.id;
RETURN OLD;
"""
ipam_prefix_insert_adjust_prefix_parent = """
-- Update the prefix with the new parent if the parent is the most appropriate prefix
UPDATE ipam_prefix
SET parent_id=NEW.id
WHERE
prefix << NEW.prefix
AND
(
(vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))
OR
(
NEW.vrf_id IS NULL
AND
NEW.status = 'container'
AND
NOT EXISTS(
SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id
)
)
)
AND id != NEW.id
AND NOT EXISTS (
SELECT 1 FROM ipam_prefix p
WHERE
p.prefix >> ipam_prefix.prefix
AND p.prefix << NEW.prefix
AND (
(p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))
OR
(p.vrf_id IS NULL AND p.status = 'container')
)
AND p.id != NEW.id
)
;
RETURN NEW;
"""
ipam_prefix_update_adjust_prefix_parent = """
-- When a prefix changes, reassign any child prefixes that no longer
-- fall within the new prefix range to the parent prefix (or set null if no parent exists)
UPDATE ipam_prefix
SET parent_id = OLD.parent_id
WHERE
parent_id = NEW.id
-- IP address no longer contained within the updated prefix
AND NOT (prefix << NEW.prefix);
-- When a prefix changes, reassign any ip addresses that no longer
-- fall within the new prefix range to the parent prefix (or set null if no parent exists)
UPDATE ipam_ipaddress
SET prefix_id = OLD.parent_id
WHERE
prefix_id = NEW.id
-- IP address no longer contained within the updated prefix
AND
NOT (address << NEW.prefix)
;
-- When a prefix changes, reassign any ip ranges that no longer
-- fall within the new prefix range to the parent prefix (or set null if no parent exists)
UPDATE ipam_iprange
SET prefix_id = OLD.parent_id
WHERE
prefix_id = NEW.id
-- IP address no longer contained within the updated prefix
AND
NOT (start_address << NEW.prefix)
AND
NOT (end_address << NEW.prefix)
;
-- When a prefix changes, reassign any ip addresses that are in-scope but
-- no longer within the same VRF
UPDATE ipam_ipaddress
SET prefix_id = OLD.parent_id
WHERE
prefix_id = NEW.id
AND
address << OLD.prefix
AND
(
NOT address << NEW.prefix
OR
(
vrf_id is NULL
AND
NEW.vrf_id IS NOT NULL
)
OR
(
OLD.vrf_id IS NULL
AND
NEW.vrf_id IS NOT NULL
AND
NEW.vrf_id != vrf_id
)
)
;
-- When a prefix changes, reassign any ip ranges that are in-scope but
-- no longer within the same VRF
UPDATE ipam_iprange
SET prefix_id = OLD.parent_id
WHERE
prefix_id = NEW.id
AND
start_address << OLD.prefix
AND
end_address << OLD.prefix
AND
(
NOT start_address << NEW.prefix
OR
NOT end_address << NEW.prefix
OR
(
vrf_id is NULL
AND
NEW.vrf_id IS NOT NULL
)
OR
(
OLD.vrf_id IS NULL
AND
NEW.vrf_id IS NOT NULL
AND
NEW.vrf_id != vrf_id
)
)
;
-- Update the prefix with the new parent if the parent is the most appropriate prefix
UPDATE ipam_prefix
SET parent_id=NEW.id
WHERE
prefix << NEW.prefix
AND
(
(vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))
OR
(
NEW.vrf_id IS NULL
AND
NEW.status = 'container'
AND
NOT EXISTS(
SELECT 1 FROM ipam_prefix p WHERE p.prefix >> prefix AND p.vrf_id = vrf_id
)
)
)
AND id != NEW.id
AND NOT EXISTS (
SELECT 1 FROM ipam_prefix p
WHERE
p.prefix >> ipam_prefix.prefix
AND p.prefix << NEW.prefix
AND (
(p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))
OR
(p.vrf_id IS NULL AND p.status = 'container')
)
AND p.id != NEW.id
)
;
UPDATE ipam_ipaddress
SET prefix_id = NEW.id
WHERE
prefix_id != NEW.id
AND
address << NEW.prefix
AND (
(vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))
OR (
NEW.vrf_id IS NULL
AND
NEW.status = 'container'
AND
NOT EXISTS(
SELECT 1 FROM ipam_prefix p WHERE p.prefix >> address AND p.vrf_id = vrf_id
)
)
)
;
UPDATE ipam_iprange
SET prefix_id = NEW.id
WHERE
prefix_id != NEW.id
AND
start_address << NEW.prefix
AND
end_address << NEW.prefix
AND (
(vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))
OR (
NEW.vrf_id IS NULL
AND
NEW.status = 'container'
AND
NOT EXISTS(
SELECT 1 FROM ipam_prefix p WHERE
p.prefix >> start_address
AND
p.prefix >> end_address
AND
p.vrf_id = vrf_id
)
)
)
;
RETURN NEW;
"""
+2 -2
View File
@@ -687,13 +687,13 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html'
tab = ViewTab(
label=_('IP Addresses'),
badge=lambda x: x.get_child_ips().count(),
badge=lambda x: x.ip_addresses.count(),
permission='ipam.view_ipaddress',
weight=700
)
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
return parent.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
def prep_table_data(self, request, queryset, parent):
if not request.GET.get('q') and not get_table_ordering(request, self.table):
+1
View File
@@ -463,6 +463,7 @@ INSTALLED_APPS = [
'sorl.thumbnail',
'taggit',
'timezone_field',
'pgtrigger',
'core',
'account',
'circuits',
+4
View File
@@ -14,6 +14,10 @@
<th scope="row">{% trans "Family" %}</th>
<td>IPv{{ object.family }}</td>
</tr>
<tr>
<th scope="row">{% trans "Prefix" %}</th>
<td>{{ object.prefix|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "VRF" %}</th>
<td>
@@ -14,6 +14,7 @@
<div class="row">
<h2 class="col-9 offset-3">{% trans "IP Addresses" %}</h2>
</div>
{% render_field model_form.prefix %}
{% render_field form.pattern %}
{% render_field model_form.status %}
{% render_field model_form.role %}
+4
View File
@@ -13,6 +13,10 @@
<th scope="row">{% trans "Family" %}</th>
<td>IPv{{ object.family }}</td>
</tr>
<tr>
<th scope="row">{% trans "Prefix" %}</th>
<td>{{ object.prefix|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Starting Address" %}</th>
<td>{{ object.start_address }}</td>
+1 -1
View File
@@ -109,7 +109,7 @@
{% endif %}
</td>
</tr>
{% with child_ip_count=object.get_child_ips.count %}
{% with child_ip_count=object.ip_addresses.count %}
<tr>
<th scope="row">{% trans "Child IPs" %}</th>
<td>
+20
View File
@@ -280,6 +280,26 @@ def as_range(n):
return range(n)
@register.filter()
def parent_depth(n, parent=None):
"""
Return the depth of a node based on the parent's depth
"""
parent_depth = 0
if parent and hasattr(parent, 'depth_count'):
parent_depth = parent.depth_count + 1
elif parent and hasattr(parent, 'depth'):
try:
parent_depth = int(parent.depth) + 1
except TypeError:
pass
try:
depth = int(n) - int(parent_depth)
except TypeError:
return n
return depth
@register.filter()
def meters_to_feet(n):
"""
+1
View File
@@ -7,6 +7,7 @@ django-graphiql-debug-toolbar==0.2.0
django-htmx==1.27.0
django-mptt==0.17.0
django-pglocks==1.0.4
django-pgtrigger==4.15.4
django-prometheus==2.4.1
django-redis==6.0.0
django-rich==2.2.0
+1
View File
@@ -11,6 +11,7 @@ preview = true
[lint.per-file-ignores]
"template_code.py" = ["E501"]
"*/migrations/*.py" = ["E501"]
[format]
quote-style = "single"