Compare commits

..

11 Commits

Author SHA1 Message Date
Arthur
76caae12fa 19724 change from old to V1
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
2025-10-22 08:57:36 -07:00
Arthur
26c91f01c6 19724 update docs
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
2025-10-21 10:27:10 -07:00
Arthur
af55da008b 19724 add the v2 to graphql testing 2025-10-21 10:16:16 -07:00
Arthur
810d1c2418 19724 add the v2 to graphql testing 2025-10-21 10:01:00 -07:00
Arthur
91b2d61ea4 19724 Use v2 API for new pagination queries
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
2025-10-20 16:52:46 -07:00
Arthur
b7b7b00885 Merge branch 'feature' into 19724-graphql 2025-10-20 16:29:32 -07:00
Arthur
595b343cd0 19724 add doc note 2025-10-14 14:38:16 -07:00
Arthur
730aee9b26 19724 fix doc query 2025-10-14 14:15:16 -07:00
Arthur
8aa1e2802b 19724 fix tests 2025-10-14 14:06:15 -07:00
Arthur
c2d19119cb 19724 update documentation 2025-10-14 13:54:58 -07:00
Arthur
0c4d0fa2e8 19724 pagingate graphql queries 2025-10-14 13:46:30 -07:00
550 changed files with 134748 additions and 74471 deletions

View File

@@ -2,7 +2,7 @@
name: ✨ Feature Request
type: Feature
description: Propose a new NetBox feature or enhancement
labels: ["netbox", "type: feature", "status: needs triage"]
labels: ["type: feature", "status: needs triage"]
body:
- type: markdown
attributes:
@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.4.8
placeholder: v4.4.4
validations:
required: true
- type: dropdown

View File

@@ -2,7 +2,7 @@
name: 🐛 Bug Report
type: Bug
description: Report a reproducible bug in the current release of NetBox
labels: ["netbox", "type: bug", "status: needs triage"]
labels: ["type: bug", "status: needs triage"]
body:
- type: markdown
attributes:
@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.4.8
placeholder: v4.4.4
validations:
required: true
- type: dropdown

View File

@@ -2,7 +2,7 @@
name: 📖 Documentation Change
type: Documentation
description: Suggest an addition or modification to the NetBox documentation
labels: ["netbox", "type: documentation", "status: needs triage"]
labels: ["type: documentation", "status: needs triage"]
body:
- type: dropdown
attributes:

View File

@@ -2,7 +2,7 @@
name: 🌍 Translation
type: Translation
description: Request support for a new language in the user interface
labels: ["netbox", "type: translation"]
labels: ["type: translation"]
body:
- type: markdown
attributes:

View File

@@ -2,7 +2,7 @@
name: 🏡 Housekeeping
type: Housekeeping
description: A change pertaining to the codebase itself (developers only)
labels: ["netbox", "type: housekeeping"]
labels: ["type: housekeeping"]
body:
- type: markdown
attributes:

View File

@@ -2,7 +2,7 @@
name: 🗑️ Deprecation
type: Deprecation
description: The removal of an existing feature or resource
labels: ["netbox", "type: deprecation"]
labels: ["type: deprecation"]
body:
- type: textarea
attributes:

View File

@@ -31,7 +31,7 @@ jobs:
NETBOX_CONFIGURATION: netbox.configuration_testing
strategy:
matrix:
python-version: ['3.12', '3.13', '3.14']
python-version: ['3.12', '3.13']
node-version: ['20.x']
services:
redis:

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.1
rev: v0.6.9
hooks:
- id: ruff
name: "Ruff linter"
@@ -21,6 +21,14 @@ repos:
language: system
pass_filenames: false
types: [python]
- id: openapi-check
name: "Validate OpenAPI schema"
description: "Check for any unexpected changes to the OpenAPI schema"
files: api/.*\.py$
entry: scripts/verify-openapi.sh
language: system
pass_filenames: false
types: [python]
- id: mkdocs-build
name: "Build documentation"
description: "Build the documentation with mkdocs"

View File

@@ -186,7 +186,6 @@
"usb-3-micro-b",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x3",
"molex-micro-fit-2x4",
"dc-terminal",
"saf-d-grid",
@@ -294,7 +293,6 @@
"usb-c",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x3",
"molex-micro-fit-2x4",
"dc-terminal",
"eaton-c39",

File diff suppressed because one or more lines are too long

View File

