Merge branch 'feature' into 6651-plugins-rq-queues

This commit is contained in:
Jeremy Stretch 2021-07-09 08:43:39 -04:00 committed by GitHub
commit fd58eeae1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1291 changed files with 33321 additions and 183711 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v2.11.7
placeholder: v2.11.9
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v2.11.7
placeholder: v2.11.9
validations:
required: true
- type: dropdown

View File

@ -5,7 +5,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
python-version: [3.7, 3.8]
services:
redis:
image: redis
@ -40,6 +40,9 @@ jobs:
pip install pycodestyle coverage
ln -s configuration.testing.py netbox/netbox/configuration.py
- name: Collect static files
run: python netbox/manage.py collectstatic --no-input
- name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 netbox/

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
*.pyc
*.swp
node_modules
/netbox/project-static/.cache
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/reports/*

View File

@ -54,11 +54,13 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
### Screenshots
![Screenshot of main page](docs/media/screenshot1.png "Main page")
![Screenshot of Main Page](docs/media/home-light.png "Main Page")
![Screenshot of rack elevation](docs/media/screenshot2.png "Rack elevation")
![Screenshot of Rack Elevation](docs/media/rack-dark.png "Rack Elevation")
![Screenshot of prefix hierarchy](docs/media/screenshot3.png "Prefix hierarchy")
![Screenshot of Prefix Hierarchy](docs/media/prefixes-light.png "Prefix Hierarchy")
![Screenshot of Cable Tracing](docs/media/cable-dark.png "Cable Tracing")
### Related projects

View File

@ -2,10 +2,6 @@
# https://github.com/django/django
Django
# Django caching using Redis
# https://github.com/Suor/django-cacheops
django-cacheops
# Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers
django-cors-headers
@ -18,6 +14,10 @@ django-debug-toolbar
# https://github.com/carltongibson/django-filter
django-filter
# Django debug toolbar extension with support for GraphiQL
# https://github.com/flavors/django-graphiql-debug-toolbar/
django-graphiql-debug-toolbar
# Modified Preorder Tree Traversal (recursive nesting of objects)
# https://github.com/django-mptt/django-mptt
django-mptt
@ -30,6 +30,10 @@ django-pglocks
# https://github.com/korfuri/django-prometheus
django-prometheus
# Django chaching backend using Redis
# https://github.com/jazzband/django-redis
django-redis
# Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq
django-rq
@ -54,6 +58,10 @@ djangorestframework
# https://github.com/axnsan12/drf-yasg
drf-yasg[validation]
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django
graphene_django
# WSGI HTTP server
# https://gunicorn.org/
gunicorn

View File

@ -0,0 +1,9 @@
#!/bin/sh
# This shell script invokes NetBox's housekeeping management command, which
# intended to be run nightly. This script can be copied into your system's
# daily cron directory (e.g. /etc/cron.daily), or referenced directly from
# within the cron configuration file.
#
# If NetBox has been installed into a nonstandard location, update the paths
# below.
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping

View File

@ -1,25 +0,0 @@
# Caching
NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database.
## Invalidating Cached Data
Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object by its type and numeric ID:
```no-highlight
$ python netbox/manage.py invalidate dcim.Device.34
```
Alternatively, it can also delete all cached results for an object type:
```no-highlight
$ python netbox/manage.py invalidate dcim.Device
```
Finally, calling it with the `all` argument will force invalidation of the entire cache database:
```no-highlight
$ python netbox/manage.py invalidate all
```

View File

@ -1,6 +1,6 @@
# Webhooks
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks.
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks.
## Configuration

View File

@ -0,0 +1,10 @@
# Housekeeping
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/optional-settings.md#changelog_retention)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be copied into your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
The `housekeeping` command can also be run manually at any time: Running the command outside of scheduled execution times will not interfere with its operation.

View File

@ -194,7 +194,7 @@ To delete multiple objects at once, call `delete()` on a filtered queryset. It's
>>> Device.objects.filter(name__icontains='test').count()
27
>>> Device.objects.filter(name__icontains='test').delete()
(35, {'dcim.DeviceBay': 0, 'secrets.Secret': 0, 'dcim.InterfaceConnection': 4,
(35, {'dcim.DeviceBay': 0, 'dcim.InterfaceConnection': 4,
'extras.ImageAttachment': 0, 'dcim.Device': 27, 'dcim.Interface': 4,
'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
```

View File

@ -52,14 +52,6 @@ BASE_PATH = 'netbox/'
---
## CACHE_TIMEOUT
Default: 900
The number of seconds that cache entries will be retained before expiring.
---
## CHANGELOG_RETENTION
Default: 90
@ -96,6 +88,12 @@ CORS_ORIGIN_WHITELIST = [
---
## CUSTOM_VALIDATORS
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic.
---
## DEBUG
Default: False
@ -144,7 +142,7 @@ In order to send email, NetBox needs an email server configured. The following i
!!! note
The `USE_SSL` and `USE_TLS` parameters are mutually exclusive.
Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) fuction accessible within the NetBox shell:
Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell:
```no-highlight
# python ./manage.py nbshell
@ -195,6 +193,14 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
---
## GRAPHQL_ENABLED
Default: True
Setting this to False will disable the GraphQL API.
---
## HTTP_PROXIES
Default: None
@ -261,7 +267,7 @@ LOGGING = {
Default: False
Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox (excluding secrets) but not make any changes.
Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes.
---
@ -472,19 +478,11 @@ When remote user authentication is in use, this is the name of the HTTP header w
---
## RELEASE_CHECK_TIMEOUT
Default: 86,400 (24 hours)
The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. This must be set to at least one hour (3600 seconds).
---
## RELEASE_CHECK_URL
Default: None (disabled)
This parameter defines the URL of the repository that will be checked periodically for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks.
This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks.
!!! note
The URL provided **must** be compatible with the [GitHub REST API](https://docs.github.com/en/rest).
@ -495,7 +493,7 @@ This parameter defines the URL of the repository that will be checked periodical
Default: `$INSTALL_ROOT/netbox/reports/`
The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path.
The file path to the location where [custom reports](../customization/reports.md) will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path.
---
@ -511,7 +509,7 @@ The maximum execution time of a background task (such as running a custom script
Default: `$INSTALL_ROOT/netbox/scripts/`
The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
---

View File

@ -1,8 +0,0 @@
# Secrets
{!docs/models/secrets/secret.md!}
{!docs/models/secrets/secretrole.md!}
---
{!docs/models/secrets/userkey.md!}

View File

@ -8,7 +8,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea
## Creating Custom Fields
Custom fields must be created through the admin UI under Extras > Custom Fields. NetBox supports six types of custom field:
Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
* Text: Free-form text (up to 255 characters)
* Integer: A whole number (positive or negative)

View File

@ -1,8 +1,8 @@
# Custom Links
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside of NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
For example, you might define a link like this:
@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as:
<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
```
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
## Context Data

View File

@ -170,14 +170,9 @@ Similar to `ChoiceVar`, but allows for the selection of multiple choices.
A particular object within NetBox. Each ObjectVar must specify a particular model, and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below.
* `model` - The model class
* `display_field` - The name of the REST API object field to display in the selection list (default: `'display'`)
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
* `null_option` - A label representing a "null" or empty choice (optional)
!!! warning
The `display_field` parameter is now deprecated, and will be removed in NetBox v3.0. All ObjectVar instances will
instead use the new standard `display` field for all serializers (introduced in NetBox v2.11).
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
```python
@ -288,7 +283,6 @@ class NewBranchScript(Script):
switch_model = ObjectVar(
description="Access switch model",
model=DeviceType,
display_field='model',
query_params={
'manufacturer_id': '$manufacturer'
}

View File

@ -0,0 +1,86 @@
# Custom Validation
NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using NetBox's `CustomValidator` class.
## CustomValidator
### Validation Rules
A custom validator can be instantiated by passing a mapping of attributes to a set of rules to which that attribute must conform. For example:
```python
from extras.validators import CustomValidator
CustomValidator({
'name': {
'min_length': 5,
'max_length': 30,
}
})
```
This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation.
The `CustomValidator` class supports several validation types:
* `min`: Minimum value
* `max`: Maximum value
* `min_length`: Minimum string length
* `max_length`: Maximum string length
* `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression)
* `required`: A value must be specified
* `prohibited`: A value must _not_ be specified
The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). The `required` and `prohibited` validators may be used for any field, and should be passed a value of `True`.
!!! warning
Bear in mind that these validators merely supplement NetBox's own validation: They will not override it. For example, if a certain model field is required by NetBox, setting a validator for it with `{'prohibited': True}` will not work.
### Custom Validation Logic
There may be instances where the provided validation types are insufficient. The `CustomValidator` class can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
```python
from extras.validators import CustomValidator
class MyValidator(CustomValidator):
def validate(self, instance):
if instance.status == 'active' and not instance.description:
self.fail("Active sites must have a description set!", field='status')
```
The `fail()` method may optionally specify a field with which to associate the supplied error message. If specified, the error message will appear to the user as associated with this field. If omitted, the error message will not be associated with any field.
## Assigning Custom Validators
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter, as such:
```python
CUSTOM_VALIDATORS = {
'dcim.site': (
Validator1,
Validator2,
Validator3
)
}
```
!!! note
Even if defining only a single validator, it must be passed as an iterable.
When it is not necessary to define a custom `validate()` method, you may opt to pass a `CustomValidator` instance directly:
```python
from extras.validators import CustomValidator
CUSTOM_VALIDATORS = {
'dcim.site': (
CustomValidator({
'name': {
'min_length': 5,
'max_length': 30,
}
}),
)
}
```

View File

@ -1,6 +1,6 @@
# Export Templates
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface.
NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates.
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension.
@ -33,6 +33,16 @@ The `as_attachment` attribute of an export template controls its behavior when r
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
## REST API Integration
When it is necessary to provide authentication credentials (such as when [`LOGIN_REQUIRED`](../configuration/optional-settings.md#login_required) has been enabled), it is recommended to render export templates via the REST API. This allows the client to specify an authentication token. To render an export template via the REST API, make a `GET` request to the model's list endpoint and append the `export` parameter specifying the export template name. For example:
```
GET /api/dcim/sites/?export=MyTemplateName
```
Note that the body of the response will contain only the rendered export template content, as opposed to a JSON object or list.
## Example
Here's an example device export template that will generate a simple Nagios configuration from a list of devices.

View File

@ -32,19 +32,15 @@ class Foo(models.Model):
raise ValidationError()
```
## 3. Add CSV helpers
Add the name of the new field to `csv_headers` and included a CSV-friendly representation of its data in the model's `to_csv()` method. These will be used when exporting objects in CSV format.
## 4. Update relevant querysets
## 3. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
## 5. Update API serializer
## 4. Update API serializer
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
## 6. Add field to forms
## 5. Add field to forms
Extend any forms to include the new field as appropriate. Common forms include:
@ -53,19 +49,19 @@ Extend any forms to include the new field as appropriate. Common forms include:
* **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
## 7. Extend object filter set
## 6. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
## 8. Add column to object table
## 7. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column.
## 9. Update the UI templates
## 8. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
## 10. Create/extend test cases
## 9. Create/extend test cases
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
@ -77,6 +73,6 @@ Create or extend the relevant test cases to verify that the new field and any ac
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
## 11. Update the model's documentation
## 10. Update the model's documentation
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.

View File

@ -25,7 +25,6 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
* `dcim`: Datacenter infrastructure management (sites, racks, and devices)
* `extras`: Additional features not considered part of the core data model
* `ipam`: IP address management (VRFs, prefixes, IP addresses, and VLANs)
* `secrets`: Encrypted storage of sensitive data (e.g. login credentials)
* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
* `users`: Authentication and user preferences
* `utilities`: Resources which are not user-facing (extendable classes, etc.)

View File

@ -10,8 +10,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log
* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects
* [Custom fields](../additional-features/custom-fields.md) - These models support the addition of user-defined fields
* [Export templates](../additional-features/export-templates.md) - Users can create custom export templates for these models
* [Custom fields](../customization/custom-fields.md) - These models support the addition of user-defined fields
* [Export templates](../customization/export-templates.md) - Users can create custom export templates for these models
* [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags
* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary
* Nesting - These models can be nested recursively to create a hierarchy
@ -47,7 +47,6 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.Service](../models/ipam/service.md)
* [ipam.VLAN](../models/ipam/vlan.md)
* [ipam.VRF](../models/ipam/vrf.md)
* [secrets.Secret](../models/secrets/secret.md)
* [tenancy.Tenant](../models/tenancy/tenant.md)
* [virtualization.Cluster](../models/virtualization/cluster.md)
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
@ -62,7 +61,6 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.RIR](../models/ipam/rir.md)
* [ipam.Role](../models/ipam/role.md)
* [ipam.VLANGroup](../models/ipam/vlangroup.md)
* [secrets.SecretRole](../models/secrets/secretrole.md)
* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md)
* [virtualization.ClusterType](../models/virtualization/clustertype.md)

View File

@ -0,0 +1,11 @@
# Signals
In addition to [Django's built-in signals](https://docs.djangoproject.com/en/stable/topics/signals/), NetBox defines some of its own, listed below.
## post_clean
This signal is sent by models which inherit from `CustomValidationMixin` at the end of their `clean()` method.
### Receivers
* `extras.signals.run_custom_validators()`

View File

@ -0,0 +1,70 @@
# GraphQL API Overview
NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by the [Graphene](https://graphene-python.org/) library and [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/).
## Queries
GraphQL enables the client to specify an arbitrary nested list of fields to include in the response. All queries are made to the root `/graphql` API endpoint. For example, to return the circuit ID and provider name of each circuit with an active status, you can issue a request such as the following:
```
curl -H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
http://netbox/graphql/ \
--data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}'
```
The response will include the requested data formatted as JSON:
```json
{
"data": {
"circuits": [
{
"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.
NetBox provides both a singular and plural query field for each object type:
* `$OBJECT`: Returns a single object. Must specify the object's unique ID as `(id: 123)`.
* `$OBJECT_list`: Returns a list of objects, optionally filtered by given parameters.
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 fitlers) to fetch all devices.
For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).
## Filtering
The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
```
{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"}
```
## Authentication
NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
```
Authorization: Token $TOKEN
```
## Disabling the GraphQL API
If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/optional-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox.

View File

@ -247,6 +247,18 @@ Password (again):
Superuser created successfully.
```
## Schedule the Housekeeping Task
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
## Test the Application
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:

View File

@ -102,5 +102,12 @@ Finally, restart the gunicorn and RQ services:
sudo systemctl restart netbox netbox-rq
```
!!! note
If upgrading from an installation that uses supervisord, please see the instructions for [migrating to systemd](migrating-to-systemd.md). The use of supervisord is no longer supported.
## Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

BIN
docs/media/cable-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

BIN
docs/media/cable-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

BIN
docs/media/home-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

BIN
docs/media/home-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

BIN
docs/media/rack-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

BIN
docs/media/rack-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

View File

@ -2,7 +2,7 @@
A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single device. A virtual chassis must be assigned a name and may be assigned a domain.
Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, secrets, services, and other attributes related to managing the VC.
Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, and other attributes related to managing the VC.
!!! note
It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices.

View File

@ -1,5 +0,0 @@
# Secrets
A secret represents a single credential or other sensitive string of characters which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext.
Each secret can also store an optional name parameter, which is not encrypted. This may be useful for storing user names.

View File

@ -1,9 +0,0 @@
# Secret Roles
Each secret is assigned a functional role which indicates what it is used for. Secret roles are customizable. Typical roles might include:
* Login credentials
* SNMP community strings
* RADIUS/TACACS+ keys
* IKE key strings
* Routing protocol shared secrets

View File

@ -1,35 +0,0 @@
# User Keys
Each user within NetBox can associate his or her account with an RSA public key. If activated by an administrator, this user key will contain a unique, encrypted copy of the AES master key needed to retrieve secret data.
User keys may be created by users individually, however they are of no use until they have been activated by a user who already possesses an active user key.
## Supported Key Format
Public key formats supported
- PKCS#1 RSAPublicKey* (PEM header: BEGIN RSA PUBLIC KEY)
- X.509 SubjectPublicKeyInfo** (PEM header: BEGIN PUBLIC KEY)
- **OpenSSH line format is not supported.**
Private key formats supported (unencrypted)
- PKCS#1 RSAPrivateKey** (PEM header: BEGIN RSA PRIVATE KEY)
- PKCS#8 PrivateKeyInfo* (PEM header: BEGIN PRIVATE KEY)
## Creating the First User Key
When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user (typically the superuser) must create a user key. This can be done by navigating to Profile > User Key.
To create a user key, you can either generate a new RSA key pair, or upload the public key belonging to a pair you already have. If generating a new key pair, **you must save the private key** locally before saving your new user key. Once your user key has been created, its public key will be displayed under your profile.
When the first user key is created in NetBox, a random master encryption key is generated automatically. This key is then encrypted using the public key provided and stored as part of your user key. **The master key cannot be recovered** without your private key.
Once a user key has been assigned an encrypted copy of the master key, it is considered activated and can now be used to encrypt and decrypt secrets.
## Creating Additional User Keys
Any user can create his or her user key by generating or uploading a public RSA key. However, a user key cannot be used to encrypt or decrypt secrets until it has been activated with an encrypted copy of the master key.
Only an administrator with an active user key can activate other user keys. To do so, access the NetBox admin UI and navigate to Secrets > User Keys. Select the user key(s) to be activated, and select "activate selected user keys" from the actions dropdown. You will need to provide your private key in order to decrypt the master key. A copy of the master key is then encrypted using the public key associated with the user key being activated.

View File

@ -113,7 +113,6 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `caching_config` | Plugin-specific cache configuration
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
@ -386,34 +385,6 @@ class SiteAnimalCount(PluginTemplateExtension):
template_extensions = [SiteAnimalCount]
```
## Caching Configuration
By default, all query operations within a plugin are cached. To change this, define a caching configuration under the PluginConfig class' `caching_config` attribute. All configuration keys will be applied within the context of the plugin; there is no need to include the plugin name. An example configuration is below:
```python
class MyPluginConfig(PluginConfig):
...
caching_config = {
'foo': {
'ops': 'get',
'timeout': 60 * 15,
},
'*': {
'ops': 'all',
}
}
```
To disable caching for your plugin entirely, set:
```python
caching_config = {
'*': None
}
```
See the [django-cacheops](https://github.com/Suor/django-cacheops) documentation for more detail on configuring caching.
## Background Tasks
By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*.
@ -440,4 +411,4 @@ In case you create dedicated queues for your plugin, it is strongly advised to a
```
python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name
```
```

View File

@ -1 +1 @@
version-2.11.md
version-3.0.md

View File

@ -1,10 +1,26 @@
# NetBox v2.11
## v2.11.8 (FUTURE)
## v2.11.9 (2021-07-08)
### Bug Fixes
* [#6456](https://github.com/netbox-community/netbox/issues/6456) - API schema type should be boolean for `_occupied` on cable termination models
* [#6710](https://github.com/netbox-community/netbox/issues/6710) - Fix assignment of VM interface parent via REST API
* [#6714](https://github.com/netbox-community/netbox/issues/6714) - Fix rendering of device type component creation forms
---
## v2.11.8 (2021-07-06)
### Enhancements
* [#5503](https://github.com/netbox-community/netbox/issues/5503) - Annotate short date & time fields with their longer form
* [#6138](https://github.com/netbox-community/netbox/issues/6138) - Add an `empty` filter modifier for character fields
* [#6200](https://github.com/netbox-community/netbox/issues/6200) - Add rack reservations to global search
* [#6368](https://github.com/netbox-community/netbox/issues/6368) - Enable virtual chassis assignment during bulk import of devices
* [#6620](https://github.com/netbox-community/netbox/issues/6620) - Show assigned VMs count under device role view
* [#6666](https://github.com/netbox-community/netbox/issues/6666) - Show management-only status under interface detail view
* [#6667](https://github.com/netbox-community/netbox/issues/6667) - Display VM memory as GB/TB as appropriate
### Bug Fixes
@ -12,6 +28,9 @@
* [#6637](https://github.com/netbox-community/netbox/issues/6637) - Fix group assignment in "available VLANs" link under VLAN group view
* [#6640](https://github.com/netbox-community/netbox/issues/6640) - Disallow numeric values in custom text fields
* [#6652](https://github.com/netbox-community/netbox/issues/6652) - Fix exception when adding components in bulk to multiple devices
* [#6676](https://github.com/netbox-community/netbox/issues/6676) - Fix device/VM counts per cluster under cluster type/group views
* [#6680](https://github.com/netbox-community/netbox/issues/6680) - Allow setting custom field values for VM interfaces on initial creation
* [#6695](https://github.com/netbox-community/netbox/issues/6695) - Fix exception when importing device type with invalid front port definition
---

View File

@ -218,7 +218,7 @@
#### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415))
Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/additional-features/custom-scripts/) for more detail.
Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/customization/custom-scripts/) for more detail.
Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release.

View File

@ -0,0 +1,115 @@
# NetBox v3.0
## v3.0-beta1 (FUTURE)
### Breaking Changes
* The default CSV export format for all objects now includes all available data. Additionally, the CSV headers now use human-friendly titles rather than the raw field names.
* Support for queryset caching configuration (`caching_config`) has been removed from the plugins API (see [#6639](https://github.com/netbox-community/netbox/issues/6639)).
* The `cacheops_*` metrics have been removed from the Prometheus exporter (see [#6639](https://github.com/netbox-community/netbox/issues/6639)).
* The `invalidate` management command has been removed.
* The redundant REST API endpoints for console, power, and interface connections have been removed. The same data can be retrieved using the respective model endpoints with the `?connected=True` filter applied.
### New Features
### REST API Token Provisioning ([#5264](https://github.com/netbox-community/netbox/issues/5264))
This release introduces the `/api/users/tokens/` REST API endpoint, which includes a child endpoint that can be employed by a user to provision a new REST API token. This allows a user to gain REST API access without needing to first create a token via the web UI.
```
$ curl -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
https://netbox/api/users/tokens/provision/
{
"username": "hankhill",
"password: "I<3C3H8",
}
```
If the supplied credentials are valid, NetBox will create and return a new token for the user.
#### Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963))
This release introduces the [`CUSTOM_VALIDATORS`](../configuration/optional-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description:
```python
from extras.validators import CustomValidator
CUSTOM_VALIDATORS = {
'dcim.site': (
CustomValidator({
'name': {
'min_length': 10,
},
'description': {
'required': True,
}
}),
)
}
```
CustomValidator can also be subclassed to enforce more complex logic by overriding its `validate()` method. See the [custom validation](../customization/custom-validation.md) documentation for more details.
### Enhancements
* [#2434](https://github.com/netbox-community/netbox/issues/2434) - Add option to assign IP address upon creating a new interface
* [#3665](https://github.com/netbox-community/netbox/issues/3665) - Enable rendering export templates via REST API
* [#3682](https://github.com/netbox-community/netbox/issues/3682) - Add `color` field to front and rear ports
* [#4609](https://github.com/netbox-community/netbox/issues/4609) - Allow marking prefixes as fully utilized
* [#5806](https://github.com/netbox-community/netbox/issues/5806) - Add kilometer and mile as choices for cable length unit
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
* [#6590](https://github.com/netbox-community/netbox/issues/6590) - Introduce a nightly housekeeping command to clear expired sessions and change records
### Other Changes
* [#5223](https://github.com/netbox-community/netbox/issues/5223) - Remove the console/power/interface connections REST API endpoints
* [#5532](https://github.com/netbox-community/netbox/issues/5532) - Drop support for Python 3.6
* [#5994](https://github.com/netbox-community/netbox/issues/5994) - Drop support for `display_field` argument on ObjectVar
* [#6068](https://github.com/netbox-community/netbox/issues/6068) - Drop support for legacy static CSV export
* [#6338](https://github.com/netbox-community/netbox/issues/6338) - Decimal fields are no longer coerced to strings in REST API
* [#6639](https://github.com/netbox-community/netbox/issues/6639) - Drop support for queryset caching (django-cacheops)
* [#6713](https://github.com/netbox-community/netbox/issues/6713) - Checking for new releases is now done as part of the housekeeping routine
### Configuration Changes
* The `CACHE_TIMEOUT` configuration parameter has been removed.
* The `RELEASE_CHECK_TIMEOUT` configuration parameter has been removed.
### REST API Changes
* Added the `/api/users/tokens/` endpoint
* The `provision/` child endpoint can be used to provision new REST API tokens by supplying a valid username and password
* Removed the following "connections" endpoints:
* `/api/dcim/console-connections`
* `/api/dcim/power-connections`
* `/api/dcim/interface-connections`
* dcim.Cable
* `length` is now a decimal value
* dcim.Device
* Removed the `display_name` attribute (use `display` instead)
* dcim.DeviceType
* Removed the `display_name` attribute (use `display` instead)
* dcim.FrontPort
* Added `color` field
* dcim.FrontPortTemplate
* Added `color` field
* dcim.Rack
* Removed the `display_name` attribute (use `display` instead)
* dcim.RearPort
* Added `color` field
* dcim.RearPortTemplate
* Added `color` field
* dcim.Site
* `latitude` and `longitude` are now decimal fields rather than strings
* extras.ContentType
* Removed the `display_name` attribute (use `display` instead)
* ipam.Prefix
* Added the `mark_utilized` boolean field
* ipam.VLAN
* Removed the `display_name` attribute (use `display` instead)
* ipam.VRF
* Removed the `display_name` attribute (use `display` instead)
* virtualization.VirtualMachine
* `vcpus` is now a decimal field rather than a string

View File

@ -11,7 +11,7 @@ An authentication token is attached to a request by setting the `Authorization`
```
$ curl -H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
http://netbox/api/dcim/sites/
https://netbox/api/dcim/sites/
{
"count": 10,
"next": null,
@ -23,8 +23,46 @@ http://netbox/api/dcim/sites/
A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response:
```
$ curl http://netbox/api/dcim/sites/
$ curl https://netbox/api/dcim/sites/
{
"detail": "Authentication credentials were not provided."
}
```
## Initial Token Provisioning
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.
To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint:
```
$ curl -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
https://netbox/api/users/tokens/provision/
{
"username": "hankhill",
"password: "I<3C3H8",
}
```
Note that we are _not_ passing an existing REST API token with this request. If the supplied credentials are valid, a new REST API token will be automatically created for the user. Note that the key will be automatically generated, and write ability will be enabled.
```json
{
"id": 6,
"url": "https://netbox/api/users/tokens/6/",
"display": "3c9cb9 (hankhill)",
"user": {
"id": 2,
"url": "https://netbox/api/users/users/2/",
"display": "hankhill",
"username": "hankhill"
},
"created": "2021-06-11T20:09:13.339367Z",
"expires": null,
"key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9",
"write_enabled": true,
"description": ""
}
```

View File

@ -61,25 +61,30 @@ These lookup expressions can be applied by adding a suffix to the desired field'
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
- `n` - not equal to (negation)
- `lt` - less than
- `lte` - less than or equal
- `gt` - greater than
- `gte` - greater than or equal
| Filter | Description |
|--------|-------------|
| `n` | Not equal to |
| `lt` | Less than |
| `lte` | Less than or equal to |
| `gt` | Greater than |
| `gte` | Greater than or equal to |
### String Fields
String based (char) fields (Name, Address, etc) support these lookup expressions:
- `n` - not equal to (negation)
- `ic` - case insensitive contains
- `nic` - negated case insensitive contains
- `isw` - case insensitive starts with
- `nisw` - negated case insensitive starts with
- `iew` - case insensitive ends with
- `niew` - negated case insensitive ends with
- `ie` - case insensitive exact match
- `nie` - negated case insensitive exact match
| Filter | Description |
|--------|-------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty (boolean) |
### Foreign Keys & Other Fields

View File

@ -67,7 +67,7 @@ Comprehensive, interactive documentation of all REST API endpoints is available
## Endpoint Hierarchy
NetBox's entire REST API is housed under the API root at `https://<hostname>/api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, plugins, secrets, tenancy, users, and virtualization. Within each application exists a separate path for each model. For example, the provider and circuit objects are located under the "circuits" application:
NetBox's entire REST API is housed under the API root at `https://<hostname>/api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, plugins, tenancy, users, and virtualization. Within each application exists a separate path for each model. For example, the provider and circuit objects are located under the "circuits" application:
* `/api/circuits/providers/`
* `/api/circuits/circuits/`

View File

@ -1,172 +0,0 @@
# Working with Secrets
As with most other objects, the REST API can be used to view, create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data.
## Generating a Session Key
In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../core-functionality/secrets.md#user-keys). The private key must be POSTed with the name `private_key`.
```no-highlight
$ curl -X POST http://netbox/api/secrets/get-session-key/ \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
--data-urlencode "private_key@<filename>"
```
```json
{
"session_key": "dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
}
```
!!! note
To read the private key from a file, use the convention above. Alternatively, the private key can be read from an environment variable using `--data-urlencode "private_key=$PRIVATE_KEY"`.
The request uses the provided private key to unlock your stored copy of the master key and generate a temporary session key, which can be attached in the `X-Session-Key` header of future API requests.
## Retrieving Secrets
A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its `plaintext` field set to null.
```no-highlight
$ curl http://netbox/api/secrets/secrets/2587/ \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4"
```
```json
{
"id": 2587,
"url": "http://netbox/api/secrets/secrets/2587/",
"device": {
"id": 1827,
"url": "http://netbox/api/dcim/devices/1827/",
"name": "MyTestDevice",
"display_name": "MyTestDevice"
},
"role": {
"id": 1,
"url": "http://netbox/api/secrets/secret-roles/1/",
"name": "Login Credentials",
"slug": "login-creds"
},
"name": "admin",
"plaintext": null,
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
"tags": [],
"custom_fields": {},
"created": "2017-03-21",
"last_updated": "2017-03-21T19:28:44.265582Z"
}
```
To decrypt a secret, we must include our session key in the `X-Session-Key` header when sending the `GET` request:
```no-highlight
$ curl http://netbox/api/secrets/secrets/2587/ \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
```
```json
{
"id": 2587,
"url": "http://netbox/api/secrets/secrets/2587/",
"device": {
"id": 1827,
"url": "http://netbox/api/dcim/devices/1827/",
"name": "MyTestDevice",
"display_name": "MyTestDevice"
},
"role": {
"id": 1,
"url": "http://netbox/api/secrets/secret-roles/1/",
"name": "Login Credentials",
"slug": "login-creds"
},
"name": "admin",
"plaintext": "foobar",
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
"tags": [],
"custom_fields": {},
"created": "2017-03-21",
"last_updated": "2017-03-21T19:28:44.265582Z"
}
```
Multiple secrets within a list can be decrypted in this manner as well:
```no-highlight
$ curl http://netbox/api/secrets/secrets/?limit=3 \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
```
```json
{
"count": 3482,
"next": "http://netbox/api/secrets/secrets/?limit=3&offset=3",
"previous": null,
"results": [
{
"id": 2587,
"plaintext": "foobar",
...
},
{
"id": 2588,
"plaintext": "MyP@ssw0rd!",
...
},
{
"id": 2589,
"plaintext": "AnotherSecret!",
...
},
]
}
```
## Creating and Updating Secrets
Session keys are required when creating or modifying secrets. The secret's `plaintext` attribute is set to its non-encrypted value, and NetBox uses the session key to compute and store the encrypted value.
```no-highlight
$ curl -X POST http://netbox/api/secrets/secrets/ \
-H "Content-Type: application/json" \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" \
--data '{"device": 1827, "role": 1, "name": "backup", "plaintext": "Drowssap1"}'
```
```json
{
"id": 6194,
"url": "http://netbox/api/secrets/secrets/9194/",
"device": {
"id": 1827,
"url": "http://netbox/api/dcim/devices/1827/",
"name": "device43",
"display_name": "device43"
},
"role": {
"id": 1,
"url": "http://netbox/api/secrets/secret-roles/1/",
"name": "Login Credentials",
"slug": "login-creds"
},
"name": "backup",
"plaintext": "Drowssap1",
"hash": "pbkdf2_sha256$1000$J9db8sI5vBrd$IK6nFXnFl+K+nR5/KY8RSDxU1skYL8G69T5N3jZxM7c=",
"tags": [],
"custom_fields": {},
"created": "2020-08-05",
"last_updated": "2020-08-05T16:51:14.990506Z"
}
```
!!! note
Don't forget to include the `Content-Type: application/json` header when making a POST or PATCH request.

36
docs/screenshots/index.md Normal file
View File

@ -0,0 +1,36 @@
# Screenshots
## Light Mode
### Home Page
![Home Page](../media/home-light.png)
### Rack Elevation
![Rack Elevation](../media/rack-light.png)
### Prefixes
![Prefixes](../media/prefixes-light.png)
### Cable Trace
![Cable Trace](../media/cable-light.png)
## Dark Mode
### Home Page
![Home Page](../media/home-dark.png)
### Rack Elevation
![Rack Elevation](../media/rack-dark.png)
### Prefixes
![Prefixes](../media/prefixes-dark.png)
### Cable Trace
![Cable Trace](../media/cable-dark.png)

View File

@ -6,6 +6,21 @@ python:
- requirements: docs/requirements.txt
theme:
name: material
palette:
- scheme: default
toggle:
icon: material/lightbulb-outline
name: Switch to Dark Mode
- scheme: slate
toggle:
icon: material/lightbulb
name: Switch to Light Mode
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox
- icon: fontawesome/brands/slack
link: https://slack.netbox.dev
extra_css:
- extra.css
markdown_extensions:
@ -43,20 +58,21 @@ nav:
- Service Mapping: 'core-functionality/services.md'
- Circuits: 'core-functionality/circuits.md'
- Power Tracking: 'core-functionality/power.md'
- Secrets: 'core-functionality/secrets.md'
- Tenancy: 'core-functionality/tenancy.md'
- Customization:
- Custom Fields: 'customization/custom-fields.md'
- Custom Validation: 'customization/custom-validation.md'
- Custom Links: 'customization/custom-links.md'
- Export Templates: 'customization/export-templates.md'
- Custom Scripts: 'customization/custom-scripts.md'
- Reports: 'customization/reports.md'
- Additional Features:
- Caching: 'additional-features/caching.md'
- Change Logging: 'additional-features/change-logging.md'
- Context Data: 'models/extras/configcontext.md'
- Custom Fields: 'additional-features/custom-fields.md'
- Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md'
- Journaling: 'additional-features/journaling.md'
- NAPALM: 'additional-features/napalm.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
- Tags: 'models/extras/tag.md'
- Webhooks: 'additional-features/webhooks.md'
- Plugins:
@ -64,23 +80,27 @@ nav:
- Developing Plugins: 'plugins/development.md'
- Administration:
- Permissions: 'administration/permissions.md'
- Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
- REST API:
- Overview: 'rest-api/overview.md'
- Filtering: 'rest-api/filtering.md'
- Authentication: 'rest-api/authentication.md'
- Working with Secrets: 'rest-api/working-with-secrets.md'
- GraphQL API:
- Overview: 'graphql-api/overview.md'
- Development:
- Introduction: 'development/index.md'
- Getting Started: 'development/getting-started.md'
- Style Guide: 'development/style-guide.md'
- Models: 'development/models.md'
- Extending Models: 'development/extending-models.md'
- Signals: 'development/signals.md'
- Application Registry: 'development/application-registry.md'
- User Preferences: 'development/user-preferences.md'
- Release Checklist: 'development/release-checklist.md'
- Release Notes:
- Version 3.0: 'release-notes/version-3.0.md'
- Version 2.11: 'release-notes/version-2.11.md'
- Version 2.10: 'release-notes/version-2.10.md'
- Version 2.9: 'release-notes/version-2.9.md'
@ -93,3 +113,4 @@ nav:
- Version 2.2: 'release-notes/version-2.2.md'
- Version 2.1: 'release-notes/version-2.1.md'
- Version 2.0: 'release-notes/version-2.0.md'
- Screenshots: 'screenshots/index.md'

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
from dcim.models import Region, Site, SiteGroup
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
)
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
@ -60,10 +60,12 @@ class ProviderCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Provider
fields = Provider.csv_headers
fields = (
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
)
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Provider.objects.all(),
widget=forms.MultipleHiddenInput
@ -102,12 +104,12 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdi
]
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Provider
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['region_id', 'site_id'],
['asn', 'tag'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -166,7 +168,7 @@ class ProviderNetworkCSVForm(CustomFieldModelCSVForm):
]
class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
widget=forms.MultipleHiddenInput
@ -190,13 +192,9 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
]
class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldFilterForm):
class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = ProviderNetwork
field_order = ['q', 'provider_id']
q = forms.CharField(
required=False,
label=_('Search')
)
field_order = ['provider_id']
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
@ -219,7 +217,7 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
]
class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
widget=forms.MultipleHiddenInput
@ -238,7 +236,7 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
class Meta:
model = CircuitType
fields = CircuitType.csv_headers
fields = ('name', 'slug', 'description')
help_texts = {
'name': 'Name of circuit type',
}
@ -312,7 +310,7 @@ class CircuitCSVForm(CustomFieldModelCSVForm):
]
class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput
@ -354,16 +352,19 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
]
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Circuit
field_order = [
'q', 'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id',
'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id',
'commit_rate',
]
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['type_id', 'status', 'commit_rate'],
['provider_id', 'provider_network_id'],
['region_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
['tag']
]
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
required=False,

View File

@ -0,0 +1,21 @@
import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
class CircuitsQuery(graphene.ObjectType):
circuit = ObjectField(CircuitType)
circuit_list = ObjectListField(CircuitType)
circuit_termination = ObjectField(CircuitTerminationType)
circuit_termination_list = ObjectListField(CircuitTerminationType)
circuit_type = ObjectField(CircuitTypeType)
circuit_type_list = ObjectListField(CircuitTypeType)
provider = ObjectField(ProviderType)
provider_list = ObjectListField(ProviderType)
provider_network = ObjectField(ProviderNetworkType)
provider_network_list = ObjectListField(ProviderNetworkType)

View File

@ -0,0 +1,50 @@
from circuits import filtersets, models
from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType
__all__ = (
'CircuitTerminationType',
'CircuitType',
'CircuitTypeType',
'ProviderType',
'ProviderNetworkType',
)
class CircuitTerminationType(BaseObjectType):
class Meta:
model = models.CircuitTermination
fields = '__all__'
filterset_class = filtersets.CircuitTerminationFilterSet
class CircuitType(TaggedObjectType):
class Meta:
model = models.Circuit
fields = '__all__'
filterset_class = filtersets.CircuitFilterSet
class CircuitTypeType(ObjectType):
class Meta:
model = models.CircuitType
fields = '__all__'
filterset_class = filtersets.CircuitTypeFilterSet
class ProviderType(TaggedObjectType):
class Meta:
model = models.Provider
fields = '__all__'
filterset_class = filtersets.ProviderFilterSet
class ProviderNetworkType(TaggedObjectType):
class Meta:
model = models.ProviderNetwork
fields = '__all__'
filterset_class = filtersets.ProviderNetworkFilterSet

View File

@ -63,9 +63,6 @@ class Provider(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
]
clone_fields = [
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
]
@ -79,18 +76,6 @@ class Provider(PrimaryModel):
def get_absolute_url(self):
return reverse('circuits:provider', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.asn,
self.account,
self.portal_url,
self.noc_contact,
self.admin_contact,
self.comments,
)
#
# Provider networks
@ -118,10 +103,6 @@ class ProviderNetwork(PrimaryModel):
blank=True
)
csv_headers = [
'provider', 'name', 'description', 'comments',
]
objects = RestrictedQuerySet.as_manager()
class Meta:
@ -140,14 +121,6 @@ class ProviderNetwork(PrimaryModel):
def get_absolute_url(self):
return reverse('circuits:providernetwork', args=[self.pk])
def to_csv(self):
return (
self.provider.name,
self.name,
self.description,
self.comments,
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class CircuitType(OrganizationalModel):
@ -170,8 +143,6 @@ class CircuitType(OrganizationalModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description']
class Meta:
ordering = ['name']
@ -181,13 +152,6 @@ class CircuitType(OrganizationalModel):
def get_absolute_url(self):
return reverse('circuits:circuittype', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.description,
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Circuit(PrimaryModel):
@ -259,9 +223,6 @@ class Circuit(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
]
clone_fields = [
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
]
@ -276,19 +237,6 @@ class Circuit(PrimaryModel):
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])
def to_csv(self):
return (
self.cid,
self.provider.name,
self.type.name,
self.get_status_display(),
self.tenant.name if self.tenant else None,
self.install_date,
self.commit_rate,
self.description,
self.comments,
)
def get_status_class(self):
return CircuitStatusChoices.CSS_CLASSES.get(self.status)

View File

@ -264,7 +264,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
'termination_a': circuit.termination_a,
'termination_z': circuit.termination_z,
'form': form,
'panel_class': 'default',
'panel_class': 'light',
'button_class': 'primary',
'return_url': circuit.get_absolute_url(),
})

View File

@ -101,7 +101,7 @@ class NestedRackSerializer(WritableNestedSerializer):
class Meta:
model = models.Rack
fields = ['id', 'url', 'display', 'name', 'display_name', 'device_count']
fields = ['id', 'url', 'display', 'name', 'device_count']
class NestedRackReservationSerializer(WritableNestedSerializer):
@ -136,7 +136,7 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
class Meta:
model = models.DeviceType
fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
@ -232,7 +232,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
class Meta:
model = models.Device
fields = ['id', 'url', 'display', 'name', 'display_name']
fields = ['id', 'url', 'display', 'name']
class NestedConsoleServerPortSerializer(WritableNestedSerializer):

View File

@ -25,6 +25,7 @@ from .nested_serializers import *
class CableTerminationSerializer(serializers.ModelSerializer):
cable_peer_type = serializers.SerializerMethodField(read_only=True)
cable_peer = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True)
def get_cable_peer_type(self, obj):
if obj._cable_peer is not None:
@ -42,6 +43,10 @@ class CableTerminationSerializer(serializers.ModelSerializer):
return serializer(obj._cable_peer, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get__occupied(self, obj):
return obj._occupied
class ConnectedEndpointSerializer(serializers.ModelSerializer):
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
@ -172,10 +177,9 @@ class RackSerializer(PrimaryModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'url', 'display', 'name', 'facility_id', 'display_name', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
# Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This
# prevents facility_id from being interpreted as a required field.
@ -284,9 +288,9 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
class Meta:
model = DeviceType
fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height',
'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count',
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count',
]
@ -385,8 +389,8 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = RearPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created',
'last_updated',
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
'created', 'last_updated',
]
@ -399,7 +403,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = FrontPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position',
'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description', 'created', 'last_updated',
]
@ -465,10 +469,10 @@ class DeviceSerializer(PrimaryModelSerializer):
class Meta:
model = Device
fields = [
'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform',
'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status',
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@ -501,10 +505,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform',
'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status',
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@ -666,8 +670,9 @@ class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
class Meta:
model = RearPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected',
'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
@ -692,9 +697,9 @@ class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
class Meta:
model = FrontPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
@ -836,31 +841,6 @@ class CablePathSerializer(serializers.ModelSerializer):
return ret
#
# Interface connections
#
class InterfaceConnectionSerializer(ValidatedModelSerializer):
interface_a = serializers.SerializerMethodField()
interface_b = NestedInterfaceSerializer(source='_path.destination')
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Interface
fields = ['interface_a', 'interface_b', 'connected_endpoint_reachable']
@swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer)
def get_interface_a(self, obj):
context = {'request': self.context['request']}
return NestedInterfaceSerializer(instance=obj, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get_connected_endpoint_reachable(self, obj):
if obj._path is not None:
return obj._path.is_active
return None
#
# Virtual chassis
#

View File

@ -46,11 +46,6 @@ router.register('rear-ports', views.RearPortViewSet)
router.register('device-bays', views.DeviceBayViewSet)
router.register('inventory-items', views.InventoryItemViewSet)
# Connections
router.register('console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections')
router.register('power-connections', views.PowerConnectionViewSet, basename='powerconnections')
router.register('interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections')
# Cables
router.register('cables', views.CableViewSet)

View File

@ -570,38 +570,6 @@ class InventoryItemViewSet(ModelViewSet):
brief_prefetch_fields = ['device']
#
# Connections
#
class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = ConsolePort.objects.prefetch_related('device', '_path').filter(
_path__destination_id__isnull=False
)
serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsoleConnectionFilterSet
class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = PowerPort.objects.prefetch_related('device', '_path').filter(
_path__destination_id__isnull=False
)
serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerConnectionFilterSet
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.prefetch_related('device', '_path').filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair
_path__destination_type__app_label='dcim',
_path__destination_type__model='interface',
_path__destination_id__isnull=False,
pk__lt=F('_path__destination_id')
)
serializer_class = serializers.InterfaceConnectionSerializer
filterset_class = filtersets.InterfaceConnectionFilterSet
#
# Cables
#

View File

@ -169,7 +169,7 @@ class DeviceStatusChoices(ChoiceSet):
STATUS_PLANNED: 'info',
STATUS_STAGED: 'primary',
STATUS_FAILED: 'danger',
STATUS_INVENTORY: 'default',
STATUS_INVENTORY: 'secondary',
STATUS_DECOMMISSIONING: 'warning',
}
@ -1066,14 +1066,21 @@ class CableStatusChoices(ChoiceSet):
class CableLengthUnitChoices(ChoiceSet):
# Metric
UNIT_KILOMETER = 'km'
UNIT_METER = 'm'
UNIT_CENTIMETER = 'cm'
# Imperial
UNIT_MILE = 'mi'
UNIT_FOOT = 'ft'
UNIT_INCH = 'in'
CHOICES = (
(UNIT_KILOMETER, 'Kilometers'),
(UNIT_METER, 'Meters'),
(UNIT_CENTIMETER, 'Centimeters'),
(UNIT_MILE, 'Miles'),
(UNIT_FOOT, 'Feet'),
(UNIT_INCH, 'Inches'),
)

View File

@ -34,10 +34,11 @@ class RackElevationSVG:
@staticmethod
def _get_device_description(device):
return '{} ({}) — {} ({}U) {} {}'.format(
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.display_name,
device.device_type.manufacturer.name,
device.device_type.model,
device.device_type.u_height,
device.asset_tag or '',
device.serial or ''
@ -64,7 +65,7 @@ class RackElevationSVG:
drawing = svgwrite.Drawing(size=(width, height))
# add the stylesheet
with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file:
with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
drawing.defs.add(drawing.style(css_file.read()))
# add gradients

View File

@ -538,7 +538,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class Meta:
model = FrontPortTemplate
fields = ['id', 'name', 'type']
fields = ['id', 'name', 'type', 'color']
class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@ -549,7 +549,7 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentF
class Meta:
model = RearPortTemplate
fields = ['id', 'name', 'type', 'positions']
fields = ['id', 'name', 'type', 'color', 'positions']
class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@ -1027,7 +1027,7 @@ class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
class Meta:
model = FrontPort
fields = ['id', 'name', 'label', 'type', 'description']
fields = ['id', 'name', 'label', 'type', 'color', 'description']
class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
@ -1038,7 +1038,7 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe
class Meta:
model = RearPort
fields = ['id', 'name', 'label', 'type', 'positions', 'description']
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):

View File

@ -13,8 +13,8 @@ from timezone_field import TimeZoneFormField
from circuits.models import Circuit, CircuitTermination, Provider
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldModelCSVForm, CustomFieldFilterForm,
CustomFieldModelForm, LocalConfigContextFilterForm,
AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelFilterForm, CustomFieldModelForm,
CustomFieldsMixin, LocalConfigContextFilterForm,
)
from extras.models import Tag
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
@ -23,7 +23,7 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField,
ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
@ -54,14 +54,14 @@ def get_device_by_name_or_pk(name):
return device
class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm):
class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
field_order = [
'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
'name', 'label', 'region_id', 'site_group_id', 'site_id',
]
field_groups = [
['name', 'label'],
['region_id', 'site_group_id', 'site_id'],
]
q = forms.CharField(
required=False,
label=_('Search')
)
name = forms.CharField(
required=False
)
@ -209,10 +209,10 @@ class RegionCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Region
fields = Region.csv_headers
fields = ('name', 'slug', 'parent', 'description')
class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Region.objects.all(),
widget=forms.MultipleHiddenInput
@ -230,12 +230,8 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['parent', 'description']
class RegionFilterForm(BootstrapMixin, CustomFieldFilterForm):
class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Site
q = forms.CharField(
required=False,
label=_('Search')
)
#
@ -266,10 +262,10 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm):
class Meta:
model = SiteGroup
fields = SiteGroup.csv_headers
fields = ('name', 'slug', 'parent', 'description')
class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
widget=forms.MultipleHiddenInput
@ -287,12 +283,8 @@ class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['parent', 'description']
class SiteGroupFilterForm(BootstrapMixin, CustomFieldFilterForm):
class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = SiteGroup
q = forms.CharField(
required=False,
label=_('Search')
)
#
@ -391,7 +383,11 @@ class SiteCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Site
fields = Site.csv_headers
fields = (
'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments',
)
help_texts = {
'time_zone': mark_safe(
'Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)'
@ -399,7 +395,7 @@ class SiteCSVForm(CustomFieldModelCSVForm):
}
class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Site.objects.all(),
widget=forms.MultipleHiddenInput
@ -444,13 +440,14 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
]
class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Site
field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id']
q = forms.CharField(
required=False,
label=_('Search')
)
field_order = ['status', 'region_id', 'tenant_group_id', 'tenant_id']
field_groups = [
['status', 'region_id'],
['tenant_group_id', 'tenant_id'],
['tag']
]
status = forms.MultipleChoiceField(
choices=SiteStatusChoices,
required=False,
@ -529,10 +526,10 @@ class LocationCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Location
fields = Location.csv_headers
fields = ('site', 'parent', 'name', 'slug', 'description')
class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Location.objects.all(),
widget=forms.MultipleHiddenInput
@ -557,12 +554,8 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['parent', 'description']
class LocationFilterForm(BootstrapMixin, CustomFieldFilterForm):
class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Location
q = forms.CharField(
required=False,
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -606,21 +599,19 @@ class RackRoleCSVForm(CustomFieldModelCSVForm):
class Meta:
model = RackRole
fields = RackRole.csv_headers
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class RackRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackRole.objects.all(),
widget=forms.MultipleHiddenInput
)
color = forms.CharField(
max_length=6, # RGB color code
required=False,
widget=ColorSelect()
color = ColorField(
required=False
)
description = forms.CharField(
max_length=200,
@ -739,7 +730,10 @@ class RackCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Rack
fields = Rack.csv_headers
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@ -751,7 +745,7 @@ class RackCSVForm(CustomFieldModelCSVForm):
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Rack.objects.all(),
widget=forms.MultipleHiddenInput
@ -851,13 +845,14 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
]
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Rack
field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
q = forms.CharField(
required=False,
label=_('Search')
)
field_order = ['region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
field_groups = [
['status', 'role_id'],
['region_id', 'site_id', 'location_id'],
['tenant_group_id', 'tenant_id'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -913,7 +908,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackElevationFilterForm(RackFilterForm):
field_order = [
'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
]
id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@ -1044,7 +1039,7 @@ class RackReservationCSVForm(CustomFieldModelCSVForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackReservation.objects.all(),
widget=forms.MultipleHiddenInput()
@ -1069,13 +1064,13 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
nullable_fields = []
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = RackReservation
field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
q = forms.CharField(
required=False,
label=_('Search')
)
field_order = ['region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
field_groups = [
['region_id', 'site_id', 'location_id'],
['user_id', 'tenant_group_id', 'tenant_id'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -1124,10 +1119,10 @@ class ManufacturerCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Manufacturer
fields = Manufacturer.csv_headers
fields = ('name', 'slug', 'description')
class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
widget=forms.MultipleHiddenInput
@ -1195,7 +1190,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
]
class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
widget=forms.MultipleHiddenInput()
@ -1218,12 +1213,15 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE
nullable_fields = []
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = DeviceType
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['manufacturer_id', 'subdevice_role'],
['console_ports', 'console_server_ports'],
['power_ports', 'power_outlets'],
['interfaces', 'pass_through_ports'],
['tag']
]
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@ -1609,7 +1607,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = FrontPortTemplate
fields = [
'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@ -1638,7 +1636,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
help_text='Select one rear port assignment for each front port being created.',
)
field_order = (
'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'description',
'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description',
)
def __init__(self, *args, **kwargs):
@ -1702,6 +1700,9 @@ class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
required=False,
widget=StaticSelect2()
)
color = ColorField(
required=False
)
description = forms.CharField(
required=False
)
@ -1715,7 +1716,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = RearPortTemplate
fields = [
'device_type', 'name', 'label', 'type', 'positions', 'description',
'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@ -1728,13 +1729,18 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
choices=PortTypeChoices,
widget=StaticSelect2(),
)
color = ColorField(
required=False
)
positions = forms.IntegerField(
min_value=REARPORT_POSITIONS_MIN,
max_value=REARPORT_POSITIONS_MAX,
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'positions', 'description')
field_order = (
'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description',
)
class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@ -1751,6 +1757,9 @@ class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
required=False,
widget=StaticSelect2()
)
color = ColorField(
required=False
)
description = forms.CharField(
required=False
)
@ -1878,8 +1887,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
)
rear_port = forms.ModelChoiceField(
queryset=RearPortTemplate.objects.all(),
to_field_name='name',
required=False
to_field_name='name'
)
class Meta:
@ -1929,21 +1937,19 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm):
class Meta:
model = DeviceRole
fields = DeviceRole.csv_headers
fields = ('name', 'slug', 'color', 'vm_role', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
widget=forms.MultipleHiddenInput
)
color = forms.CharField(
max_length=6, # RGB color code
required=False,
widget=ColorSelect()
color = ColorField(
required=False
)
vm_role = forms.NullBooleanField(
required=False,
@ -1993,10 +1999,10 @@ class PlatformCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Platform
fields = Platform.csv_headers
fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
class PlatformBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Platform.objects.all(),
widget=forms.MultipleHiddenInput
@ -2236,6 +2242,12 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
choices=DeviceStatusChoices,
help_text='Operational status'
)
virtual_chassis = CSVModelChoiceField(
queryset=VirtualChassis.objects.all(),
to_field_name='name',
required=False,
help_text='Virtual chassis'
)
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
@ -2246,6 +2258,10 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
class Meta:
fields = []
model = Device
help_texts = {
'vc_position': 'Virtual chassis position',
'vc_priority': 'Virtual chassis priority',
}
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@ -2284,7 +2300,8 @@ class DeviceCSVForm(BaseDeviceCSVForm):
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'cluster', 'comments',
'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
'comments',
]
def __init__(self, data=None, *args, **kwargs):
@ -2319,7 +2336,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay', 'cluster', 'comments',
'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
]
def __init__(self, data=None, *args, **kwargs):
@ -2346,7 +2363,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
self.instance.rack = parent.rack
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
@ -2402,16 +2419,19 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
]
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = Device
field_order = [
'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
]
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['region_id', 'site_id', 'location_id', 'rack_id'],
['status', 'role_id', 'asset_tag'],
['tenant_group_id', 'tenant_id'],
['manufacturer_id', 'device_type_id'],
['mac_address', 'has_primary_ip'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -2543,7 +2563,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
# Device components
#
class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm):
class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
"""
Base form for the creation of device components (models subclassed from ComponentModel).
"""
@ -2560,7 +2580,7 @@ class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm):
)
class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldForm, ComponentForm):
class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
@ -2582,6 +2602,12 @@ class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldForm, ComponentForm)
class ConsolePortFilterForm(DeviceComponentFilterForm):
model = ConsolePort
field_groups = [
['name', 'label'],
['type', 'speed'],
['region_id', 'site_group_id', 'site_id'],
['tag']
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
required=False,
@ -2638,7 +2664,7 @@ class ConsolePortBulkEditForm(
form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=ConsolePort.objects.all(),
@ -2673,7 +2699,7 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm):
class Meta:
model = ConsolePort
fields = ConsolePort.csv_headers
fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
#
@ -2683,6 +2709,12 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm):
class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
model = ConsoleServerPort
field_groups = [
['name', 'label'],
['type', 'speed'],
['region_id', 'site_group_id', 'site_id'],
['tag']
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
required=False,
@ -2739,7 +2771,7 @@ class ConsoleServerPortBulkEditForm(
form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=ConsoleServerPort.objects.all(),
@ -2774,7 +2806,7 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
class Meta:
model = ConsoleServerPort
fields = ConsoleServerPort.csv_headers
fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
#
@ -2784,6 +2816,11 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
class PowerPortFilterForm(DeviceComponentFilterForm):
model = PowerPort
field_groups = [
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id'],
['tag'],
]
type = forms.MultipleChoiceField(
choices=PowerPortTypeChoices,
required=False,
@ -2844,7 +2881,7 @@ class PowerPortBulkEditForm(
form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPort.objects.all(),
@ -2872,7 +2909,9 @@ class PowerPortCSVForm(CustomFieldModelCSVForm):
class Meta:
model = PowerPort
fields = PowerPort.csv_headers
fields = (
'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
)
#
@ -2882,6 +2921,11 @@ class PowerPortCSVForm(CustomFieldModelCSVForm):
class PowerOutletFilterForm(DeviceComponentFilterForm):
model = PowerOutlet
field_groups = [
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id'],
['tag'],
]
type = forms.MultipleChoiceField(
choices=PowerOutletTypeChoices,
required=False,
@ -2961,7 +3005,7 @@ class PowerOutletBulkEditForm(
form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=PowerOutlet.objects.all(),
@ -3017,7 +3061,7 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm):
class Meta:
model = PowerOutlet
fields = PowerOutlet.csv_headers
fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -3049,6 +3093,12 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm):
class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface
field_groups = [
['name', 'label', 'type', 'enabled'],
['mgmt_only', 'mac_address'],
['region_id', 'site_group_id', 'site_id'],
['tag'],
]
type = forms.MultipleChoiceField(
choices=InterfaceTypeChoices,
required=False,
@ -3221,7 +3271,7 @@ class InterfaceBulkEditForm(
]),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=Interface.objects.all(),
@ -3351,7 +3401,10 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Interface
fields = Interface.csv_headers
fields = (
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
'mgmt_only', 'description', 'mode',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -3389,12 +3442,20 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
#
class FrontPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id'],
['tag']
]
model = FrontPort
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
color = ColorField(
required=False
)
tag = TagFilterField(model)
@ -3407,8 +3468,8 @@ class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = FrontPort
fields = [
'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'mark_connected', 'description',
'tags',
'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -3433,13 +3494,17 @@ class FrontPortCreateForm(ComponentCreateForm):
choices=PortTypeChoices,
widget=StaticSelect2(),
)
color = ColorField(
required=False
)
rear_port_set = forms.MultipleChoiceField(
choices=[],
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.',
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'mark_connected', 'description', 'tags',
'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description',
'tags',
)
def __init__(self, *args, **kwargs):
@ -3498,10 +3563,10 @@ class FrontPortCreateForm(ComponentCreateForm):
class FrontPortBulkEditForm(
form_from_model(FrontPort, ['label', 'type', 'mark_connected', 'description']),
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=FrontPort.objects.all(),
@ -3529,7 +3594,10 @@ class FrontPortCSVForm(CustomFieldModelCSVForm):
class Meta:
model = FrontPort
fields = FrontPort.csv_headers
fields = (
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
'description',
)
help_texts = {
'rear_port_position': 'Mapped position on corresponding rear port',
}
@ -3563,11 +3631,19 @@ class FrontPortCSVForm(CustomFieldModelCSVForm):
class RearPortFilterForm(DeviceComponentFilterForm):
model = RearPort
field_groups = [
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id'],
['tag']
]
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
color = ColorField(
required=False
)
tag = TagFilterField(model)
@ -3580,7 +3656,7 @@ class RearPortForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = RearPort
fields = [
'device', 'name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags',
'device', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -3594,6 +3670,9 @@ class RearPortCreateForm(ComponentCreateForm):
choices=PortTypeChoices,
widget=StaticSelect2(),
)
color = ColorField(
required=False
)
positions = forms.IntegerField(
min_value=REARPORT_POSITIONS_MIN,
max_value=REARPORT_POSITIONS_MAX,
@ -3601,12 +3680,13 @@ class RearPortCreateForm(ComponentCreateForm):
help_text='The number of front ports which may be mapped to each rear port'
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags',
'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description',
'tags',
)
class RearPortBulkCreateForm(
form_from_model(RearPort, ['type', 'positions', 'mark_connected']),
form_from_model(RearPort, ['type', 'color', 'positions', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = RearPort
@ -3614,10 +3694,10 @@ class RearPortBulkCreateForm(
class RearPortBulkEditForm(
form_from_model(RearPort, ['label', 'type', 'mark_connected', 'description']),
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=RearPort.objects.all(),
@ -3640,7 +3720,7 @@ class RearPortCSVForm(CustomFieldModelCSVForm):
class Meta:
model = RearPort
fields = RearPort.csv_headers
fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description')
help_texts = {
'positions': 'Number of front ports which may be mapped'
}
@ -3652,6 +3732,11 @@ class RearPortCSVForm(CustomFieldModelCSVForm):
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
field_groups = [
['name', 'label'],
['region_id', 'site_group_id', 'site_id'],
['tag']
]
tag = TagFilterField(model)
@ -3706,7 +3791,7 @@ class DeviceBayBulkEditForm(
form_from_model(DeviceBay, ['label', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=DeviceBay.objects.all(),
@ -3734,7 +3819,7 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm):
class Meta:
model = DeviceBay
fields = DeviceBay.csv_headers
fields = ('device', 'name', 'label', 'installed_device', 'description')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -3840,7 +3925,9 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
class Meta:
model = InventoryItem
fields = InventoryItem.csv_headers
fields = (
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
)
class InventoryItemBulkCreateForm(
@ -3858,7 +3945,7 @@ class InventoryItemBulkEditForm(
form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
CustomFieldBulkEditForm
CustomFieldModelBulkEditForm
):
pk = forms.ModelMultipleChoiceField(
queryset=InventoryItem.objects.all(),
@ -3875,6 +3962,12 @@ class InventoryItemBulkEditForm(
class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
field_groups = [
['name', 'label', 'manufacturer_id'],
['serial', 'asset_tag', 'discovered'],
['region_id', 'site_group_id', 'site_id'],
['tag']
]
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@ -4289,7 +4382,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
return length_unit if length_unit is not None else ''
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Cable.objects.all(),
widget=forms.MultipleHiddenInput
@ -4310,10 +4403,8 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo
max_length=100,
required=False
)
color = forms.CharField(
max_length=6, # RGB color code
required=False,
widget=ColorSelect()
color = ColorField(
required=False
)
length = forms.IntegerField(
min_value=1,
@ -4343,12 +4434,14 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo
})
class CableFilterForm(BootstrapMixin, CustomFieldFilterForm):
class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Cable
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['type', 'status', 'color'],
['device_id', 'rack_id'],
['region_id', 'site_id', 'tenant_id'],
['tag']
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -4386,10 +4479,8 @@ class CableFilterForm(BootstrapMixin, CustomFieldFilterForm):
choices=add_blank_choice(CableStatusChoices),
widget=StaticSelect2()
)
color = forms.CharField(
max_length=6, # RGB color code
required=False,
widget=ColorSelect()
color = ColorField(
required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@ -4619,6 +4710,10 @@ class DeviceVCMembershipForm(forms.ModelForm):
# Require VC position (only required when the Device is a VirtualChassis member)
self.fields['vc_position'].required = True
# Add bootstrap classes to form elements.
self.fields['vc_position'].widget.attrs = {'class': 'form-control'}
self.fields['vc_priority'].widget.attrs = {'class': 'form-control'}
# Validation of vc_position is optional. This is only required when adding a new member to an existing
# VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
self.validate_vc_position = validate_vc_position
@ -4688,7 +4783,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
return device
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
widget=forms.MultipleHiddenInput()
@ -4712,16 +4807,17 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm):
class Meta:
model = VirtualChassis
fields = VirtualChassis.csv_headers
fields = ('name', 'domain', 'master')
class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = VirtualChassis
field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
q = forms.CharField(
required=False,
label=_('Search')
)
field_order = ['region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
field_groups = [
['region_id', 'site_group_id', 'site_id'],
['tenant_group_id', 'tenant_id'],
['tag']
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -4805,7 +4901,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm):
class Meta:
model = PowerPanel
fields = PowerPanel.csv_headers
fields = ('site', 'location', 'name')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@ -4817,7 +4913,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm):
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput
@ -4856,12 +4952,8 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE
nullable_fields = ['location']
class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = PowerPanel
q = forms.CharField(
required=False,
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -5006,7 +5098,10 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm):
class Meta:
model = PowerFeed
fields = PowerFeed.csv_headers
fields = (
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'voltage', 'amperage', 'max_utilization', 'comments',
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@ -5029,7 +5124,7 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerFeed.objects.all(),
widget=forms.MultipleHiddenInput
@ -5090,12 +5185,15 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
]
class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = PowerFeed
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['region_id', 'site_group_id', 'site_id'],
['power_panel_id', 'rack_id'],
['type', 'supply', 'max_utilization'],
['phase', 'voltage', 'amperage'],
['status', 'tag']
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@ -0,0 +1,105 @@
import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
class DCIMQuery(graphene.ObjectType):
cable = ObjectField(CableType)
cable_list = ObjectListField(CableType)
console_port = ObjectField(ConsolePortType)
console_port_list = ObjectListField(ConsolePortType)
console_port_template = ObjectField(ConsolePortTemplateType)
console_port_template_list = ObjectListField(ConsolePortTemplateType)
console_server_port = ObjectField(ConsoleServerPortType)
console_server_port_list = ObjectListField(ConsoleServerPortType)
console_server_port_template = ObjectField(ConsoleServerPortTemplateType)
console_server_port_template_list = ObjectListField(ConsoleServerPortTemplateType)
device = ObjectField(DeviceType)
device_list = ObjectListField(DeviceType)
device_bay = ObjectField(DeviceBayType)
device_bay_list = ObjectListField(DeviceBayType)
device_bay_template = ObjectField(DeviceBayTemplateType)
device_bay_template_list = ObjectListField(DeviceBayTemplateType)
device_role = ObjectField(DeviceRoleType)
device_role_list = ObjectListField(DeviceRoleType)
device_type = ObjectField(DeviceTypeType)
device_type_list = ObjectListField(DeviceTypeType)
front_port = ObjectField(FrontPortType)
front_port_list = ObjectListField(FrontPortType)
front_port_template = ObjectField(FrontPortTemplateType)
front_port_template_list = ObjectListField(FrontPortTemplateType)
interface = ObjectField(InterfaceType)
interface_list = ObjectListField(InterfaceType)
interface_template = ObjectField(InterfaceTemplateType)
interface_template_list = ObjectListField(InterfaceTemplateType)
inventory_item = ObjectField(InventoryItemType)
inventory_item_list = ObjectListField(InventoryItemType)
location = ObjectField(LocationType)
location_list = ObjectListField(LocationType)
manufacturer = ObjectField(ManufacturerType)
manufacturer_list = ObjectListField(ManufacturerType)
platform = ObjectField(PlatformType)
platform_list = ObjectListField(PlatformType)
power_feed = ObjectField(PowerFeedType)
power_feed_list = ObjectListField(PowerFeedType)
power_outlet = ObjectField(PowerOutletType)
power_outlet_list = ObjectListField(PowerOutletType)
power_outlet_template = ObjectField(PowerOutletTemplateType)
power_outlet_template_list = ObjectListField(PowerOutletTemplateType)
power_panel = ObjectField(PowerPanelType)
power_panel_list = ObjectListField(PowerPanelType)
power_port = ObjectField(PowerPortType)
power_port_list = ObjectListField(PowerPortType)
power_port_template = ObjectField(PowerPortTemplateType)
power_port_template_list = ObjectListField(PowerPortTemplateType)
rack = ObjectField(RackType)
rack_list = ObjectListField(RackType)
rack_reservation = ObjectField(RackReservationType)
rack_reservation_list = ObjectListField(RackReservationType)
rack_role = ObjectField(RackRoleType)
rack_role_list = ObjectListField(RackRoleType)
rear_port = ObjectField(RearPortType)
rear_port_list = ObjectListField(RearPortType)
rear_port_template = ObjectField(RearPortTemplateType)
rear_port_template_list = ObjectListField(RearPortTemplateType)
region = ObjectField(RegionType)
region_list = ObjectListField(RegionType)
site = ObjectField(SiteType)
site_list = ObjectListField(SiteType)
site_group = ObjectField(SiteGroupType)
site_group_list = ObjectListField(SiteGroupType)
virtual_chassis = ObjectField(VirtualChassisType)
virtual_chassis_list = ObjectListField(VirtualChassisType)

View File

@ -0,0 +1,353 @@
from dcim import filtersets, models
from netbox.graphql.types import BaseObjectType, ObjectType, TaggedObjectType
__all__ = (
'CableType',
'ConsolePortType',
'ConsolePortTemplateType',
'ConsoleServerPortType',
'ConsoleServerPortTemplateType',
'DeviceType',
'DeviceBayType',
'DeviceBayTemplateType',
'DeviceRoleType',
'DeviceTypeType',
'FrontPortType',
'FrontPortTemplateType',
'InterfaceType',
'InterfaceTemplateType',
'InventoryItemType',
'LocationType',
'ManufacturerType',
'PlatformType',
'PowerFeedType',
'PowerOutletType',
'PowerOutletTemplateType',
'PowerPanelType',
'PowerPortType',
'PowerPortTemplateType',
'RackType',
'RackReservationType',
'RackRoleType',
'RearPortType',
'RearPortTemplateType',
'RegionType',
'SiteType',
'SiteGroupType',
'VirtualChassisType',
)
class CableType(TaggedObjectType):
class Meta:
model = models.Cable
fields = '__all__'
filterset_class = filtersets.CableFilterSet
def resolve_type(self, info):
return self.type or None
def resolve_length_unit(self, info):
return self.length_unit or None
class ConsolePortType(TaggedObjectType):
class Meta:
model = models.ConsolePort
exclude = ('_path',)
filterset_class = filtersets.ConsolePortFilterSet
def resolve_type(self, info):
return self.type or None
class ConsolePortTemplateType(BaseObjectType):
class Meta:
model = models.ConsolePortTemplate
fields = '__all__'
filterset_class = filtersets.ConsolePortTemplateFilterSet
def resolve_type(self, info):
return self.type or None
class ConsoleServerPortType(TaggedObjectType):
class Meta:
model = models.ConsoleServerPort
exclude = ('_path',)
filterset_class = filtersets.ConsoleServerPortFilterSet
def resolve_type(self, info):
return self.type or None
class ConsoleServerPortTemplateType(BaseObjectType):
class Meta:
model = models.ConsoleServerPortTemplate
fields = '__all__'
filterset_class = filtersets.ConsoleServerPortTemplateFilterSet
def resolve_type(self, info):
return self.type or None
class DeviceType(TaggedObjectType):
class Meta:
model = models.Device
fields = '__all__'
filterset_class = filtersets.DeviceFilterSet
def resolve_face(self, info):
return self.face or None
class DeviceBayType(TaggedObjectType):
class Meta:
model = models.DeviceBay
fields = '__all__'
filterset_class = filtersets.DeviceBayFilterSet
class DeviceBayTemplateType(BaseObjectType):
class Meta:
model = models.DeviceBayTemplate
fields = '__all__'
filterset_class = filtersets.DeviceBayTemplateFilterSet
class DeviceRoleType(ObjectType):
class Meta:
model = models.DeviceRole
fields = '__all__'
filterset_class = filtersets.DeviceRoleFilterSet
class DeviceTypeType(TaggedObjectType):
class Meta:
model = models.DeviceType
fields = '__all__'
filterset_class = filtersets.DeviceTypeFilterSet
def resolve_subdevice_role(self, info):
return self.subdevice_role or None
class FrontPortType(TaggedObjectType):
class Meta:
model = models.FrontPort
fields = '__all__'
filterset_class = filtersets.FrontPortFilterSet
class FrontPortTemplateType(BaseObjectType):
class Meta:
model = models.FrontPortTemplate
fields = '__all__'
filterset_class = filtersets.FrontPortTemplateFilterSet
class InterfaceType(TaggedObjectType):
class Meta:
model = models.Interface
exclude = ('_path',)
filterset_class = filtersets.InterfaceFilterSet
def resolve_mode(self, info):
return self.mode or None
class InterfaceTemplateType(BaseObjectType):
class Meta:
model = models.InterfaceTemplate
fields = '__all__'
filterset_class = filtersets.InterfaceTemplateFilterSet
class InventoryItemType(TaggedObjectType):
class Meta:
model = models.InventoryItem
fields = '__all__'
filterset_class = filtersets.InventoryItemFilterSet
class LocationType(ObjectType):
class Meta:
model = models.Location
fields = '__all__'
filterset_class = filtersets.LocationFilterSet
class ManufacturerType(ObjectType):
class Meta:
model = models.Manufacturer
fields = '__all__'
filterset_class = filtersets.ManufacturerFilterSet
class PlatformType(ObjectType):
class Meta:
model = models.Platform
fields = '__all__'
filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(TaggedObjectType):
class Meta:
model = models.PowerFeed
exclude = ('_path',)
filterset_class = filtersets.PowerFeedFilterSet
class PowerOutletType(TaggedObjectType):
class Meta:
model = models.PowerOutlet
exclude = ('_path',)
filterset_class = filtersets.PowerOutletFilterSet
def resolve_feed_leg(self, info):
return self.feed_leg or None
def resolve_type(self, info):
return self.type or None
class PowerOutletTemplateType(BaseObjectType):
class Meta:
model = models.PowerOutletTemplate
fields = '__all__'
filterset_class = filtersets.PowerOutletTemplateFilterSet
def resolve_feed_leg(self, info):
return self.feed_leg or None
def resolve_type(self, info):
return self.type or None
class PowerPanelType(TaggedObjectType):
class Meta:
model = models.PowerPanel
fields = '__all__'
filterset_class = filtersets.PowerPanelFilterSet
class PowerPortType(TaggedObjectType):
class Meta:
model = models.PowerPort
exclude = ('_path',)
filterset_class = filtersets.PowerPortFilterSet
def resolve_type(self, info):
return self.type or None
class PowerPortTemplateType(BaseObjectType):
class Meta:
model = models.PowerPortTemplate
fields = '__all__'
filterset_class = filtersets.PowerPortTemplateFilterSet
def resolve_type(self, info):
return self.type or None
class RackType(TaggedObjectType):
class Meta:
model = models.Rack
fields = '__all__'
filterset_class = filtersets.RackFilterSet
def resolve_type(self, info):
return self.type or None
def resolve_outer_unit(self, info):
return self.outer_unit or None
class RackReservationType(TaggedObjectType):
class Meta:
model = models.RackReservation
fields = '__all__'
filterset_class = filtersets.RackReservationFilterSet
class RackRoleType(ObjectType):
class Meta:
model = models.RackRole
fields = '__all__'
filterset_class = filtersets.RackRoleFilterSet
class RearPortType(TaggedObjectType):
class Meta:
model = models.RearPort
fields = '__all__'
filterset_class = filtersets.RearPortFilterSet
class RearPortTemplateType(BaseObjectType):
class Meta:
model = models.RearPortTemplate
fields = '__all__'
filterset_class = filtersets.RearPortTemplateFilterSet
class RegionType(ObjectType):
class Meta:
model = models.Region
fields = '__all__'
filterset_class = filtersets.RegionFilterSet
class SiteType(TaggedObjectType):
class Meta:
model = models.Site
fields = '__all__'
filterset_class = filtersets.SiteFilterSet
class SiteGroupType(ObjectType):
class Meta:
model = models.SiteGroup
fields = '__all__'
filterset_class = filtersets.SiteGroupFilterSet
class VirtualChassisType(TaggedObjectType):
class Meta:
model = models.VirtualChassis
fields = '__all__'
filterset_class = filtersets.VirtualChassisFilterSet

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0131_consoleport_speed'),
]
operations = [
migrations.AlterField(
model_name='cable',
name='length',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
]

View File

@ -0,0 +1,32 @@
from django.db import migrations
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0132_cable_length'),
]
operations = [
migrations.AddField(
model_name='frontport',
name='color',
field=utilities.fields.ColorField(blank=True, max_length=6),
),
migrations.AddField(
model_name='frontporttemplate',
name='color',
field=utilities.fields.ColorField(blank=True, max_length=6),
),
migrations.AddField(
model_name='rearport',
name='color',
field=utilities.fields.ColorField(blank=True, max_length=6),
),
migrations.AddField(
model_name='rearporttemplate',
name='color',
field=utilities.fields.ColorField(blank=True, max_length=6),
),
]

View File

@ -25,6 +25,7 @@ __all__ = (
'Interface',
'InterfaceTemplate',
'InventoryItem',
'Location',
'Manufacturer',
'Platform',
'PowerFeed',
@ -34,7 +35,6 @@ __all__ = (
'PowerPort',
'PowerPortTemplate',
'Rack',
'Location',
'RackReservation',
'RackRole',
'RearPort',

View File

@ -74,7 +74,9 @@ class Cable(PrimaryModel):
color = ColorField(
blank=True
)
length = models.PositiveSmallIntegerField(
length = models.DecimalField(
max_digits=8,
decimal_places=2,
blank=True,
null=True
)
@ -109,11 +111,6 @@ class Cable(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit',
]
class Meta:
ordering = ['pk']
unique_together = (
@ -287,20 +284,6 @@ class Cable(PrimaryModel):
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.pk
def to_csv(self):
return (
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
self.termination_a_id,
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
self.termination_b_id,
self.get_type_display(),
self.get_status_display(),
self.label,
self.color,
self.length,
self.length_unit,
)
def get_status_class(self):
return CableStatusChoices.CSS_CLASSES.get(self.status)

View File

@ -6,7 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel
from utilities.fields import NaturalOrderingField
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface
from .device_components import (
@ -267,6 +267,9 @@ class FrontPortTemplate(ComponentTemplateModel):
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
blank=True
)
rear_port = models.ForeignKey(
to='dcim.RearPortTemplate',
on_delete=models.CASCADE,
@ -290,19 +293,24 @@ class FrontPortTemplate(ComponentTemplateModel):
def clean(self):
super().clean()
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
"Rear port ({}) must belong to the same device type".format(self.rear_port)
)
try:
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
"Rear port ({}) must belong to the same device type".format(self.rear_port)
)
)
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
)
)
except RearPortTemplate.DoesNotExist:
pass
def instantiate(self, device):
if self.rear_port:
@ -314,6 +322,7 @@ class FrontPortTemplate(ComponentTemplateModel):
name=self.name,
label=self.label,
type=self.type,
color=self.color,
rear_port=rear_port,
rear_port_position=self.rear_port_position
)
@ -328,6 +337,9 @@ class RearPortTemplate(ComponentTemplateModel):
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
blank=True
)
positions = models.PositiveSmallIntegerField(
default=1,
validators=[
@ -346,6 +358,7 @@ class RearPortTemplate(ComponentTemplateModel):
name=self.name,
label=self.label,
type=self.type,
color=self.color,
positions=self.positions
)

View File

@ -12,7 +12,7 @@ from dcim.constants import *
from dcim.fields import MACAddressField
from extras.utils import extras_features
from netbox.models import PrimaryModel
from utilities.fields import NaturalOrderingField
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
@ -229,8 +229,6 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
help_text='Port speed in bits per second'
)
csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
@ -238,17 +236,6 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
def get_absolute_url(self):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.type,
self.speed,
self.mark_connected,
self.description,
)
#
# Console server ports
@ -272,8 +259,6 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
help_text='Port speed in bits per second'
)
csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
@ -281,17 +266,6 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
def get_absolute_url(self):
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.type,
self.speed,
self.mark_connected,
self.description,
)
#
# Power ports
@ -321,10 +295,6 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
help_text="Allocated power draw (watts)"
)
csv_headers = [
'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
]
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
@ -332,18 +302,6 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
def get_absolute_url(self):
return reverse('dcim:powerport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.mark_connected,
self.maximum_draw,
self.allocated_draw,
self.description,
)
def clean(self):
super().clean()
@ -433,8 +391,6 @@ class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
help_text="Phase (for three-phase feeds)"
)
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
@ -442,18 +398,6 @@ class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
def get_absolute_url(self):
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.mark_connected,
self.power_port.name if self.power_port else None,
self.get_feed_leg_display(),
self.description,
)
def clean(self):
super().clean()
@ -570,11 +514,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
related_query_name='interface'
)
csv_headers = [
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
'mgmt_only', 'description', 'mode',
]
class Meta:
ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name')
@ -582,23 +521,6 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier if self.device else None,
self.name,
self.label,
self.parent.name if self.parent else None,
self.lag.name if self.lag else None,
self.get_type_display(),
self.enabled,
self.mark_connected,
self.mac_address,
self.mtu,
self.mgmt_only,
self.description,
self.get_mode_display(),
)
def clean(self):
super().clean()
@ -692,6 +614,9 @@ class FrontPort(ComponentModel, CableTermination):
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
blank=True
)
rear_port = models.ForeignKey(
to='dcim.RearPort',
on_delete=models.CASCADE,
@ -705,10 +630,6 @@ class FrontPort(ComponentModel, CableTermination):
]
)
csv_headers = [
'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description',
]
class Meta:
ordering = ('device', '_name')
unique_together = (
@ -719,18 +640,6 @@ class FrontPort(ComponentModel, CableTermination):
def get_absolute_url(self):
return reverse('dcim:frontport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.mark_connected,
self.rear_port.name,
self.rear_port_position,
self.description,
)
def clean(self):
super().clean()
@ -757,6 +666,9 @@ class RearPort(ComponentModel, CableTermination):
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
blank=True
)
positions = models.PositiveSmallIntegerField(
default=1,
validators=[
@ -765,8 +677,6 @@ class RearPort(ComponentModel, CableTermination):
]
)
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
@ -785,17 +695,6 @@ class RearPort(ComponentModel, CableTermination):
f"({frontport_count})"
})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.mark_connected,
self.positions,
self.description,
)
#
# Device bays
@ -814,8 +713,6 @@ class DeviceBay(ComponentModel):
null=True
)
csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
@ -823,15 +720,6 @@ class DeviceBay(ComponentModel):
def get_absolute_url(self):
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.installed_device.identifier if self.installed_device else None,
self.description,
)
def clean(self):
super().clean()
@ -907,26 +795,9 @@ class InventoryItem(MPTTModel, ComponentModel):
objects = TreeManager()
csv_headers = [
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
]
class Meta:
ordering = ('device__id', 'parent__id', '_name')
unique_together = ('device', 'parent', 'name')
def get_absolute_url(self):
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.name or '{{{}}}'.format(self.device.pk),
self.name,
self.label,
self.manufacturer.name if self.manufacturer else None,
self.part_id,
self.serial,
self.asset_tag,
self.discovered,
self.description,
)

View File

@ -56,8 +56,6 @@ class Manufacturer(OrganizationalModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description']
class Meta:
ordering = ['name']
@ -67,13 +65,6 @@ class Manufacturer(OrganizationalModel):
def get_absolute_url(self):
return reverse('dcim:manufacturer', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.description
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceType(PrimaryModel):
@ -336,10 +327,6 @@ class DeviceType(PrimaryModel):
if self.rear_image:
self.rear_image.delete(save=False)
@property
def display_name(self):
return f'{self.manufacturer.name} {self.model}'
@property
def is_parent_device(self):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
@ -383,8 +370,6 @@ class DeviceRole(OrganizationalModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
class Meta:
ordering = ['name']
@ -394,15 +379,6 @@ class DeviceRole(OrganizationalModel):
def get_absolute_url(self):
return reverse('dcim:devicerole', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.color,
self.vm_role,
self.description,
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Platform(OrganizationalModel):
@ -446,8 +422,6 @@ class Platform(OrganizationalModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
class Meta:
ordering = ['name']
@ -457,16 +431,6 @@ class Platform(OrganizationalModel):
def get_absolute_url(self):
return reverse('dcim:platform', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.manufacturer.name if self.manufacturer else None,
self.napalm_driver,
self.napalm_args,
self.description,
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Device(PrimaryModel, ConfigContextModel):
@ -612,19 +576,9 @@ class Device(PrimaryModel, ConfigContextModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
secrets = GenericRelation(
to='secrets.Secret',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='device'
)
objects = ConfigContextModelQuerySet.as_manager()
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack_name', 'position', 'face', 'comments',
]
clone_fields = [
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster',
]
@ -638,7 +592,13 @@ class Device(PrimaryModel, ConfigContextModel):
)
def __str__(self):
return self.display_name or super().__str__()
if self.name:
return self.name
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
elif self.device_type:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__()
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@ -820,36 +780,6 @@ class Device(PrimaryModel, ConfigContextModel):
device.rack = self.rack
device.save()
def to_csv(self):
return (
self.name or '',
self.device_role.name,
self.tenant.name if self.tenant else None,
self.device_type.manufacturer.name,
self.device_type.model,
self.platform.name if self.platform else None,
self.serial,
self.asset_tag,
self.get_status_display(),
self.site.name,
self.rack.location.name if self.rack and self.rack.location else None,
self.rack.name if self.rack else None,
self.position,
self.get_face_display(),
self.comments,
)
@property
def display_name(self):
if self.name:
return self.name
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
elif self.device_type:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
else:
return '' # Device has not yet been created
@property
def identifier(self):
"""
@ -944,8 +874,6 @@ class VirtualChassis(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'domain', 'master']
class Meta:
ordering = ['name']
verbose_name_plural = 'virtual chassis'
@ -982,10 +910,3 @@ class VirtualChassis(PrimaryModel):
)
return super().delete(*args, **kwargs)
def to_csv(self):
return (
self.name,
self.domain,
self.master.name if self.master else None,
)

View File

@ -42,8 +42,6 @@ class PowerPanel(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'location', 'name']
class Meta:
ordering = ['site', 'name']
unique_together = ['site', 'name']
@ -54,13 +52,6 @@ class PowerPanel(PrimaryModel):
def get_absolute_url(self):
return reverse('dcim:powerpanel', args=[self.pk])
def to_csv(self):
return (
self.site.name,
self.location.name if self.location else None,
self.name,
)
def clean(self):
super().clean()
@ -133,10 +124,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'voltage', 'amperage', 'max_utilization', 'comments',
]
clone_fields = [
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'available_power',
@ -152,24 +139,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
def get_absolute_url(self):
return reverse('dcim:powerfeed', args=[self.pk])
def to_csv(self):
return (
self.power_panel.site.name,
self.power_panel.name,
self.rack.location.name if self.rack and self.rack.location else None,
self.rack.name if self.rack else None,
self.name,
self.get_status_display(),
self.get_type_display(),
self.mark_connected,
self.get_supply_display(),
self.get_phase_display(),
self.voltage,
self.amperage,
self.max_utilization,
self.comments,
)
def clean(self):
super().clean()

View File

@ -58,8 +58,6 @@ class RackRole(OrganizationalModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description']
class Meta:
ordering = ['name']
@ -69,14 +67,6 @@ class RackRole(OrganizationalModel):
def get_absolute_url(self):
return reverse('dcim:rackrole', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.color,
self.description,
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Rack(PrimaryModel):
@ -191,10 +181,6 @@ class Rack(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
]
clone_fields = [
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit',
@ -209,7 +195,9 @@ class Rack(PrimaryModel):
)
def __str__(self):
return self.display_name or super().__str__()
if self.facility_id:
return f'{self.name} ({self.facility_id})'
return self.name
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@ -249,27 +237,6 @@ class Rack(PrimaryModel):
'location': f"Location must be from the same site, {self.site}."
})
def to_csv(self):
return (
self.site.name,
self.location.name if self.location else None,
self.name,
self.facility_id,
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.role.name if self.role else None,
self.get_type_display() if self.type else None,
self.serial,
self.asset_tag,
self.width,
self.u_height,
self.desc_units,
self.outer_width,
self.outer_depth,
self.outer_unit,
self.comments,
)
@property
def units(self):
if self.desc_units:
@ -277,12 +244,6 @@ class Rack(PrimaryModel):
else:
return reversed(range(1, self.u_height + 1))
@property
def display_name(self):
if self.facility_id:
return f'{self.name} ({self.facility_id})'
return self.name
def get_status_class(self):
return RackStatusChoices.CSS_CLASSES.get(self.status)
@ -497,8 +458,6 @@ class RackReservation(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'location', 'rack', 'units', 'tenant', 'user', 'description']
class Meta:
ordering = ['created', 'pk']
@ -535,17 +494,6 @@ class RackReservation(PrimaryModel):
)
})
def to_csv(self):
return (
self.rack.site.name,
self.rack.location if self.rack.location else None,
self.rack.name,
','.join([str(u) for u in self.units]),
self.tenant.name if self.tenant else None,
self.user.username,
self.description
)
@property
def unit_list(self):
return array_to_string(self.units)

View File

@ -54,19 +54,9 @@ class Region(NestedGroupModel):
blank=True
)
csv_headers = ['name', 'slug', 'parent', 'description']
def get_absolute_url(self):
return reverse('dcim:region', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.parent.name if self.parent else None,
self.description,
)
def get_site_count(self):
return Site.objects.filter(
Q(region=self) |
@ -106,19 +96,9 @@ class SiteGroup(NestedGroupModel):
blank=True
)
csv_headers = ['name', 'slug', 'parent', 'description']
def get_absolute_url(self):
return reverse('dcim:sitegroup', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.parent.name if self.parent else None,
self.description,
)
def get_site_count(self):
return Site.objects.filter(
Q(group=self) |
@ -236,11 +216,6 @@ class Site(PrimaryModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments',
]
clone_fields = [
'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
@ -255,28 +230,6 @@ class Site(PrimaryModel):
def get_absolute_url(self):
return reverse('dcim:site', args=[self.pk])
def to_csv(self):
return (
self.name,
self.slug,
self.get_status_display(),
self.region.name if self.region else None,
self.group.name if self.group else None,
self.tenant.name if self.tenant else None,
self.facility,
self.asn,
self.time_zone,
self.description,
self.physical_address,
self.shipping_address,
self.latitude,
self.longitude,
self.contact_name,
self.contact_phone,
self.contact_email,
self.comments,
)
def get_status_class(self):
return SiteStatusChoices.CSS_CLASSES.get(self.status)
@ -318,7 +271,6 @@ class Location(NestedGroupModel):
to='extras.ImageAttachment'
)
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
clone_fields = ['site', 'parent', 'description']
class Meta:
@ -331,15 +283,6 @@ class Location(NestedGroupModel):
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])
def to_csv(self):
return (
self.site,
self.parent.name if self.parent else '',
self.name,
self.slug,
self.description,
)
def clean(self):
super().clean()

View File

@ -1,6 +1,5 @@
import logging
from cacheops import invalidate_obj
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
from django.db import transaction
@ -33,7 +32,6 @@ def rebuild_paths(obj):
for cp in cable_paths:
cp.delete()
if cp.origin:
invalidate_obj(cp.origin)
create_cablepath(cp.origin)

View File

@ -52,10 +52,20 @@ def get_cabletermination_row_class(record):
return ''
def get_interface_state_attribute(record):
"""
Get interface enabled state as string to attach to <tr/> DOM element.
"""
if record.enabled:
return "enabled"
else:
return "disabled"
#
# Device roles
#
class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
@ -528,6 +538,7 @@ class DeviceInterfaceTable(InterfaceTable):
row_attrs = {
'class': get_cabletermination_row_class,
'data-name': lambda record: record.name,
'data-enabled': get_interface_state_attribute,
}
@ -538,6 +549,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
'args': [Accessor('device_id')],
}
)
color = ColorColumn()
rear_port_position = tables.Column(
verbose_name='Position'
)
@ -551,10 +563,12 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected',
'cable', 'cable_color', 'cable_peer', 'tags',
'pk', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags',
)
default_columns = (
'pk', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
class DeviceFrontPortTable(FrontPortTable):
@ -592,6 +606,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
'args': [Accessor('device_id')],
}
)
color = ColorColumn()
tags = TagColumn(
url_name='dcim:rearport_list'
)
@ -599,10 +614,10 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable',
'pk', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'cable_color', 'cable_peer', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'color', 'description')
class DeviceRearPortTable(RearPortTable):

View File

@ -4,7 +4,9 @@ from dcim.models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, TagColumn, ToggleColumn,
)
__all__ = (
'ConsolePortTemplateTable',
@ -164,6 +166,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
rear_port_position = tables.Column(
verbose_name='Position'
)
color = ColorColumn()
actions = ButtonsColumn(
model=FrontPortTemplate,
buttons=('edit', 'delete'),
@ -172,11 +175,12 @@ class FrontPortTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = FrontPortTemplate
fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions')
fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
empty_text = "None"
class RearPortTemplateTable(ComponentTemplateTable):
color = ColorColumn()
actions = ButtonsColumn(
model=RearPortTemplate,
buttons=('edit', 'delete'),
@ -185,7 +189,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = RearPortTemplate
fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions')
fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
empty_text = "None"

View File

@ -26,17 +26,17 @@ CABLE_TERMINATION_PARENT = """
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
</a>
"""
DEVICEBAY_STATUS = """
{% if record.installed_device_id %}
<span class="label label-{{ record.installed_device.get_status_class }}">
<span class="badge bg-{{ record.installed_device.get_status_class }}">
{{ record.installed_device.get_status_display }}
</span>
{% else %}
<span class="label label-default">Vacant</span>
<span class="badge bg-secondary">Vacant</span>
{% endif %}
"""
@ -60,7 +60,7 @@ INTERFACE_TAGGED_VLANS = """
POWERFEED_CABLE = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace">
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
"""
@ -72,7 +72,7 @@ POWERFEED_CABLETERMINATION = """
"""
LOCATION_ELEVATIONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
<i class="mdi mdi-server"></i>
</a>
"""
@ -83,205 +83,199 @@ LOCATION_ELEVATIONS = """
CONSOLEPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
CONSOLESERVERPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
POWERPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
POWEROUTLET_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-xs">
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
INTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-xs btn-success" title="Add IP address">
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-sm btn-success" title="Add IP address">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif record.is_connectable and perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
FRONTPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
REARPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
@ -289,11 +283,11 @@ REARPORT_BUTTONS = """
DEVICEBAY_BUTTONS = """
{% if perms.dcim.change_devicebay %}
{% if record.installed_device %}
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-xs">
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-minus-thick" aria-hidden="true" title="Remove device"></i>
</a>
{% else %}
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-xs">
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true" title="Install device"></i>
</a>
{% endif %}

View File

@ -251,7 +251,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
class RackTest(APIViewTestCases.APIViewTestCase):
model = Rack
brief_fields = ['device_count', 'display', 'display_name', 'id', 'name', 'url']
brief_fields = ['device_count', 'display', 'id', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
@ -418,7 +418,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
model = DeviceType
brief_fields = ['device_count', 'display', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
bulk_update_data = {
'part_number': 'ABC123',
}
@ -866,7 +866,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
class DeviceTest(APIViewTestCases.APIViewTestCase):
model = Device
brief_fields = ['display', 'display_name', 'id', 'name', 'url']
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'status': 'failed',
}
@ -1211,8 +1211,9 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
{
'device': device.pk,
'name': 'Interface 6',
'type': '1000base-t',
'type': 'virtual',
'mode': InterfaceModeChoices.MODE_TAGGED,
'parent': interfaces[0].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
},

View File

@ -6,6 +6,7 @@ from dcim.filtersets import *
from dcim.models import *
from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup
from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests
from virtualization.models import Cluster, ClusterType
@ -959,9 +960,9 @@ class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
RearPortTemplate.objects.bulk_create(rear_ports)
FrontPortTemplate.objects.bulk_create((
FrontPortTemplate(device_type=device_types[0], name='Front Port 1', rear_port=rear_ports[0], type=PortTypeChoices.TYPE_8P8C),
FrontPortTemplate(device_type=device_types[1], name='Front Port 2', rear_port=rear_ports[1], type=PortTypeChoices.TYPE_110_PUNCH),
FrontPortTemplate(device_type=device_types[2], name='Front Port 3', rear_port=rear_ports[2], type=PortTypeChoices.TYPE_BNC),
FrontPortTemplate(device_type=device_types[0], name='Front Port 1', rear_port=rear_ports[0], type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED),
FrontPortTemplate(device_type=device_types[1], name='Front Port 2', rear_port=rear_ports[1], type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN),
FrontPortTemplate(device_type=device_types[2], name='Front Port 3', rear_port=rear_ports[2], type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE),
))
def test_name(self):
@ -977,6 +978,10 @@ class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_color(self):
params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RearPortTemplate.objects.all()
@ -995,9 +1000,9 @@ class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceType.objects.bulk_create(device_types)
RearPortTemplate.objects.bulk_create((
RearPortTemplate(device_type=device_types[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=1),
RearPortTemplate(device_type=device_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_110_PUNCH, positions=2),
RearPortTemplate(device_type=device_types[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, positions=3),
RearPortTemplate(device_type=device_types[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, positions=1),
RearPortTemplate(device_type=device_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, positions=2),
RearPortTemplate(device_type=device_types[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, positions=3),
))
def test_name(self):
@ -1013,6 +1018,10 @@ class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_color(self):
params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_positions(self):
params = {'positions': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -2153,9 +2162,9 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
RearPort.objects.bulk_create(rear_ports)
front_ports = (
FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'),
FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, rear_port=rear_ports[0], rear_port_position=1, description='First'),
FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, rear_port=rear_ports[1], rear_port_position=2, description='Second'),
FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, rear_port=rear_ports[2], rear_port_position=3, description='Third'),
FrontPort(device=devices[3], name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1),
FrontPort(device=devices[3], name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1),
FrontPort(device=devices[3], name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1),
@ -2179,6 +2188,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_color(self):
params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -2260,9 +2273,9 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Device.objects.bulk_create(devices)
rear_ports = (
RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'),
RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'),
RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'),
RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, positions=1, description='First'),
RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, positions=2, description='Second'),
RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, positions=3, description='Third'),
RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
@ -2286,6 +2299,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_color(self):
params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_positions(self):
params = {'positions': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -580,11 +580,11 @@ device-bays:
db1 = DeviceBayTemplate.objects.first()
self.assertEqual(db1.name, 'Device Bay 1')
def test_devicetype_export(self):
def test_export_objects(self):
url = reverse('dcim:devicetype_list')
self.add_permissions('dcim.view_devicetype')
# Test default YAML export
response = self.client.get('{}?export'.format(url))
self.assertEqual(response.status_code, 200)
data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader))
@ -592,6 +592,11 @@ device-bays:
self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1')
self.assertEqual(data[0]['model'], 'Device Type 1')
# Test table-based export
response = self.client.get(f'{url}?export=table')
self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
#
# DeviceType components
@ -1023,6 +1028,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
VirtualChassis.objects.create(name='Virtual Chassis 1')
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_role': deviceroles[1].pk,
@ -1048,10 +1055,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"device_role,manufacturer,device_type,status,name,site,location,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front",
"device_role,manufacturer,device_type,status,name,site,location,rack,position,face,virtual_chassis,vc_position,vc_priority",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front,Virtual Chassis 1,1,10",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front,Virtual Chassis 1,2,20",
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30",
)
cls.bulk_edit_data = {

View File

@ -1,7 +1,10 @@
import logging
from copy import deepcopy
from collections import OrderedDict
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import F, Prefetch
@ -16,7 +19,6 @@ from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJou
from ipam.models import IPAddress, Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic
from secrets.models import Secret
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
@ -1292,9 +1294,6 @@ class DeviceView(generic.ObjectView):
# Services
services = Service.objects.restrict(request.user, 'view').filter(device=instance)
# Secrets
secrets = Secret.objects.restrict(request.user, 'view').filter(device=instance)
# Find up to ten devices in the same site with the same functional role for quick reference.
related_devices = Device.objects.restrict(request.user, 'view').filter(
site=instance.site, device_role=instance.device_role
@ -1306,7 +1305,6 @@ class DeviceView(generic.ObjectView):
return {
'services': services,
'secrets': secrets,
'vc_members': vc_members,
'related_devices': related_devices,
'active_tab': 'device',
@ -1912,6 +1910,30 @@ class InterfaceCreateView(generic.ComponentCreateView):
model_form = forms.InterfaceForm
template_name = 'dcim/device_component_add.html'
def post(self, request):
"""
Override inherited post() method to handle request to assign newly created
interface objects (first object) to an IP Address object.
"""
logger = logging.getLogger('netbox.dcim.views.InterfaceCreateView')
form = self.form(request.POST, initial=request.GET)
new_objs = self.validate_form(request, form)
if form.is_valid() and not form.errors:
if '_addanother' in request.POST:
return redirect(request.get_full_path())
elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and request.user.has_perm('ipam.add_ipaddress'):
first_obj = new_objs[0].pk
return redirect(f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}')
else:
return redirect(self.get_return_url(request))
return render(request, self.template_name, {
'component_type': self.queryset.model._meta.verbose_name,
'form': form,
'return_url': self.get_return_url(request),
})
class InterfaceEditView(generic.ObjectEditView):
queryset = Interface.objects.all()
@ -2510,23 +2532,6 @@ class ConsoleConnectionsListView(generic.ObjectListView):
table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html'
def queryset_to_csv(self):
csv_data = [
# Headers
','.join(['console_server', 'port', 'device', 'console_port', 'reachable'])
]
for obj in self.queryset:
csv = csv_format([
obj._path.destination.device.identifier if obj._path.destination else None,
obj._path.destination.name if obj._path.destination else None,
obj.device.identifier,
obj.name,
obj._path.is_active
])
csv_data.append(csv)
return '\n'.join(csv_data)
def extra_context(self):
return {
'title': 'Console Connections'
@ -2540,23 +2545,6 @@ class PowerConnectionsListView(generic.ObjectListView):
table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html'
def queryset_to_csv(self):
csv_data = [
# Headers
','.join(['pdu', 'outlet', 'device', 'power_port', 'reachable'])
]
for obj in self.queryset:
csv = csv_format([
obj._path.destination.device.identifier if obj._path.destination else None,
obj._path.destination.name if obj._path.destination else None,
obj.device.identifier,
obj.name,
obj._path.is_active
])
csv_data.append(csv)
return '\n'.join(csv_data)
def extra_context(self):
return {
'title': 'Power Connections'
@ -2574,25 +2562,6 @@ class InterfaceConnectionsListView(generic.ObjectListView):
table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html'
def queryset_to_csv(self):
csv_data = [
# Headers
','.join([
'device_a', 'interface_a', 'device_b', 'interface_b', 'reachable'
])
]
for obj in self.queryset:
csv = csv_format([
obj._path.destination.device.identifier if obj._path.destination else None,
obj._path.destination.name if obj._path.destination else None,
obj.device.identifier,
obj.name,
obj._path.is_active
])
csv_data.append(csv)
return '\n'.join(csv_data)
def extra_context(self):
return {
'title': 'Interface Connections'

View File

@ -1,200 +1,6 @@
from django import forms
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from utilities.forms import ContentTypeChoiceField, ContentTypeMultipleChoiceField, LaxURLField
from utilities.utils import content_type_name
from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
from .utils import FeatureQuery
#
# Webhooks
#
class WebhookForm(forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks')
)
payload_url = LaxURLField(
label='URL'
)
class Meta:
model = Webhook
exclude = ()
@admin.register(Webhook)
class WebhookAdmin(admin.ModelAdmin):
list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete',
'ssl_verification',
]
list_filter = [
'enabled', 'type_create', 'type_update', 'type_delete', 'content_types',
]
form = WebhookForm
fieldsets = (
(None, {
'fields': ('name', 'content_types', 'enabled')
}),
('Events', {
'fields': ('type_create', 'type_update', 'type_delete')
}),
('HTTP Request', {
'fields': (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
),
'classes': ('monospace',)
}),
('SSL', {
'fields': ('ssl_verification', 'ca_file_path')
})
)
def models(self, obj):
return ', '.join([ct.name for ct in obj.content_types.all()])
#
# Custom fields
#
class CustomFieldForm(forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
exclude = []
widgets = {
'default': forms.TextInput(),
'validation_regex': forms.Textarea(
attrs={
'cols': 80,
'rows': 3,
}
)
}
@admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin):
actions = None
form = CustomFieldForm
list_display = [
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
]
list_filter = [
'type', 'required', 'content_types',
]
fieldsets = (
('Custom Field', {
'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic')
}),
('Assignment', {
'description': 'A custom field must be assigned to one or more object types.',
'fields': ('content_types',)
}),
('Validation Rules', {
'fields': ('validation_minimum', 'validation_maximum', 'validation_regex'),
'classes': ('monospace',)
}),
('Choices', {
'description': 'A selection field must have two or more choices assigned to it.',
'fields': ('choices',)
})
)
def models(self, obj):
ct_names = [content_type_name(ct) for ct in obj.content_types.all()]
return mark_safe('<br/>'.join(ct_names))
#
# Custom links
#
class CustomLinkForm(forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = CustomLink
exclude = []
widgets = {
'link_text': forms.Textarea,
'link_url': forms.Textarea,
}
help_texts = {
'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
'first in a list.',
'link_text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. '
'Links which render as empty text will not be displayed.',
'link_url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
}
@admin.register(CustomLink)
class CustomLinkAdmin(admin.ModelAdmin):
fieldsets = (
('Custom Link', {
'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
}),
('Templates', {
'fields': ('link_text', 'link_url'),
'classes': ('monospace',)
})
)
list_display = [
'name', 'content_type', 'group_name', 'weight',
]
list_filter = [
'content_type',
]
form = CustomLinkForm
#
# Export templates
#
class ExportTemplateForm(forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = ExportTemplate
exclude = []
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
fieldsets = (
('Export Template', {
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment')
}),
('Content', {
'fields': ('template_code',),
'classes': ('monospace',)
})
)
list_display = [
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
]
list_filter = [
'content_type',
]
form = ExportTemplateForm
from .models import JobResult
#

View File

@ -453,12 +453,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
class ContentTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
display_name = serializers.SerializerMethodField()
class Meta:
model = ContentType
fields = ['id', 'url', 'display', 'app_label', 'model', 'display_name']
@swagger_serializer_method(serializer_or_field=serializers.CharField)
def get_display_name(self, obj):
return obj.app_labeled_name
fields = ['id', 'url', 'display', 'app_label', 'model']

View File

@ -5,4 +5,5 @@ class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
import extras.lookups
import extras.signals

View File

@ -45,7 +45,7 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
class CustomLinkButtonClassChoices(ChoiceSet):
CLASS_DEFAULT = 'default'
CLASS_DEFAULT = 'outline-dark'
CLASS_PRIMARY = 'primary'
CLASS_SUCCESS = 'success'
CLASS_INFO = 'info'
@ -106,7 +106,7 @@ class JournalEntryKindChoices(ChoiceSet):
)
CSS_CLASSES = {
KIND_INFO: 'default',
KIND_INFO: 'info',
KIND_SUCCESS: 'success',
KIND_WARNING: 'warning',
KIND_DANGER: 'danger',
@ -134,7 +134,7 @@ class LogLevelChoices(ChoiceSet):
)
CSS_CLASSES = {
LOG_DEFAULT: 'default',
LOG_DEFAULT: 'secondary',
LOG_SUCCESS: 'success',
LOG_INFO: 'info',
LOG_WARNING: 'warning',

View File

@ -7,13 +7,14 @@ from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField,
JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, CSVContentTypeField, CSVModelForm,
CSVMultipleContentTypeField, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
from .models import *
from .utils import FeatureQuery
@ -21,58 +22,431 @@ from .utils import FeatureQuery
# Custom fields
#
class CustomFieldForm(forms.Form):
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
class Meta:
model = CustomField
fields = '__all__'
fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
class CustomFieldCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
help_text="One or more assigned object types"
)
class Meta:
model = CustomField
fields = (
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
'weight',
)
class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomField.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
class Meta:
nullable_fields = []
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['type', 'content_types'],
['weight', 'required'],
]
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
required=False,
widget=StaticSelect2Multiple()
)
weight = forms.IntegerField(
required=False
)
required = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Custom links
#
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = CustomLink
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
('Templates', ('link_text', 'link_url')),
)
class CustomLinkCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'),
help_text="Assigned object type"
)
class Meta:
model = CustomLink
fields = (
'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
)
class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomLink.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
weight = forms.IntegerField(
required=False
)
button_class = forms.ChoiceField(
choices=CustomLinkButtonClassChoices,
required=False,
widget=StaticSelect2()
)
class Meta:
nullable_fields = []
class CustomLinkFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['content_type'],
['weight', 'new_window'],
]
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
weight = forms.IntegerField(
required=False
)
new_window = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Export templates
#
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
)
class Meta:
model = ExportTemplate
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'description')),
('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
class ExportTemplateCSVForm(CSVModelForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
help_text="Assigned object type"
)
class Meta:
model = ExportTemplate
fields = (
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
)
class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ExportTemplate.objects.all(),
widget=forms.MultipleHiddenInput
)
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
mime_type = forms.CharField(
max_length=50,
required=False
)
file_extension = forms.CharField(
max_length=15,
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
class Meta:
nullable_fields = ['description', 'mime_type', 'file_extension']
class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['content_type', 'mime_type'],
['file_extension', 'as_attachment'],
]
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
mime_type = forms.CharField(
required=False
)
file_extension = forms.CharField(
required=False
)
as_attachment = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Webhooks
#
class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks')
)
class Meta:
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
class WebhookCSVForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks'),
help_text="One or more assigned object types"
)
class Meta:
model = Webhook
fields = (
'name', 'enabled', 'content_types', 'type_create', 'type_update', 'type_delete', 'payload_url',
'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification',
'ca_file_path'
)
class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Webhook.objects.all(),
widget=forms.MultipleHiddenInput
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_create = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_update = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
type_delete = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
http_method = forms.ChoiceField(
choices=WebhookHttpMethodChoices,
required=False
)
payload_url = forms.CharField(
required=False
)
ssl_verification = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
secret = forms.CharField(
required=False
)
ca_file_path = forms.CharField(
required=False
)
class Meta:
nullable_fields = ['secret', 'ca_file_path']
class WebhookFilterForm(BootstrapMixin, forms.Form):
field_groups = [
['content_types', 'http_method'],
['enabled', 'type_create', 'type_update', 'type_delete'],
]
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields')
)
http_method = forms.MultipleChoiceField(
choices=WebhookHttpMethodChoices,
required=False,
widget=StaticSelect2Multiple()
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_create = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_update = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_delete = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Custom field models
#
class CustomFieldsMixin:
"""
Extend Form to include custom field support.
"""
model = None
def __init__(self, *args, **kwargs):
if self.model is None:
raise NotImplementedError("CustomFieldForm must specify a model class.")
self.custom_fields = []
super().__init__(*args, **kwargs)
# Append relevant custom fields to the form instance
obj_type = ContentType.objects.get_for_model(self.model)
for cf in CustomField.objects.filter(content_types=obj_type):
field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field()
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
class CustomFieldModelForm(forms.ModelForm):
"""
Extend ModelForm to include custom field support.
Extend a Form to include custom field support.
"""
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
self.custom_fields = []
super().__init__(*args, **kwargs)
self._append_customfield_fields()
def _get_content_type(self):
"""
Return the ContentType of the form's model.
"""
if not hasattr(self, 'model'):
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model)
def _get_form_field(self, customfield):
return customfield.to_form_field()
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this model.
Append form fields for all CustomFields assigned to this object type.
"""
content_type = self._get_content_type()
# Append form fields; assign initial values if modifying and existing object
for cf in CustomField.objects.filter(content_types=self.obj_type):
field_name = 'cf_{}'.format(cf.name)
if self.instance.pk:
self.fields[field_name] = cf.to_form_field(set_initial=False)
self.fields[field_name].initial = self.instance.custom_field_data.get(cf.name)
else:
self.fields[field_name] = cf.to_form_field()
for customfield in CustomField.objects.filter(content_types=content_type):
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
"""
Extend ModelForm to include custom field support.
"""
def _get_content_type(self):
return ContentType.objects.get_for_model(self._meta.model)
def _get_form_field(self, customfield):
if self.instance.pk:
form_field = customfield.to_form_field(set_initial=False)
form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
return form_field
return customfield.to_form_field()
def clean(self):
# Save custom field data on instance
@ -84,18 +458,11 @@ class CustomFieldModelForm(forms.ModelForm):
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
def _append_customfield_fields(self):
# Append form fields
for cf in CustomField.objects.filter(content_types=self.obj_type):
field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field(for_csv_import=True)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True)
class CustomFieldBulkEditForm(BulkEditForm):
class CustomFieldModelBulkEditForm(BulkEditForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -114,7 +481,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
self.custom_fields.append(cf.name)
class CustomFieldFilterForm(forms.Form):
class CustomFieldModelFilterForm(forms.Form):
def __init__(self, *args, **kwargs):
@ -153,7 +520,7 @@ class TagCSVForm(CSVModelForm):
class Meta:
model = Tag
fields = Tag.csv_headers
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
@ -193,10 +560,8 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=Tag.objects.all(),
widget=forms.MultipleHiddenInput
)
color = forms.CharField(
max_length=6,
required=False,
widget=ColorSelect()
color = ColorField(
required=False
)
description = forms.CharField(
max_length=200,
@ -294,13 +659,15 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
field_order = [
'q', 'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id',
'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id',
'tenant_group_id', 'tenant_id',
]
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['region_id', 'site_group_id', 'site_id'],
['device_type_id', 'role_id', 'platform_id'],
['cluster_group_id', 'cluster_id'],
['tenant_group_id', 'tenant_id', 'tag']
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -393,6 +760,12 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class JournalEntryForm(BootstrapMixin, forms.ModelForm):
comments = CommentField()
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect2()
)
class Meta:
model = JournalEntry
fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments']
@ -422,10 +795,10 @@ class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
model = JournalEntry
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['created_before', 'created_after', 'created_by_id'],
['assigned_object_type_id', 'kind']
]
created_after = forms.DateTimeField(
required=False,
label=_('After'),
@ -465,10 +838,10 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form):
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
model = ObjectChange
q = forms.CharField(
required=False,
label=_('Search')
)
field_groups = [
['time_before', 'time_after', 'action'],
['user_id', 'changed_object_type_id'],
]
time_after = forms.DateTimeField(
required=False,
label=_('After'),

View File

@ -0,0 +1,30 @@
import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
class ExtrasQuery(graphene.ObjectType):
config_context = ObjectField(ConfigContextType)
config_context_list = ObjectListField(ConfigContextType)
custom_field = ObjectField(CustomFieldType)
custom_field_list = ObjectListField(CustomFieldType)
custom_link = ObjectField(CustomLinkType)
custom_link_list = ObjectListField(CustomLinkType)
export_template = ObjectField(ExportTemplateType)
export_template_list = ObjectListField(ExportTemplateType)
image_attachment = ObjectField(ImageAttachmentType)
image_attachment_list = ObjectListField(ImageAttachmentType)
journal_entry = ObjectField(JournalEntryType)
journal_entry_list = ObjectListField(JournalEntryType)
tag = ObjectField(TagType)
tag_list = ObjectListField(TagType)
webhook = ObjectField(WebhookType)
webhook_list = ObjectListField(WebhookType)

View File

@ -0,0 +1,77 @@
from extras import filtersets, models
from netbox.graphql.types import BaseObjectType
__all__ = (
'ConfigContextType',
'CustomFieldType',
'CustomLinkType',
'ExportTemplateType',
'ImageAttachmentType',
'JournalEntryType',
'TagType',
'WebhookType',
)
class ConfigContextType(BaseObjectType):
class Meta:
model = models.ConfigContext
fields = '__all__'
filterset_class = filtersets.ConfigContextFilterSet
class CustomFieldType(BaseObjectType):
class Meta:
model = models.CustomField
fields = '__all__'
filterset_class = filtersets.CustomFieldFilterSet
class CustomLinkType(BaseObjectType):
class Meta:
model = models.CustomLink
fields = '__all__'
filterset_class = filtersets.CustomLinkFilterSet
class ExportTemplateType(BaseObjectType):
class Meta:
model = models.ExportTemplate
fields = '__all__'
filterset_class = filtersets.ExportTemplateFilterSet
class ImageAttachmentType(BaseObjectType):
class Meta:
model = models.ImageAttachment
fields = '__all__'
filterset_class = filtersets.ImageAttachmentFilterSet
class JournalEntryType(BaseObjectType):
class Meta:
model = models.JournalEntry
fields = '__all__'
filterset_class = filtersets.JournalEntryFilterSet
class TagType(BaseObjectType):
class Meta:
model = models.Tag
exclude = ('extras_taggeditem_items',)
filterset_class = filtersets.TagFilterSet
class WebhookType(BaseObjectType):
class Meta:
model = models.Webhook
fields = '__all__'
filterset_class = filtersets.WebhookFilterSet

17
netbox/extras/lookups.py Normal file
View File

@ -0,0 +1,17 @@
from django.db.models import CharField, Lookup
class Empty(Lookup):
"""
Filter on whether a string is empty.
"""
lookup_name = 'empty'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
CharField.register_lookup(Empty)

View File

@ -0,0 +1,87 @@
from datetime import timedelta
from importlib import import_module
import requests
from django.conf import settings
from django.core.cache import cache
from django.core.management.base import BaseCommand
from django.db import DEFAULT_DB_ALIAS
from django.utils import timezone
from packaging import version
from extras.models import ObjectChange
class Command(BaseCommand):
help = "Perform nightly housekeeping tasks. (This command can be run at any time.)"
def handle(self, *args, **options):
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
self.stdout.write("[*] Clearing expired authentication sessions")
if options['verbosity'] >= 2:
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
engine = import_module(settings.SESSION_ENGINE)
try:
engine.SessionStore.clear_expired()
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
except NotImplementedError:
self.stdout.write(
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)
# Delete expired ObjectRecords
self.stdout.write("[*] Checking for expired changelog records")
if settings.CHANGELOG_RETENTION:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
if options['verbosity'] >= 2:
self.stdout.write(f"Retention period: {settings.CHANGELOG_RETENTION} days")
self.stdout.write(f"\tCut-off time: {cutoff}")
expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
if expired_records:
self.stdout.write(f"\tDeleting {expired_records} expired records... ", self.style.WARNING, ending="")
self.stdout.flush()
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
self.stdout.write("Done.", self.style.WARNING)
else:
self.stdout.write("\tNo expired records found.")
else:
self.stdout.write(
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
)
# Check for new releases (if enabled)
self.stdout.write("[*] Checking for latest release")
if settings.RELEASE_CHECK_URL:
headers = {
'Accept': 'application/vnd.github.v3+json',
}
try:
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
response = requests.get(
url=settings.RELEASE_CHECK_URL,
headers=headers,
proxies=settings.HTTP_PROXIES
)
response.raise_for_status()
releases = []
for release in response.json():
if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
continue
releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases)
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
self.stdout.write(f"\tLatest release: {latest_release[0]}")
# Cache the most recent release
cache.set('latest_release', latest_release, None)
except requests.exceptions.RequestException as exc:
self.stdout.write(f"\tRequest error: {exc}")
else:
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
self.stdout.write("Finished.", self.style.SUCCESS)

View File

@ -9,7 +9,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization']
BANNER_TEXT = """### NetBox interactive shell ({node})
### Python {python} | Django {django} | NetBox {netbox}

View File

@ -1,4 +1,3 @@
from cacheops import invalidate_model
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
@ -108,8 +107,5 @@ class Command(BaseCommand):
elif options['verbosity']:
self.stdout.write(self.style.SUCCESS(str(count)))
# Invalidate cached queries
invalidate_model(model)
if options['verbosity']:
self.stdout.write(self.style.SUCCESS("Done."))

View File

@ -52,7 +52,6 @@ class Migration(migrations.Migration):
('circuits', '0015_custom_tag_models'),
('dcim', '0070_custom_tag_models'),
('ipam', '0025_custom_tag_models'),
('secrets', '0006_custom_tag_models'),
('tenancy', '0006_custom_tag_models'),
('virtualization', '0009_custom_tag_models'),
]

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