Compare commits

..

13 Commits

Author SHA1 Message Date
Arthur
17d413cf29 review feedback 2026-03-06 11:03:09 -08:00
Arthur
c78972d4e9 Merge branch 'feature' into 17654-asn 2026-03-05 07:51:50 -08:00
Arthur
37dab713cb doc field 2026-03-04 15:31:47 -08:00
Arthur
9ca0424428 update 2026-03-04 15:14:46 -08:00
Arthur
25ab954992 update tests 2026-03-04 14:58:55 -08:00
Arthur
4b67f22c80 add to tests 2026-03-04 14:44:52 -08:00
Arthur
0072403677 fix 2026-03-04 14:18:23 -08:00
Arthur
37ce01a89f fix ruff issues 2026-03-04 11:21:29 -08:00
Arthur
28468c32c2 #17654 Add role to ASN 2026-03-04 11:20:56 -08:00
bctiemann
6eafffb497 Closes: #21304 - Add stronger deprecation warning on use of housekeeping management command (#21483)
* 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
Jeremy Stretch
1a404f5c0f Merge branch 'main' into feature 2026-02-25 17:07:26 -05:00
bctiemann
3320e07b70 Closes #21284: Add deprecation note to webhooks documentation (#21491)
* 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
51 changed files with 1099 additions and 795 deletions

View File