@@ -35,7 +35,6 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
* [`POWERFEED_DEFAULT_MAX_UTILIZATION`](./default-values.md#powerfeed_default_max_utilization)
* [`POWERFEED_DEFAULT_VOLTAGE`](./default-values.md#powerfeed_default_voltage)
* [`PREFER_IPV4`](./miscellaneous.md#prefer_ipv4)
* [`PROTECTION_RULES`](./data-validation.md#protection_rules)
* [`RACK_ELEVATION_DEFAULT_UNIT_HEIGHT`](./default-values.md#rack_elevation_default_unit_height)
* [`RACK_ELEVATION_DEFAULT_UNIT_WIDTH`](./default-values.md#rack_elevation_default_unit_width)

View File

@@ -53,16 +53,6 @@ Sets content for the top banner in the user interface.
---
## COPILOT_ENABLED
!!! tip "Dynamic Configuration Parameter"
Default: `True`
Enables or disables the [NetBox Copilot](https://netboxlabs.com/docs/copilot/) agent globally. When enabled, users can opt to toggle the agent individually.
---
## CENSUS_REPORTING_ENABLED
Default: `True`

View File

@@ -81,7 +81,7 @@ If `True`, the cookie employed for cross-site request forgery (CSRF) protection
Default: `[]`
Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://`).
Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://).
```python
CSRF_TRUSTED_ORIGINS = (

View File

@@ -232,9 +232,6 @@ STORAGES = {
},
"scripts": {
"BACKEND": "extras.storage.ScriptFileSystemStorage",
"OPTIONS": {
"allow_overwrite": True,
},
},
}
```
@@ -250,7 +247,6 @@ STORAGES = {
"OPTIONS": {
'access_key': 'access key',
'secret_key': 'secret key',
"allow_overwrite": True,
}
},
}

View File

@@ -95,7 +95,7 @@ An example fieldset definition is provided below:
```python
class MyScript(Script):
class Meta(Script.Meta):
class Meta:
fieldsets = (
('First group', ('field1', 'field2', 'field3')),
('Second group', ('field4', 'field5')),
@@ -393,61 +393,6 @@ A complete date & time. Returns a `datetime.datetime` object.
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
#### Prefilling variables via URL parameters
Script form fields can be prefilled by appending query parameters to the script URL. Each parameter name must match the variable name defined on the script class. Prefilled values are treated as initial values and can be edited before execution. Multiple values can be supplied by repeating the same parameter. Query values must be percentencoded where required (for example, spaces as `%20`).
Examples:
For string and integer variables, when a script defines:
```python
from extras.scripts import Script, StringVar, IntegerVar
class MyScript(Script):
name = StringVar()
count = IntegerVar()
```
the following URL prefills the `name` and `count` fields:
```
https://<netbox>/extras/scripts/<script_id>/?name=Branch42&count=3
```
For object variables (`ObjectVar`), supply the objects primary key (PK):
```
https://<netbox>/extras/scripts/<script_id>/?device=1
```
If an object ID cannot be resolved or the object is not visible to the requesting user, the field remains unpopulated.
Supported variable types:
| Variable class | Expected input | Example query string |
|--------------------------|---------------------------------|---------------------------------------------|
| `StringVar` | string (percentencoded) | `?name=Branch42` |
| `TextVar` | string (percentencoded) | `?notes=Initial%20value` |
| `IntegerVar` | integer | `?count=3` |
| `DecimalVar` | decimal number | `?ratio=0.75` |
| `BooleanVar` | value → `True`; empty → `False` | `?enabled=true` (True), `?enabled=` (False) |
| `ChoiceVar` | choice value (not label) | `?role=edge` |
| `MultiChoiceVar` | choice values (repeat) | `?roles=edge&roles=core` |
| `ObjectVar(Device)` | PK (integer) | `?device=1` |
| `MultiObjectVar(Device)` | PKs (repeat) | `?devices=1&devices=2` |
| `IPAddressVar` | IP address | `?ip=198.51.100.10` |
| `IPAddressWithMaskVar` | IP address with mask | `?addr=192.0.2.1/24` |
| `IPNetworkVar` | IP network prefix | `?network=2001:db8::/64` |
| `DateVar` | date `YYYY-MM-DD` | `?date=2025-01-05` |
| `DateTimeVar` | ISO datetime | `?when=2025-01-05T14:30:00` |
| `FileVar` | — (not supported) | — |
!!! note
- The parameter names above are examples; use the actual variable attribute names defined by the script.
- For `BooleanVar`, only an empty value (`?enabled=`) unchecks the box; any other value including `false` or `0` checks it.
- File uploads (`FileVar`) cannot be prefilled via URL parameters.
### Via the API
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
@@ -499,7 +444,7 @@ from extras.scripts import *
class NewBranchScript(Script):
class Meta(Script.Meta):
class Meta:
name = "New Branch"
description = "Provision a new branch site"
field_order = ['site_name', 'switch_count', 'switch_model']

View File

@@ -20,10 +20,6 @@ A dictionary mapping data backend types to their respective classes. These are u
Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates.
### `filtersets`
A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator.
### `model_features`
A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.

View File

@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
| Feature | Feature Mixin | Registry Key | Description |
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
| [Bookmarks](../features/user-preferences.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |

View File

@@ -6,14 +6,10 @@ For enduser guidance on resetting saved table layouts, see [Features > User P
## Available Preferences
| Name | Description |
|----------------------------|---------------------------------------------------------------|
| `csv_delimiter` | The delimiting character used when exporting CSV data |
| `data_format` | Preferred format when rendering raw data (JSON or YAML) |
| `locale.language` | The language selected for UI translation |
| `pagination.per_page` | The number of items to display per page of a paginated table |
| `pagination.placement` | Where to display the paginator controls relative to the table |
| `tables.${table}.columns` | The ordered list of columns to display when viewing the table |
| `tables.${table}.ordering` | A list of column names by which the table should be ordered |
| `ui.copilot_enabled` | Toggles the NetBox Copilot AI agent |
| `ui.tables.striping` | Toggles visual striping of tables in the UI |
| Name | Description |
|--------------------------|---------------------------------------------------------------|
| data_format | Preferred format when rendering raw data (JSON or YAML) |
| pagination.per_page | The number of items to display per page of a paginated table |
| pagination.placement | Where to display the paginator controls relative to the table |
| tables.${table}.columns | The ordered list of columns to display when viewing the table |
| tables.${table}.ordering | A list of column names by which the table should be ordered |

View File

@@ -90,10 +90,3 @@ http://netbox:8000/api/extras/config-templates/123/render/ \
"bar": 123
}'
```
!!! note "Permissions"
Rendering configuration templates via the REST API requires appropriate permissions for the relevant object type:
* To render a device's configuration via `/api/dcim/devices/{id}/render-config/`, assign a permission for "DCIM > Device" with the `render_config` action.
* To render a virtual machine's configuration via `/api/virtualization/virtual-machines/{id}/render-config/`, assign a permission for "Virtualization > Virtual Machine" with the `render_config` action.
* To render a config template directly via `/api/extras/config-templates/{id}/render/`, assign a permission for "Extras > Config Template" with the `render` action.

View File

@@ -1,10 +0,0 @@
# Resource Ownership
!!! info "This feature was introduced in NetBox v4.5."
Most objects in NetBox can be assigned an owner. An owner is a set of users and/or groups who are responsible for the administration of associated objects. For example, you might designate the operations team at a site as the owner for all prefixes and VLANs deployed at that site. The users and groups assigned to an owner are referred to as its members.
!!! note
Ownership of an object should not be confused with the concept of [tenancy](./tenancy.md), which indicates the dedication of an object to a specific tenant. For instance, a tenant might represent a customer served by the object, whereas an owner typically represents a set of internal users responsible for the management of the object.
Owners can be organized into groups for easier management.

View File

@@ -1,6 +1,6 @@
# Tenancy
Most core objects within NetBox's data model support _tenancy_. This is the association of an object with a particular tenant to convey assignment or dependency. For example, an enterprise might represent its internal business units as tenants, whereas a managed services provider might create a tenant in NetBox to represent each of its customers.
Most core objects within NetBox's data model support _tenancy_. This is the association of an object with a particular tenant to convey ownership or dependency. For example, an enterprise might represent its internal business units as tenants, whereas a managed services provider might create a tenant in NetBox to represent each of its customers.
```mermaid
flowchart TD
@@ -19,36 +19,20 @@ Tenants can be grouped by any logic that your use case demands, and groups can b
Typically, the tenant model is used to represent a customer or internal organization, however it can be used for whatever purpose meets your needs.
Most core objects within NetBox can be assigned to a particular tenant, so this model provides a very convenient way to correlate resource allocation across object types. For example, each of your customers might have its own racks, devices, IP addresses, circuits and so on: These can all be easily tracked via tenant assignment.
Most core objects within NetBox can be assigned to particular tenant, so this model provides a very convenient way to correlate ownership across object types. For example, each of your customers might have its own racks, devices, IP addresses, circuits and so on: These can all be easily tracked via tenant assignment.
The following objects can be assigned to tenants:
* Circuits
* Circuit groups
* Virtual circuits
* Cables
* Devices
* Virtual device contexts
* Power feeds
* Sites
* Racks
* Rack reservations
* Sites
* Locations
* ASNs
* ASN ranges
* Aggregates
* Devices
* VRFs
* Prefixes
* IP ranges
* IP addresses
* VLANs
* VLAN groups
* VRFs
* Route targets
* Circuits
* Clusters
* Virtual machines
* L2VPNs
* Tunnels
* Wireless LANs
* Wireless links
Tenancy represents the dedication of an object to a specific tenant. As such, each object may only be assigned to a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so the assignment of a tenant would not be appropriate.
Tenant assignment is used to signify the ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate.

View File

@@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
http://netbox/graphql/ \
--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {cid provider {name}}}"}'
--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {results {cid provider {name}}}}"}'
```
The response will include the requested data formatted as JSON:
@@ -36,6 +36,30 @@ The response will include the requested data formatted as JSON:
}
}
```
If using the GraphQL API v2 the format will be:
```json
{
"data": {
"circuit_list": {
"results": [
{
"cid": "1002840283",
"provider": {
"name": "CenturyLink"
}
},
{
"cid": "1002840457",
"provider": {
"name": "CenturyLink"
}
}
]
}
}
}
```
!!! note
It's recommended to pass the return data through a JSON parser such as `jq` for better readability.
@@ -47,12 +71,15 @@ NetBox provides both a singular and plural query field for each object type:
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
!!! note "Changed in NetBox v4.5"
If using the GraphQL API v2, List queries now return paginated results. The actual objects are contained within the `results` field of the response, along with `total_count` and `page_info` fields for pagination metadata. Prior to v4.5, list queries returned objects directly as an array.
For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry.rocks/docs/django/guide/filters).
## Filtering
!!! note "Changed in NetBox v4.3"
The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3.
The filtering syntax for the GraphQL API has changed substantially in NetBox v4.3.
Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites:
@@ -67,6 +94,21 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
site_list(
filters: {
status: STATUS_ACTIVE
}
) {
results {
name
}
}
}
```
Filters can be combined with logical operators, such as `OR` and `NOT`. For example, the following will return every site that is planned _or_ assigned to a tenant named Foo:
@@ -88,6 +130,28 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
site_list(
filters: {
status: STATUS_PLANNED,
OR: {
tenant: {
name: {
exact: "Foo"
}
}
}
}
) {
results {
name
}
}
}
```
Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device:
@@ -102,6 +166,21 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
device_list {
results {
id
name
interfaces(filters: {enabled: {exact: true}}) {
name
}
}
}
}
```
## Multiple Return Types
@@ -128,6 +207,31 @@ Certain queries can return multiple types of objects, for example cable terminat
}
}
```
If using the GraphQL API v2 the format will be:
```
{
cable_list {
results {
id
a_terminations {
... on CircuitTerminationType {
id
class_type
}
... on ConsolePortType {
id
class_type
}
... on ConsoleServerPortType {
id
class_type
}
}
}
}
}
```
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
@@ -142,6 +246,47 @@ query {
}
}
```
### Pagination in GraphQL API V2
All list queries return paginated results using the `OffsetPaginated` type, which includes:
- `results`: The list of objects matching the query
- `total_count`: The total number of objects matching the filters (without pagination)
- `page_info`: Pagination metadata including `offset` and `limit`
By default, queries return up to 100 results. You can control pagination by specifying the `pagination` parameter with `offset` and `limit` values:
```
query {
device_list(pagination: { offset: 0, limit: 20 }) {
total_count
page_info {
offset
limit
}
results {
id
name
}
}
}
```
If you don't need pagination metadata, you can simply query the `results`:
```
query {
device_list {
results {
id
name
}
}
}
```
!!! note
When not specifying the `pagination` parameter, avoid querying `page_info.limit` as it may return an undefined value. Either provide explicit pagination parameters or only query the `results` and `total_count` fields.
## Authentication

View File

@@ -21,21 +21,6 @@ The cable's operational status. Choices include:
* Planned
* Decommissioning
### Profile
!!! note "This field was introduced in NetBox v4.5."
The profile to which the cable conforms. The profile determines the mapping of termination between the two ends and enables logical tracing across complex connections, such as breakout cables. Supported profiles are listed below.
* Straight (single position)
* Straight (multi-position)
* Shuffle (2x2 MPO8)
* Shuffle (4x4 MPO8)
A single-position cable is allowed only one termination point at each end. There is no limit to the number of terminations a multi-position cable may have. Each end of a cable must have the same number of terminations, unless connected to a pass-through port or to a circuit termination.
The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved.
### Type
The cable's physical medium or classification.

View File

@@ -1,23 +0,0 @@
# Owner
An owner is a set of users and/or groups who are responsible for the administration of certain resources within NetBox. The users and groups assigned to an owner are referred to as its members. Owner assignments are useful for indicating which parties are responsible for the administration of a particular object.
Most objects within NetBox can be assigned an owner, although this is not required.
## Fields
### Name
The owner's name.
### Group
The [group](./ownergroup.md) to which the owner is assigned. The assignment of an owner to a group is optional.
### User Groups
Groups of users that are members of the owner.
### Users
Individual users that are members of the owner.

View File

@@ -1,9 +0,0 @@
# Owner Groups
Groups are used to correlate and organize [owners](./owner.md). The assignment of an owner to a group has no bearing on the relationship of owned objects to their owners.
## Fields
### Name
The name of the group.

View File

@@ -21,13 +21,6 @@ The VM's operational status.
!!! tip
Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Start on boot
The start on boot setting from the hypervisor.
!!! tip
Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Site & Cluster
The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned.

View File

@@ -60,13 +60,6 @@ Four of the standard Python logging levels are supported:
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
### Jobs running for Model instances
A Job can be executed for a specific instance of a Model.
To enable this functionality, the model must include the `JobsMixin`.
When enqueuing a Job, you can associate it with a particular instance by passing that instance to the `instance` parameter.
### Scheduled Jobs
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.
@@ -80,10 +73,9 @@ As described above, jobs can be scheduled for immediate execution or at any late
from django.db import models
from core.choices import JobIntervalChoices
from netbox.models import NetBoxModel
from netbox.models.features import JobsMixin
from .jobs import MyTestJob
class MyModel(JobsMixin, NetBoxModel):
class MyModel(NetBoxModel):
foo = models.CharField()
def save(self, *args, **kwargs):

View File

@@ -6,17 +6,12 @@ Filter sets define the mechanisms available for filtering or searching through a
To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below.
!!! info "New in NetBox v4.5: FilterSet Registration"
NetBox v4.5 introduced the `register_filterset()` utility function. This enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality.
```python
# filtersets.py
import django_filters
from netbox.filtersets import NetBoxModelFilterSet
from utilities.filtersets import register_filterset
from .models import MyModel
@register_filterset
class MyFilterSet(NetBoxModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=(
@@ -32,14 +27,6 @@ class MyFilterSet(NetBoxModelFilterSet):
fields = ('some', 'other', 'fields')
```
In addition to the base NetBoxModelFilterSet class, the following filterset classes are also available for subclasses of standard base models.
| Model Class | FilterSet Class |
|-----------------------|--------------------------------------------------|
| `PrimaryModel` | `netbox.filtersets.PrimaryModelFilterSet` |
| `OrganizationalModel` | `netbox.filtersets.OrganizationalModelFilterSet` |
| `NestedGroupModel` | `netbox.filtersets.NestedGroupModelFilterSet` |
### Declaring Filter Sets
To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class:
@@ -55,7 +42,7 @@ class MyModelListView(ObjectListView):
filterset = MyModelFilterSet
```
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
```python
# api/views.py
@@ -68,29 +55,6 @@ class MyModelViewSet(...):
filterset_class = filtersets.MyModelFilterSet
```
### Implementing Quick Search
The `ObjectListView` has a field called Quick Search. For Quick Search to work the corresponding FilterSet has to override the `search` method that is implemented in `NetBoxModelFilterSet`. This function takes a queryset and can perform arbitrary operations on it and return it. A common use-case is to search for the given search value in multiple fields:
```python
from django.db.models import Q
from netbox.filtersets import NetBoxModelFilterSet
from utilities.filtersets import register_filterset
@register_filterset
class MyFilterSet(NetBoxModelFilterSet):
...
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
```
The `search` method is also used by the `q` filter in `NetBoxModelFilterSet` which in turn is used by the Search field in the filters tab.
## Filter Classes
### TagFilter
@@ -105,9 +69,7 @@ This class filters `tags` using the `slug` field. For example:
```python
from django_filters import FilterSet
from extras.filters import TagFilter
from utilities.filtersets import register_filterset
@register_filterset
class MyModelFilterSet(FilterSet):
tag = TagFilter()
```
@@ -123,9 +85,7 @@ This class filters `tags` using the `id` field. For example:
```python
from django_filters import FilterSet
from extras.filters import TagIDFilter
from utilities.filtersets import register_filterset
@register_filterset
class MyModelFilterSet(FilterSet):
tag_id = TagIDFilter()
```

View File

@@ -2,7 +2,7 @@
## Form Classes
NetBox provides several base form classes for use by plugins. Additional form classes are also available for other standard base model classes (PrimaryModel, OrganizationalModel, and NestedGroupModel).
NetBox provides several base form classes for use by plugins.
| Form Class | Purpose |
|----------------------------|--------------------------------------|
@@ -19,17 +19,7 @@ This is the base form for creating and editing NetBox models. It extends Django'
|-------------|---------------------------------------------------------------------------------------|
| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
#### Subclasses
The corresponding model-specific subclasses of `NetBoxModelForm` are documented below.
| Model Class | Form Class |
|-----------------------|---------------------------|
| `PrimaryModel` | `PrimaryModelForm` |
| `OrganizationalModel` | `OrganizationalModelForm` |
| `NestedGroupModel` | `NestedGroupModelForm` |
#### Example
**Example**
```python
from django.utils.translation import gettext_lazy as _
@@ -59,19 +49,9 @@ class MyModelForm(NetBoxModelForm):
### `NetBoxModelImportForm`
This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for importing various types of CSV data, listed [below](#csv-import-fields).
This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below.
#### Subclasses
The corresponding model-specific subclasses of `NetBoxModelImportForm` are documented below.
| Model Class | Form Class |
|-----------------------|---------------------------------|
| `PrimaryModel` | `PrimaryModelImportForm` |
| `OrganizationalModel` | `OrganizationalModelImportForm` |
| `NestedGroupModel` | `NestedGroupModelImportForm` |
#### Example
**Example**
```python
from django.utils.translation import gettext_lazy as _
@@ -103,17 +83,7 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
| `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) |
#### Subclasses
The corresponding model-specific subclasses of `NetBoxModelBulkEditForm` are documented below.
| Model Class | Form Class |
|-----------------------|-----------------------------------|
| `PrimaryModel` | `PrimaryModelBulkEditForm` |
| `OrganizationalModel` | `OrganizationalModelBulkEditForm` |
| `NestedGroupModel` | `NestedGroupModelBulkEditForm` |
#### Example
**Example**
```python
from django import forms
@@ -155,17 +125,7 @@ This form class is used to render a form expressly for filtering a list of objec
| `model` | The model of object being edited |
| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
#### Subclasses
The corresponding model-specific subclasses of `NetBoxModelFilterSetForm` are documented below.
| Model Class | Form Class |
|-----------------------|------------------------------------|
| `PrimaryModel` | `PrimaryModelFilterSetForm` |
| `OrganizationalModel` | `OrganizationalModelFilterSetForm` |
| `NestedGroupModel` | `NestedGroupModelFilterSetForm` |
#### Example
**Example**
```python
from dcim.models import Site

View File

@@ -46,19 +46,3 @@ NetBox provides two object type classes for use by plugins.
::: netbox.graphql.types.NetBoxObjectType
options:
members: false
## GraphQL Filters
NetBox provides a base filter class for use by plugins which employ subclasseses of `NetBoxModel`.
::: netbox.graphql.filters.NetBoxModelFilter
options:
members: false
Additionally, the following filter classes are available for subclasses of standard base models.
| Model Class | FilterSet Class |
|-----------------------|----------------------------------------------------|
| `PrimaryModel` | `netbox.graphql.filters.PrimaryModelFilter` |
| `OrganizationalModel` | `netbox.graphql.filters.OrganizationalModelFilter` |
| `NestedGroupModel` | `netbox.graphql.filters.NestedGroupModelFilter` |

View File

@@ -325,14 +325,14 @@ class CircuitTypeType(OrganizationalObjectType):
### Change filters.py
Filter classes should inherit from `netbox.graphql.filters.BaseModelFilter`.
Strawberry currently doesn't directly support django-filter, so an explicit filters.py file will need to be created. NetBox includes a new `autotype_decorator` used to automatically wrap FilterSets to reduce the required code to a minimum.
```python title="New"
import strawberry
import strawberry_django
from circuits import filtersets, models
from netbox.graphql.filters import BaseModelFilter
from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
__all__ = (
'CircuitFilter',
@@ -340,7 +340,8 @@ __all__ = (
@strawberry_django.filter(models.Circuit, lookups=True)
class CircuitFilter(BaseModelFilter):
@autotype_decorator(filtersets.CircuitFilterSet)
class CircuitFilter(BaseFilterMixin):
pass
```

View File

@@ -67,46 +67,6 @@ class MyModel(ExportTemplatesMixin, TagsMixin, models.Model):
...
```
### Additional Models
In addition to the base NetBoxModel class, the following additional classes are provided for convenience.
!!! info "These model classes were added to the plugins API in NetBox v4.5."
#### PrimaryModel
PrimaryModel is the go-to class for most object types. It extends NetBoxModel with `description` and `comments` fields, and it introduces support for ownership assignment.
| Field | Required | Unique | Description |
|---------------|----------|--------|---------------------------------------------|
| `owner` | No | No | The object's owner |
| `description` | No | No | A human-friendly description for the object |
| `comments` | No | No | General comments |
#### OrganizationalModel
OrganizationalModel is used by object types whose function is primarily the organization of other objects.
| Field | Required | Unique | Description |
|---------------|----------|--------|---------------------------------------------|
| `name` | Yes | Yes | The name of the object |
| `slug` | Yes | Yes | A unique URL-friendly identifier |
| `owner` | No | No | The object's owner |
| `description` | No | No | A human-friendly description for the object |
#### NestedGroupModel
NestedGroupModel is used for objects which arrange into a recursive hierarchy (like regions and locations) via its self-referential `parent` foreign key.
| Field | Required | Unique | Description |
|---------------|----------|--------|-----------------------------------------------------------------|
| `name` | Yes | Yes | The name of the object |
| `slug` | Yes | Yes | A unique URL-friendly identifier |
| `parent` | No | No | The object (of the same type) under which this object is nested |
| `owner` | No | No | The object's owner |
| `description` | No | No | A human-friendly description for the object |
| `comments` | No | No | General comments |
## Database Migrations
Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.)

View File

@@ -27,14 +27,6 @@ Serializers are responsible for converting Python objects to JSON data suitable
The default nested representation of an object is defined by the `brief_fields` attributes under the serializer's `Meta` class. (Older versions of NetBox required the definition of a separate nested serializer.)
In addition to the base NetBoxModelSerializer class, the following serializer classes are also available for subclasses of standard base models.
| Model Class | Serializer Class |
|-----------------------|--------------------------------------------------------|
| `PrimaryModel` | `netbox.api.serializers.PrimaryModelSerializer` |
| `OrganizationalModel` | `netbox.api.serializers.OrganizationalModelSerializer` |
| `NestedGroupModel` | `netbox.api.serializers.NestedGroupModelSerializer` |
#### Example
To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class.

View File

@@ -36,14 +36,6 @@ class MyModelTable(NetBoxTable):
default_columns = ('pk', 'name', ...)
```
In addition to the base NetBoxTable class, the following table classes are also available for subclasses of standard base models.
| Model Class | Table Class |
|-----------------------|------------------------------------------|
| `PrimaryModel` | `netbox.tables.PrimaryModelTable` |
| `OrganizationalModel` | `netbox.tables.OrganizationalModelTable` |
| `NestedGroupModel` | `netbox.tables.NestedGroupModelTable` |
### Table Configuration
The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example:

View File

@@ -1,148 +0,0 @@
# UI Components
!!! note "New in NetBox v4.5"
All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
!!! danger "Beta Feature"
UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
## Page Layout
A layout defines the general arrangement of content on a page into rows and columns. The layout is defined under the [view](./views.md) and declares a set of rows, each of which may have one or more columns. Below is an example layout.
```
+-------+-------+-------+
| Col 1 | Col 2 | Col 3 |
+-------+-------+-------+
| Col 4 |
+-----------+-----------+
| Col 5 | Col 6 |
+-----------+-----------+
```
The above layout can be achieved with the following declaration under a view:
```python
from netbox.ui import layout
from netbox.views import generic
class MyView(generic.ObjectView):
layout = layout.Layout(
layout.Row(
layout.Column(),
layout.Column(),
layout.Column(),
),
layout.Row(
layout.Column(),
),
layout.Row(
layout.Column(),
layout.Column(),
),
)
```
!!! note
Currently, layouts are supported only for subclasses of [`generic.ObjectView`](./views.md#netbox.views.generic.ObjectView).
::: netbox.ui.layout.Layout
::: netbox.ui.layout.SimpleLayout
::: netbox.ui.layout.Row
::: netbox.ui.layout.Column
## Panels
Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.
Plugins can define their own panels by inheriting from the base class `netbox.ui.panels.Panel`. Override the `get_context()` method to pass additional context to your custom panel template. An example is provided below.
```python
from django.utils.translation import gettext_lazy as _
from netbox.ui.panels import Panel
class RecentChangesPanel(Panel):
template_name = 'my_plugin/panels/recent_changes.html'
title = _('Recent Changes')
def get_context(self, context):
return {
**super().get_context(context),
'changes': get_changes()[:10],
}
```
NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
::: netbox.ui.panels.Panel
::: netbox.ui.panels.ObjectPanel
::: netbox.ui.panels.ObjectAttributesPanel
#### Object Attributes
The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
| Class | Description |
|--------------------------------------|--------------------------------------------------|
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object |
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
| `netbox.ui.attrs.TextAttr` | A string (text) value |
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
::: netbox.ui.panels.OrganizationalObjectPanel
::: netbox.ui.panels.NestedGroupObjectPanel
::: netbox.ui.panels.CommentsPanel
::: netbox.ui.panels.JSONPanel
::: netbox.ui.panels.RelatedObjectsPanel
::: netbox.ui.panels.ObjectsTablePanel
::: netbox.ui.panels.TemplatePanel
::: netbox.ui.panels.PluginContentPanel
## Panel Actions
Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
```python
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, panels
panels.ObjectsTablePanel(
model='dcim.Region',
title=_('Child Regions'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
```
::: netbox.ui.actions.PanelAction
::: netbox.ui.actions.LinkAction
::: netbox.ui.actions.AddObject
::: netbox.ui.actions.CopyContent

View File

@@ -10,14 +10,6 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 4.5](./version-4.5.md) (January 2026)
* Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
* Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
* Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
* Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
* Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
#### [Version 4.4](./version-4.4.md) (September 2025)
* Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))

View File

@@ -1,111 +1,5 @@
# NetBox v4.4
## v4.4.8 (2025-12-09)
### Enhancements
* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
### Bug Fixes
* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
---
## v4.4.7 (2025-11-25)
### Enhancements
* [#20371](https://github.com/netbox-community/netbox/issues/20371) - Add Molex Micro-Fit 2x3 for power ports & power outlets
* [#20731](https://github.com/netbox-community/netbox/issues/20731) - Enable specifying `data_source` & `data_file` when bulk import config templates
* [#20820](https://github.com/netbox-community/netbox/issues/20820) - Enable filtering of custom fields by object type
* [#20823](https://github.com/netbox-community/netbox/issues/20823) - Disallow creation of API tokens with an expiration date in the past
* [#20841](https://github.com/netbox-community/netbox/issues/20841) - Support advanced filtering for available rack types when creating/editing a rack
### Bug Fixes
* [#20134](https://github.com/netbox-community/netbox/issues/20134) - Prevent out-of-band HTMX content swaps in embedded tables
* [#20432](https://github.com/netbox-community/netbox/issues/20432) - Fix tracing of cables across multiple circuits in parallel
* [#20465](https://github.com/netbox-community/netbox/issues/20465) - Ensure that scripts are updated immediately when a new file is uploaded
* [#20638](https://github.com/netbox-community/netbox/issues/20638) - Correct OpenAPI schema for bulk create operations
* [#20649](https://github.com/netbox-community/netbox/issues/20649) - Enforce view permissions on REST API endpoint for custom scripts
* [#20740](https://github.com/netbox-community/netbox/issues/20740) - Ensure permissions constraints are enforced when executing custom scripts via the REST API
* [#20743](https://github.com/netbox-community/netbox/issues/20743) - Pass request context to custom script when triggered by an event rule
* [#20766](https://github.com/netbox-community/netbox/issues/20766) - Fix inadvertent translations on server error page
* [#20775](https://github.com/netbox-community/netbox/issues/20775) - Fix `TypeError` exception when bulk renaming unnamed devices
* [#20822](https://github.com/netbox-community/netbox/issues/20822) - Add missing `auto_sync_enabled` field in bulk edit forms
* [#20827](https://github.com/netbox-community/netbox/issues/20827) - Fix UI styling issue when toggling between light and dark mode
* [#20839](https://github.com/netbox-community/netbox/issues/20839) - Fix filtering by object type in UI for custom links and saved filters
* [#20840](https://github.com/netbox-community/netbox/issues/20840) - Remove extraneous references to airflow for RackType model
* [#20844](https://github.com/netbox-community/netbox/issues/20844) - Fix object type filter for L2VPN terminations
* [#20859](https://github.com/netbox-community/netbox/issues/20859) - Prevent dashboard crash due to exception raised by a widget
* [#20865](https://github.com/netbox-community/netbox/issues/20865) - Enforce proper min/max values for latitude & longitude fields
---
## v4.4.6 (2025-11-11)
### Enhancements
* [#14171](https://github.com/netbox-community/netbox/issues/14171) - Support VLAN assignment for device & VM interfaces being bulk imported
* [#20297](https://github.com/netbox-community/netbox/issues/20297) - Introduce additional coaxial cable types
### Bug Fixes
* [#20378](https://github.com/netbox-community/netbox/issues/20378) - Prevent exception when attempting to delete a data source utilized by a custom script
* [#20645](https://github.com/netbox-community/netbox/issues/20645) - CSVChoiceField should defer to model field's default value when CSV field is empty
* [#20647](https://github.com/netbox-community/netbox/issues/20647) - Improve handling of empty strings during bulk imports
* [#20653](https://github.com/netbox-community/netbox/issues/20653) - Fix filtering of jobs by object type ID
* [#20660](https://github.com/netbox-community/netbox/issues/20660) - Optimize loading of custom script modules from remote storage
* [#20670](https://github.com/netbox-community/netbox/issues/20670) - Improve validation of related objects during bulk import
* [#20688](https://github.com/netbox-community/netbox/issues/20688) - Suppress non-harmful "No active configuration revision found" warning message
* [#20697](https://github.com/netbox-community/netbox/issues/20697) - Prevent duplication of signals which increment/decrement related object counts
* [#20699](https://github.com/netbox-community/netbox/issues/20699) - Ensure proper ordering of changelog entries resulting from cascading deletions
* [#20713](https://github.com/netbox-community/netbox/issues/20713) - Ensure a pre-change snapshot is recorded on virtual chassis members being added/removed
* [#20721](https://github.com/netbox-community/netbox/issues/20721) - Fix breadcrumb navigation links in UI for background tasks
* [#20738](https://github.com/netbox-community/netbox/issues/20738) - Deleting a virtual chassis should nullify the `vc_position` of all former members
* [#20750](https://github.com/netbox-community/netbox/issues/20750) - Fix cloning of permissions when only one action is enabled
* [#20755](https://github.com/netbox-community/netbox/issues/20755) - Prevent duplicate results under certain conditions when filtering providers
* [#20771](https://github.com/netbox-community/netbox/issues/20771) - Comments are required when creating a new journal entry
* [#20774](https://github.com/netbox-community/netbox/issues/20774) - Bulk action button labels should be translated
---
## v4.4.5 (2025-10-28)
### Enhancements
* [#19751](https://github.com/netbox-community/netbox/issues/19751) - Disable occupied module bays in form dropdowns when installing a new module
* [#20301](https://github.com/netbox-community/netbox/issues/20301) - Add a "dismiss all" option to the notifications dropdown
* [#20399](https://github.com/netbox-community/netbox/issues/20399) - Add `assigned` and `primary` boolean filters for MAC addresses
* [#20567](https://github.com/netbox-community/netbox/issues/20567) - Add contacts column to services table
* [#20675](https://github.com/netbox-community/netbox/issues/20675) - Enable [NetBox Copilot](https://netboxlabs.com/products/netbox-copilot/) integration
* [#20692](https://github.com/netbox-community/netbox/issues/20692) - Add contacts column to IP addresses table
* [#20700](https://github.com/netbox-community/netbox/issues/20700) - Add contacts table column for various additional models
### Bug Fixes
* [#19872](https://github.com/netbox-community/netbox/issues/19872) - Ensure custom script validation failures display error messages
* [#20389](https://github.com/netbox-community/netbox/issues/20389) - Fix "select all" behavior for bulk rename views
* [#20422](https://github.com/netbox-community/netbox/issues/20422) - Enable filtering of aggregates and prefixes by family in GraphQL API
* [#20459](https://github.com/netbox-community/netbox/issues/20459) - Fix validation of `is_oob` & `is_primary` fields under IP address bulk import
* [#20466](https://github.com/netbox-community/netbox/issues/20466) - Fix querying of devices with a primary IP assigned in GraphQL API
* [#20498](https://github.com/netbox-community/netbox/issues/20498) - Enforce the validation regex (if set) for custom URL fields
* [#20524](https://github.com/netbox-community/netbox/issues/20524) - Raise a validation error when attempting to schedule a custom script for a past date/time
* [#20541](https://github.com/netbox-community/netbox/issues/20541) - Fix resolution of GraphQL object fields which rely on custom filters
* [#20551](https://github.com/netbox-community/netbox/issues/20551) - Fix automatic slug generation in quick-add UI form
* [#20606](https://github.com/netbox-community/netbox/issues/20606) - Enable copying of values from table columns rendered as badges
* [#20641](https://github.com/netbox-community/netbox/issues/20641) - Fix `AttributeError` exception raised by the object changes REST API endpoint
* [#20646](https://github.com/netbox-community/netbox/issues/20646) - Prevent cables from connecting to objects marked as connected
* [#20655](https://github.com/netbox-community/netbox/issues/20655) - Fix `FieldError` exception when attempting to sort permissions list by actions
---
## v4.4.4 (2025-10-15)
### Bug Fixes

View File

@@ -1,150 +0,0 @@
## v4.5.0 (FUTURE)
### Breaking Changes
* Python 3.10 and 3.11 are no longer supported. NetBox now requires Python 3.12, 3.13, or 3.14.
* GraphQL API queries which filter by object IDs or enums must now specify a filter lookup similar to other fields. For example, `id: 123` becomes `id: {exact: 123 }`.
* Rendering a device or virtual machine configuration is now restricted to users with the `render_config` permission for the applicable object type.
* Retrieval of API token plaintexts is no longer supported. The `ALLOW_TOKEN_RETRIEVAL` config parameter has been removed.
* API tokens can no longer be reassigned from one user to another.
* A config context assigned to a platform will now also apply to any children of that platform. (Although this is typically desired behavior, it may introduce unanticipated changes for existing deployments.)
* The `/api/dcim/cable-terminations/` REST API endpoint is now read-only. Cable terminations must be set on cables directly via the `/api/dcim/cables/` endpoint.
* The UI view dedicated to swapping A/Z circuit terminations has been removed.
* The experimental HTMX navigation feature has been removed.
* The obsolete boolean field `is_staff` has been removed from the `User` model.
* Removal of deprecated behavior
* The `/api/extras/object-types/` REST API endpoint has been removed. (Use `/api/core/object-types/` instead.)
* Webhooks no longer specify a `model` in payload data. (Reference `object_type` instead, which includes the parent app label.)
* The obsolete module `core.models.contenttypes` has been removed (replaced in v4.4 by `core.models.object_types`).
* The `load_yaml()` and `load_json()` utility methods have been removed from the base class for custom scripts.
### New Features
#### Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter. Plugins can register their own filtersets using the `register_filterset()` decorator to enable this new functionality.
(Note that this feature does not introduce any new filters. Rather, it makes available in the UI filters which already exist.)
#### Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
This release introduces a new version of API token (v2) which implements several security improvements. HMAC hashing with a cryptographic pepper is used to authenticate these tokens, obviating the need to store plaintexts. The new tokens also employ a non-sensitive key which can be shared to identify tokens without divulging their plaintexts. We've also adopted the standard "bearer" HTTP header format, as shown below.
```
# v1 token header
Authorization: Token <TOKEN>
# v2 token header
Authorization: Bearer nbt_<KEY>.<TOKEN>
```
Note that v2 token keys are prefixed with the fixed string `nbt_`, which can be used to aid in secret detection.
Backward compatibility with legacy (v1) tokens is retained in this release. However, users are strongly encouraged to begin using only v2 tokens, as support for legacy tokens will be removed in NetBox v4.7.
#### Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
An optional `owner` foreign key field has been added to most models. This enables the assignment of objects to a new Owner model, which represents a set of users and/or groups. Through this relationship, we can now convey ownership of objects within NetBox natively, without needing to rely on the assignment of tags or custom fields.
(Note that ownership differs significantly in function from tenancy. Ownership determines the parties responsible for the maintenance of an object, whereas as tenancy conveys an operational dependency.)
#### Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
The previous many-to-one mapping of front to rear ports has been expanded to support bidirectional mappings. The `rear_port` and `rear_port_position` fields on the FrontPort model have been replaced with an intermediary PortMapping model, which supports any number of assignments between front port/position pair and a rear port/position pair. This change unlocks the ability to model complex inline devices that swap individual fiber pairs between cables.
#### Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
Cables can now be assigned profiles which determine how they are treated for path tracing. A profile indicates the number of discrete parallel channels or lanes carried by the cable among its endpoints. For example, a 1-to-4 breakout cable has four lanes, shared at one end via a common termination and split out at the other end to four separate terminations. Profiles, when assigned, enable NetBox to more accurately trace a specific connection within a cable, rather than the cable as a whole.
The assignment of cable profiles is optional: Cable tracing will continue to operate as before for cables with no profile assigned.
### Enhancements
* [#16681](https://github.com/netbox-community/netbox/issues/16681) - Introduce a `render_config` permission, which is now required to render a device or virtual machine configuration
* [#18658](https://github.com/netbox-community/netbox/issues/18658) - Add a `start_on_boot` choice field for virtual machines
* [#19095](https://github.com/netbox-community/netbox/issues/19095) - Add support for Python 3.13 and 3.14
* [#19338](https://github.com/netbox-community/netbox/issues/19338) - Enable filter lookups for object IDs and enums in GraphQL API queries
* [#19523](https://github.com/netbox-community/netbox/issues/19523) - Cache the number of instances for device, module, and rack types, and enable filtering by these counts
* [#20417](https://github.com/netbox-community/netbox/issues/20417) - Add an optional `color` field for device type power outlets
* [#20476](https://github.com/netbox-community/netbox/issues/20476) - Once provisioned, the owner of an API token cannot be changed
* [#20492](https://github.com/netbox-community/netbox/issues/20492) - Completely disabled the means to retrieve legacy API token plaintexts (removed the `ALLOW_TOKEN_RETRIEVAL` config parameter)
* [#20639](https://github.com/netbox-community/netbox/issues/20639) - Apply config contexts to devices/VMs assigned any child platform of the parent platform
* [#20834](https://github.com/netbox-community/netbox/issues/20834) - Add an `enabled` boolean field to API tokens
* [#20917](https://github.com/netbox-community/netbox/issues/20917) - Include usage reference on API token views
* [#20925](https://github.com/netbox-community/netbox/issues/20925) - Add optional `comments` field to all subclasses of `OrganizationalModel`
* [#20929](https://github.com/netbox-community/netbox/issues/20929) - Require the `render_config` permission to view a rendered device/VM configuration in the UI
* [#20936](https://github.com/netbox-community/netbox/issues/20936) - Introduce the `/api/authentication-check/` REST API endpoint for validating authentication tokens
* [#20959](https://github.com/netbox-community/netbox/issues/20959) - Include a count of related module types for a manufacturer in the REST API
### Plugins
* [#13182](https://github.com/netbox-community/netbox/issues/13182) - Added `PrimaryModel`, `OrganizationalModel`, and `NestedGroupModel` to the plugins API, as well as their respective base classes for various resources
### Other Changes
* [#16137](https://github.com/netbox-community/netbox/issues/16137) - Remove the obsolete boolean field `is_staff` from the `User` model
* [#17571](https://github.com/netbox-community/netbox/issues/17571) - Remove the experimental HTMX navigation feature
* [#17936](https://github.com/netbox-community/netbox/issues/17936) - Introduce a dedicated `GFKSerializerField` for representing generic foreign keys in API serializers
* [#19889](https://github.com/netbox-community/netbox/issues/19889) - Drop support for Python 3.10 and 3.11
* [#19898](https://github.com/netbox-community/netbox/issues/19898) - Remove the obsolete REST API endpoint `/api/extras/object-types/`
* [#20088](https://github.com/netbox-community/netbox/issues/20088) - Remove the non-deterministic `model` key from webhook payload data
* [#20095](https://github.com/netbox-community/netbox/issues/20095) - Remove the obsolete module `core.models.contenttypes`
* [#20096](https://github.com/netbox-community/netbox/issues/20096) - Remove the `load_yaml()` and `load_json()` utility methods from the `BaseScript` class
* [#20204](https://github.com/netbox-community/netbox/issues/20204) - Started migrating object views from custom HTML templates to declarative layouts
* [#20295](https://github.com/netbox-community/netbox/issues/20295) - Cable terminations may be modified via the REST API only by modifying the cable itself
* [#20617](https://github.com/netbox-community/netbox/issues/20617) - Introduce `BaseModel` as the global base class for models
* [#20683](https://github.com/netbox-community/netbox/issues/20683) - Remove the UI view dedicated to swapping A/Z circuit terminations
* [#20926](https://github.com/netbox-community/netbox/issues/20926) - Standardize naming of GraphQL filters
### REST API Changes
* Most objects now include an optional `owner` foreign key field.
* The `/api/dcim/cable-terminations` endpoint is now read-only.
* Introduced the `/api/authentication-check/` endpoint to test REST API credentials
* `circuits.CircuitGroup`
* Add optional `comments` field
* `circuits.CircuitType`
* Add optional `comments` field
* `circuits.VirtualCircuitType`
* Add optional `comments` field
* `dcim.Cable`
* Add the optional `profile` choice field
* `dcim.FrontPort`
* Removed the `rear_port` and `rear_port_position` fields
* Add the `positions` integer field
* Add the `rear_ports` list for port mappings
* `dcim.InventoryItemRole`
* Add optional `comments` field
* `dcim.Manufacturer`
* Add optional `comments` field
* Add read-only `moduletype_count` integer field
* `dcim.ModuleType`
* Add read-only `module_count` integer field
* `dcim.PowerOutletTemplate`
* Add optional `color` field
* `dcim.RackRole`
* Add optional `comments` field
* `dcim.RackType`
* Add read-only `rack_count` integer field
* `dcim.RearPort`
* Add the `front_ports` list for port mappings
* `ipam.ASNRange`
* Add optional `comments` field
* `ipam.RIR`
* Add optional `comments` field
* `ipam.Role`
* Add optional `comments` field
* `ipam.VLANGroup`
* Add optional `comments` field
* `tenancy.ContactRole`
* Add optional `comments` field
* `users.Token`
* Add `enabled` boolean field
* `virtualization.ClusterGroup`
* Add optional `comments` field
* `virtualization.ClusterType`
* Add optional `comments` field
* `virtualization.VirtualMachine`
* Add optional `start_on_boot` choice field
* `vpn.TunnelGroup`
* Add optional `comments` field

View File

@@ -77,7 +77,6 @@ nav:
- Wireless: 'features/wireless.md'
- Virtualization: 'features/virtualization.md'
- VPN Tunnels: 'features/vpn-tunnels.md'
- Resource Ownership: 'features/resource-ownership.md'
- Tenancy: 'features/tenancy.md'
- Contacts: 'features/contacts.md'
- Search: 'features/search.md'
@@ -143,7 +142,6 @@ nav:
- Getting Started: 'plugins/development/index.md'
- Models: 'plugins/development/models.md'
- Views: 'plugins/development/views.md'
- UI Components: 'plugins/development/ui-components.md'
- Navigation: 'plugins/development/navigation.md'
- Templates: 'plugins/development/templates.md'
- Tables: 'plugins/development/tables.md'
@@ -275,9 +273,6 @@ nav:
- ContactRole: 'models/tenancy/contactrole.md'
- Tenant: 'models/tenancy/tenant.md'
- TenantGroup: 'models/tenancy/tenantgroup.md'
- Users:
- Owner: 'models/users/owner.md'
- OwnerGroup: 'models/users/ownergroup.md'
- Virtualization:
- Cluster: 'models/virtualization/cluster.md'
- ClusterGroup: 'models/virtualization/clustergroup.md'
@@ -322,7 +317,6 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 4.5: 'release-notes/version-4.5.md'
- Version 4.4: 'release-notes/version-4.4.md'
- Version 4.3: 'release-notes/version-4.3.md'
- Version 4.2: 'release-notes/version-4.2.md'

View File

@@ -25,12 +25,10 @@ from extras.models import Bookmark
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.ui import layout
from netbox.views import generic
from users import forms
from users.models import UserConfig
from users.tables import TokenTable
from users.ui.panels import TokenExamplePanel, TokenPanel
from utilities.request import safe_for_redirect
from utilities.string import remove_linebreaks
from utilities.views import register_model_view
@@ -344,21 +342,12 @@ class UserTokenListView(LoginRequiredMixin, View):
@register_model_view(UserToken)
class UserTokenView(LoginRequiredMixin, View):
layout = layout.SimpleLayout(
left_panels=[
TokenPanel(),
],
right_panels=[
TokenExamplePanel(),
],
)
def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
return render(request, 'account/token.html', {
'object': token,
'layout': self.layout,
})

View File

@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
@@ -10,12 +11,10 @@ from circuits.models import (
from dcim.api.serializers_.device_components import InterfaceSerializer
from dcim.api.serializers_.cables import CabledObjectSerializer
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import (
NetBoxModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer,
)
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from netbox.choices import DistanceUnitChoices
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
__all__ = (
@@ -30,7 +29,7 @@ __all__ = (
)
class CircuitTypeSerializer(OrganizationalModelSerializer):
class CircuitTypeSerializer(NetBoxModelSerializer):
# Related object counts
circuit_count = RelatedObjectCountField('circuits')
@@ -38,8 +37,8 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
class Meta:
model = CircuitType
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
@@ -54,7 +53,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
default=None
)
termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
termination = GFKSerializerField(read_only=True)
termination = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CircuitTermination
@@ -63,16 +62,24 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
'upstream_speed', 'xconnect_id', 'description',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
if obj.termination_id is None:
return None
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data
class CircuitGroupSerializer(OrganizationalModelSerializer):
class CircuitGroupSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
circuit_count = RelatedObjectCountField('assignments')
class Meta:
model = CircuitGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count'
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant',
'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count'
]
brief_fields = ('id', 'url', 'display', 'name')
@@ -92,7 +99,7 @@ class CircuitGroupAssignmentSerializer_(NetBoxModelSerializer):
brief_fields = ('id', 'url', 'display', 'group', 'priority')
class CircuitSerializer(PrimaryModelSerializer):
class CircuitSerializer(NetBoxModelSerializer):
provider = ProviderSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=CircuitStatusChoices, required=False)
@@ -108,7 +115,7 @@ class CircuitSerializer(PrimaryModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant',
'install_date', 'termination_date', 'commit_rate', 'description', 'distance', 'distance_unit',
'termination_a', 'termination_z', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'assignments',
]
brief_fields = ('id', 'url', 'display', 'provider', 'cid', 'description')
@@ -125,7 +132,7 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
default=None
)
termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
termination = GFKSerializerField(read_only=True)
termination = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CircuitTermination
@@ -137,12 +144,20 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
]
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
if obj.termination_id is None:
return None
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data
class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
member_type = ContentTypeField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS)
)
member = GFKSerializerField(read_only=True)
member = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CircuitGroupAssignment
@@ -152,8 +167,16 @@ class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
]
brief_fields = ('id', 'url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_member(self, obj):
if obj.member_id is None:
return None
serializer = get_serializer_for_model(obj.member)
context = {'request': self.context['request']}
return serializer(obj.member, nested=True, context=context).data
class VirtualCircuitTypeSerializer(OrganizationalModelSerializer):
class VirtualCircuitTypeSerializer(NetBoxModelSerializer):
# Related object counts
virtual_circuit_count = RelatedObjectCountField('virtual_circuits')
@@ -161,13 +184,13 @@ class VirtualCircuitTypeSerializer(OrganizationalModelSerializer):
class Meta:
model = VirtualCircuitType
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'virtual_circuit_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'virtual_circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count')
class VirtualCircuitSerializer(PrimaryModelSerializer):
class VirtualCircuitSerializer(NetBoxModelSerializer):
provider_network = ProviderNetworkSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
type = VirtualCircuitTypeSerializer(nested=True)
@@ -178,7 +201,7 @@ class VirtualCircuitSerializer(PrimaryModelSerializer):
model = VirtualCircuit
fields = [
'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'type', 'status',
'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'provider_network', 'cid', 'description')

View File

@@ -4,7 +4,7 @@ from circuits.models import Provider, ProviderAccount, ProviderNetwork
from ipam.api.serializers_.asns import ASNSerializer
from ipam.models import ASN
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import PrimaryModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from .nested import NestedProviderAccountSerializer
__all__ = (
@@ -14,7 +14,7 @@ __all__ = (
)
class ProviderSerializer(PrimaryModelSerializer):
class ProviderSerializer(NetBoxModelSerializer):
accounts = SerializedPKRelatedField(
queryset=ProviderAccount.objects.all(),
serializer=NestedProviderAccountSerializer,
@@ -35,32 +35,32 @@ class ProviderSerializer(PrimaryModelSerializer):
class Meta:
model = Provider
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'accounts', 'description', 'owner', 'comments',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'accounts', 'description', 'comments',
'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class ProviderAccountSerializer(PrimaryModelSerializer):
class ProviderAccountSerializer(NetBoxModelSerializer):
provider = ProviderSerializer(nested=True)
name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
class Meta:
model = ProviderAccount
fields = [
'id', 'url', 'display_url', 'display', 'provider', 'name', 'account', 'description', 'owner', 'comments',
'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
class ProviderNetworkSerializer(PrimaryModelSerializer):
class ProviderNetworkSerializer(NetBoxModelSerializer):
provider = ProviderSerializer(nested=True)
class Meta:
model = ProviderNetwork
fields = [
'id', 'url', 'display_url', 'display', 'provider', 'name', 'service_id', 'description', 'owner', 'comments',
'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -6,12 +6,11 @@ from django.utils.translation import gettext as _
from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Interface, Location, Region, Site, SiteGroup
from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
)
from utilities.filtersets import register_filterset
from .choices import *
from .models import *
@@ -30,8 +29,7 @@ __all__ = (
)
@register_filterset
class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations___region',
@@ -91,12 +89,13 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(accounts__account__icontains=value) |
Q(accounts__name__icontains=value) |
Q(comments__icontains=value)
)
@register_filterset
class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
@@ -123,8 +122,7 @@ class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
).distinct()
@register_filterset
class ProviderNetworkFilterSet(PrimaryModelFilterSet):
class ProviderNetworkFilterSet(NetBoxModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
@@ -151,7 +149,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
).distinct()
@register_filterset
class CircuitTypeFilterSet(OrganizationalModelFilterSet):
class Meta:
@@ -159,8 +156,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'color', 'description')
@register_filterset
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
@@ -271,7 +267,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
).distinct()
@register_filterset
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -353,7 +348,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
model = CircuitTermination
fields = (
'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description',
'mark_connected', 'pp_info', 'cable_end', 'cable_connector',
'mark_connected', 'pp_info', 'cable_end',
)
def search(self, queryset, name, value):
@@ -367,7 +362,6 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
).distinct()
@register_filterset
class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
class Meta:
@@ -375,7 +369,6 @@ class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
fields = ('id', 'name', 'slug', 'description')
@register_filterset
class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -475,7 +468,6 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
)
@register_filterset
class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
class Meta:
@@ -483,8 +475,7 @@ class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'color', 'description')
@register_filterset
class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='provider_network__provider',
queryset=Provider.objects.all(),
@@ -540,7 +531,6 @@ class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
).distinct()
@register_filterset
class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -11,11 +11,11 @@ from circuits.models import *
from dcim.models import Site
from ipam.models import ASN
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import (
ColorField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
@@ -36,12 +36,18 @@ __all__ = (
)
class ProviderBulkEditForm(PrimaryModelBulkEditForm):
class ProviderBulkEditForm(NetBoxModelBulkEditForm):
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = Provider
fieldsets = (
@@ -52,12 +58,18 @@ class ProviderBulkEditForm(PrimaryModelBulkEditForm):
)
class ProviderAccountBulkEditForm(PrimaryModelBulkEditForm):
class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = ProviderAccount
fieldsets = (
@@ -68,7 +80,7 @@ class ProviderAccountBulkEditForm(PrimaryModelBulkEditForm):
)
class ProviderNetworkBulkEditForm(PrimaryModelBulkEditForm):
class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
@@ -79,6 +91,12 @@ class ProviderNetworkBulkEditForm(PrimaryModelBulkEditForm):
required=False,
label=_('Service ID')
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = ProviderNetwork
fieldsets = (
@@ -89,20 +107,25 @@ class ProviderNetworkBulkEditForm(PrimaryModelBulkEditForm):
)
class CircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
color = ColorField(
label=_('Color'),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
model = CircuitType
fieldsets = (
FieldSet('color', 'description'),
)
nullable_fields = ('color', 'description', 'comments')
nullable_fields = ('color', 'description')
class CircuitBulkEditForm(PrimaryModelBulkEditForm):
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
type = DynamicModelChoiceField(
label=_('Type'),
queryset=CircuitType.objects.all(),
@@ -160,6 +183,12 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
required=False,
initial=''
)
description = forms.CharField(
label=_('Description'),
max_length=100,
required=False
)
comments = CommentField()
model = Circuit
fieldsets = (
@@ -232,7 +261,12 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
pass
class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@@ -241,7 +275,7 @@ class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
model = CircuitGroup
nullable_fields = (
'description', 'tenant', 'comments',
'description', 'tenant',
)
@@ -264,20 +298,25 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('priority',)
class VirtualCircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
class VirtualCircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
color = ColorField(
label=_('Color'),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
model = VirtualCircuitType
fieldsets = (
FieldSet('color', 'description'),
)
nullable_fields = ('color', 'description', 'comments')
nullable_fields = ('color', 'description')
class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
@@ -304,6 +343,12 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
queryset=Tenant.objects.all(),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=100,
required=False
)
comments = CommentField()
model = VirtualCircuit
fieldsets = (

View File

@@ -7,7 +7,7 @@ from circuits.constants import *
from circuits.models import *
from dcim.models import Interface
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, PrimaryModelImportForm
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
@@ -28,17 +28,17 @@ __all__ = (
)
class ProviderImportForm(PrimaryModelImportForm):
class ProviderImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = Provider
fields = (
'name', 'slug', 'description', 'owner', 'comments', 'tags',
'name', 'slug', 'description', 'comments', 'tags',
)
class ProviderAccountImportForm(PrimaryModelImportForm):
class ProviderAccountImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
@@ -49,11 +49,11 @@ class ProviderAccountImportForm(PrimaryModelImportForm):
class Meta:
model = ProviderAccount
fields = (
'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
'provider', 'name', 'account', 'description', 'comments', 'tags',
)
class ProviderNetworkImportForm(PrimaryModelImportForm):
class ProviderNetworkImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
@@ -64,19 +64,19 @@ class ProviderNetworkImportForm(PrimaryModelImportForm):
class Meta:
model = ProviderNetwork
fields = [
'provider', 'name', 'service_id', 'description', 'owner', 'comments', 'tags'
'provider', 'name', 'service_id', 'description', 'comments', 'tags'
]
class CircuitTypeImportForm(OrganizationalModelImportForm):
class CircuitTypeImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = CircuitType
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
fields = ('name', 'slug', 'color', 'description', 'tags')
class CircuitImportForm(PrimaryModelImportForm):
class CircuitImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
@@ -119,7 +119,7 @@ class CircuitImportForm(PrimaryModelImportForm):
model = Circuit
fields = [
'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date',
'commit_rate', 'distance', 'distance_unit', 'description', 'owner', 'comments', 'tags'
'commit_rate', 'distance', 'distance_unit', 'description', 'comments', 'tags'
]
@@ -165,7 +165,7 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination
}
class CircuitGroupImportForm(OrganizationalModelImportForm):
class CircuitGroupImportForm(NetBoxModelImportForm):
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@@ -176,7 +176,7 @@ class CircuitGroupImportForm(OrganizationalModelImportForm):
class Meta:
model = CircuitGroup
fields = ('name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags')
fields = ('name', 'slug', 'description', 'tenant', 'tags')
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
@@ -195,14 +195,15 @@ class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
fields = ('member_type', 'member_id', 'group', 'priority')
class VirtualCircuitTypeImportForm(OrganizationalModelImportForm):
class VirtualCircuitTypeImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = VirtualCircuitType
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
fields = ('name', 'slug', 'color', 'description', 'tags')
class VirtualCircuitImportForm(PrimaryModelImportForm):
class VirtualCircuitImportForm(NetBoxModelImportForm):
provider_network = CSVModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
@@ -238,8 +239,8 @@ class VirtualCircuitImportForm(PrimaryModelImportForm):
class Meta:
model = VirtualCircuit
fields = [
'cid', 'provider_network', 'provider_account', 'type', 'status', 'tenant', 'description', 'owner',
'comments', 'tags',
'cid', 'provider_network', 'provider_account', 'type', 'status', 'tenant', 'description', 'comments',
'tags',
]

View File

@@ -9,7 +9,7 @@ from circuits.models import *
from dcim.models import Location, Region, Site, SiteGroup
from ipam.models import ASN
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
@@ -31,10 +31,10 @@ __all__ = (
)
class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Provider
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('asn_id', name=_('ASN')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -66,10 +66,10 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
tag = TagFilterField(model)
class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ProviderAccount
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'account', name=_('Attributes')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
@@ -85,10 +85,10 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
tag = TagFilterField(model)
class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
model = ProviderNetwork
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'service_id', name=_('Attributes')),
)
provider_id = DynamicModelMultipleChoiceField(
@@ -104,10 +104,10 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
tag = TagFilterField(model)
class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
model = CircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
)
tag = TagFilterField(model)
@@ -118,10 +118,10 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
)
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Circuit
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet(
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
@@ -271,10 +271,10 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class CircuitGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = CircuitGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
tag = TagFilterField(model)
@@ -309,10 +309,10 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class VirtualCircuitTypeFilterForm(NetBoxModelFilterSetForm):
model = VirtualCircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
)
tag = TagFilterField(model)
@@ -323,10 +323,10 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
)
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = VirtualCircuit
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type_id', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),

View File

@@ -10,11 +10,11 @@ from circuits.constants import *
from circuits.models import *
from dcim.models import Interface, Site
from ipam.models import ASN
from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import get_field_value
from utilities.forms.fields import (
ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
)
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields
@@ -36,13 +36,14 @@ __all__ = (
)
class ProviderForm(PrimaryModelForm):
class ProviderForm(NetBoxModelForm):
slug = SlugField()
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
required=False
)
comments = CommentField()
fieldsets = (
FieldSet('name', 'slug', 'asns', 'description', 'tags'),
@@ -51,32 +52,34 @@ class ProviderForm(PrimaryModelForm):
class Meta:
model = Provider
fields = [
'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
'name', 'slug', 'asns', 'description', 'comments', 'tags',
]
class ProviderAccountForm(PrimaryModelForm):
class ProviderAccountForm(NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
selector=True,
quick_add=True
)
comments = CommentField()
class Meta:
model = ProviderAccount
fields = [
'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
'provider', 'name', 'account', 'description', 'comments', 'tags',
]
class ProviderNetworkForm(PrimaryModelForm):
class ProviderNetworkForm(NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
selector=True,
quick_add=True
)
comments = CommentField()
fieldsets = (
FieldSet('provider', 'name', 'service_id', 'description', 'tags'),
@@ -85,23 +88,25 @@ class ProviderNetworkForm(PrimaryModelForm):
class Meta:
model = ProviderNetwork
fields = [
'provider', 'name', 'service_id', 'description', 'owner', 'comments', 'tags',
'provider', 'name', 'service_id', 'description', 'comments', 'tags',
]
class CircuitTypeForm(OrganizationalModelForm):
class CircuitTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'owner', 'tags'),
FieldSet('name', 'slug', 'color', 'description', 'tags'),
)
class Meta:
model = CircuitType
fields = [
'name', 'slug', 'color', 'description', 'comments', 'tags',
'name', 'slug', 'color', 'description', 'tags',
]
class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
class CircuitForm(DistanceValidationMixin, TenancyForm, NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
@@ -120,6 +125,7 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
queryset=CircuitType.objects.all(),
quick_add=True
)
comments = CommentField()
fieldsets = (
FieldSet(
@@ -141,7 +147,7 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
model = Circuit
fields = [
'cid', 'type', 'provider', 'provider_account', 'status', 'install_date', 'termination_date', 'commit_rate',
'distance', 'distance_unit', 'description', 'tenant_group', 'tenant', 'owner', 'comments', 'tags',
'distance', 'distance_unit', 'description', 'tenant_group', 'tenant', 'comments', 'tags',
]
widgets = {
'install_date': DatePicker(),
@@ -227,7 +233,9 @@ class CircuitTerminationForm(NetBoxModelForm):
self.instance.termination = self.cleaned_data.get('termination')
class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
class CircuitGroupForm(TenancyForm, NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('Circuit Group')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -236,7 +244,7 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
class Meta:
model = CircuitGroup
fields = [
'name', 'slug', 'description', 'tenant_group', 'tenant', 'owner', 'comments', 'tags',
'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
]
@@ -299,7 +307,9 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
self.instance.member = self.cleaned_data.get('member')
class VirtualCircuitTypeForm(OrganizationalModelForm):
class VirtualCircuitTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'tags'),
)
@@ -307,11 +317,11 @@ class VirtualCircuitTypeForm(OrganizationalModelForm):
class Meta:
model = VirtualCircuitType
fields = [
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
'name', 'slug', 'color', 'description', 'tags',
]
class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
@@ -326,6 +336,7 @@ class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
queryset=VirtualCircuitType.objects.all(),
quick_add=True
)
comments = CommentField()
fieldsets = (
FieldSet(
@@ -339,7 +350,7 @@ class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
model = VirtualCircuit
fields = [
'cid', 'provider_network', 'provider_account', 'type', 'status', 'description', 'tenant_group', 'tenant',
'owner', 'comments', 'tags',
'comments', 'tags',
]

View File

@@ -3,18 +3,17 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from strawberry_django import BaseFilterLookup
from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin
if TYPE_CHECKING:
from netbox.graphql.enums import ColorEnum
__all__ = (
'CircuitTypeFilterMixin',
'BaseCircuitTypeFilterMixin',
)
@dataclass
class CircuitTypeFilterMixin:
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin):
color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()

View File

@@ -4,15 +4,20 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
from strawberry_django import FilterLookup, DateFilterLookup
from circuits import models
from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import DistanceFilterMixin, ImageAttachmentFilterMixin
from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter
from netbox.graphql.filter_mixins import (
DistanceFilterMixin,
ImageAttachmentFilterMixin,
OrganizationalModelFilterMixin,
PrimaryModelFilterMixin,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from .filter_mixins import BaseCircuitTypeFilterMixin
if TYPE_CHECKING:
from core.graphql.filters import ContentTypeFilter
@@ -38,17 +43,16 @@ __all__ = (
@strawberry_django.filter_type(models.CircuitTermination, lookups=True)
class CircuitTerminationFilter(
BaseObjectTypeFilterMixin,
CustomFieldsFilterMixin,
TagsFilterMixin,
ChangeLoggedModelFilter,
ChangeLogFilterMixin,
CabledObjectModelFilterMixin,
):
circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)
term_side: (
BaseFilterLookup[Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')]] | None
) = (
term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
strawberry_django.filter_field()
)
termination_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -89,7 +93,7 @@ class CircuitFilter(
ImageAttachmentFilterMixin,
DistanceFilterMixin,
TenancyFilterMixin,
PrimaryModelFilter
PrimaryModelFilterMixin
):
cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -104,7 +108,7 @@ class CircuitFilter(
strawberry_django.filter_field()
)
type_id: ID | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
strawberry_django.filter_field()
)
install_date: DateFilterLookup[date] | None = strawberry_django.filter_field()
@@ -118,17 +122,19 @@ class CircuitFilter(
@strawberry_django.filter_type(models.CircuitType, lookups=True)
class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass
@strawberry_django.filter_type(models.CircuitGroup, lookups=True)
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter):
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
pass
@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
class CircuitGroupAssignmentFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):
member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -137,13 +143,13 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
strawberry_django.filter_field()
)
group_id: ID | None = strawberry_django.filter_field()
priority: BaseFilterLookup[Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
slug: FilterLookup[str] | None = strawberry_django.filter_field()
asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -153,7 +159,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.ProviderAccount, lookups=True)
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -163,7 +169,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
class ProviderNetworkFilter(PrimaryModelFilter):
class ProviderNetworkFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -173,12 +179,12 @@ class ProviderNetworkFilter(PrimaryModelFilter):
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
cid: FilterLookup[str] | None = strawberry_django.filter_field()
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@@ -192,7 +198,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
strawberry_django.filter_field()
)
type_id: ID | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
strawberry_django.filter_field()
)
group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -201,16 +207,14 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
class VirtualCircuitTerminationFilter(
BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
):
virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
)
virtual_circuit_id: ID | None = strawberry_django.filter_field()
role: (
BaseFilterLookup[
Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')]
] | None
) = (
role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
strawberry_django.filter_field()
)
interface: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class CircuitsQuery:
class CircuitsQueryV1:
circuit: CircuitType = strawberry_django.field()
circuit_list: List[CircuitType] = strawberry_django.field()
@@ -40,3 +41,41 @@ class CircuitsQuery:
virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field()
@strawberry.type(name="Query")
class CircuitsQuery:
circuit: CircuitType = strawberry_django.field()
circuit_list: OffsetPaginated[CircuitType] = strawberry_django.offset_paginated()
circuit_termination: CircuitTerminationType = strawberry_django.field()
circuit_termination_list: OffsetPaginated[CircuitTerminationType] = strawberry_django.offset_paginated()
circuit_type: CircuitTypeType = strawberry_django.field()
circuit_type_list: OffsetPaginated[CircuitTypeType] = strawberry_django.offset_paginated()
circuit_group: CircuitGroupType = strawberry_django.field()
circuit_group_list: OffsetPaginated[CircuitGroupType] = strawberry_django.offset_paginated()
circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field()
circuit_group_assignment_list: OffsetPaginated[CircuitGroupAssignmentType] = strawberry_django.offset_paginated()
provider: ProviderType = strawberry_django.field()
provider_list: OffsetPaginated[ProviderType] = strawberry_django.offset_paginated()
provider_account: ProviderAccountType = strawberry_django.field()
provider_account_list: OffsetPaginated[ProviderAccountType] = strawberry_django.offset_paginated()
provider_network: ProviderNetworkType = strawberry_django.field()
provider_network_list: OffsetPaginated[ProviderNetworkType] = strawberry_django.offset_paginated()
virtual_circuit: VirtualCircuitType = strawberry_django.field()
virtual_circuit_list: OffsetPaginated[VirtualCircuitType] = strawberry_django.offset_paginated()
virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
virtual_circuit_termination_list: OffsetPaginated[VirtualCircuitTerminationType] = (
strawberry_django.offset_paginated()
)
virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
virtual_circuit_type_list: OffsetPaginated[VirtualCircuitTypeType] = strawberry_django.offset_paginated()

View File

@@ -6,7 +6,7 @@ import strawberry_django
from circuits import models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, OrganizationalObjectType
from tenancy.graphql.types import TenantType
from .filters import *
@@ -35,7 +35,8 @@ __all__ = (
filters=ProviderFilter,
pagination=True
)
class ProviderType(ContactsMixin, PrimaryObjectType):
class ProviderType(NetBoxObjectType, ContactsMixin):
networks: List[Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')]]
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
@@ -48,8 +49,9 @@ class ProviderType(ContactsMixin, PrimaryObjectType):
filters=ProviderAccountFilter,
pagination=True
)
class ProviderAccountType(ContactsMixin, PrimaryObjectType):
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
@@ -59,8 +61,9 @@ class ProviderAccountType(ContactsMixin, PrimaryObjectType):
filters=ProviderNetworkFilter,
pagination=True
)
class ProviderNetworkType(PrimaryObjectType):
class ProviderNetworkType(NetBoxObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
@@ -102,13 +105,14 @@ class CircuitTypeType(OrganizationalObjectType):
filters=CircuitFilter,
pagination=True
)
class CircuitType(PrimaryObjectType, ContactsMixin):
class CircuitType(NetBoxObjectType, ContactsMixin):
provider: ProviderType
provider_account: ProviderAccountType | None
termination_a: CircuitTerminationType | None
termination_z: CircuitTerminationType | None
type: CircuitTypeType
tenant: TenantType | None
terminations: List[CircuitTerminationType]
@@ -174,11 +178,12 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
filters=VirtualCircuitFilter,
pagination=True
)
class VirtualCircuitType(PrimaryObjectType):
class VirtualCircuitType(NetBoxObjectType):
provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
provider_account: ProviderAccountType | None
type: Annotated["VirtualCircuitTypeType", strawberry.lazy('circuits.graphql.types')] = strawberry_django.field(
select_related=["type"]
)
tenant: TenantType | None
terminations: List[VirtualCircuitTerminationType]

View File

@@ -1,68 +0,0 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0052_extend_circuit_abs_distance_upper_limit'),
('users', '0015_owner'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
migrations.AddField(
model_name='circuitgroup',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
migrations.AddField(
model_name='circuittype',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
migrations.AddField(
model_name='provider',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
migrations.AddField(
model_name='provideraccount',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
migrations.AddField(
model_name='providernetwork',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
migrations.AddField(
model_name='virtualcircuit',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
migrations.AddField(
model_name='virtualcircuittype',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
]

View File

@@ -1,39 +0,0 @@
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0053_owner'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='circuittermination',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-08 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0054_cable_connector_positions'),
]
operations = [
migrations.AddField(
model_name='circuitgroup',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='circuittype',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='virtualcircuittype',
name='comments',
field=models.TextField(blank=True),
),
]

View File

@@ -1,17 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0055_add_comments_to_organizationalmodel'),
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0224_add_comments_to_organizationalmodel'),
('extras', '0134_owner'),
]
operations = [
migrations.AddIndex(
model_name='circuittermination',
index=models.Index(fields=['termination_type', 'termination_id'], name='circuits_ci_termina_505dda_idx'),
),
]

View File

@@ -335,9 +335,6 @@ class CircuitTermination(
name='%(app_label)s_%(class)s_unique_circuit_term_side'
),
)
indexes = (
models.Index(fields=('termination_type', 'termination_id')),
)
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')

View File

@@ -20,7 +20,6 @@ class CircuitGroupIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
@@ -45,7 +44,6 @@ class CircuitTypeIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
@@ -111,6 +109,5 @@ class VirtualCircuitTypeIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)

View File

@@ -1,9 +1,11 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from circuits.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from netbox.tables import NetBoxTable, columns
from .columns import CommitRateColumn
__all__ = (
@@ -22,7 +24,7 @@ CIRCUITTERMINATION_LINK = """
"""
class CircuitTypeTable(OrganizationalModelTable):
class CircuitTypeTable(NetBoxTable):
name = tables.Column(
linkify=True,
verbose_name=_('Name'),
@@ -37,16 +39,16 @@ class CircuitTypeTable(OrganizationalModelTable):
verbose_name=_('Circuits')
)
class Meta(OrganizationalModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = CircuitType
fields = (
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'comments', 'tags', 'created',
'last_updated', 'actions',
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated',
'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'color', 'description')
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name=_('Circuit ID')
@@ -77,6 +79,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
verbose_name=_('Commit Rate')
)
distance = columns.DistanceColumn()
comments = columns.MarkdownColumn(
verbose_name=_('Comments')
)
tags = columns.TagColumn(
url_name='circuits:circuit_list'
)
@@ -85,7 +90,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
linkify_item=True
)
class Meta(PrimaryModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'tenant_group',
@@ -158,7 +163,7 @@ class CircuitTerminationTable(NetBoxTable):
)
class CircuitGroupTable(OrganizationalModelTable):
class CircuitGroupTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
@@ -172,10 +177,10 @@ class CircuitGroupTable(OrganizationalModelTable):
url_name='circuits:circuitgroup_list'
)
class Meta(OrganizationalModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = CircuitGroup
fields = (
'pk', 'name', 'description', 'circuit_group_assignment_count', 'comments', 'tags',
'pk', 'name', 'description', 'circuit_group_assignment_count', 'tags',
'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')

View File

@@ -1,11 +1,11 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from circuits.models import *
from netbox.tables import PrimaryModelTable, columns
from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin
from netbox.tables import NetBoxTable, columns
__all__ = (
'ProviderTable',
'ProviderAccountTable',
@@ -13,7 +13,7 @@ __all__ = (
)
class ProviderTable(ContactsColumnMixin, PrimaryModelTable):
class ProviderTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
@@ -42,11 +42,14 @@ class ProviderTable(ContactsColumnMixin, PrimaryModelTable):
url_params={'provider_id': 'pk'},
verbose_name=_('Circuits')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
url_name='circuits:provider_list'
)
class Meta(PrimaryModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = Provider
fields = (
'pk', 'id', 'name', 'accounts', 'account_count', 'asns', 'asn_count', 'circuit_count', 'description',
@@ -55,7 +58,7 @@ class ProviderTable(ContactsColumnMixin, PrimaryModelTable):
default_columns = ('pk', 'name', 'account_count', 'circuit_count')
class ProviderAccountTable(ContactsColumnMixin, PrimaryModelTable):
class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
account = tables.Column(
linkify=True,
verbose_name=_('Account'),
@@ -73,11 +76,14 @@ class ProviderAccountTable(ContactsColumnMixin, PrimaryModelTable):
url_params={'provider_account_id': 'pk'},
verbose_name=_('Circuits')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
url_name='circuits:provideraccount_list'
)
class Meta(PrimaryModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = ProviderAccount
fields = (
'pk', 'id', 'account', 'name', 'provider', 'circuit_count', 'comments', 'contacts', 'tags', 'created',
@@ -86,7 +92,7 @@ class ProviderAccountTable(ContactsColumnMixin, PrimaryModelTable):
default_columns = ('pk', 'account', 'name', 'provider', 'circuit_count')
class ProviderNetworkTable(PrimaryModelTable):
class ProviderNetworkTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
@@ -95,11 +101,14 @@ class ProviderNetworkTable(PrimaryModelTable):
verbose_name=_('Provider'),
linkify=True
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
url_name='circuits:providernetwork_list'
)
class Meta(PrimaryModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = ProviderNetwork
fields = (
'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from circuits.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
__all__ = (
@@ -12,7 +12,7 @@ __all__ = (
)
class VirtualCircuitTypeTable(OrganizationalModelTable):
class VirtualCircuitTypeTable(NetBoxTable):
name = tables.Column(
linkify=True,
verbose_name=_('Name'),
@@ -27,7 +27,7 @@ class VirtualCircuitTypeTable(OrganizationalModelTable):
verbose_name=_('Circuits')
)
class Meta(OrganizationalModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = VirtualCircuitType
fields = (
'pk', 'id', 'name', 'virtual_circuit_count', 'color', 'description', 'slug', 'tags', 'created',
@@ -36,7 +36,7 @@ class VirtualCircuitTypeTable(OrganizationalModelTable):
default_columns = ('pk', 'name', 'virtual_circuit_count', 'color', 'description')
class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name=_('Circuit ID')
@@ -63,11 +63,14 @@ class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel
url_params={'virtual_circuit_id': 'pk'},
verbose_name=_('Terminations')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments')
)
tags = columns.TagColumn(
url_name='circuits:virtualcircuit_list'
)
class Meta(PrimaryModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = VirtualCircuit
fields = (
'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'type', 'status', 'tenant',

View File

@@ -433,7 +433,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CircuitTermination.objects.all()
filterset = CircuitTerminationFilterSet
ignore_fields = ('cable', 'cable_positions')
ignore_fields = ('cable',)
@classmethod
def setUpTestData(cls):

View File

@@ -18,6 +18,11 @@ urlpatterns = [
path('circuit-types/<int:pk>/', include(get_model_urls('circuits', 'circuittype'))),
path('circuits/', include(get_model_urls('circuits', 'circuit', detail=False))),
path(
'circuits/<int:pk>/terminations/swap/',
views.CircuitSwapTerminations.as_view(),
name='circuit_terminations_swap'
),
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
path('circuit-terminations/', include(get_model_urls('circuits', 'circuittermination', detail=False))),

View File

@@ -1,8 +1,13 @@
from django.contrib import messages
from django.db import router, transaction
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
@@ -78,7 +83,6 @@ class ProviderBulkEditView(generic.BulkEditView):
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
class ProviderBulkRenameView(generic.BulkRenameView):
queryset = Provider.objects.all()
filterset = filtersets.ProviderFilterSet
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
@@ -146,7 +150,6 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
class ProviderAccountBulkRenameView(generic.BulkRenameView):
queryset = ProviderAccount.objects.all()
filterset = filtersets.ProviderAccountFilterSet
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
@@ -223,7 +226,6 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
queryset = ProviderNetwork.objects.all()
filterset = filtersets.ProviderNetworkFilterSet
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
@@ -288,7 +290,6 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
class CircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = CircuitType.objects.all()
filterset = filtersets.CircuitTypeFilterSet
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
@@ -361,7 +362,6 @@ class CircuitBulkEditView(generic.BulkEditView):
class CircuitBulkRenameView(generic.BulkRenameView):
queryset = Circuit.objects.all()
field_name = 'cid'
filterset = filtersets.CircuitFilterSet
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
@@ -373,6 +373,82 @@ class CircuitBulkDeleteView(generic.BulkDeleteView):
table = tables.CircuitTable
class CircuitSwapTerminations(generic.ObjectEditView):
"""
Swap the A and Z terminations of a circuit.
"""
queryset = Circuit.objects.all()
def get(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm()
# Circuit must have at least one termination to swap
if not circuit.termination_a and not circuit.termination_z:
messages.error(request, _(
"No terminations have been defined for circuit {circuit}."
).format(circuit=circuit))
return redirect('circuits:circuit', pk=circuit.pk)
return render(request, 'circuits/circuit_terminations_swap.html', {
'circuit': circuit,
'termination_a': circuit.termination_a,
'termination_z': circuit.termination_z,
'form': form,
'panel_class': 'light',
'button_class': 'primary',
'return_url': circuit.get_absolute_url(),
})
def post(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm(request.POST)
if form.is_valid():
termination_a = CircuitTermination.objects.filter(pk=circuit.termination_a_id).first()
termination_z = CircuitTermination.objects.filter(pk=circuit.termination_z_id).first()
if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
with transaction.atomic(using=router.db_for_write(CircuitTermination)):
termination_a.term_side = '_'
termination_a.save()
termination_z.term_side = 'A'
termination_z.save()
termination_a.term_side = 'Z'
termination_a.save()
circuit.refresh_from_db()
circuit.termination_a = termination_z
circuit.termination_z = termination_a
circuit.save()
elif termination_a:
termination_a.term_side = 'Z'
termination_a.save()
circuit.refresh_from_db()
circuit.termination_a = None
circuit.save()
else:
termination_z.term_side = 'A'
termination_z.save()
circuit.refresh_from_db()
circuit.termination_z = None
circuit.save()
messages.success(request, _("Swapped terminations for circuit {circuit}.").format(circuit=circuit))
return redirect('circuits:circuit', pk=circuit.pk)
return render(request, 'circuits/circuit_terminations_swap.html', {
'circuit': circuit,
'termination_a': circuit.termination_a,
'termination_z': circuit.termination_z,
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'return_url': circuit.get_absolute_url(),
})
#
# Circuit terminations
#
@@ -481,7 +557,6 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
class CircuitGroupBulkRenameView(generic.BulkRenameView):
queryset = CircuitGroup.objects.all()
filterset = filtersets.CircuitGroupFilterSet
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
@@ -597,7 +672,6 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuitType.objects.all()
filterset = filtersets.VirtualCircuitTypeFilterSet
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
@@ -670,7 +744,6 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
class VirtualCircuitBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all()
field_name = 'cid'
filterset = filtersets.VirtualCircuitFilterSet
@register_model_view(VirtualCircuit, 'bulk_delete', path='delete', detail=False)

View File

@@ -12,7 +12,6 @@ from drf_spectacular.utils import Direction
from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer
from netbox.api.viewsets import NetBoxModelViewSet
# see netbox.api.routers.NetBoxRouter
BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update")
@@ -50,11 +49,6 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
)
def viewset_handles_bulk_create(view):
"""Check if view automatically provides list-based bulk create"""
return isinstance(view, NetBoxModelViewSet)
class NetBoxAutoSchema(AutoSchema):
"""
Overrides to drf_spectacular.openapi.AutoSchema to fix following issues:
@@ -134,36 +128,6 @@ class NetBoxAutoSchema(AutoSchema):
return response_serializers
def _get_request_for_media_type(self, serializer, direction='request'):
"""
Override to generate oneOf schema for serializers that support both
single object and array input (NetBoxModelViewSet POST operations).
Refs: #20638
"""
# Get the standard schema first
schema, required = super()._get_request_for_media_type(serializer, direction)
# If this serializer supports arrays (marked in get_request_serializer),
# wrap the schema in oneOf to allow single object OR array
if (
direction == 'request' and
schema is not None and
getattr(self.view, 'action', None) == 'create' and
viewset_handles_bulk_create(self.view)
):
return {
'oneOf': [
schema, # Single object
{
'type': 'array',
'items': schema, # Array of objects
}
]
}, required
return schema, required
def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
name = super()._get_serializer_name(serializer, direction, bypass_extensions)

View File

@@ -1,11 +1,13 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.choices import *
from core.models import ObjectChange
from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import BaseModelSerializer
from users.api.serializers_.users import UserSerializer
from utilities.api import get_serializer_for_model
__all__ = (
'ObjectChangeSerializer',
@@ -24,10 +26,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
changed_object_type = ContentTypeField(
read_only=True
)
changed_object = GFKSerializerField(
read_only=True
)
object_repr = serializers.CharField(
changed_object = serializers.SerializerMethodField(
read_only=True
)
prechange_data = serializers.JSONField(
@@ -45,6 +44,22 @@ class ObjectChangeSerializer(BaseModelSerializer):
model = ObjectChange
fields = [
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
'changed_object_type', 'changed_object_id', 'changed_object', 'object_repr', 'message',
'prechange_data', 'postchange_data',
'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data',
'postchange_data',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_changed_object(self, obj):
"""
Serialize a nested representation of the changed object.
"""
if obj.changed_object is None:
return None
try:
serializer = get_serializer_for_model(obj.changed_object)
except SerializerNotFound:
return obj.object_repr
data = serializer(obj.changed_object, nested=True, context={'request': self.context['request']}).data
return data

View File

@@ -1,7 +1,7 @@
from core.choices import *
from core.models import DataFile, DataSource
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from netbox.utils import get_data_backend_choices
__all__ = (
@@ -10,7 +10,7 @@ __all__ = (
)
class DataSourceSerializer(PrimaryModelSerializer):
class DataSourceSerializer(NetBoxModelSerializer):
type = ChoiceField(
choices=get_data_backend_choices()
)
@@ -26,8 +26,8 @@ class DataSourceSerializer(PrimaryModelSerializer):
model = DataSource
fields = [
'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description',
'sync_interval', 'parameters', 'ignore_rules', 'owner', 'comments', 'custom_fields', 'created',
'last_updated', 'last_synced', 'file_count',
'sync_interval', 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated',
'last_synced', 'file_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -1,13 +1,8 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.choices import *
from core.models import Job
from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer
from users.api.serializers_.users import UserSerializer
from utilities.api import get_serializer_for_model
__all__ = (
'JobSerializer',
@@ -23,28 +18,11 @@ class JobSerializer(BaseModelSerializer):
object_type = ContentTypeField(
read_only=True
)
object = serializers.SerializerMethodField(
read_only=True
)
class Meta:
model = Job
fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, obj):
"""
Serialize a nested representation of the object.
"""
if obj.object is None:
return None
try:
serializer = get_serializer_for_model(obj.object)
except SerializerNotFound:
return obj.object_repr
context = {'request': self.context['request']}
return serializer(obj.object, nested=True, context=context).data

View File

@@ -3,11 +3,10 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, PrimaryModelFilterSet
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices
from users.models import User
from utilities.filters import ContentTypeFilter
from utilities.filtersets import register_filterset
from .choices import *
from .models import *
@@ -21,8 +20,7 @@ __all__ = (
)
@register_filterset
class DataSourceFilterSet(PrimaryModelFilterSet):
class DataSourceFilterSet(NetBoxModelFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=get_data_backend_choices,
null_value=None
@@ -50,7 +48,6 @@ class DataSourceFilterSet(PrimaryModelFilterSet):
)
@register_filterset
class DataFileFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search'
@@ -78,16 +75,11 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
)
@register_filterset
class JobFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ObjectType.objects.with_feature('jobs'),
field_name='object_type_id',
)
object_type = ContentTypeFilter()
created = django_filters.DateTimeFilter()
created__before = django_filters.DateTimeFilter(
@@ -132,7 +124,7 @@ class JobFilterSet(BaseFilterSet):
class Meta:
model = Job
fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
def search(self, queryset, name, value):
if not value.strip():
@@ -143,7 +135,6 @@ class JobFilterSet(BaseFilterSet):
)
@register_filterset
class ObjectTypeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -169,7 +160,6 @@ class ObjectTypeFilterSet(BaseFilterSet):
return queryset.filter(features__icontains=value)
@register_filterset
class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -209,7 +199,6 @@ class ObjectChangeFilterSet(BaseFilterSet):
)
@register_filterset
class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -3,8 +3,9 @@ from django.utils.translation import gettext_lazy as _
from core.choices import JobIntervalChoices
from core.models import *
from netbox.forms import PrimaryModelBulkEditForm
from netbox.forms import NetBoxModelBulkEditForm
from netbox.utils import get_data_backend_choices
from utilities.forms.fields import CommentField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
@@ -13,7 +14,7 @@ __all__ = (
)
class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField(
label=_('Type'),
choices=get_data_backend_choices,
@@ -24,11 +25,17 @@ class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
widget=BulkEditNullBooleanSelect(),
label=_('Enabled')
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
sync_interval = forms.ChoiceField(
choices=JobIntervalChoices,
required=False,
label=_('Sync interval')
)
comments = CommentField()
parameters = forms.JSONField(
label=_('Parameters'),
required=False

View File

@@ -1,16 +1,16 @@
from core.models import *
from netbox.forms import PrimaryModelImportForm
from netbox.forms import NetBoxModelImportForm
__all__ = (
'DataSourceImportForm',
)
class DataSourceImportForm(PrimaryModelImportForm):
class DataSourceImportForm(NetBoxModelImportForm):
class Meta:
model = DataSource
fields = (
'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules',
'owner', 'comments',
'comments',
)

View File

@@ -3,13 +3,13 @@ from django.utils.translation import gettext_lazy as _
from core.choices import *
from core.models import *
from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm
from netbox.forms import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from netbox.utils import get_data_backend_choices
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker
@@ -23,10 +23,10 @@ __all__ = (
)
class DataSourceFilterForm(PrimaryModelFilterSetForm):
class DataSourceFilterForm(NetBoxModelFilterSetForm):
model = DataSource
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id'),
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
)
type = forms.MultipleChoiceField(
@@ -51,7 +51,6 @@ class DataSourceFilterForm(PrimaryModelFilterSetForm):
choices=JobIntervalChoices,
required=False
)
tag = TagFilterField(model)
class DataFileFilterForm(NetBoxModelFilterSetForm):
@@ -71,13 +70,13 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'status', name=_('Attributes')),
FieldSet('object_type', 'status', name=_('Attributes')),
FieldSet(
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
),
)
object_type_id = ContentTypeChoiceField(
object_type = ContentTypeChoiceField(
label=_('Object Type'),
queryset=ObjectType.objects.with_feature('jobs'),
required=False,

View File

@@ -9,11 +9,11 @@ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin
from core.models import *
from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm, PrimaryModelForm
from netbox.forms import NetBoxModelForm
from netbox.registry import registry
from netbox.utils import get_data_backend_choices
from utilities.forms import get_field_value
from utilities.forms.fields import JSONField
from utilities.forms.fields import CommentField, JSONField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect
@@ -26,17 +26,17 @@ __all__ = (
EMPTY_VALUES = ('', None, [], ())
class DataSourceForm(PrimaryModelForm):
class DataSourceForm(NetBoxModelForm):
type = forms.ChoiceField(
choices=get_data_backend_choices,
widget=HTMXSelect()
)
comments = CommentField()
class Meta:
model = DataSource
fields = [
'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'owner',
'comments', 'tags',
'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'comments', 'tags',
]
widgets = {
'ignore_rules': forms.Textarea(
@@ -166,8 +166,8 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')),
FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')),
FieldSet(
'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION',
'MAPS_URL', name=_('Miscellaneous'),
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
name=_('Miscellaneous')
),
FieldSet('comment', name=_('Config Revision'))
)

View File

@@ -1,11 +0,0 @@
import strawberry
from core.choices import *
__all__ = (
'DataSourceStatusEnum',
'ObjectChangeActionEnum',
)
DataSourceStatusEnum = strawberry.enum(DataSourceStatusChoices.as_enum(prefix='status'))
ObjectChangeActionEnum = strawberry.enum(ObjectChangeActionChoices.as_enum(prefix='action'))

View File

@@ -4,19 +4,31 @@ from typing import Annotated, TYPE_CHECKING
import strawberry
import strawberry_django
from strawberry import ID
from strawberry_django import DatetimeFilterLookup
if TYPE_CHECKING:
from .filters import *
__all__ = (
'ChangeLoggingMixin',
'BaseFilterMixin',
'BaseObjectTypeFilterMixin',
'ChangeLogFilterMixin',
)
# @strawberry.input
class BaseFilterMixin: ...
@dataclass
class ChangeLoggingMixin:
# TODO: "changelog" is not a valid field name; needs to be updated for ObjectChange
class BaseObjectTypeFilterMixin(BaseFilterMixin):
id: ID | None = strawberry.UNSET
@dataclass
class ChangeLogFilterMixin(BaseFilterMixin):
id: ID | None = strawberry.UNSET
changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)

View File

@@ -5,11 +5,11 @@ import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType as DjangoContentType
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
from strawberry_django import DatetimeFilterLookup, FilterLookup
from core import models
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
from .enums import *
from core.graphql.filter_mixins import BaseFilterMixin
from netbox.graphql.filter_mixins import PrimaryModelFilterMixin
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter
@@ -24,7 +24,8 @@ __all__ = (
@strawberry_django.filter_type(models.DataFile, lookups=True)
class DataFileFilter(BaseModelFilter):
class DataFileFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field()
created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -39,13 +40,11 @@ class DataFileFilter(BaseModelFilter):
@strawberry_django.filter_type(models.DataSource, lookups=True)
class DataSourceFilter(PrimaryModelFilter):
class DataSourceFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
type: FilterLookup[str] | None = strawberry_django.filter_field()
source_url: FilterLookup[str] | None = strawberry_django.filter_field()
status: (
BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None
) = strawberry_django.filter_field()
status: FilterLookup[str] | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field()
parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -58,14 +57,13 @@ class DataSourceFilter(PrimaryModelFilter):
@strawberry_django.filter_type(models.ObjectChange, lookups=True)
class ObjectChangeFilter(BaseModelFilter):
class ObjectChangeFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field()
time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
user_name: FilterLookup[str] | None = strawberry_django.filter_field()
request_id: FilterLookup[str] | None = strawberry_django.filter_field()
action: (
BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
) = strawberry_django.filter_field()
action: FilterLookup[str] | None = strawberry_django.filter_field()
changed_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@@ -85,6 +83,7 @@ class ObjectChangeFilter(BaseModelFilter):
@strawberry_django.filter_type(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseModelFilter):
class ContentTypeFilter(BaseFilterMixin):
id: ID | None = strawberry_django.filter_field()
app_label: FilterLookup[str] | None = strawberry_django.filter_field()
model: FilterLookup[str] | None = strawberry_django.filter_field()

View File

@@ -2,14 +2,24 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class CoreQuery:
class CoreQueryV1:
data_file: DataFileType = strawberry_django.field()
data_file_list: List[DataFileType] = strawberry_django.field()
data_source: DataSourceType = strawberry_django.field()
data_source_list: List[DataSourceType] = strawberry_django.field()
@strawberry.type(name="Query")
class CoreQuery:
data_file: DataFileType = strawberry_django.field()
data_file_list: OffsetPaginated[DataFileType] = strawberry_django.offset_paginated()
data_source: DataSourceType = strawberry_django.field()
data_source_list: OffsetPaginated[DataSourceType] = strawberry_django.offset_paginated()

View File

@@ -5,7 +5,7 @@ import strawberry_django
from django.contrib.contenttypes.models import ContentType as DjangoContentType
from core import models
from netbox.graphql.types import BaseObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, NetBoxObjectType
from .filters import *
__all__ = (
@@ -32,7 +32,8 @@ class DataFileType(BaseObjectType):
filters=DataSourceFilter,
pagination=True
)
class DataSourceType(PrimaryObjectType):
class DataSourceType(NetBoxObjectType):
datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]

View File

@@ -1,19 +0,0 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_configrevision_active'),
('users', '0015_owner'),
]
operations = [
migrations.AddField(
model_name='datasource',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
),
),
]

View File

@@ -6,6 +6,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.core.files.storage import storages
from django.urls import reverse
from django.utils.translation import gettext as _
from ..choices import ManagedFileRootPathChoices
@@ -63,6 +64,9 @@ class ManagedFile(SyncedDataMixin, models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('core:managedfile', args=[self.pk])
@property
def name(self):
return self.file_path

View File

@@ -1,4 +1,4 @@
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction

View File

@@ -3,7 +3,6 @@ from threading import local
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import CASCADE
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
from django.dispatch import receiver, Signal
@@ -221,8 +220,14 @@ def handle_deleted_object(sender, instance, **kwargs):
obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete is not CASCADE:
elif type(relation) is ManyToOneRel and relation.field.null is True:
setattr(obj, related_field_name, None)
# make sure the object hasn't been deleted - in case of
# deletion chaining of related objects
try:
obj.refresh_from_db()
except DoesNotExist:
continue
obj.save()
# Enqueue the object for event processing

View File

@@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from core.models import *
from netbox.tables import NetBoxTable, PrimaryModelTable, columns
from netbox.tables import NetBoxTable, columns
from .columns import BackendTypeColumn
from .template_code import DATA_SOURCE_SYNC_BUTTON
@@ -12,7 +12,7 @@ __all__ = (
)
class DataSourceTable(PrimaryModelTable):
class DataSourceTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
@@ -42,7 +42,7 @@ class DataSourceTable(PrimaryModelTable):
extra_buttons=DATA_SOURCE_SYNC_BUTTON,
)
class Meta(PrimaryModelTable.Meta):
class Meta(NetBoxTable.Meta):
model = DataSource
fields = (
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'sync_interval', 'comments',

View File

@@ -5,16 +5,14 @@ from rest_framework import status
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from dcim.choices import InterfaceTypeChoices, ModuleStatusChoices, SiteStatusChoices
from dcim.models import (
Cable, CableTermination, Device, DeviceRole, DeviceType, Manufacturer, Module, ModuleBay, ModuleType, Interface,
Site,
)
from dcim.choices import SiteStatusChoices
from dcim.models import Site, CableTermination, Device, DeviceType, DeviceRole, Interface, Cable
from extras.choices import *
from extras.models import CustomField, CustomFieldChoiceSet, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, create_test_device, post_data
from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase
from dcim.models import Manufacturer
class ChangeLogViewTest(ModelViewTestCase):
@@ -624,64 +622,3 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
self.assertEqual(objectchange.postchange_data, None)
def test_deletion_ordering(self):
"""
Check that the cascading deletion of dependent objects is recorded in the correct order.
"""
device = create_test_device('device1')
module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Module Type 1')
self.add_permissions('dcim.add_module', 'dcim.add_interface', 'dcim.delete_module')
self.assertEqual(ObjectChange.objects.count(), 0) # Sanity check
# Create a new Module
data = {
'device': device.pk,
'module_bay': module_bay.pk,
'module_type': module_type.pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
}
url = reverse('dcim-api:module-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
module = device.modules.first()
# Create an Interface on the Module
data = {
'device': device.pk,
'module': module.pk,
'name': 'Interface 1',
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
}
url = reverse('dcim-api:interface-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
interface = device.interfaces.first()
# Delete the Module
url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Module.objects.count(), 0)
self.assertEqual(Interface.objects.count(), 0)
# Verify the creation of the expected ObjectChange records. We should see four total records, in this order:
# 1. Module created
# 2. Interface created
# 3. Interface deleted
# 4. Module deleted
changes = ObjectChange.objects.order_by('time')
self.assertEqual(len(changes), 4)
self.assertEqual(changes[0].changed_object_type, ContentType.objects.get_for_model(Module))
self.assertEqual(changes[0].changed_object_id, module.pk)
self.assertEqual(changes[0].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
self.assertEqual(changes[1].changed_object_id, interface.pk)
self.assertEqual(changes[1].action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Interface))
self.assertEqual(changes[2].changed_object_id, interface.pk)
self.assertEqual(changes[2].action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(changes[3].changed_object_type, ContentType.objects.get_for_model(Module))
self.assertEqual(changes[3].changed_object_id, module.pk)
self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)

View File

@@ -1,108 +0,0 @@
"""
Unit tests for OpenAPI schema generation.
Refs: #20638
"""
import json
from django.test import TestCase
class OpenAPISchemaTestCase(TestCase):
"""Tests for OpenAPI schema generation."""
def setUp(self):
"""Fetch schema via API endpoint."""
response = self.client.get('/api/schema/', {'format': 'json'})
self.assertEqual(response.status_code, 200)
self.schema = json.loads(response.content)
def test_post_operation_documents_single_or_array(self):
"""
POST operations on NetBoxModelViewSet endpoints should document
support for both single objects and arrays via oneOf.
Refs: #20638
"""
# Test representative endpoints across different apps
test_paths = [
'/api/core/data-sources/',
'/api/dcim/sites/',
'/api/users/users/',
'/api/ipam/ip-addresses/',
]
for path in test_paths:
with self.subTest(path=path):
operation = self.schema['paths'][path]['post']
# Get the request body schema
request_schema = operation['requestBody']['content']['application/json']['schema']
# Should have oneOf with two options
self.assertIn('oneOf', request_schema, f"POST {path} should have oneOf schema")
self.assertEqual(
len(request_schema['oneOf']), 2,
f"POST {path} oneOf should have exactly 2 options"
)
# First option: single object (has $ref or properties)
single_schema = request_schema['oneOf'][0]
self.assertTrue(
'$ref' in single_schema or 'properties' in single_schema,
f"POST {path} first oneOf option should be single object"
)
# Second option: array of objects
array_schema = request_schema['oneOf'][1]
self.assertEqual(
array_schema['type'], 'array',
f"POST {path} second oneOf option should be array"
)
self.assertIn('items', array_schema, f"POST {path} array should have items")
def test_bulk_update_operations_require_array_only(self):
"""
Bulk update/patch operations should require arrays only, not oneOf.
They don't support single object input.
Refs: #20638
"""
test_paths = [
'/api/dcim/sites/',
'/api/users/users/',
]
for path in test_paths:
for method in ['put', 'patch']:
with self.subTest(path=path, method=method):
operation = self.schema['paths'][path][method]
request_schema = operation['requestBody']['content']['application/json']['schema']
# Should be array-only, not oneOf
self.assertNotIn(
'oneOf', request_schema,
f"{method.upper()} {path} should NOT have oneOf (array-only)"
)
self.assertEqual(
request_schema['type'], 'array',
f"{method.upper()} {path} should require array"
)
self.assertIn(
'items', request_schema,
f"{method.upper()} {path} array should have items"
)
def test_bulk_delete_requires_array(self):
"""
Bulk delete operations should require arrays.
Refs: #20638
"""
path = '/api/dcim/sites/'
operation = self.schema['paths'][path]['delete']
request_schema = operation['requestBody']['content']['application/json']['schema']
# Should be array-only
self.assertNotIn('oneOf', request_schema, "DELETE should NOT have oneOf")
self.assertEqual(request_schema['type'], 'array', "DELETE should require array")
self.assertIn('items', request_schema, "DELETE array should have items")

View File

@@ -125,7 +125,6 @@ class DataSourceBulkEditView(generic.BulkEditView):
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
class DataSourceBulkRenameView(generic.BulkRenameView):
queryset = DataSource.objects.all()
filterset = filtersets.DataSourceFilterSet
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)

View File

@@ -2,12 +2,10 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
from utilities.api import get_serializer_for_model
__all__ = (
'ConnectedEndpointsSerializer',
'PortSerializer',
)
@@ -37,53 +35,3 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
@extend_schema_field(serializers.BooleanField)
def get_connected_endpoints_reachable(self, obj):
return obj._path and obj._path.is_complete and obj._path.is_active
class PortSerializer(serializers.ModelSerializer):
"""
Base serializer for front & rear port and port templates.
"""
@property
def _mapper(self):
"""
Return the model and ForeignKey field name used to track port mappings for this model.
"""
if self.Meta.model is FrontPort:
return PortMapping, 'front_port'
if self.Meta.model is RearPort:
return PortMapping, 'rear_port'
if self.Meta.model is FrontPortTemplate:
return PortTemplateMapping, 'front_port'
if self.Meta.model is RearPortTemplate:
return PortTemplateMapping, 'rear_port'
raise ValueError(f"Could not determine mapping details for {self.__class__}")
def create(self, validated_data):
mappings = validated_data.pop('mappings', [])
instance = super().create(validated_data)
# Create port mappings
mapping_model, fk_name = self._mapper
for attrs in mappings:
mapping_model.objects.create(**{
fk_name: instance,
**attrs,
})
return instance
def update(self, instance, validated_data):
mappings = validated_data.pop('mappings', None)
instance = super().update(instance, validated_data)
if mappings is not None:
# Update port mappings
mapping_model, fk_name = self._mapper
mapping_model.objects.filter(**{fk_name: instance}).delete()
for attrs in mappings:
mapping_model.objects.create(**{
fk_name: instance,
**attrs,
})
return instance

View File

@@ -5,10 +5,7 @@ from rest_framework import serializers
from dcim.choices import *
from dcim.models import Cable, CablePath, CableTermination
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import (
BaseModelSerializer, GenericObjectSerializer, NetBoxModelSerializer, PrimaryModelSerializer,
)
from netbox.api.serializers import BaseModelSerializer, GenericObjectSerializer, NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
@@ -21,20 +18,19 @@ __all__ = (
)
class CableSerializer(PrimaryModelSerializer):
class CableSerializer(NetBoxModelSerializer):
a_terminations = GenericObjectSerializer(many=True, required=False)
b_terminations = GenericObjectSerializer(many=True, required=False)
status = ChoiceField(choices=LinkStatusChoices, required=False)
profile = ChoiceField(choices=CableProfileChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
class Meta:
model = Cable
fields = [
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant',
'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
brief_fields = ('id', 'url', 'display', 'label', 'description')
@@ -55,19 +51,24 @@ class CableTerminationSerializer(NetBoxModelSerializer):
termination_type = ContentTypeField(
read_only=True,
)
termination = GFKSerializerField(read_only=True)
termination = serializers.SerializerMethodField(
read_only=True,
)
class Meta:
model = CableTermination
fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
'termination', 'connector', 'positions', 'created', 'last_updated',
'termination', 'created', 'last_updated',
]
read_only_fields = fields
brief_fields = (
'id', 'url', 'display', 'cable', 'cable_end', 'connector', 'positions', 'termination_type',
'termination_id',
)
brief_fields = ('id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_termination(self, obj):
serializer = get_serializer_for_model(obj.termination)
context = {'request': self.context['request']}
return serializer(obj.termination, nested=True, context=context).data
class CablePathSerializer(serializers.ModelSerializer):

View File

@@ -1,26 +1,26 @@
from django.utils.translation import gettext as _
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping,
PowerOutlet, PowerPort, RearPort, VirtualDeviceContext,
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
RearPort, VirtualDeviceContext,
)
from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
from ipam.api.serializers_.vrfs import VRFSerializer
from ipam.models import VLAN
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import NetBoxModelSerializer
from users.api.serializers_.mixins import OwnerMixin
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from utilities.api import get_serializer_for_model
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
from wireless.choices import *
from wireless.models import WirelessLAN
from .base import ConnectedEndpointsSerializer, PortSerializer
from .base import ConnectedEndpointsSerializer
from .cables import CabledObjectSerializer
from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
from .manufacturers import ManufacturerSerializer
@@ -41,12 +41,7 @@ __all__ = (
)
class ConsoleServerPortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -70,18 +65,13 @@ class ConsoleServerPortSerializer(
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ConsolePortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -105,18 +95,13 @@ class ConsolePortSerializer(
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerPortSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -136,18 +121,13 @@ class PowerPortSerializer(
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerOutletSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -180,17 +160,12 @@ class PowerOutletSerializer(
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class InterfaceSerializer(
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
device = DeviceSerializer(nested=True)
vdcs = SerializedPKRelatedField(
queryset=VirtualDeviceContext.objects.all(),
@@ -252,7 +227,7 @@ class InterfaceSerializer(
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@@ -320,20 +295,7 @@ class InterfaceSerializer(
return super().validate(data)
class RearPortMappingSerializer(serializers.ModelSerializer):
position = serializers.IntegerField(
source='rear_port_position'
)
front_port = serializers.PrimaryKeyRelatedField(
queryset=FrontPort.objects.all(),
)
class Meta:
model = PortMapping
fields = ('position', 'front_port', 'front_port_position')
class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -342,36 +304,28 @@ class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializ
allow_null=True
)
type = ChoiceField(choices=PortTypeChoices)
front_ports = RearPortMappingSerializer(
source='mappings',
many=True,
required=False,
)
class Meta:
model = RearPort
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class FrontPortMappingSerializer(serializers.ModelSerializer):
position = serializers.IntegerField(
source='front_port_position'
)
rear_port = serializers.PrimaryKeyRelatedField(
queryset=RearPort.objects.all(),
)
class FrontPortRearPortSerializer(WritableNestedSerializer):
"""
NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
"""
class Meta:
model = PortMapping
fields = ('position', 'rear_port', 'rear_port_position')
model = RearPort
fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -380,23 +334,19 @@ class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSeriali
allow_null=True
)
type = ChoiceField(choices=PortTypeChoices)
rear_ports = FrontPortMappingSerializer(
source='mappings',
many=True,
required=False,
)
rear_port = FrontPortRearPortSerializer()
class Meta:
model = FrontPort
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
class ModuleBaySerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@@ -416,12 +366,12 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
model = ModuleBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
class DeviceBaySerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
@@ -429,12 +379,12 @@ class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
model = DeviceBay
fields = [
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
'owner', 'tags', 'custom_fields', 'created', 'last_updated',
'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
class InventoryItemSerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
@@ -444,7 +394,7 @@ class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
required=False,
allow_null=True
)
component = GFKSerializerField(read_only=True)
component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True)
status = ChoiceField(choices=InventoryItemStatusChoices, required=False)
@@ -453,6 +403,14 @@ class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role',
'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type',
'component_id', 'component', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
'component_id', 'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj):
if obj.component is None:
return None
serializer = get_serializer_for_model(obj.component)
context = {'request': self.context['request']}
return serializer(obj.component, nested=True, context=context).data

View File

@@ -11,15 +11,15 @@ from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceCont
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from ipam.api.serializers_.ip import IPAddressSerializer
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import PrimaryModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from virtualization.api.serializers_.clusters import ClusterSerializer
from .devicetypes import *
from .nested import NestedDeviceBaySerializer, NestedDeviceSerializer, NestedModuleBaySerializer
from .platforms import PlatformSerializer
from .racks import RackSerializer
from .roles import DeviceRoleSerializer
from .nested import NestedDeviceBaySerializer, NestedDeviceSerializer, NestedModuleBaySerializer
from .sites import LocationSerializer, SiteSerializer
from .virtualchassis import VirtualChassisSerializer
@@ -32,7 +32,7 @@ __all__ = (
)
class DeviceSerializer(PrimaryModelSerializer):
class DeviceSerializer(NetBoxModelSerializer):
device_type = DeviceTypeSerializer(nested=True)
role = DeviceRoleSerializer(nested=True)
tenant = TenantSerializer(
@@ -84,8 +84,8 @@ class DeviceSerializer(PrimaryModelSerializer):
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'local_context_data',
'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
@@ -111,7 +111,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
@@ -122,7 +122,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
return obj.get_config_context()
class VirtualDeviceContextSerializer(PrimaryModelSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
@@ -138,13 +138,13 @@ class VirtualDeviceContextSerializer(PrimaryModelSerializer):
model = VirtualDeviceContext
fields = [
'id', 'url', 'display_url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip',
'primary_ip4', 'primary_ip6', 'status', 'description', 'owner', 'comments', 'tags', 'custom_fields',
'primary_ip4', 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'interface_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
class ModuleSerializer(PrimaryModelSerializer):
class ModuleSerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True)
module_bay = NestedModuleBaySerializer()
module_type = ModuleTypeSerializer(nested=True)
@@ -154,23 +154,31 @@ class ModuleSerializer(PrimaryModelSerializer):
model = Module
fields = [
'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
'asset_tag', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'asset_tag', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
class MACAddressSerializer(PrimaryModelSerializer):
class MACAddressSerializer(NetBoxModelSerializer):
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(MACADDRESS_ASSIGNMENT_MODELS),
required=False,
allow_null=True
)
assigned_object = GFKSerializerField(read_only=True)
assigned_object = serializers.SerializerMethodField(read_only=True)
class Meta:
model = MACAddress
fields = [
'id', 'url', 'display_url', 'display', 'mac_address', 'assigned_object_type', 'assigned_object_id',
'assigned_object', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'assigned_object', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, obj):
if obj.assigned_object is None:
return None
serializer = get_serializer_for_model(obj.assigned_object)
context = {'request': self.context['request']}
return serializer(obj.assigned_object, nested=True, context=context).data

View File

@@ -1,18 +1,17 @@
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate,
RearPortTemplate,
InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
from utilities.api import get_serializer_for_model
from wireless.choices import *
from .base import PortSerializer
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
from .manufacturers import ManufacturerSerializer
from .nested import NestedInterfaceTemplateSerializer
@@ -156,7 +155,7 @@ class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
model = PowerOutletTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
'color', 'power_port', 'feed_leg', 'description', 'created', 'last_updated',
'power_port', 'feed_leg', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@@ -207,20 +206,7 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description')
class RearPortTemplateMappingSerializer(serializers.ModelSerializer):
position = serializers.IntegerField(
source='rear_port_position'
)
front_port = serializers.PrimaryKeyRelatedField(
queryset=FrontPortTemplate.objects.all(),
)
class Meta:
model = PortTemplateMapping
fields = ('position', 'front_port', 'front_port_position')
class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
class RearPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
required=False,
nested=True,
@@ -234,35 +220,17 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
default=None
)
type = ChoiceField(choices=PortTypeChoices)
front_ports = RearPortTemplateMappingSerializer(
source='mappings',
many=True,
required=False,
)
class Meta:
model = RearPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'front_ports', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
'positions', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class FrontPortTemplateMappingSerializer(serializers.ModelSerializer):
position = serializers.IntegerField(
source='front_port_position'
)
rear_port = serializers.PrimaryKeyRelatedField(
queryset=RearPortTemplate.objects.all(),
)
class Meta:
model = PortTemplateMapping
fields = ('position', 'rear_port', 'rear_port_position')
class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
class FrontPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer(
nested=True,
required=False,
@@ -276,17 +244,13 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
default=None
)
type = ChoiceField(choices=PortTypeChoices)
rear_ports = FrontPortTemplateMappingSerializer(
source='mappings',
many=True,
required=False,
)
rear_port = RearPortTemplateSerializer(nested=True)
class Meta:
model = FrontPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'rear_ports', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
'rear_port', 'rear_port_position', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@@ -349,7 +313,7 @@ class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
required=False,
allow_null=True
)
component = GFKSerializerField(read_only=True)
component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
@@ -360,3 +324,11 @@ class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
'_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_component(self, obj):
if obj.component is None:
return None
serializer = get_serializer_for_model(obj.component)
context = {'request': self.context['request']}
return serializer(obj.component, nested=True, context=context).data

View File

@@ -5,8 +5,8 @@ from rest_framework import serializers
from dcim.choices import *
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
from netbox.api.fields import AttributesField, ChoiceField
from netbox.api.serializers import PrimaryModelSerializer
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from netbox.choices import *
from .manufacturers import ManufacturerSerializer
from .platforms import PlatformSerializer
@@ -18,7 +18,7 @@ __all__ = (
)
class DeviceTypeSerializer(PrimaryModelSerializer):
class DeviceTypeSerializer(NetBoxModelSerializer):
manufacturer = ManufacturerSerializer(nested=True)
default_platform = PlatformSerializer(nested=True, required=False, allow_null=True)
u_height = serializers.DecimalField(
@@ -45,14 +45,16 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
device_bay_template_count = serializers.IntegerField(read_only=True)
module_bay_template_count = serializers.IntegerField(read_only=True)
inventory_item_template_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
# Related object counts
device_count = RelatedObjectCountField('instances')
class Meta:
model = DeviceType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'weight_unit', 'front_image', 'rear_image', 'description', 'owner', 'comments', 'tags', 'custom_fields',
'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'console_port_template_count',
'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
@@ -61,18 +63,18 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
class ModuleTypeProfileSerializer(PrimaryModelSerializer):
class ModuleTypeProfileSerializer(NetBoxModelSerializer):
class Meta:
model = ModuleTypeProfile
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ModuleTypeSerializer(PrimaryModelSerializer):
class ModuleTypeSerializer(NetBoxModelSerializer):
profile = ModuleTypeProfileSerializer(
nested=True,
required=False,
@@ -98,13 +100,12 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
required=False,
allow_null=True
)
module_count = serializers.IntegerField(read_only=True)
class Meta:
model = ModuleType
fields = [
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'module_count',
'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')

View File

@@ -1,25 +1,23 @@
from dcim.models import Manufacturer
from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import OrganizationalModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
__all__ = (
'ManufacturerSerializer',
)
class ManufacturerSerializer(OrganizationalModelSerializer):
class ManufacturerSerializer(NetBoxModelSerializer):
# Related object counts
devicetype_count = RelatedObjectCountField('device_types')
moduletype_count = RelatedObjectCountField('module_types')
inventoryitem_count = RelatedObjectCountField('inventory_items')
platform_count = RelatedObjectCountField('platforms')
class Meta:
model = Manufacturer
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
'platform_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')

View File

@@ -24,7 +24,7 @@ class PlatformSerializer(NestedGroupModelSerializer):
model = Platform
fields = [
'id', 'url', 'display_url', 'display', 'parent', 'name', 'slug', 'manufacturer', 'config_template',
'description', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'virtualmachine_count', '_depth',
]
brief_fields = (

View File

@@ -1,7 +1,7 @@
from dcim.choices import *
from dcim.models import PowerFeed, PowerPanel
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.serializers import PrimaryModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from .base import ConnectedEndpointsSerializer
from .cables import CabledObjectSerializer
@@ -14,7 +14,7 @@ __all__ = (
)
class PowerPanelSerializer(PrimaryModelSerializer):
class PowerPanelSerializer(NetBoxModelSerializer):
site = SiteSerializer(nested=True)
location = LocationSerializer(
nested=True,
@@ -29,13 +29,13 @@ class PowerPanelSerializer(PrimaryModelSerializer):
class Meta:
model = PowerPanel
fields = [
'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'description', 'owner', 'comments',
'tags', 'custom_fields', 'powerfeed_count', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags',
'custom_fields', 'powerfeed_count', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
class PowerFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
power_panel = PowerPanelSerializer(nested=True)
rack = RackSerializer(
nested=True,
@@ -71,7 +71,6 @@ class PowerFeedSerializer(PrimaryModelSerializer, CabledObjectSerializer, Connec
'id', 'url', 'display_url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply',
'phase', 'voltage', 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'description', 'tenant', 'owner', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'_occupied',
'description', 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

View File

@@ -5,7 +5,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import Rack, RackReservation, RackRole, RackType
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from netbox.choices import *
from netbox.config import ConfigItem
from tenancy.api.serializers_.tenants import TenantSerializer
@@ -22,7 +22,7 @@ __all__ = (
)
class RackRoleSerializer(OrganizationalModelSerializer):
class RackRoleSerializer(NetBoxModelSerializer):
# Related object counts
rack_count = RelatedObjectCountField('racks')
@@ -30,13 +30,13 @@ class RackRoleSerializer(OrganizationalModelSerializer):
class Meta:
model = RackRole
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'rack_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackBaseSerializer(PrimaryModelSerializer):
class RackBaseSerializer(NetBoxModelSerializer):
form_factor = ChoiceField(
choices=RackFormFactorChoices,
allow_blank=True,
@@ -62,18 +62,19 @@ class RackBaseSerializer(PrimaryModelSerializer):
class RackTypeSerializer(RackBaseSerializer):
manufacturer = ManufacturerSerializer(nested=True)
rack_count = serializers.IntegerField(read_only=True)
manufacturer = ManufacturerSerializer(
nested=True
)
class Meta:
model = RackType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'rack_count',
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count')
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description')
class RackSerializer(RackBaseSerializer):
@@ -129,13 +130,13 @@ class RackSerializer(RackBaseSerializer):
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
'mounting_depth', 'airflow', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count', 'powerfeed_count',
'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
class RackReservationSerializer(PrimaryModelSerializer):
class RackReservationSerializer(NetBoxModelSerializer):
rack = RackSerializer(
nested=True,
)
@@ -156,7 +157,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
model = RackReservation
fields = [
'id', 'url', 'display_url', 'display', 'rack', 'units', 'status', 'created', 'last_updated', 'user',
'tenant', 'description', 'owner', 'comments', 'tags', 'custom_fields',
'tenant', 'description', 'comments', 'tags', 'custom_fields',
]
brief_fields = ('id', 'url', 'display', 'status', 'user', 'description', 'units')

View File

@@ -3,7 +3,7 @@ from rest_framework import serializers
from dcim.models import DeviceRole, InventoryItemRole
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from .nested import NestedDeviceRoleSerializer
__all__ = (
@@ -23,14 +23,14 @@ class DeviceRoleSerializer(NestedGroupModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'parent',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
'owner', 'comments', '_depth',
'comments', '_depth',
]
brief_fields = (
'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth'
)
class InventoryItemRoleSerializer(OrganizationalModelSerializer):
class InventoryItemRoleSerializer(NetBoxModelSerializer):
# Related object counts
inventoryitem_count = RelatedObjectCountField('inventory_items')
@@ -38,7 +38,7 @@ class InventoryItemRoleSerializer(OrganizationalModelSerializer):
class Meta:
model = InventoryItemRole
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'inventoryitem_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'inventoryitem_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')

View File

@@ -6,7 +6,7 @@ from dcim.models import Location, Region, Site, SiteGroup
from ipam.api.serializers_.asns import ASNSerializer
from ipam.models import ASN
from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from .nested import NestedLocationSerializer, NestedRegionSerializer, NestedSiteGroupSerializer
@@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer):
model = Region
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', 'prefix_count', 'owner', 'comments', '_depth',
'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
@@ -41,12 +41,12 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
model = SiteGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', 'prefix_count', 'owner', 'comments', '_depth',
'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteSerializer(PrimaryModelSerializer):
class SiteSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = RegionSerializer(nested=True, required=False, allow_null=True)
group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
@@ -72,7 +72,7 @@ class SiteSerializer(PrimaryModelSerializer):
model = Site
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count',
'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
]
@@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count',
'prefix_count', 'owner', 'comments', '_depth',
'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from dcim.models import VirtualChassis
from netbox.api.serializers import PrimaryModelSerializer
from netbox.api.serializers import NetBoxModelSerializer
from .nested import NestedDeviceSerializer
__all__ = (
@@ -9,7 +9,7 @@ __all__ = (
)
class VirtualChassisSerializer(PrimaryModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer):
master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
members = NestedDeviceSerializer(many=True, read_only=True)
@@ -19,7 +19,7 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
class Meta:
model = VirtualChassis
fields = [
'id', 'url', 'display_url', 'display', 'name', 'domain', 'master', 'description', 'owner', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'member_count', 'members',
'id', 'url', 'display_url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'member_count', 'members',
]
brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')

View File

@@ -11,7 +11,7 @@ class DCIMConfig(AppConfig):
from netbox.models.features import register_models
from utilities.counters import connect_counters
from . import signals, search # noqa: F401
from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
from .models import CableTermination, Device, DeviceType, VirtualChassis
# Register models
register_models(*self.get_models())
@@ -31,4 +31,4 @@ class DCIMConfig(AppConfig):
})
# Register counters
connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis)
connect_counters(Device, DeviceType, VirtualChassis)

View File

@@ -1,390 +0,0 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from dcim.choices import CableEndChoices
from dcim.models import CableTermination
class BaseCableProfile:
"""Base class for representing a cable profile."""
# Mappings of connectors to the number of positions presented by each, at either end of the cable. For example, a
# 12-strand MPO fiber cable would have one connector at either end with six positions (six bidirectional fiber
# pairs).
a_connectors = {}
b_connectors = {}
# Defined a mapping of A/B connector & position pairings. If not defined, all positions are presumed to be
# symmetrical (i.e. 1:1 on side A maps to 1:1 on side B). If defined, it must be constructed as a dictionary of
# two-item tuples, e.g. {(1, 1): (1, 1)}.
_mapping = None
def clean(self, cable):
# Enforce maximum terminations limits
a_terminations_count = len(cable.a_terminations)
b_terminations_count = len(cable.b_terminations)
max_a_terminations = len(self.a_connectors)
max_b_terminations = len(self.b_connectors)
if a_terminations_count > max_a_terminations:
raise ValidationError({
'a_terminations': _(
'A side of cable has {count} terminations but only {max} are permitted for profile {profile}'
).format(
count=a_terminations_count,
profile=cable.get_profile_display(),
max=max_a_terminations,
)
})
if b_terminations_count > max_b_terminations:
raise ValidationError({
'b_terminations': _(
'B side of cable has {count} terminations but only {max} are permitted for profile {profile}'
).format(
count=b_terminations_count,
profile=cable.get_profile_display(),
max=max_b_terminations,
)
})
def get_mapped_position(self, side, connector, position):
"""
Return the mapped far-end connector & position for a given cable end the local connector & position.
"""
# By default, assume all positions are symmetrical.
if self._mapping:
return self._mapping.get((connector, position))
return connector, position
def get_peer_termination(self, termination, position):
"""
Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable.
"""
try:
connector, position = self.get_mapped_position(
termination.cable_end,
termination.cable_connector,
position
)
except TypeError:
raise ValueError(
f"Could not map connector {termination.cable_connector} position {position} on side "
f"{termination.cable_end}"
)
try:
ct = CableTermination.objects.get(
cable=termination.cable,
cable_end=termination.opposite_cable_end,
connector=connector,
positions__contains=[position],
)
return ct.termination, position
except CableTermination.DoesNotExist:
return None, None
@staticmethod
def get_position_list(n):
"""Return a list of integers from 1 to n, inclusive."""
return list(range(1, n + 1))
# Profile naming:
# - Single: One connector per side, with one or more positions
# - Trunk: Two or more connectors per side, with one or more positions per connector
# - Breakout: One or more connectors on the A side which map to a greater number of B side connectors
# - Shuffle: A cable with nonlinear position mappings between sides
class Single1C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 1,
}
b_connectors = a_connectors
class Single1C2PCableProfile(BaseCableProfile):
a_connectors = {
1: 2,
}
b_connectors = a_connectors
class Single1C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
}
b_connectors = a_connectors
class Single1C6PCableProfile(BaseCableProfile):
a_connectors = {
1: 6,
}
b_connectors = a_connectors
class Single1C8PCableProfile(BaseCableProfile):
a_connectors = {
1: 8,
}
b_connectors = a_connectors
class Single1C12PCableProfile(BaseCableProfile):
a_connectors = {
1: 12,
}
b_connectors = a_connectors
class Single1C16PCableProfile(BaseCableProfile):
a_connectors = {
1: 16,
}
b_connectors = a_connectors
class Trunk2C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 1,
2: 1,
}
b_connectors = a_connectors
class Trunk2C2PCableProfile(BaseCableProfile):
a_connectors = {
1: 2,
2: 2,
}
b_connectors = a_connectors
class Trunk2C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
}
b_connectors = a_connectors
class Trunk2C6PCableProfile(BaseCableProfile):
a_connectors = {
1: 6,
2: 6,
}
b_connectors = a_connectors
class Trunk2C8PCableProfile(BaseCableProfile):
a_connectors = {
1: 8,
2: 8,
}
b_connectors = a_connectors
class Trunk2C12PCableProfile(BaseCableProfile):
a_connectors = {
1: 12,
2: 12,
}
b_connectors = a_connectors
class Trunk4C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
}
b_connectors = a_connectors
class Trunk4C2PCableProfile(BaseCableProfile):
a_connectors = {
1: 2,
2: 2,
3: 2,
4: 2,
}
b_connectors = a_connectors
class Trunk4C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
3: 4,
4: 4,
}
b_connectors = a_connectors
class Trunk4C6PCableProfile(BaseCableProfile):
a_connectors = {
1: 6,
2: 6,
3: 6,
4: 6,
}
b_connectors = a_connectors
class Trunk4C8PCableProfile(BaseCableProfile):
a_connectors = {
1: 8,
2: 8,
3: 8,
4: 8,
}
b_connectors = a_connectors
class Trunk8C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
3: 4,
4: 4,
5: 4,
6: 4,
7: 4,
8: 4,
}
b_connectors = a_connectors
class Breakout1C4Px4C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
}
b_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
}
_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(1, 3): (3, 1),
(1, 4): (4, 1),
(2, 1): (1, 2),
(3, 1): (1, 3),
(4, 1): (1, 4),
}
class Breakout1C6Px6C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 6,
}
b_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
5: 1,
6: 1,
}
_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(1, 3): (3, 1),
(1, 4): (4, 1),
(1, 5): (5, 1),
(1, 6): (6, 1),
(2, 1): (1, 2),
(3, 1): (1, 3),
(4, 1): (1, 4),
(5, 1): (1, 5),
(6, 1): (1, 6),
}
class Trunk2C4PShuffleCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
}
b_connectors = a_connectors
_mapping = {
(1, 1): (1, 1),
(1, 2): (1, 2),
(1, 3): (2, 1),
(1, 4): (2, 2),
(2, 1): (1, 3),
(2, 2): (1, 4),
(2, 3): (2, 3),
(2, 4): (2, 4),
}
class Trunk4C4PShuffleCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
3: 4,
4: 4,
}
b_connectors = a_connectors
_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(1, 3): (3, 1),
(1, 4): (4, 1),
(2, 1): (1, 2),
(2, 2): (2, 2),
(2, 3): (3, 2),
(2, 4): (4, 2),
(3, 1): (1, 3),
(3, 2): (2, 3),
(3, 3): (3, 3),
(3, 4): (4, 3),
(4, 1): (1, 4),
(4, 2): (2, 4),
(4, 3): (3, 4),
(4, 4): (4, 4),
}
class Breakout2C4Px8C1PShuffleCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
}
b_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
5: 1,
6: 1,
7: 1,
8: 1,
}
_a_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(1, 3): (5, 1),
(1, 4): (6, 1),
(2, 1): (3, 1),
(2, 2): (4, 1),
(2, 3): (7, 1),
(2, 4): (8, 1),
}
_b_mapping = {
(1, 1): (1, 1),
(2, 1): (1, 2),
(3, 1): (2, 1),
(4, 1): (2, 2),
(5, 1): (1, 3),
(6, 1): (1, 4),
(7, 1): (2, 3),
(8, 1): (2, 4),
}
def get_mapped_position(self, side, connector, position):
if side.upper() == CableEndChoices.SIDE_A:
return self._a_mapping.get((connector, position))
return self._b_mapping.get((connector, position))

Some files were not shown because too many files have changed in this diff Show More