@@ -3,14 +3,20 @@ name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Only run for PRs submitted by organization members or owners
if: |
github.repository == 'netbox-community/netbox' &&
(github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER')
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
@@ -27,7 +33,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
@@ -35,3 +41,4 @@ jobs:
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

View File

@@ -84,8 +84,6 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
* Community members are limited to a maximum of **three open PRs** at any time. This is to avoid the accumulation of too much parallel work and maintain focus on already PRs under review. If you already have three NetBox PRs open, please wait for at least one of them to be merged (or closed) before opening another.
* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
@@ -98,10 +96,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
greater than 80 characters in length
> [!CAUTION]
> Any contributions which include solely AI-generated or reproduced content will be rejected. All PRs must be submitted by a human.
> Any contributions which include AI-generated or reproduced content will be rejected.
* Some other tips to keep in mind:
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
* All new functionality must include relevant tests where applicable.

View File

@@ -23,14 +23,19 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
The following data is available as context for Jinja2 templates:
* `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
* `model` - The NetBox model which triggered the change.
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
* `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
### Default Request Body
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
@@ -38,20 +43,18 @@ If no body template is specified, the request body will be populated with a JSON
```json
{
"event": "created",
"timestamp": "2026-03-06T15:11:23.503186+00:00",
"object_type": "dcim.site",
"timestamp": "2021-03-09 17:55:33.968016+00:00",
"model": "site",
"username": "jstretch",
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
"data": {
"id": 4,
"url": "/api/dcim/sites/4/",
"display_url": "/dcim/sites/4/",
"display": "Site 1",
"id": 19,
"name": "Site 1",
"slug": "site-1",
"status": {
"status":
"value": "active",
"label": "Active"
"label": "Active",
"id": 1
},
"region": null,
...
@@ -59,10 +62,8 @@ If no body template is specified, the request body will be populated with a JSON
"snapshots": {
"prechange": null,
"postchange": {
"created": "2026-03-06T15:11:23.484Z",
"owner": null,
"description": "",
"comments": "",
"created": "2021-03-09",
"last_updated": "2021-03-09T17:55:33.851Z",
"name": "Site 1",
"slug": "site-1",
"status": "active",

View File

@@ -77,14 +77,19 @@ The file path to a particular certificate authority (CA) file to use when valida
## Context Data
The following context variables are available to the text and link templates.
The following context variables are available in to the text and link templates.
| Variable | Description |
|---------------|------------------------------------------------------|
| `event` | The event type (`created`, `updated`, or `deleted`) |
| `timestamp` | The time at which the event occurred |
| `object_type` | The type of object impacted (`app_label.model_name`) |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
| Variable | Description |
|--------------|----------------------------------------------------|
| `event` | The event type (`create`, `update`, or `delete`) |
| `timestamp` | The time at which the event occured |
| `model` | The type of object impacted |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.

View File

@@ -14,6 +14,10 @@ The 16- or 32-bit AS number.
The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of this particular ASN.
### Role
The user-defined functional [role](./role.md) assigned to this ASN.
### Sites
The [site(s)](../dcim/site.md) to which this ASN is assigned.

View File

@@ -43,6 +43,11 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy fields"
The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
!!! note "Consider namespacing webhook data"
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:

View File

@@ -306,9 +306,12 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value):
# Extend `search()` to include querying on Location.facility
# extended in order to include querying on Location.facility
queryset = super().search(queryset, name, value)
if value.strip():
return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
return queryset

View File

@@ -1529,11 +1529,8 @@ class CableImportForm(PrimaryModelImportForm):
model = content_type.model_class()
try:
if (
device.virtual_chassis and
device.virtual_chassis.master == device and
not model.objects.filter(device=device, name=name).exists()
):
if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)

View File

@@ -267,32 +267,32 @@ class DeviceFilter(
longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -383,36 +383,36 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
inventoryitemtemplates: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='inventory_item_templates')
)
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@@ -696,32 +696,32 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
)
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_ports')
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_server_ports')
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlets')
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_ports')
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_ports')
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_ports')
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bays')
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bays')
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -765,33 +765,36 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)
consoleserverporttemplates: (
console_port_templates: (
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
console_server_port_templates: (
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field(name='console_server_port_templates')
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_port_templates')
)
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='power_outlet_templates')
)
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='interface_templates')
)
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='front_port_templates')
)
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='rear_port_templates')
)
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='device_bay_templates')
)
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='module_bay_templates')
)
) = strawberry_django.filter_field()
power_port_templates: (
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
power_outlet_templates: (
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
interface_templates: (
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
front_port_templates: (
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
rear_port_templates: (
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
device_bay_templates: (
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_bay_templates: (
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
inventory_item_templates: (
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
) = strawberry_django.filter_field()
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()

View File

@@ -137,12 +137,6 @@ class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
class DeviceRolePanel(panels.NestedGroupObjectPanel):
color = attrs.ColorAttr('color')
vm_role = attrs.BooleanAttr('vm_role', label=_('VM role'))
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
class DeviceTypePanel(panels.ObjectAttributesPanel):
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
model = attrs.TextAttr('model')
@@ -159,36 +153,11 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
rear_image = attrs.ImageAttr('rear_image')
class ModulePanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
device_type = attrs.RelatedObjectAttr('device.device_type', linkify=True, grouped_by='manufacturer')
module_bay = attrs.NestedObjectAttr('module_bay', linkify=True)
status = attrs.ChoiceAttr('status')
description = attrs.TextAttr('description')
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
class ModuleTypePanel(panels.ObjectAttributesPanel):
profile = attrs.RelatedObjectAttr('profile', linkify=True)
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
model = attrs.TextAttr('model', label=_('Model name'))
part_number = attrs.TextAttr('part_number')
description = attrs.TextAttr('description')
airflow = attrs.ChoiceAttr('airflow')
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
class PlatformPanel(panels.NestedGroupObjectPanel):
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
class VirtualChassisMembersPanel(panels.ObjectPanel):
"""
A panel which lists all members of a virtual chassis.

View File

@@ -16,7 +16,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
from ipam.tables import VLANTranslationRuleTable
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import *
from netbox.ui import actions, layout
from netbox.ui.panels import (
@@ -25,7 +25,6 @@ from netbox.ui.panels import (
NestedGroupObjectPanel,
ObjectsTablePanel,
OrganizationalObjectPanel,
Panel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -389,7 +388,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
@@ -1668,22 +1667,6 @@ class ModuleTypeListView(generic.ObjectListView):
@register_model_view(ModuleType)
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ModuleType.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ModuleTypePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
Panel(
title=_('Attributes'),
template_name='dcim/panels/module_type_attributes.html',
),
RelatedObjectsPanel(),
CustomFieldsPanel(),
ImageAttachmentsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -2323,27 +2306,6 @@ class DeviceRoleListView(generic.ObjectListView):
@register_model_view(DeviceRole)
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceRole.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.DeviceRolePanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.DeviceRole',
title=_('Child Device Roles'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.DeviceRole', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
)
def get_extra_context(self, request, instance):
return {
@@ -2423,27 +2385,6 @@ class PlatformListView(generic.ObjectListView):
@register_model_view(Platform)
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Platform.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.PlatformPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.Platform',
title=_('Child Platforms'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.Platform', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
]
)
def get_extra_context(self, request, instance):
return {
@@ -2837,21 +2778,6 @@ class ModuleListView(generic.ObjectListView):
@register_model_view(Module)
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Module.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.ModulePanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
Panel(
title=_('Module Type'),
template_name='dcim/panels/module_type.html',
),
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -3230,6 +3156,21 @@ class InterfaceView(generic.ObjectView):
)
lag_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=instance,
data=vlans,
orderable=False
)
vlan_table.configure(request)
# Get VLAN translation rules
vlan_translation_table = None
if instance.vlan_translation_policy:
@@ -3245,6 +3186,7 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}

View File

@@ -1,3 +1,4 @@
import warnings
from datetime import timedelta
from importlib import import_module
@@ -17,11 +18,12 @@ class Command(BaseCommand):
help = "Perform nightly housekeeping tasks [DEPRECATED]"
def handle(self, *args, **options):
self.stdout.write(
warnings.warn(
"\n\nDEPRECATION WARNING\n"
"Running this command is no longer necessary: All housekeeping tasks\n"
"are addressed automatically via NetBox's built-in job scheduler. It\n"
"will be removed in a future release.",
self.style.WARNING
"will be removed in a future release.\n",
category=FutureWarning,
)
config = Config()

View File

@@ -81,7 +81,7 @@ class Command(BaseCommand):
logger.error(f'\t{field}: {error.get("message")}')
raise CommandError()
# Remove extra fields from ScriptForm before passing data to script
# Remove extra fields from ScriptForm before passng data to script
form.cleaned_data.pop('_schedule_at')
form.cleaned_data.pop('_interval')
form.cleaned_data.pop('_commit')
@@ -94,12 +94,10 @@ class Command(BaseCommand):
data=form.cleaned_data,
request=NetBoxFakeRequest({
'META': {},
'COOKIES': {},
'POST': data,
'GET': {},
'FILES': {},
'user': user,
'method': 'POST',
'path': '',
'id': uuid.uuid4()
}),

View File

@@ -6,6 +6,8 @@ from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from .roles import RoleSerializer
__all__ = (
'ASNRangeSerializer',
'ASNSerializer',
@@ -56,6 +58,7 @@ class ASNSiteSerializer(PrimaryModelSerializer):
class ASNSerializer(PrimaryModelSerializer):
rir = RIRSerializer(nested=True, required=False, allow_null=True)
role = RoleSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
@@ -72,8 +75,8 @@ class ASNSerializer(PrimaryModelSerializer):
class Meta:
model = ASN
fields = [
'id', 'url', 'display_url', 'display', 'asn', 'rir', 'tenant', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count', 'sites',
'id', 'url', 'display_url', 'display', 'asn', 'rir', 'role', 'tenant', 'description', 'owner', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count', 'sites',
]
brief_fields = ('id', 'url', 'display', 'asn', 'description')

View File

@@ -12,11 +12,13 @@ class RoleSerializer(OrganizationalModelSerializer):
# Related object counts
prefix_count = RelatedObjectCountField('prefixes')
vlan_count = RelatedObjectCountField('vlans')
asn_count = RelatedObjectCountField('asns')
class Meta:
model = Role
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'weight', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'prefix_count', 'vlan_count',
'custom_fields', 'created', 'last_updated', 'prefix_count', 'vlan_count', 'asn_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count',
'asn_count')

View File

@@ -289,6 +289,18 @@ class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label=_('Provider (slug)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(),
distinct=False,
label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=Role.objects.all(),
distinct=False,
to_field_name='slug',
label=_('Role (slug)'),
)
class Meta:
model = ASN

View File

@@ -121,6 +121,11 @@ class ASNBulkEditForm(PrimaryModelBulkEditForm):
required=False,
label=_('RIR')
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
required=False,
label=_('Role')
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@@ -129,9 +134,9 @@ class ASNBulkEditForm(PrimaryModelBulkEditForm):
model = ASN
fieldsets = (
FieldSet('sites', 'rir', 'tenant', 'description'),
FieldSet('sites', 'rir', 'role', 'tenant', 'description'),
)
nullable_fields = ('tenant', 'description', 'comments')
nullable_fields = ('role', 'tenant', 'description', 'comments')
class AggregateBulkEditForm(PrimaryModelBulkEditForm):

View File

@@ -138,6 +138,13 @@ class ASNImportForm(PrimaryModelImportForm):
to_field_name='name',
help_text=_('Assigned RIR')
)
role = CSVModelChoiceField(
label=_('Role'),
queryset=Role.objects.all(),
required=False,
to_field_name='name',
help_text=_('Functional role')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@@ -148,7 +155,7 @@ class ASNImportForm(PrimaryModelImportForm):
class Meta:
model = ASN
fields = ('asn', 'rir', 'tenant', 'description', 'owner', 'comments', 'tags')
fields = ('asn', 'rir', 'role', 'tenant', 'description', 'owner', 'comments', 'tags')
class RoleImportForm(OrganizationalModelImportForm):

View File

@@ -151,7 +151,7 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = ASN
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
FieldSet('rir_id', 'role_id', 'site_group_id', 'site_id', name=_('Assignment')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
@@ -160,6 +160,11 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
required=False,
label=_('RIR')
)
role_id = DynamicModelMultipleChoiceField(
queryset=Role.objects.all(),
required=False,
label=_('Role')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,

View File

@@ -152,6 +152,12 @@ class ASNForm(TenancyForm, PrimaryModelForm):
label=_('RIR'),
quick_add=True
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
label=_('Role'),
required=False,
quick_add=True
)
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
label=_('Sites'),
@@ -159,14 +165,14 @@ class ASNForm(TenancyForm, PrimaryModelForm):
)
fieldsets = (
FieldSet('asn', 'rir', 'sites', 'description', 'tags', name=_('ASN')),
FieldSet('asn', 'rir', 'role', 'sites', 'description', 'tags', name=_('ASN')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class Meta:
model = ASN
fields = [
'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags'
'asn', 'rir', 'role', 'sites', 'tenant_group', 'tenant', 'description', 'owner', 'comments', 'tags'
]
widgets = {
'date_added': DatePicker(),

View File

@@ -57,6 +57,8 @@ __all__ = (
class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
role_id: ID | None = strawberry_django.filter_field()
asn: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)

View File

@@ -77,6 +77,7 @@ class BaseIPAddressFamilyType:
class ASNType(ContactsMixin, PrimaryObjectType):
asn: BigInt
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
sites: list[SiteType]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.11 on 2026-03-04 19:14
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='asn',
name='role',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asns', to='ipam.role'
),
),
]

View File

@@ -137,6 +137,15 @@ class ASN(ContactsMixin, PrimaryModel):
verbose_name=_('ASN'),
help_text=_('16- or 32-bit autonomous system number')
)
role = models.ForeignKey(
to='ipam.Role',
on_delete=models.SET_NULL,
related_name='asns',
blank=True,
null=True,
verbose_name=_('role'),
help_text=_("The primary function of this ASN")
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,

View File

@@ -23,7 +23,7 @@ class ASNIndex(SearchIndex):
('prefixed_name', 110),
('description', 500),
)
display_attrs = ('rir', 'tenant', 'description')
display_attrs = ('rir', 'role', 'tenant', 'description')
@register_search

View File

@@ -71,6 +71,10 @@ class ASNTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
url_params={'asn_id': 'pk'},
verbose_name=_('Provider Count')
)
role = tables.Column(
verbose_name=_('Role'),
linkify=True
)
sites = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Sites')
@@ -82,9 +86,9 @@ class ASNTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = ASN
fields = (
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description',
'contacts', 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions',
'pk', 'asn', 'asn_asdot', 'rir', 'role', 'site_count', 'provider_count', 'tenant', 'tenant_group',
'description', 'contacts', 'comments', 'sites', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = (
'pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant',
'pk', 'asn', 'rir', 'role', 'site_count', 'provider_count', 'sites', 'description', 'tenant',
)

View File

@@ -120,6 +120,11 @@ class RoleTable(OrganizationalModelTable):
url_params={'role_id': 'pk'},
verbose_name=_('VLANs')
)
asn_count = columns.LinkedCountColumn(
viewname='ipam:asn_list',
url_params={'role_id': 'pk'},
verbose_name=_('ASNs')
)
tags = columns.TagColumn(
url_name='ipam:role_list'
)
@@ -127,10 +132,10 @@ class RoleTable(OrganizationalModelTable):
class Meta(OrganizationalModelTable.Meta):
model = Role
fields = (
'pk', 'id', 'name', 'slug', 'prefix_count', 'iprange_count', 'vlan_count', 'description', 'weight',
'comments', 'tags', 'created', 'last_updated', 'actions',
'pk', 'id', 'name', 'slug', 'prefix_count', 'iprange_count', 'vlan_count', 'asn_count', 'description',
'weight', 'comments', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'prefix_count', 'iprange_count', 'vlan_count', 'description')
default_columns = ('pk', 'name', 'prefix_count', 'iprange_count', 'vlan_count', 'asn_count', 'description')
#

View File

@@ -1,17 +1,19 @@
import django_tables2 as tables
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Interface
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
from ipam.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin
from tenancy.tables import TenancyColumnsMixin, TenantColumn
from virtualization.models import VMInterface
from .template_code import *
__all__ = (
'InterfaceVLANTable',
'VLANDevicesTable',
'VLANGroupTable',
'VLANMembersTable',
@@ -196,6 +198,47 @@ class VLANVirtualMachinesTable(VLANMembersTable):
exclude = ('id', )
class InterfaceVLANTable(NetBoxTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name=_('VID')
)
tagged = columns.BooleanColumn(
verbose_name=_('Tagged'),
false_mark=None
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name=_('Group')
)
tenant = TenantColumn(
verbose_name=_('Tenant'),
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
role = tables.Column(
verbose_name=_('Role'),
linkify=True
)
class Meta(NetBoxTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
exclude = ('id', )
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)
#
# VLAN Translation
#

View File

@@ -151,6 +151,12 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
)
RIR.objects.bulk_create(rirs)
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
)
Role.objects.bulk_create(roles)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2')
@@ -164,10 +170,10 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
Tenant.objects.bulk_create(tenants)
asns = (
ASN(asn=65000, rir=rirs[0], tenant=tenants[0]),
ASN(asn=65001, rir=rirs[0], tenant=tenants[1]),
ASN(asn=4200000000, rir=rirs[1], tenant=tenants[0]),
ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]),
ASN(asn=65000, rir=rirs[0], role=roles[0], tenant=tenants[0]),
ASN(asn=65001, rir=rirs[0], role=roles[0], tenant=tenants[1]),
ASN(asn=4200000000, rir=rirs[1], role=roles[1], tenant=tenants[0]),
ASN(asn=4200000001, rir=rirs[1], role=roles[1], tenant=tenants[1]),
)
ASN.objects.bulk_create(asns)
@@ -180,10 +186,12 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
{
'asn': 64512,
'rir': rirs[0].pk,
'role': roles[0].pk,
},
{
'asn': 65002,
'rir': rirs[0].pk,
'role': roles[1].pk,
},
{
'asn': 4200000002,
@@ -375,7 +383,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase):
class RoleTest(APIViewTestCases.APIViewTestCase):
model = Role
brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
brief_fields = ['asn_count', 'description', 'display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
create_data = [
{
'name': 'Role 4',
@@ -404,6 +412,17 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
)
Role.objects.bulk_create(roles)
rirs = (
RIR(name='RIR 1', slug='rir-1', is_private=True),
)
RIR.objects.bulk_create(rirs)
asns = (
ASN(asn=65000, rir=rirs[0], role=roles[0]),
ASN(asn=65001, rir=rirs[0], role=roles[0]),
)
ASN.objects.bulk_create(asns)
class PrefixTest(APIViewTestCases.APIViewTestCase):
model = Prefix

View File

@@ -114,6 +114,13 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
]
RIR.objects.bulk_create(rirs)
roles = [
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
Role(name='Role 3', slug='role-3'),
]
Role.objects.bulk_create(roles)
tenants = [
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
@@ -124,12 +131,12 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
asns = (
ASN(asn=65001, rir=rirs[0], tenant=tenants[0], description='foobar1'),
ASN(asn=65002, rir=rirs[1], tenant=tenants[1], description='foobar2'),
ASN(asn=65003, rir=rirs[2], tenant=tenants[2], description='foobar3'),
ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]),
ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]),
ASN(asn=4200000002, rir=rirs[2], tenant=tenants[2]),
ASN(asn=65001, rir=rirs[0], role=roles[0], tenant=tenants[0], description='foobar1'),
ASN(asn=65002, rir=rirs[1], role=roles[1], tenant=tenants[1], description='foobar2'),
ASN(asn=65003, rir=rirs[2], role=roles[2], tenant=tenants[2], description='foobar3'),
ASN(asn=4200000000, rir=rirs[0], role=roles[0], tenant=tenants[0]),
ASN(asn=4200000001, rir=rirs[1], role=roles[1], tenant=tenants[1]),
ASN(asn=4200000002, rir=rirs[2], role=roles[2], tenant=tenants[2]),
)
ASN.objects.bulk_create(asns)
@@ -186,6 +193,13 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rir': [rirs[0].slug, rirs[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_role(self):
roles = Role.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'role': [roles[0].slug, roles[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}

View File

@@ -84,6 +84,12 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
]
RIR.objects.bulk_create(rirs)
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
)
Role.objects.bulk_create(roles)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2')
@@ -97,10 +103,10 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Tenant.objects.bulk_create(tenants)
asns = (
ASN(asn=65001, rir=rirs[0], tenant=tenants[0]),
ASN(asn=65002, rir=rirs[1], tenant=tenants[1]),
ASN(asn=4200000001, rir=rirs[0], tenant=tenants[0]),
ASN(asn=4200000002, rir=rirs[1], tenant=tenants[1]),
ASN(asn=65001, rir=rirs[0], role=roles[0], tenant=tenants[0]),
ASN(asn=65002, rir=rirs[1], role=roles[1], tenant=tenants[1]),
ASN(asn=4200000001, rir=rirs[0], role=roles[0], tenant=tenants[0]),
ASN(asn=4200000002, rir=rirs[1], role=roles[1], tenant=tenants[1]),
)
ASN.objects.bulk_create(asns)
@@ -114,6 +120,7 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'asn': 65000,
'rir': rirs[0].pk,
'role': roles[0].pk,
'tenant': tenants[0].pk,
'site': sites[0].pk,
'description': 'A new ASN',
@@ -121,11 +128,11 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"asn,rir",
"65003,RIR 1",
"65004,RIR 2",
"4200000003,RIR 1",
"4200000004,RIR 2",
"asn,rir,role",
f"65003,RIR 1,{roles[0].name}",
f"65004,RIR 2,{roles[1].name}",
f"4200000003,RIR 1,{roles[0].name}",
f"4200000004,RIR 2,{roles[1].name}",
)
cls.csv_update_data = (
@@ -137,6 +144,7 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'rir': rirs[1].pk,
'role': roles[1].pk,
'description': 'Next description',
}

View File

@@ -496,7 +496,8 @@ class RoleListView(generic.ObjectListView):
queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'),
iprange_count=count_related(IPRange, 'role'),
vlan_count=count_related(VLAN, 'role')
vlan_count=count_related(VLAN, 'role'),
asn_count=count_related(ASN, 'role')
)
filterset = filtersets.RoleFilterSet
filterset_form = forms.RoleFilterForm

View File

@@ -79,9 +79,6 @@ class IntegerLookup:
if not filters:
return queryset, Q()
if isinstance(filters, RangeLookup):
prefix = f'{prefix}range__'
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
@@ -105,9 +102,6 @@ class BigIntegerLookup:
if not filters:
return queryset, Q()
if isinstance(filters, RangeLookup):
prefix = f'{prefix}range__'
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
@@ -131,9 +125,6 @@ class FloatLookup:
if not filters:
return queryset, Q()
if isinstance(filters, RangeLookup):
prefix = f'{prefix}range__'
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)

View File

@@ -40,24 +40,15 @@ class CoreMiddleware:
with apply_request_processors(request):
response = self.get_response(request)
# Set or renew the language cookie based on the user's preference. This handles two cases:
# 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
# the request so we set the cookie here on the login response.
# 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
# the session expiry.
if hasattr(request, '_language_cookie'):
language = request._language_cookie
elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
language = request.user.config.get('locale.language')
else:
language = None
if language:
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Check if language cookie should be renewed
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
if language := request.user.config.get('locale.language'):
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from rest_framework import status
from dcim.choices import LocationStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
from dcim.models import Location, Site
from utilities.testing import APITestCase, TestCase, disable_warnings
@@ -138,40 +138,6 @@ class GraphQLAPITestCase(APITestCase):
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site']['locations']), 0)
def test_graphql_integer_range_lookup(self):
"""
Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.
"""
self.add_permissions('dcim.view_device')
url = reverse('graphql')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
device_role = DeviceRole.objects.create(name='Test Role', slug='test-role')
site = Site.objects.first()
vc = VirtualChassis.objects.create(name='Test VC')
devices = [
Device(name=f'Device {i}', device_type=device_type, role=device_role, site=site,
virtual_chassis=vc, vc_position=i)
for i in range(1, 6)
]
Device.objects.bulk_create(devices)
# range_lookup should return devices with vc_position between 2 and 4 inclusive
query = """
{
device_list(filters: {vc_position: {range_lookup: {start: 2, end: 4}}}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['device_list']), 3)
def test_offset_pagination(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')

View File

@@ -44,18 +44,15 @@ class Panel:
Parameters:
title (str): The human-friendly title of the panel
actions (list): An iterable of PanelActions to include in the panel header
template_name (str): Overrides the default template name, if defined
"""
template_name = None
title = None
actions = None
def __init__(self, title=None, actions=None, template_name=None):
def __init__(self, title=None, actions=None):
if title is not None:
self.title = title
self.actions = actions or self.actions or []
if template_name is not None:
self.template_name = template_name
def get_context(self, context):
"""
@@ -320,8 +317,9 @@ class TemplatePanel(Panel):
Parameters:
template_name (str): The name of the template to render
"""
def __init__(self, template_name):
super().__init__(template_name=template_name)
def __init__(self, template_name, **kwargs):
super().__init__(**kwargs)
self.template_name = template_name
def render(self, context):
# Pass the entire context to the template

View File

@@ -28,7 +28,7 @@
</div>
</div>
<div class="card table-responsive">
<div class="card">
{% render_table table %}
</div>
{% endblock content %}

View File

@@ -15,3 +15,67 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Device Role" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Color" %}</th>
<td>
<span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
</td>
</tr>
<tr>
<th scope="row">{% trans "VM Role" %}</th>
<td>{% checkmark object.vm_role %}</td>
</tr>
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Child Device Roles" %}
{% if perms.dcim.add_devicerole %}
<div class="card-actions">
<a href="{% url 'dcim:devicerole_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device Role" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'dcim:devicerole_list' parent_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -86,11 +86,6 @@
<th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
<td>{{ object.qinq_svlan|linkify|placeholder }}</td>
</tr>
{% elif object.mode %}
<tr>
<th scope="row">{% trans "Untagged VLAN" %}</th>
<td>{{ object.untagged_vlan|linkify|placeholder }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Transmit power (dBm)" %}</th>
@@ -416,10 +411,7 @@
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "VLANs" %}</h2>
{% htmx_table 'ipam:vlan_list' interface_id=object.pk %}
</div>
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
</div>
</div>
{% if object.is_lag %}

View File

@@ -46,3 +46,75 @@
</div>
{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Module" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device Type" %}</th>
<td>{{ object.device.device_type|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Module Bay" %}</th>
<td>{% nested_tree object.module_bay %}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.module_type.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Model" %}</th>
<td>{{ object.module_type|linkify }}</td>
</tr>
{% for k, v in object.module_type.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>{{ v|placeholder }}</td>
</tr>
{% endfor %}
</table>
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,4 +1,7 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
@@ -11,5 +14,92 @@
{% endblock %}
{% block extra_controls %}
{% include 'dcim/inc/moduletype_buttons.html' %}
{% include 'dcim/inc/moduletype_buttons.html' %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Profile" %}</th>
<td>{{ object.profile|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Model Name" %}</th>
<td>{{ object.model }}</td>
</tr>
<tr>
<th scope="row">{% trans "Part Number" %}</th>
<td>{{ object.part_number|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Attributes" %}</h2>
{% if not object.profile %}
<div class="card-body text-muted">
{% trans "No profile assigned" %}
</div>
{% elif object.attributes %}
<table class="table table-hover attr-table">
{% for k, v in object.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>
{% if v is True or v is False %}
{% checkmark v %}
{% else %}
{{ v|placeholder }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="card-body text-muted">
{% trans "None" %}
</div>
{% endif %}
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -1,27 +0,0 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.module_type.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Model" %}</th>
<td>{{ object.module_type|linkify }}</td>
</tr>
{% for k, v in object.module_type.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>
{% if v is True or v is False %}
{% checkmark v %}
{% else %}
{{ v|placeholder }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endblock panel_content %}

View File

@@ -1,29 +0,0 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
{% if not object.profile %}
<div class="card-body text-muted">
{% trans "No profile assigned" %}
</div>
{% elif object.attributes %}
<table class="table table-hover attr-table">
{% for k, v in object.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>
{% if v is True or v is False %}
{% checkmark v %}
{% else %}
{{ v|placeholder }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="card-body text-muted">
{% trans "None" %}
</div>
{% endif %}
{% endblock panel_content %}

View File

@@ -18,3 +18,61 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Platform" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Child Platforms" %}
{% if perms.dcim.add_platform %}
<div class="card-actions">
<a href="{% url 'dcim:platform_add' %}?parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Platform" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'dcim:platform_list' parent_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -92,7 +92,7 @@ Context:
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" id="object-list-return-url" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{# Warn of any missing prerequisite objects #}
{% if prerequisite_model %}

View File

@@ -32,9 +32,4 @@
{% action_buttons actions model multi=True %}
</div>
{% endif %}
{# Update the return_url to reflect any changed query parameters (e.g. per_page) #}
{% if not table.embedded %}
<input type="hidden" id="object-list-return-url" name="return_url" value="{{ request.get_full_path }}" hx-swap-oob="outerHTML:#object-list-return-url" />
{% endif %}
{% endif %}

View File

@@ -29,6 +29,16 @@
<a href="{% url 'ipam:asn_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>
{% if object.role %}
<a href="{% url 'ipam:asn_list' %}?role={{ object.role.slug }}">{{ object.role }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import logging
from django.contrib.auth.signals import user_logged_in, user_login_failed
from django.contrib.auth.signals import user_login_failed
from django.db.models.signals import post_save
from django.dispatch import receiver
@@ -23,18 +23,6 @@ def log_user_login_failed(sender, credentials, request, **kwargs):
logger.info(f"Failed login attempt for username: {username}")
@receiver(user_logged_in)
def set_language_on_login(sender, user, request, **kwargs):
"""
Store the user's preferred language on the request so that middleware can set the language cookie. This ensures the
language preference is applied even when logging in via an external auth provider (e.g. social-app-django) that
does not go through NetBox's LoginView.
"""
if hasattr(user, 'config'):
if language := user.config.get('locale.language'):
request._language_cookie = language
@receiver(post_save, sender=User)
def create_userconfig(instance, created, raw=False, **kwargs):
"""

View File

@@ -13,7 +13,7 @@ from dcim.tables import DeviceTable
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import IPAddress, VLANGroup
from ipam.tables import VLANTranslationRuleTable
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from ipam.ui.panels import FHRPGroupAssignmentsPanel
from netbox.object_actions import (
AddObject,
@@ -594,11 +594,7 @@ class VMInterfaceView(generic.ObjectView):
),
],
),
ObjectsTablePanel(
model='ipam.VLAN',
title=_('Assigned VLANs'),
filters={'vminterface_id': lambda ctx: ctx['object'].pk},
),
ContextTablePanel('vlan_table', title=_('Assigned VLANs')),
ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
],
@@ -624,8 +620,24 @@ class VMInterfaceView(generic.ObjectView):
)
vlan_translation_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
vlans.append(instance.untagged_vlan)
vlans[0].tagged = False
for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=instance,
data=vlans,
orderable=False
)
vlan_table.configure(request)
return {
'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}

View File

@@ -126,8 +126,8 @@ class L2VPNTermination(NetBoxModel):
if self.assigned_object:
obj_id = self.assigned_object.pk
obj_type = ObjectType.objects.get_for_model(self.assigned_object)
terminations = L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type)
if terminations.exclude(pk=self.pk).exists():
if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
exclude(pk=self.pk).count() > 0:
raise ValidationError(
_('L2VPN Termination already assigned ({assigned_object})').format(
assigned_object=self.assigned_object