Merge branch 'feature' into 10472-graphene-3

This commit is contained in:
Arthur 2022-10-03 10:44:25 -07:00
commit edf28eb494
96 changed files with 1279 additions and 579 deletions

View File

@ -1,5 +1,7 @@
name: CI name: CI
on: [push, pull_request] on: [push, pull_request]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -4,6 +4,11 @@ name: 'Lock threads'
on: on:
schedule: schedule:
- cron: '0 3 * * *' - cron: '0 3 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs: jobs:
lock: lock:
@ -11,7 +16,6 @@ jobs:
steps: steps:
- uses: dessant/lock-threads@v3 - uses: dessant/lock-threads@v3
with: with:
github-token: ${{ github.token }}
issue-inactive-days: 90 issue-inactive-days: 90
pr-inactive-days: 30 pr-inactive-days: 30
issue-lock-reason: 'resolved' issue-lock-reason: 'resolved'

View File

@ -1,14 +1,21 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: 'Close stale issues/PRs' name: 'Close stale issues/PRs'
on: on:
schedule: schedule:
- cron: '0 4 * * *' - cron: '0 4 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs: jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v5 - uses: actions/stale@v6
with: with:
close-issue-message: > close-issue-message: >
This issue has been automatically closed due to lack of activity. In an This issue has been automatically closed due to lack of activity. In an

View File

@ -2,8 +2,8 @@
{% block site_meta %} {% block site_meta %}
{{ super() }} {{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs #} {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
{% if not config.extra.readthedocs %} {% if page.canonical_url != 'https://docs.netbox.dev/' %}
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -13,6 +13,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
* Text: Free-form text (intended for single-line use) * Text: Free-form text (intended for single-line use)
* Long text: Free-form of any length; supports Markdown rendering * Long text: Free-form of any length; supports Markdown rendering
* Integer: A whole number (positive or negative) * Integer: A whole number (positive or negative)
* Decimal: A fixed-precision decimal number (4 decimal places)
* Boolean: True or false * Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD) * Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI * URL: This will be presented as a link in the web UI

View File

@ -60,7 +60,7 @@ Create the HTML template for the object view. (The other views each typically em
## 10. Add the model to the navigation menu ## 10. Add the model to the navigation menu
Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`. Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.
## 11. REST API components ## 11. REST API components

View File

@ -41,6 +41,10 @@ Indicates whether this is a parent type (capable of housing child devices), a ch
The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules). The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules).
### Weight
The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Front & Rear Images ### Front & Rear Images
Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams. Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams.

View File

@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu
### Part Number ### Part Number
An alternative part number to uniquely identify the module type. An alternative part number to uniquely identify the module type.
### Weight
The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound).

View File

@ -65,6 +65,10 @@ The height of the rack, measured in units.
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Descending Units ### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.) If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.)

View File

@ -14,6 +14,7 @@ Plugins can do a lot, including:
* Provide their own "pages" (views) in the web user interface * Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links * Inject template content and navigation links
* Extend NetBox's REST and GraphQL APIs * Extend NetBox's REST and GraphQL APIs
* Load additional Django apps
* Add custom request/response middleware * Add custom request/response middleware
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig):
default_settings = { default_settings = {
'baz': True 'baz': True
} }
django_apps = ["foo", "bar", "baz"]
config = FooBarConfig config = FooBarConfig
``` ```
@ -101,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
| `required_settings` | A list of any configuration parameters that **must** be defined by the user | | `required_settings` | A list of any configuration parameters that **must** be defined by the user |
| `default_settings` | A dictionary of configuration parameters and their default values | | `default_settings` | A dictionary of configuration parameters and their default values |
| `django_apps` | A list of additional Django apps to load alongside the plugin |
| `min_version` | Minimum version of NetBox with which the plugin is compatible | | `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum 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 | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
@ -112,6 +115,14 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
#### Important Notes About `django_apps`
Loading additional apps may cause more harm than good and could make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intended only for advanced use cases that require a deeper Django integration.
Apps from this list are inserted *before* the plugin's `PluginConfig` in the order defined. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin.
Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin.
## Create setup.py ## Create setup.py
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: `setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:

View File

@ -1,25 +1,67 @@
# Navigation # Navigation
## Menus
!!! note
This feature was introduced in NetBox v3.4.
A plugin can register its own submenu as part of NetBox's navigation menu. This is done by defining a variable named `menu` in `navigation.py`, pointing to an instance of the `PluginMenu` class. Each menu must define a label and grouped menu items (discussed below), and may optionally specify an icon. An example is shown below.
```python title="navigation.py"
from extras.plugins import PluginMenu
menu = PluginMenu(
label='My Plugin',
groups=(
('Foo', (item1, item2, item3)),
('Bar', (item4, item5)),
),
icon='mdi mdi-router'
)
```
Note that each group is a two-tuple containing a label and an iterable of menu items. The group's label serves as the section header within the submenu. A group label is required even if you have only one group of items.
!!! tip
The path to the menu class can be modified by setting `menu` in the PluginConfig instance.
A `PluginMenu` has the following attributes:
| Attribute | Required | Description |
|--------------|----------|---------------------------------------------------|
| `label` | Yes | The text displayed as the menu heading |
| `groups` | Yes | An iterable of named groups containing menu items |
| `icon_class` | - | The CSS name of the icon to use for the heading |
!!! tip
Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)
### The Default Menu
If your plugin has only a small number of menu items, it may be desirable to use NetBox's shared "Plugins" menu rather than creating your own. To do this, simply declare `menu_items` as a list of `PluginMenuItems` in `navigation.py`. The listed items will appear under a heading bearing the name of your plugin in the "Plugins" submenu.
```python title="navigation.py"
menu_items = (item1, item2, item3)
```
!!! tip
The path to the menu items list can be modified by setting `menu_items` in the PluginConfig instance.
## Menu Items ## Menu Items
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
!!! tip ```python filename="navigation.py"
The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance.
```python
from extras.plugins import PluginMenuButton, PluginMenuItem from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices from utilities.choices import ButtonColorChoices
menu_items = ( item1 = PluginMenuItem(
PluginMenuItem( link='plugins:myplugin:myview',
link='plugins:netbox_animal_sounds:random_animal', link_text='Some text',
link_text='Random sound', buttons=(
buttons=( PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), )
)
),
) )
``` ```
@ -34,17 +76,19 @@ A `PluginMenuItem` has the following attributes:
## Menu Buttons ## Menu Buttons
Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects.
A `PluginMenuButton` has the following attributes: A `PluginMenuButton` has the following attributes:
| Attribute | Required | Description | | Attribute | Required | Description |
|---------------|----------|--------------------------------------------------------------------| |---------------|----------|--------------------------------------------------------------------|
| `link` | Yes | Name of the URL path to which this button links | | `link` | Yes | Name of the URL path to which this button links |
| `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) | | `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) |
| `icon_class` | Yes | Button icon CSS class* | | `icon_class` | Yes | Button icon CSS class |
| `color` | - | One of the choices provided by `ButtonColorChoices` | | `color` | - | One of the choices provided by `ButtonColorChoices` |
| `permissions` | - | A list of permissions required to display this button | | `permissions` | - | A list of permissions required to display this button |
*NetBox supports [Material Design Icons](https://materialdesignicons.com/). Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
!!! note !!! tip
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)

View File

@ -2,17 +2,27 @@
## v3.3.5 (FUTURE) ## v3.3.5 (FUTURE)
### Enhancements
* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions
### Bug Fixes ### Bug Fixes
* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view * [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view
* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments
* [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned * [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned
* [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field * [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field
* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values
* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI
* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms
* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window
--- ---
## v3.3.4 (2022-09-16) ## v3.3.4 (2022-09-16)
### Bug Fixes ### Bug Fixes
* [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI * [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI
* [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface * [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface

View File

@ -3,19 +3,46 @@
!!! warning "PostgreSQL 11 Required" !!! warning "PostgreSQL 11 Required"
NetBox v3.4 requires PostgreSQL 11 or later. NetBox v3.4 requires PostgreSQL 11 or later.
### Breaking Changes
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error.
* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
### New Features
#### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071))
A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained.
### Enhancements ### Enhancements
* [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
* [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types
* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
### Plugins API ### Plugins API
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter
* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin * [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
### Other Changes ### Other Changes
* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11 * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
### REST API Changes ### REST API Changes
* circuits.provider
* Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields
* dcim.DeviceType
* Added optional `weight` and `weight_unit` fields
* dcim.ModuleType
* Added optional `weight` and `weight_unit` fields
* dcim.Rack
* Added optional `weight` and `weight_unit` fields
* ipam.FHRPGroup * ipam.FHRPGroup
* Added optional `name` field * Added optional `name` field

View File

@ -38,7 +38,6 @@ plugins:
show_root_toc_entry: false show_root_toc_entry: false
show_source: false show_source: false
extra: extra:
readthedocs: !ENV READTHEDOCS
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox link: https://github.com/netbox-community/netbox

View File

@ -31,7 +31,7 @@ class ProviderSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'id', 'url', 'display', 'name', 'slug', 'account',
'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
] ]

View File

@ -65,7 +65,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class Meta: class Meta:
model = Provider model = Provider
fields = ['id', 'name', 'slug', 'asn', 'account'] fields = ['id', 'name', 'slug', 'account']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -73,8 +73,6 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(account__icontains=value) | Q(account__icontains=value) |
Q(noc_contact__icontains=value) |
Q(admin_contact__icontains=value) |
Q(comments__icontains=value) Q(comments__icontains=value)
) )

View File

@ -20,10 +20,6 @@ __all__ = (
class ProviderBulkEditForm(NetBoxModelBulkEditForm): class ProviderBulkEditForm(NetBoxModelBulkEditForm):
asn = forms.IntegerField(
required=False,
label='ASN (legacy)'
)
asns = DynamicModelMultipleChoiceField( asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(), queryset=ASN.objects.all(),
label=_('ASNs'), label=_('ASNs'),
@ -34,20 +30,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
label='Account number' label='Account number'
) )
portal_url = forms.URLField(
required=False,
label='Portal'
)
noc_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='NOC contact'
)
admin_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='Admin contact'
)
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=SmallTextarea,
label='Comments' label='Comments'
@ -55,10 +37,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
model = Provider model = Provider
fieldsets = ( fieldsets = (
(None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), (None, ('asns', 'account', )),
) )
nullable_fields = ( nullable_fields = (
'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'asns', 'account', 'comments',
) )

View File

@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm):
class Meta: class Meta:
model = Provider model = Provider
fields = ( fields = (
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'name', 'slug', 'account', 'comments',
) )

View File

@ -30,29 +30,17 @@ class ProviderForm(NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), ('Provider', ('name', 'slug', 'asns', 'tags')),
('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), ('Support Info', ('account',)),
) )
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', 'name', 'slug', 'account', 'asns', 'comments', 'tags',
] ]
widgets = {
'noc_contact': SmallTextarea(
attrs={'rows': 5}
),
'admin_contact': SmallTextarea(
attrs={'rows': 5}
),
}
help_texts = { help_texts = {
'name': "Full name of the provider", 'name': "Full name of the provider",
'asn': "BGP autonomous system number (if applicable)",
'portal_url': "URL of the provider's customer support portal",
'noc_contact': "NOC email address and phone number",
'admin_contact': "Administrative contact email address and phone number",
} }

View File

@ -1,5 +1,5 @@
import dcim.fields import dcim.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('cid', models.CharField(max_length=100)), ('cid', models.CharField(max_length=100)),
('status', models.CharField(default='active', max_length=50)), ('status', models.CharField(default='active', max_length=50)),
@ -58,7 +58,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -73,7 +73,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -93,7 +93,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)), ('description', models.CharField(blank=True, max_length=200)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import taggit.managers import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='circuittermination', model_name='circuittermination',
name='custom_field_data', name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder),
), ),
migrations.AddField( migrations.AddField(
model_name='circuittermination', model_name='circuittermination',

View File

@ -0,0 +1,59 @@
import os
from django.db import migrations
from django.db.utils import DataError
def check_legacy_data(apps, schema_editor):
"""
Abort the migration if any legacy provider fields still contain data.
"""
Provider = apps.get_model('circuits', 'Provider')
provider_count = Provider.objects.exclude(asn__isnull=True).count()
if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ:
raise DataError(
f"Unable to proceed with deleting asn field from Provider model: Found {provider_count} "
f"providers with legacy ASN data. Please ensure all legacy provider ASN data has been "
f"migrated to ASN objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA "
f"environment variable to bypass this safeguard and delete all legacy provider ASN data."
)
provider_count = Provider.objects.exclude(admin_contact='', noc_contact='', portal_url='').count()
if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ:
raise DataError(
f"Unable to proceed with deleting contact fields from Provider model: Found {provider_count} "
f"providers with legacy contact data. Please ensure all legacy provider contact data has been "
f"migrated to contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA "
f"environment variable to bypass this safeguard and delete all legacy provider contact data."
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0039_unique_constraints'),
]
operations = [
migrations.RunPython(
code=check_legacy_data,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='provider',
name='admin_contact',
),
migrations.RemoveField(
model_name='provider',
name='asn',
),
migrations.RemoveField(
model_name='provider',
name='noc_contact',
),
migrations.RemoveField(
model_name='provider',
name='portal_url',
),
]

View File

@ -24,12 +24,6 @@ class Provider(NetBoxModel):
max_length=100, max_length=100,
unique=True unique=True
) )
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN',
help_text='32-bit autonomous system number'
)
asns = models.ManyToManyField( asns = models.ManyToManyField(
to='ipam.ASN', to='ipam.ASN',
related_name='providers', related_name='providers',
@ -40,18 +34,6 @@ class Provider(NetBoxModel):
blank=True, blank=True,
verbose_name='Account number' verbose_name='Account number'
) )
portal_url = models.URLField(
blank=True,
verbose_name='Portal URL'
)
noc_contact = models.TextField(
blank=True,
verbose_name='NOC contact'
)
admin_contact = models.TextField(
blank=True,
verbose_name='Admin contact'
)
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
@ -62,7 +44,7 @@ class Provider(NetBoxModel):
) )
clone_fields = ( clone_fields = (
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'account',
) )
class Meta: class Meta:

View File

@ -41,10 +41,10 @@ class ProviderTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Provider model = Provider
fields = ( fields = (
'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', 'pk', 'id', 'name', 'asns', 'account', 'asn_count',
'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') default_columns = ('pk', 'name', 'account', 'circuit_count')
class ProviderNetworkTable(NetBoxTable): class ProviderNetworkTable(NetBoxTable):

View File

@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider model = Provider
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'asn': 1234, 'account': '1234',
} }
@classmethod @classmethod

View File

@ -25,11 +25,11 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns) ASN.objects.bulk_create(asns)
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), Provider(name='Provider 1', slug='provider-1', account='1234'),
Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), Provider(name='Provider 2', slug='provider-2', account='2345'),
Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'), Provider(name='Provider 3', slug='provider-3', account='3456'),
Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'), Provider(name='Provider 4', slug='provider-4', account='4567'),
Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), Provider(name='Provider 5', slug='provider-5', account='5678'),
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
providers[0].asns.set([asns[0]]) providers[0].asns.set([asns[0]])
@ -82,10 +82,6 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['provider-1', 'provider-2']} params = {'slug': ['provider-1', 'provider-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn(self): # Legacy field
params = {'asn': ['65001', '65002']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn_id(self): # ASN object assignment def test_asn_id(self): # ASN object assignment
asns = ASN.objects.all()[:2] asns = ASN.objects.all()[:2]
params = {'asn_id': [asns[0].pk, asns[1].pk]} params = {'asn_id': [asns[0].pk, asns[1].pk]}

View File

@ -23,9 +23,9 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ASN.objects.bulk_create(asns) ASN.objects.bulk_create(asns)
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3', asn=65003), Provider(name='Provider 3', slug='provider-3'),
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
providers[0].asns.set([asns[0], asns[1]]) providers[0].asns.set([asns[0], asns[1]])
@ -37,12 +37,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'name': 'Provider X', 'name': 'Provider X',
'slug': 'provider-x', 'slug': 'provider-x',
'asn': 65123,
'asns': [asns[6].pk, asns[7].pk], 'asns': [asns[6].pk, asns[7].pk],
'account': '1234', 'account': '1234',
'portal_url': 'http://example.com/portal',
'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com',
'comments': 'Another provider', 'comments': 'Another provider',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
@ -55,11 +51,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'asn': 65009,
'account': '5678', 'account': '5678',
'portal_url': 'http://example.com/portal2',
'noc_contact': 'noc2@example.com',
'admin_contact': 'admin2@example.com',
'comments': 'New comments', 'comments': 'New comments',
} }
@ -104,8 +96,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 2', slug='provider-2'),
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)

View File

@ -201,6 +201,7 @@ class RackSerializer(NetBoxModelSerializer):
default=None) default=None)
width = ChoiceField(choices=RackWidthChoices, required=False) width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True)
@ -208,8 +209,9 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack model = Rack
fields = [ fields = [
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', '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', 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', 'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
] ]
@ -315,27 +317,29 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
) )
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags',
'last_updated', 'device_count', 'custom_fields', 'created', 'last_updated', 'device_count',
] ]
class ModuleTypeSerializer(NetBoxModelSerializer): class ModuleTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer()
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
# module_count = serializers.IntegerField(read_only=True) # module_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -1314,6 +1314,24 @@ class CableLengthUnitChoices(ChoiceSet):
) )
class WeightUnitChoices(ChoiceSet):
# Metric
UNIT_KILOGRAM = 'kg'
UNIT_GRAM = 'g'
# Imperial
UNIT_POUND = 'lb'
UNIT_OUNCE = 'oz'
CHOICES = (
(UNIT_KILOGRAM, 'Kilograms'),
(UNIT_GRAM, 'Grams'),
(UNIT_POUND, 'Pounds'),
(UNIT_OUNCE, 'Ounces'),
)
# #
# CableTerminations # CableTerminations
# #

View File

@ -320,7 +320,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'outer_unit', 'weight', 'weight_unit'
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = ['id', 'model', 'part_number'] fields = ['id', 'model', 'part_number', 'weight', 'weight_unit']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -887,6 +887,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
to_field_name='slug', to_field_name='slug',
label='Device model (slug)', label='Device model (slug)',
) )
name = MultiValueCharFilter(
lookup_expr='iexact'
)
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=DeviceStatusChoices, choices=DeviceStatusChoices,
null_value=None null_value=None
@ -950,7 +953,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'name', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -285,15 +285,26 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
widget=SmallTextarea, widget=SmallTextarea,
label='Comments' label='Comments'
) )
weight = forms.DecimalField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
)
model = Rack model = Rack
fieldsets = ( fieldsets = (
('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')),
('Location', ('region', 'site_group', 'site', 'location')), ('Location', ('region', 'site_group', 'site', 'location')),
('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')),
('Weight', ('weight', 'weight_unit')),
) )
nullable_fields = ( nullable_fields = (
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'weight', 'weight_unit'
) )
@ -355,12 +366,23 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
widget=StaticSelect() widget=StaticSelect()
) )
weight = forms.DecimalField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
)
model = DeviceType model = DeviceType
fieldsets = ( fieldsets = (
(None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')),
('Weight', ('weight', 'weight_unit')),
) )
nullable_fields = ('part_number', 'airflow') nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit')
class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
@ -371,12 +393,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
part_number = forms.CharField( part_number = forms.CharField(
required=False required=False
) )
weight = forms.DecimalField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
)
model = ModuleType model = ModuleType
fieldsets = ( fieldsets = (
(None, ('manufacturer', 'part_number')), ('Module Type', ('manufacturer', 'part_number')),
('Weight', ('weight', 'weight_unit')),
) )
nullable_fields = ('part_number',) nullable_fields = ('part_number', 'weight', 'weight_unit')
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
@ -553,17 +586,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
'type', 'status', 'tenant', 'label', 'color', 'length', 'type', 'status', 'tenant', 'label', 'color', 'length',
) )
def clean(self):
super().clean()
# Validate length/unit
length = self.cleaned_data.get('length')
length_unit = self.cleaned_data.get('length_unit')
if length and not length_unit:
raise forms.ValidationError({
'length_unit': "Must specify a unit when setting length"
})
class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
domain = forms.CharField( domain = forms.CharField(

View File

@ -228,6 +228,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'weight_unit')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -281,6 +282,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class RackElevationFilterForm(RackFilterForm): class RackElevationFilterForm(RackFilterForm):
@ -370,6 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
)), )),
('Weight', ('weight', 'weight_unit')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -465,6 +474,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
) )
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class ModuleTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
@ -476,6 +492,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'pass_through_ports',
)), )),
('Weight', ('weight', 'weight_unit')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -529,6 +546,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
) )
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class DeviceRoleFilterForm(NetBoxModelFilterSetForm): class DeviceRoleFilterForm(NetBoxModelFilterSetForm):

View File

@ -260,7 +260,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [ fields = [
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'comments', 'tags', 'outer_unit', 'weight', 'weight_unit', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
@ -273,6 +273,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
'type': StaticSelect(), 'type': StaticSelect(),
'width': StaticSelect(), 'width': StaticSelect(),
'outer_unit': StaticSelect(), 'outer_unit': StaticSelect(),
'weight_unit': StaticSelect(),
} }
@ -363,6 +364,7 @@ class DeviceTypeForm(NetBoxModelForm):
('Chassis', ( ('Chassis', (
'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
)), )),
('Attributes', ('weight', 'weight_unit')),
('Images', ('front_image', 'rear_image')), ('Images', ('front_image', 'rear_image')),
) )
@ -370,7 +372,7 @@ class DeviceTypeForm(NetBoxModelForm):
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
'front_image', 'rear_image', 'comments', 'tags', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags',
] ]
widgets = { widgets = {
'airflow': StaticSelect(), 'airflow': StaticSelect(),
@ -380,7 +382,8 @@ class DeviceTypeForm(NetBoxModelForm):
}), }),
'rear_image': ClearableFileInput(attrs={ 'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS 'accept': DEVICETYPE_IMAGE_FORMATS
}) }),
'weight_unit': StaticSelect(),
} }
@ -392,16 +395,20 @@ class ModuleTypeForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
('Module Type', ( ('Module Type', (
'manufacturer', 'model', 'part_number', 'tags', 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit'
)), )),
) )
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'manufacturer', 'model', 'part_number', 'comments', 'tags', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
] ]
widgets = {
'weight_unit': StaticSelect(),
}
class DeviceRoleForm(NetBoxModelForm): class DeviceRoleForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()

View File

@ -211,6 +211,9 @@ class DeviceTypeType(NetBoxObjectType):
def resolve_airflow(self, info): def resolve_airflow(self, info):
return self.airflow or None return self.airflow or None
def resolve_weight_unit(self, info):
return self.weight_unit or None
class FrontPortType(ComponentObjectType, CabledObjectMixin): class FrontPortType(ComponentObjectType, CabledObjectMixin):
@ -328,6 +331,9 @@ class ModuleTypeType(NetBoxObjectType):
fields = '__all__' fields = '__all__'
filterset_class = filtersets.ModuleTypeFilterSet filterset_class = filtersets.ModuleTypeFilterSet
def resolve_weight_unit(self, info):
return self.weight_unit or None
class PlatformType(OrganizationalObjectType): class PlatformType(OrganizationalObjectType):
@ -416,6 +422,9 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
def resolve_outer_unit(self, info): def resolve_outer_unit(self, info):
return self.outer_unit or None return self.outer_unit or None
def resolve_weight_unit(self, info):
return self.weight_unit or None
class RackReservationType(NetBoxObjectType): class RackReservationType(NetBoxObjectType):

View File

@ -1,6 +1,6 @@
import dcim.fields import dcim.fields
import django.contrib.postgres.fields import django.contrib.postgres.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -28,7 +28,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('termination_a_id', models.PositiveIntegerField()), ('termination_a_id', models.PositiveIntegerField()),
('termination_b_id', models.PositiveIntegerField()), ('termination_b_id', models.PositiveIntegerField()),
@ -60,7 +60,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -96,7 +96,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -132,7 +132,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('local_context_data', models.JSONField(blank=True, null=True)), ('local_context_data', models.JSONField(blank=True, null=True)),
('name', models.CharField(blank=True, max_length=64, null=True)), ('name', models.CharField(blank=True, max_length=64, null=True)),
@ -155,7 +155,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -186,7 +186,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -203,7 +203,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('model', models.CharField(max_length=100)), ('model', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)), ('slug', models.SlugField(max_length=100)),
@ -224,7 +224,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -261,7 +261,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('label', models.CharField(blank=True, max_length=64)), ('label', models.CharField(blank=True, max_length=64)),
@ -302,7 +302,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -326,7 +326,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)), ('slug', models.SlugField(max_length=100)),
@ -345,7 +345,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -360,7 +360,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -377,7 +377,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)), ('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)),
('mark_connected', models.BooleanField(default=False)), ('mark_connected', models.BooleanField(default=False)),
@ -401,7 +401,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -438,7 +438,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
], ],
@ -451,7 +451,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -490,7 +490,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -516,7 +516,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
('description', models.CharField(max_length=200)), ('description', models.CharField(max_length=200)),
@ -530,7 +530,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -546,7 +546,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -583,7 +583,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -602,7 +602,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -630,7 +630,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -649,7 +649,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('domain', models.CharField(blank=True, max_length=30)), ('domain', models.CharField(blank=True, max_length=30)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
@ -107,7 +107,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('model', models.CharField(max_length=100)), ('model', models.CharField(max_length=100)),
('part_number', models.CharField(blank=True, max_length=50)), ('part_number', models.CharField(blank=True, max_length=50)),
@ -125,7 +125,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -145,7 +145,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('local_context_data', models.JSONField(blank=True, null=True)), ('local_context_data', models.JSONField(blank=True, null=True)),
('serial', models.CharField(blank=True, max_length=50)), ('serial', models.CharField(blank=True, max_length=50)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),

View File

@ -1,4 +1,5 @@
from django.db import migrations, models from django.db import migrations, models
import django.db.models.functions.text
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -170,11 +171,11 @@ class Migration(migrations.Migration):
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='device', model_name='device',
constraint=models.UniqueConstraint(fields=('name', 'site', 'tenant'), name='dcim_device_unique_name_site_tenant'), constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), models.F('tenant'), name='dcim_device_unique_name_site_tenant'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='device', model_name='device',
constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'site'), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'), constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), condition=models.Q(('tenant__isnull', True)), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='device', model_name='device',

View File

@ -0,0 +1,58 @@
# Generated by Django 4.0.7 on 2022-09-23 01:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0162_unique_constraints'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='devicetype',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='devicetype',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='moduletype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='moduletype',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='moduletype',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='rack',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='rack',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@ -1,13 +1,15 @@
import decimal import decimal
import yaml import yaml
from functools import cached_property
from django.apps import apps from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import F, ProtectedError from django.db.models import F, ProtectedError
from django.db.models.functions import Lower
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -20,6 +22,7 @@ from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from .device_components import * from .device_components import *
from .mixins import WeightMixin
__all__ = ( __all__ = (
@ -70,7 +73,7 @@ class Manufacturer(OrganizationalModel):
return reverse('dcim:manufacturer', args=[self.pk]) return reverse('dcim:manufacturer', args=[self.pk])
class DeviceType(NetBoxModel): class DeviceType(NetBoxModel, WeightMixin):
""" """
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
well as high-level functional role(s). well as high-level functional role(s).
@ -138,7 +141,7 @@ class DeviceType(NetBoxModel):
) )
clone_fields = ( clone_fields = (
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
) )
class Meta: class Meta:
@ -314,7 +317,7 @@ class DeviceType(NetBoxModel):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
class ModuleType(NetBoxModel): class ModuleType(NetBoxModel, WeightMixin):
""" """
A ModuleType represents a hardware element that can be installed within a device and which houses additional A ModuleType represents a hardware element that can be installed within a device and which houses additional
components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
@ -343,7 +346,7 @@ class ModuleType(NetBoxModel):
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
clone_fields = ('manufacturer',) clone_fields = ('manufacturer', 'weight', 'weight_unit',)
class Meta: class Meta:
ordering = ('manufacturer', 'model') ordering = ('manufacturer', 'model')
@ -662,11 +665,11 @@ class Device(NetBoxModel, ConfigContextModel):
ordering = ('_name', 'pk') # Name may be null ordering = ('_name', 'pk') # Name may be null
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('name', 'site', 'tenant'), Lower('name'), 'site', 'tenant',
name='%(app_label)s_%(class)s_unique_name_site_tenant' name='%(app_label)s_%(class)s_unique_name_site_tenant'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('name', 'site'), Lower('name'), 'site',
name='%(app_label)s_%(class)s_unique_name_site', name='%(app_label)s_%(class)s_unique_name_site',
condition=Q(tenant__isnull=True), condition=Q(tenant__isnull=True),
violation_error_message="Device name must be unique per site." violation_error_message="Device name must be unique per site."
@ -945,6 +948,18 @@ class Device(NetBoxModel, ConfigContextModel):
def get_status_color(self): def get_status_color(self):
return DeviceStatusChoices.colors.get(self.status) return DeviceStatusChoices.colors.get(self.status)
@cached_property
def total_weight(self):
total_weight = sum(
module.module_type._abs_weight
for module in Module.objects.filter(device=self)
.exclude(module_type___abs_weight__isnull=True)
.prefetch_related('module_type')
)
if self.device_type._abs_weight:
total_weight += self.device_type._abs_weight
return round(total_weight / 1000, 2)
class Module(NetBoxModel, ConfigContextModel): class Module(NetBoxModel, ConfigContextModel):
""" """

View File

@ -0,0 +1,45 @@
from django.core.exceptions import ValidationError
from django.db import models
from dcim.choices import *
from utilities.utils import to_grams
class WeightMixin(models.Model):
weight = models.DecimalField(
max_digits=8,
decimal_places=2,
blank=True,
null=True
)
weight_unit = models.CharField(
max_length=50,
choices=WeightUnitChoices,
blank=True,
)
# Stores the normalized weight (in grams) for database ordering
_abs_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
# Store the given weight (if any) in grams for use in database ordering
if self.weight and self.weight_unit:
self._abs_weight = to_grams(self.weight, self.weight_unit)
else:
self._abs_weight = None
super().save(*args, **kwargs)
def clean(self):
super().clean()
# Validate weight and weight_unit
if self.weight is not None and not self.weight_unit:
raise ValidationError("Must specify a unit when setting a weight")
elif self.weight is None:
self.weight_unit = ''

View File

@ -1,4 +1,5 @@
import decimal import decimal
from functools import cached_property
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -18,7 +19,8 @@ from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange from utilities.utils import array_to_string, drange
from .device_components import PowerPort from .device_components import PowerPort
from .devices import Device from .devices import Device, Module
from .mixins import WeightMixin
from .power import PowerFeed from .power import PowerFeed
__all__ = ( __all__ = (
@ -62,7 +64,7 @@ class RackRole(OrganizationalModel):
return reverse('dcim:rackrole', args=[self.pk]) return reverse('dcim:rackrole', args=[self.pk])
class Rack(NetBoxModel): class Rack(NetBoxModel, WeightMixin):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location. Each Rack is assigned to a Site and (optionally) a Location.
@ -185,7 +187,7 @@ class Rack(NetBoxModel):
clone_fields = ( clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'outer_depth', 'outer_unit', 'weight', 'weight_unit',
) )
class Meta: class Meta:
@ -454,6 +456,22 @@ class Rack(NetBoxModel):
return int(allocated_draw / available_power_total * 100) return int(allocated_draw / available_power_total * 100)
@cached_property
def total_weight(self):
total_weight = sum(
device.device_type._abs_weight
for device in self.devices.exclude(device_type___abs_weight__isnull=True).prefetch_related('device_type')
)
total_weight += sum(
module.module_type._abs_weight
for module in Module.objects.filter(device__rack=self)
.exclude(module_type___abs_weight__isnull=True)
.prefetch_related('module_type')
)
if self._abs_weight:
total_weight += self._abs_weight
return round(total_weight / 1000, 2)
class RackReservation(NetBoxModel): class RackReservation(NetBoxModel):
""" """

View File

@ -35,7 +35,7 @@ class Node(Hyperlink):
""" """
def __init__(self, position, width, url, color, labels, radius=10, **extra): def __init__(self, position, width, url, color, labels, radius=10, **extra):
super(Node, self).__init__(href=url, target='_blank', **extra) super(Node, self).__init__(href=url, target='_parent', **extra)
x, y = position x, y = position

View File

@ -9,6 +9,7 @@ from svgwrite.text import Text
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.db.models import Q from django.db.models import Q
from django.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
@ -41,7 +42,7 @@ def get_device_description(device):
device.device_role, device.device_role,
device.device_type.manufacturer.name, device.device_type.manufacturer.name,
device.device_type.model, device.device_type.model,
device.device_type.u_height, floatformat(device.device_type.u_height),
device.asset_tag or '', device.asset_tag or '',
device.serial or '' device.serial or ''
) )

View File

@ -5,7 +5,7 @@ from dcim.models import (
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
) )
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
__all__ = ( __all__ = (
'ConsolePortTemplateTable', 'ConsolePortTemplateTable',
@ -85,12 +85,22 @@ class DeviceTypeTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:devicetype_list' url_name='dcim:devicetype_list'
) )
u_height = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
u_height = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = DeviceType model = DeviceType
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated', 'airflow', 'weight', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

View File

@ -2,6 +2,7 @@ import django_tables2 as tables
from dcim.models import Module, ModuleType from dcim.models import Module, ModuleType
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from .template_code import DEVICE_WEIGHT
__all__ = ( __all__ = (
'ModuleTable', 'ModuleTable',
@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:moduletype_list' url_name='dcim:moduletype_list'
) )
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = ModuleType model = ModuleType
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'pk', 'model', 'manufacturer', 'part_number',

View File

@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin from tenancy.tables import TenancyColumnsMixin
from .template_code import DEVICE_WEIGHT
__all__ = ( __all__ = (
'RackTable', 'RackTable',
@ -82,13 +83,17 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
template_code="{{ record.outer_depth }} {{ record.outer_unit }}", template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name='Outer Depth' verbose_name='Outer Depth'
) )
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Rack model = Rack
fields = ( fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments',
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@ -15,6 +15,11 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
""" """
DEVICE_WEIGHT = """
{% load helpers %}
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
"""
DEVICE_LINK = """ DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}"> <a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }} {{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}

View File

@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
racks = ( racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
) )
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
@ -517,6 +517,14 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RackReservation.objects.all() queryset = RackReservation.objects.all()
@ -688,9 +696,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
device_types = ( device_types = (
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
) )
DeviceType.objects.bulk_create(device_types) DeviceType.objects.bulk_create(device_types)
@ -839,6 +847,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'inventory_items': 'false'} params = {'inventory_items': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
@ -855,9 +871,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
module_types = ( module_types = (
ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'), ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'), ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'), ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
) )
ModuleType.objects.bulk_create(module_types) ModuleType.objects.bulk_create(module_types)
@ -943,6 +959,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'pass_through_ports': 'false'} params = {'pass_through_ports': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ConsolePortTemplate.objects.all() queryset = ConsolePortTemplate.objects.all()
@ -1611,6 +1635,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_name(self): def test_name(self):
params = {'name': ['Device 1', 'Device 2']} params = {'name': ['Device 1', 'Device 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# Test case insensitivity
params = {'name': ['DEVICE 1', 'DEVICE 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asset_tag(self): def test_asset_tag(self):
params = {'asset_tag': ['1001', '1002']} params = {'asset_tag': ['1001', '1002']}

View File

@ -399,6 +399,27 @@ class DeviceTestCase(TestCase):
self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2) self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2)
def test_device_name_case_sensitivity(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
name='device 1'
)
device1.save()
device2 = Device(
site=device1.site,
device_type=device1.device_type,
device_role=device1.device_role,
name='DEVICE 1'
)
# Uniqueness validation for name should ignore case
with self.assertRaises(ValidationError):
device2.full_clean()
def test_device_duplicate_names(self): def test_device_duplicate_names(self):
device1 = Device( device1 = Device(

View File

@ -99,6 +99,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
types = CustomFieldTypeChoices types = CustomFieldTypeChoices
if obj.type == types.TYPE_INTEGER: if obj.type == types.TYPE_INTEGER:
return 'integer' return 'integer'
if obj.type == types.TYPE_DECIMAL:
return 'decimal'
if obj.type == types.TYPE_BOOLEAN: if obj.type == types.TYPE_BOOLEAN:
return 'boolean' return 'boolean'
if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):

View File

@ -10,6 +10,7 @@ class CustomFieldTypeChoices(ChoiceSet):
TYPE_TEXT = 'text' TYPE_TEXT = 'text'
TYPE_LONGTEXT = 'longtext' TYPE_LONGTEXT = 'longtext'
TYPE_INTEGER = 'integer' TYPE_INTEGER = 'integer'
TYPE_DECIMAL = 'decimal'
TYPE_BOOLEAN = 'boolean' TYPE_BOOLEAN = 'boolean'
TYPE_DATE = 'date' TYPE_DATE = 'date'
TYPE_URL = 'url' TYPE_URL = 'url'
@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_TEXT, 'Text'), (TYPE_TEXT, 'Text'),
(TYPE_LONGTEXT, 'Text (long)'), (TYPE_LONGTEXT, 'Text (long)'),
(TYPE_INTEGER, 'Integer'), (TYPE_INTEGER, 'Integer'),
(TYPE_DECIMAL, 'Decimal'),
(TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_BOOLEAN, 'Boolean (true/false)'),
(TYPE_DATE, 'Date'), (TYPE_DATE, 'Date'),
(TYPE_URL, 'URL'), (TYPE_URL, 'URL'),

View File

@ -34,7 +34,9 @@ class CustomFieldsMixin:
return ContentType.objects.get_for_model(self.model) return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type) return CustomField.objects.filter(content_types=content_type).exclude(
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
)
def _get_form_field(self, customfield): def _get_form_field(self, customfield):
return customfield.to_form_field() return customfield.to_form_field()
@ -50,13 +52,6 @@ class CustomFieldsMixin:
field_name = f'cf_{customfield.name}' field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield) self.fields[field_name] = self._get_form_field(customfield)
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
self.fields[field_name].disabled = True
if self.fields[field_name].help_text:
self.fields[field_name].help_text += '<br />'
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
'Field is set to read-only.'
# Annotate the field in the list of CustomField form fields # Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield self.custom_fields[field_name] = customfield
if customfield.group_name not in self.custom_field_groups: if customfield.group_name not in self.custom_field_groups:

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import taggit.managers import taggit.managers
@ -13,7 +13,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='journalentry', model_name='journalentry',
name='custom_field_data', name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder),
), ),
migrations.AddField( migrations.AddField(
model_name='journalentry', model_name='journalentry',

View File

@ -1,5 +1,6 @@
import re import re
from datetime import datetime, date from datetime import datetime, date
import decimal
import django_filters import django_filters
from django import forms from django import forms
@ -219,14 +220,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
}) })
# Minimum/maximum values can be set only for numeric fields # Minimum/maximum values can be set only for numeric fields
if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
raise ValidationError({ if self.validation_minimum:
'validation_minimum': "A minimum value may be set only for numeric fields" raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"})
}) if self.validation_maximum:
if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"})
raise ValidationError({
'validation_maximum': "A maximum value may be set only for numeric fields"
})
# Regex validation can be set only for text fields # Regex validation can be set only for text fields
regex_types = ( regex_types = (
@ -297,12 +295,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
return model.objects.filter(pk__in=value) return model.objects.filter(pk__in=value)
return value return value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
""" """
Return a form field suitable for setting a CustomField's value for an object. Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
""" """
initial = self.default if set_initial else None initial = self.default if set_initial else None
@ -317,6 +316,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
max_value=self.validation_maximum max_value=self.validation_maximum
) )
# Decimal
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
field = forms.DecimalField(
required=required,
initial=initial,
max_digits=12,
decimal_places=4,
min_value=self.validation_minimum,
max_value=self.validation_maximum
)
# Boolean # Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = ( choices = (
@ -398,6 +408,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
if self.description: if self.description:
field.help_text = escape(self.description) field.help_text = escape(self.description)
# Annotate read-only fields
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
field.disabled = True
prepend = '<br />' if field.help_text else ''
field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
return field return field
def to_filter(self, lookup_expr=None): def to_filter(self, lookup_expr=None):
@ -426,6 +442,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
filter_class = filters.MultiValueNumberFilter filter_class = filters.MultiValueNumberFilter
# Decimal
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
filter_class = filters.MultiValueDecimalFilter
# Boolean # Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
filter_class = django_filters.BooleanFilter filter_class = django_filters.BooleanFilter
@ -475,7 +495,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
raise ValidationError(f"Value must match regex '{self.validation_regex}'") raise ValidationError(f"Value must match regex '{self.validation_regex}'")
# Validate integer # Validate integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER: elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
if type(value) is not int: if type(value) is not int:
raise ValidationError("Value must be an integer.") raise ValidationError("Value must be an integer.")
if self.validation_minimum is not None and value < self.validation_minimum: if self.validation_minimum is not None and value < self.validation_minimum:
@ -483,12 +503,23 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
if self.validation_maximum is not None and value > self.validation_maximum: if self.validation_maximum is not None and value > self.validation_maximum:
raise ValidationError(f"Value must not exceed {self.validation_maximum}") raise ValidationError(f"Value must not exceed {self.validation_maximum}")
# Validate decimal
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
try:
decimal.Decimal(value)
except decimal.InvalidOperation:
raise ValidationError("Value must be a decimal.")
if self.validation_minimum is not None and value < self.validation_minimum:
raise ValidationError(f"Value must be at least {self.validation_minimum}")
if self.validation_maximum is not None and value > self.validation_maximum:
raise ValidationError(f"Value must not exceed {self.validation_maximum}")
# Validate boolean # Validate boolean
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError("Value must be true or false.") raise ValidationError("Value must be true or false.")
# Validate date # Validate date
if self.type == CustomFieldTypeChoices.TYPE_DATE: elif self.type == CustomFieldTypeChoices.TYPE_DATE:
if type(value) is not date: if type(value) is not date:
try: try:
datetime.strptime(value, '%Y-%m-%d') datetime.strptime(value, '%Y-%m-%d')
@ -496,14 +527,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
raise ValidationError("Date values must be in the format YYYY-MM-DD.") raise ValidationError("Date values must be in the format YYYY-MM-DD.")
# Validate selected choice # Validate selected choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT: elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
if value not in self.choices: if value not in self.choices:
raise ValidationError( raise ValidationError(
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
) )
# Validate all selected choices # Validate all selected choices
if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
if not set(value).issubset(self.choices): if not set(value).issubset(self.choices):
raise ValidationError( raise ValidationError(
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"

View File

@ -6,15 +6,16 @@ from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template from django.template.loader import get_template
from extras.registry import registry
from utilities.choices import ButtonColorChoices
from extras.plugins.utils import import_object from extras.plugins.utils import import_object
from extras.registry import registry
from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices
# Initialize plugin registry # Initialize plugin registry
registry['plugins'] = { registry['plugins'] = {
'graphql_schemas': [], 'graphql_schemas': [],
'menus': [],
'menu_items': {}, 'menu_items': {},
'preferences': {}, 'preferences': {},
'template_extensions': collections.defaultdict(list), 'template_extensions': collections.defaultdict(list),
@ -54,9 +55,13 @@ class PluginConfig(AppConfig):
# Django-rq queues dedicated to the plugin # Django-rq queues dedicated to the plugin
queues = [] queues = []
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
# Default integration paths. Plugin authors can override these to customize the paths to # Default integration paths. Plugin authors can override these to customize the paths to
# integrated components. # integrated components.
graphql_schema = 'graphql.schema' graphql_schema = 'graphql.schema'
menu = 'navigation.menu'
menu_items = 'navigation.menu_items' menu_items = 'navigation.menu_items'
template_extensions = 'template_content.template_extensions' template_extensions = 'template_content.template_extensions'
user_preferences = 'preferences.preferences' user_preferences = 'preferences.preferences'
@ -69,9 +74,10 @@ class PluginConfig(AppConfig):
if template_extensions is not None: if template_extensions is not None:
register_template_extensions(template_extensions) register_template_extensions(template_extensions)
# Register navigation menu items (if defined) # Register navigation menu or menu items (if defined)
menu_items = import_object(f"{self.__module__}.{self.menu_items}") if menu := import_object(f"{self.__module__}.{self.menu}"):
if menu_items is not None: register_menu(menu)
if menu_items := import_object(f"{self.__module__}.{self.menu_items}"):
register_menu_items(self.verbose_name, menu_items) register_menu_items(self.verbose_name, menu_items)
# Register GraphQL schema (if defined) # Register GraphQL schema (if defined)
@ -200,6 +206,18 @@ def register_template_extensions(class_list):
# Navigation menu links # Navigation menu links
# #
class PluginMenu:
icon_class = 'mdi mdi-puzzle'
def __init__(self, label, groups, icon_class=None):
self.label = label
self.groups = [
MenuGroup(label, items) for label, items in groups
]
if icon_class is not None:
self.icon_class = icon_class
class PluginMenuItem: class PluginMenuItem:
""" """
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
@ -246,6 +264,12 @@ class PluginMenuButton:
self.color = color self.color = color
def register_menu(menu):
if not isinstance(menu, PluginMenu):
raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
registry['plugins']['menus'].append(menu)
def register_menu_items(section_name, class_list): def register_menu_items(section_name, class_list):
""" """
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)

View File

@ -1,7 +1,7 @@
from extras.plugins import PluginMenuButton, PluginMenuItem from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
menu_items = ( items = (
PluginMenuItem( PluginMenuItem(
link='plugins:dummy_plugin:dummy_models', link='plugins:dummy_plugin:dummy_models',
link_text='Item 1', link_text='Item 1',
@ -23,3 +23,9 @@ menu_items = (
link_text='Item 2', link_text='Item 2',
), ),
) )
menu = PluginMenu(
label='Dummy',
groups=(('Group 1', items),),
)
menu_items = items

View File

@ -1,3 +1,5 @@
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
@ -102,6 +104,32 @@ class CustomFieldTest(TestCase):
instance.refresh_from_db() instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name)) self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_decimal_field(self):
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='decimal_field',
type=CustomFieldTypeChoices.TYPE_DECIMAL,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
for value in (123456.54, 0, -123456.78):
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_boolean_field(self): def test_boolean_field(self):
# Create a custom field & check that initial value is null # Create a custom field & check that initial value is null
@ -373,7 +401,8 @@ class CustomFieldAPITest(APITestCase):
custom_fields = ( custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123),
CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45),
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'),
@ -424,14 +453,15 @@ class CustomFieldAPITest(APITestCase):
custom_fields[0].name: 'bar', custom_fields[0].name: 'bar',
custom_fields[1].name: 'DEF', custom_fields[1].name: 'DEF',
custom_fields[2].name: 456, custom_fields[2].name: 456,
custom_fields[3].name: True, custom_fields[3].name: Decimal('456.78'),
custom_fields[4].name: '2020-01-02', custom_fields[4].name: True,
custom_fields[5].name: 'http://example.com/2', custom_fields[5].name: '2020-01-02',
custom_fields[6].name: '{"foo": 1, "bar": 2}', custom_fields[6].name: 'http://example.com/2',
custom_fields[7].name: 'Bar', custom_fields[7].name: '{"foo": 1, "bar": 2}',
custom_fields[8].name: ['Bar', 'Baz'], custom_fields[8].name: 'Bar',
custom_fields[9].name: vlans[1].pk, custom_fields[9].name: ['Bar', 'Baz'],
custom_fields[10].name: [vlans[2].pk, vlans[3].pk], custom_fields[10].name: vlans[1].pk,
custom_fields[11].name: [vlans[2].pk, vlans[3].pk],
} }
sites[1].save() sites[1].save()
@ -440,6 +470,7 @@ class CustomFieldAPITest(APITestCase):
CustomFieldTypeChoices.TYPE_TEXT: 'string', CustomFieldTypeChoices.TYPE_TEXT: 'string',
CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
CustomFieldTypeChoices.TYPE_INTEGER: 'integer', CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
CustomFieldTypeChoices.TYPE_DATE: 'string', CustomFieldTypeChoices.TYPE_DATE: 'string',
CustomFieldTypeChoices.TYPE_URL: 'string', CustomFieldTypeChoices.TYPE_URL: 'string',
@ -473,7 +504,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields'], { self.assertEqual(response.data['custom_fields'], {
'text_field': None, 'text_field': None,
'longtext_field': None, 'longtext_field': None,
'number_field': None, 'integer_field': None,
'decimal_field': None,
'boolean_field': None, 'boolean_field': None,
'date_field': None, 'date_field': None,
'url_field': None, 'url_field': None,
@ -497,7 +529,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['name'], site2.name)
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) self.assertEqual(response.data['custom_fields']['integer_field'], site2_cfvs['integer_field'])
self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field']) self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
@ -531,7 +564,8 @@ class CustomFieldAPITest(APITestCase):
response_cf = response.data['custom_fields'] response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
@ -548,7 +582,8 @@ class CustomFieldAPITest(APITestCase):
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@ -568,7 +603,8 @@ class CustomFieldAPITest(APITestCase):
'custom_fields': { 'custom_fields': {
'text_field': 'bar', 'text_field': 'bar',
'longtext_field': 'blah blah blah', 'longtext_field': 'blah blah blah',
'number_field': 456, 'integer_field': 456,
'decimal_field': 456.78,
'boolean_field': True, 'boolean_field': True,
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
@ -590,7 +626,8 @@ class CustomFieldAPITest(APITestCase):
data_cf = data['custom_fields'] data_cf = data['custom_fields']
self.assertEqual(response_cf['text_field'], data_cf['text_field']) self.assertEqual(response_cf['text_field'], data_cf['text_field'])
self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field']) self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field'])
self.assertEqual(response_cf['number_field'], data_cf['number_field']) self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['date_field'], data_cf['date_field'])
self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field'])
@ -607,7 +644,8 @@ class CustomFieldAPITest(APITestCase):
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
@ -652,7 +690,8 @@ class CustomFieldAPITest(APITestCase):
response_cf = response.data[i]['custom_fields'] response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
@ -669,7 +708,8 @@ class CustomFieldAPITest(APITestCase):
site = Site.objects.get(pk=response.data[i]['id']) site = Site.objects.get(pk=response.data[i]['id'])
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@ -686,7 +726,8 @@ class CustomFieldAPITest(APITestCase):
custom_field_data = { custom_field_data = {
'text_field': 'bar', 'text_field': 'bar',
'longtext_field': 'abcdefghij', 'longtext_field': 'abcdefghij',
'number_field': 456, 'integer_field': 456,
'decimal_field': 456.78,
'boolean_field': True, 'boolean_field': True,
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
@ -726,7 +767,8 @@ class CustomFieldAPITest(APITestCase):
response_cf = response.data[i]['custom_fields'] response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], custom_field_data['text_field']) self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field'])
self.assertEqual(response_cf['number_field'], custom_field_data['number_field']) self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
@ -743,7 +785,8 @@ class CustomFieldAPITest(APITestCase):
site = Site.objects.get(pk=response.data[i]['id']) site = Site.objects.get(pk=response.data[i]['id'])
self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field']) self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field']) self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
@ -763,7 +806,7 @@ class CustomFieldAPITest(APITestCase):
data = { data = {
'custom_fields': { 'custom_fields': {
'text_field': 'ABCD', 'text_field': 'ABCD',
'number_field': 1234, 'integer_field': 1234,
}, },
} }
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
@ -775,8 +818,9 @@ class CustomFieldAPITest(APITestCase):
# Validate response data # Validate response data
response_cf = response.data['custom_fields'] response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field']) self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field'])
self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field'])
self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
@ -792,8 +836,9 @@ class CustomFieldAPITest(APITestCase):
# Validate database data # Validate database data
site2.refresh_from_db() site2.refresh_from_db()
self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field'])
self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site2.custom_field_data['integer_field'], data['custom_fields']['integer_field'])
self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_field'])
self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
@ -808,20 +853,20 @@ class CustomFieldAPITest(APITestCase):
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site') self.add_permissions('dcim.change_site')
cf_integer = CustomField.objects.get(name='number_field') cf_integer = CustomField.objects.get(name='integer_field')
cf_integer.validation_minimum = 10 cf_integer.validation_minimum = 10
cf_integer.validation_maximum = 20 cf_integer.validation_maximum = 20
cf_integer.save() cf_integer.save()
data = {'custom_fields': {'number_field': 9}} data = {'custom_fields': {'integer_field': 9}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
data = {'custom_fields': {'number_field': 21}} data = {'custom_fields': {'integer_field': 21}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
data = {'custom_fields': {'number_field': 15}} data = {'custom_fields': {'integer_field': 15}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
@ -860,6 +905,7 @@ class CustomFieldImportTest(TestCase):
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
@ -880,10 +926,10 @@ class CustomFieldImportTest(TestCase):
Import a Site in CSV format, including a value for each CustomField. Import a Site in CSV format, including a value for each CustomField.
""" """
data = ( data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''), ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''),
) )
csv_data = '\n'.join(','.join(row) for row in data) csv_data = '\n'.join(','.join(row) for row in data)
@ -893,10 +939,11 @@ class CustomFieldImportTest(TestCase):
# Validate data for site 1 # Validate data for site 1
site1 = Site.objects.get(name='Site 1') site1 = Site.objects.get(name='Site 1')
self.assertEqual(len(site1.custom_field_data), 9) self.assertEqual(len(site1.custom_field_data), 10)
self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123) self.assertEqual(site1.custom_field_data['integer'], 123)
self.assertEqual(site1.custom_field_data['decimal'], 123.45)
self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['boolean'], True)
self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
@ -906,10 +953,11 @@ class CustomFieldImportTest(TestCase):
# Validate data for site 2 # Validate data for site 2
site2 = Site.objects.get(name='Site 2') site2 = Site.objects.get(name='Site 2')
self.assertEqual(len(site2.custom_field_data), 9) self.assertEqual(len(site2.custom_field_data), 10)
self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456) self.assertEqual(site2.custom_field_data['integer'], 456)
self.assertEqual(site2.custom_field_data['decimal'], 456.78)
self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['boolean'], False)
self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
@ -1034,53 +1082,78 @@ class CustomFieldModelFilterTest(TestCase):
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Decimal filtering
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
cf.save()
cf.content_types.set([obj_type])
# Boolean filtering # Boolean filtering
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Exact text filtering # Exact text filtering
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) name='cf4',
type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Loose text filtering # Loose text filtering
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) name='cf5',
type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Date filtering # Date filtering
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE) cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Exact URL filtering # Exact URL filtering
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) name='cf7',
type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Loose URL filtering # Loose URL filtering
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) name='cf8',
type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Selection filtering # Selection filtering
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) cf = CustomField(
name='cf9',
type=CustomFieldTypeChoices.TYPE_SELECT,
choices=['Foo', 'Bar', 'Baz']
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Multiselect filtering # Multiselect filtering
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) cf = CustomField(
name='cf10',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=['A', 'B', 'C', 'X']
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Object filtering # Object filtering
cf = CustomField( cf = CustomField(
name='cf10', name='cf11',
type=CustomFieldTypeChoices.TYPE_OBJECT, type=CustomFieldTypeChoices.TYPE_OBJECT,
object_type=ContentType.objects.get_for_model(Manufacturer) object_type=ContentType.objects.get_for_model(Manufacturer)
) )
@ -1089,7 +1162,7 @@ class CustomFieldModelFilterTest(TestCase):
# Multi-object filtering # Multi-object filtering
cf = CustomField( cf = CustomField(
name='cf11', name='cf12',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
object_type=ContentType.objects.get_for_model(Manufacturer) object_type=ContentType.objects.get_for_model(Manufacturer)
) )
@ -1099,42 +1172,45 @@ class CustomFieldModelFilterTest(TestCase):
Site.objects.bulk_create([ Site.objects.bulk_create([
Site(name='Site 1', slug='site-1', custom_field_data={ Site(name='Site 1', slug='site-1', custom_field_data={
'cf1': 100, 'cf1': 100,
'cf2': True, 'cf2': 100.1,
'cf3': 'foo', 'cf3': True,
'cf4': 'foo', 'cf4': 'foo',
'cf5': '2016-06-26', 'cf5': 'foo',
'cf6': 'http://a.example.com', 'cf6': '2016-06-26',
'cf7': 'http://a.example.com', 'cf7': 'http://a.example.com',
'cf8': 'Foo', 'cf8': 'http://a.example.com',
'cf9': ['A', 'X'], 'cf9': 'Foo',
'cf10': manufacturers[0].pk, 'cf10': ['A', 'X'],
'cf11': [manufacturers[0].pk, manufacturers[3].pk], 'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
}), }),
Site(name='Site 2', slug='site-2', custom_field_data={ Site(name='Site 2', slug='site-2', custom_field_data={
'cf1': 200, 'cf1': 200,
'cf2': True, 'cf2': 200.2,
'cf3': 'foobar', 'cf3': True,
'cf4': 'foobar', 'cf4': 'foobar',
'cf5': '2016-06-27', 'cf5': 'foobar',
'cf6': 'http://b.example.com', 'cf6': '2016-06-27',
'cf7': 'http://b.example.com', 'cf7': 'http://b.example.com',
'cf8': 'Bar', 'cf8': 'http://b.example.com',
'cf9': ['B', 'X'], 'cf9': 'Bar',
'cf10': manufacturers[1].pk, 'cf10': ['B', 'X'],
'cf11': [manufacturers[1].pk, manufacturers[3].pk], 'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
}), }),
Site(name='Site 3', slug='site-3', custom_field_data={ Site(name='Site 3', slug='site-3', custom_field_data={
'cf1': 300, 'cf1': 300,
'cf2': False, 'cf2': 300.3,
'cf3': 'bar', 'cf3': False,
'cf4': 'bar', 'cf4': 'bar',
'cf5': '2016-06-28', 'cf5': 'bar',
'cf6': 'http://c.example.com', 'cf6': '2016-06-28',
'cf7': 'http://c.example.com', 'cf7': 'http://c.example.com',
'cf8': 'Baz', 'cf8': 'http://c.example.com',
'cf9': ['C', 'X'], 'cf9': 'Baz',
'cf10': manufacturers[2].pk, 'cf10': ['C', 'X'],
'cf11': [manufacturers[2].pk, manufacturers[3].pk], 'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
}), }),
]) ])
@ -1146,60 +1222,68 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
def test_filter_decimal(self):
self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
def test_filter_boolean(self): def test_filter_boolean(self):
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
def test_filter_text_strict(self): def test_filter_text_strict(self):
self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
def test_filter_text_loose(self): def test_filter_text_loose(self):
self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
def test_filter_date(self): def test_filter_date(self):
self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
def test_filter_url_strict(self): def test_filter_url_strict(self):
self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0) self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0)
self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0) self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
def test_filter_url_loose(self): def test_filter_url_loose(self):
self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self): def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self): def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
def test_filter_object(self): def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
self.assertEqual(self.filterset({'cf_cf10': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
def test_filter_multiobject(self): def test_filter_multiobject(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3)

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from circuits.models import Provider from ipam.models import ASN, RIR
from dcim.models import Site from dcim.models import Site
from extras.validators import CustomValidator from extras.validators import CustomValidator
@ -67,21 +67,25 @@ custom_validator = MyValidator()
class CustomValidatorTest(TestCase): class CustomValidatorTest(TestCase):
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) @classmethod
def setUpTestData(cls):
RIR.objects.create(name='RIR 1', slug='rir-1')
@override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]})
def test_configuration(self): def test_configuration(self):
self.assertIn('circuits.provider', settings.CUSTOM_VALIDATORS) self.assertIn('ipam.asn', settings.CUSTOM_VALIDATORS)
validator = settings.CUSTOM_VALIDATORS['circuits.provider'][0] validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0]
self.assertIsInstance(validator, CustomValidator) self.assertIsInstance(validator, CustomValidator)
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]})
def test_min(self): def test_min(self):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Provider(name='Provider 1', slug='provider-1', asn=1).clean() ASN(asn=1, rir=RIR.objects.first()).clean()
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [max_validator]}) @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [max_validator]})
def test_max(self): def test_max(self):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Provider(name='Provider 1', slug='provider-1', asn=65535).clean() ASN(asn=65535, rir=RIR.objects.first()).clean()
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]})
def test_min_length(self): def test_min_length(self):

View File

@ -23,6 +23,9 @@ class CustomFieldModelFormTest(TestCase):
cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf_integer.content_types.set([obj_type]) cf_integer.content_types.set([obj_type])
cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL)
cf_integer.content_types.set([obj_type])
cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf_boolean.content_types.set([obj_type]) cf_boolean.content_types.set([obj_type])

View File

@ -5,6 +5,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from extras.plugins import PluginMenu
from extras.registry import registry from extras.registry import registry
from extras.tests.dummy_plugin import config as dummy_config from extras.tests.dummy_plugin import config as dummy_config
from netbox.graphql.schema import Query from netbox.graphql.schema import Query
@ -58,9 +59,17 @@ class PluginTest(TestCase):
response = client.get(url) response = client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_menu(self):
"""
Check menu registration.
"""
menu = registry['plugins']['menus'][0]
self.assertIsInstance(menu, PluginMenu)
self.assertEqual(menu.label, 'Dummy')
def test_menu_items(self): def test_menu_items(self):
""" """
Check that plugin MenuItems and MenuButtons are registered. Check menu_items registration.
""" """
self.assertIn('Dummy plugin', registry['plugins']['menu_items']) self.assertIn('Dummy plugin', registry['plugins']['menu_items'])
menu_items = registry['plugins']['menu_items']['Dummy plugin'] menu_items = registry['plugins']['menu_items']['Dummy plugin']

View File

@ -1,5 +1,5 @@
import django.contrib.postgres.fields import django.contrib.postgres.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -29,7 +29,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('prefix', ipam.fields.IPNetworkField()), ('prefix', ipam.fields.IPNetworkField()),
('date_added', models.DateField(blank=True, null=True)), ('date_added', models.DateField(blank=True, null=True)),
@ -44,7 +44,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('address', ipam.fields.IPAddressField()), ('address', ipam.fields.IPAddressField()),
('status', models.CharField(default='active', max_length=50)), ('status', models.CharField(default='active', max_length=50)),
@ -64,7 +64,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('prefix', ipam.fields.IPNetworkField()), ('prefix', ipam.fields.IPNetworkField()),
('status', models.CharField(default='active', max_length=50)), ('status', models.CharField(default='active', max_length=50)),
@ -81,7 +81,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -99,7 +99,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -115,7 +115,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=21, unique=True)), ('name', models.CharField(max_length=21, unique=True)),
('description', models.CharField(blank=True, max_length=200)), ('description', models.CharField(blank=True, max_length=200)),
@ -129,7 +129,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('rd', models.CharField(blank=True, max_length=21, null=True, unique=True)), ('rd', models.CharField(blank=True, max_length=21, null=True, unique=True)),
@ -151,7 +151,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)), ('slug', models.SlugField(max_length=100)),
@ -170,7 +170,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])), ('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)])),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
@ -193,7 +193,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('protocol', models.CharField(max_length=50)), ('protocol', models.CharField(max_length=50)),

View File

@ -1,6 +1,6 @@
# Generated by Django 3.2.5 on 2021-07-16 14:15 # Generated by Django 3.2.5 on 2021-07-16 14:15
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.db.models.expressions import django.db.models.expressions
@ -22,7 +22,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('start_address', ipam.fields.IPAddressField()), ('start_address', ipam.fields.IPAddressField()),
('end_address', ipam.fields.IPAddressField()), ('end_address', ipam.fields.IPAddressField()),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('group_id', models.PositiveSmallIntegerField()), ('group_id', models.PositiveSmallIntegerField()),
('protocol', models.CharField(max_length=50)), ('protocol', models.CharField(max_length=50)),

View File

@ -1,7 +1,7 @@
# Generated by Django 3.2.8 on 2021-11-02 16:16 # Generated by Django 3.2.8 on 2021-11-02 16:16
import dcim.fields import dcim.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('asn', dcim.fields.ASNField(unique=True)), ('asn', dcim.fields.ASNField(unique=True)),
('description', models.CharField(blank=True, max_length=200)), ('description', models.CharField(blank=True, max_length=200)),

View File

@ -1,5 +1,5 @@
import django.contrib.postgres.fields import django.contrib.postgres.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import taggit.managers import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('protocol', models.CharField(max_length=50)), ('protocol', models.CharField(max_length=50)),
('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)), ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
@ -20,7 +20,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)), ('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField()), ('slug', models.SlugField()),
('type', models.CharField(max_length=50)), ('type', models.CharField(max_length=50)),
@ -42,7 +42,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)), ('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('assigned_object_id', models.PositiveBigIntegerField()), ('assigned_object_id', models.PositiveBigIntegerField()),
('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')),

View File

@ -46,7 +46,7 @@ class BaseFilterSet(django_filters.FilterSet):
'filter_class': filters.MultiValueDateTimeFilter 'filter_class': filters.MultiValueDateTimeFilter
}, },
models.DecimalField: { models.DecimalField: {
'filter_class': filters.MultiValueNumberFilter 'filter_class': filters.MultiValueDecimalFilter
}, },
models.EmailField: { models.EmailField: {
'filter_class': filters.MultiValueCharFilter 'filter_class': filters.MultiValueCharFilter
@ -95,6 +95,7 @@ class BaseFilterSet(django_filters.FilterSet):
filters.MultiValueDateFilter, filters.MultiValueDateFilter,
filters.MultiValueDateTimeFilter, filters.MultiValueDateTimeFilter,
filters.MultiValueNumberFilter, filters.MultiValueNumberFilter,
filters.MultiValueDecimalFilter,
filters.MultiValueTimeFilter filters.MultiValueTimeFilter
)): )):
return FILTER_NUMERIC_BASED_LOOKUP_MAP return FILTER_NUMERIC_BASED_LOOKUP_MAP

View File

@ -2,7 +2,7 @@ from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
from extras.forms.customfields import CustomFieldsMixin from extras.forms.customfields import CustomFieldsMixin
from extras.models import CustomField, Tag from extras.models import CustomField, Tag
from utilities.forms import BootstrapMixin, CSVModelForm from utilities.forms import BootstrapMixin, CSVModelForm
@ -63,6 +63,11 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
""" """
tags = None # Temporary fix in lieu of tag import support (see #9158) tags = None # Temporary fix in lieu of tag import support (see #9158)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).filter(
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
)
def _get_form_field(self, customfield): def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True) return customfield.to_form_field(for_csv_import=True)
@ -125,10 +130,10 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
) )
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude( return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON) Q(type=CustomFieldTypeChoices.TYPE_JSON)
) )
def _get_form_field(self, customfield): def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False) return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)

View File

@ -4,7 +4,6 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db.models.signals import class_prepared from django.db.models.signals import class_prepared
from django.dispatch import receiver from django.dispatch import receiver
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -12,6 +11,7 @@ from taggit.managers import TaggableManager
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
from extras.utils import is_taggable, register_features from extras.utils import is_taggable, register_features
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.json import CustomFieldJSONEncoder
from utilities.utils import serialize_object from utilities.utils import serialize_object
__all__ = ( __all__ = (
@ -124,7 +124,7 @@ class CustomFieldsMixin(models.Model):
Enables support for custom fields. Enables support for custom fields.
""" """
custom_field_data = models.JSONField( custom_field_data = models.JSONField(
encoder=DjangoJSONEncoder, encoder=CustomFieldJSONEncoder,
blank=True, blank=True,
default=dict default=dict
) )

View File

@ -0,0 +1,92 @@
from dataclasses import dataclass
from typing import Sequence, Optional
from utilities.choices import ButtonColorChoices
__all__ = (
'get_model_item',
'get_model_buttons',
'Menu',
'MenuGroup',
'MenuItem',
'MenuItemButton',
)
#
# Navigation menu data classes
#
@dataclass
class MenuItemButton:
link: str
title: str
icon_class: str
permissions: Optional[Sequence[str]] = ()
color: Optional[str] = None
@dataclass
class MenuItem:
link: str
link_text: str
permissions: Optional[Sequence[str]] = ()
buttons: Optional[Sequence[MenuItemButton]] = ()
@dataclass
class MenuGroup:
label: str
items: Sequence[MenuItem]
@dataclass
class Menu:
label: str
icon_class: str
groups: Sequence[MenuGroup]
#
# Utility functions
#
def get_model_item(app_label, model_name, label, actions=('add', 'import')):
return MenuItem(
link=f'{app_label}:{model_name}_list',
link_text=label,
permissions=[f'{app_label}.view_{model_name}'],
buttons=get_model_buttons(app_label, model_name, actions)
)
def get_model_buttons(app_label, model_name, actions=('add', 'import')):
buttons = []
if 'add' in actions:
buttons.append(
MenuItemButton(
link=f'{app_label}:{model_name}_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'{app_label}.add_{model_name}'],
color=ButtonColorChoices.GREEN
)
)
if 'import' in actions:
buttons.append(
MenuItemButton(
link=f'{app_label}:{model_name}_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'{app_label}.add_{model_name}'],
color=ButtonColorChoices.CYAN
)
)
return buttons

View File

@ -1,86 +1,5 @@
from dataclasses import dataclass
from typing import Sequence, Optional
from extras.registry import registry from extras.registry import registry
from utilities.choices import ButtonColorChoices from . import *
#
# Nav menu data classes
#
@dataclass
class MenuItemButton:
link: str
title: str
icon_class: str
permissions: Optional[Sequence[str]] = ()
color: Optional[str] = None
@dataclass
class MenuItem:
link: str
link_text: str
permissions: Optional[Sequence[str]] = ()
buttons: Optional[Sequence[MenuItemButton]] = ()
@dataclass
class MenuGroup:
label: str
items: Sequence[MenuItem]
@dataclass
class Menu:
label: str
icon_class: str
groups: Sequence[MenuGroup]
#
# Utility functions
#
def get_model_item(app_label, model_name, label, actions=('add', 'import')):
return MenuItem(
link=f'{app_label}:{model_name}_list',
link_text=label,
permissions=[f'{app_label}.view_{model_name}'],
buttons=get_model_buttons(app_label, model_name, actions)
)
def get_model_buttons(app_label, model_name, actions=('add', 'import')):
buttons = []
if 'add' in actions:
buttons.append(
MenuItemButton(
link=f'{app_label}:{model_name}_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'{app_label}.add_{model_name}'],
color=ButtonColorChoices.GREEN
)
)
if 'import' in actions:
buttons.append(
MenuItemButton(
link=f'{app_label}:{model_name}_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'{app_label}.add_{model_name}'],
color=ButtonColorChoices.CYAN
)
)
return buttons
# #
@ -405,21 +324,19 @@ MENUS = [
# Add plugin menus # Add plugin menus
# #
for menu in registry['plugins']['menus']:
MENUS.append(menu)
if registry['plugins']['menu_items']: if registry['plugins']['menu_items']:
plugin_menu_groups = []
for plugin_name, items in registry['plugins']['menu_items'].items(): # Build the default plugins menu
plugin_menu_groups.append( groups = [
MenuGroup( MenuGroup(label=label, items=items)
label=plugin_name, for label, items in registry['plugins']['menu_items'].items()
items=items ]
) plugins_menu = Menu(
)
PLUGIN_MENU = Menu(
label="Plugins", label="Plugins",
icon_class="mdi mdi-puzzle", icon_class="mdi mdi-puzzle",
groups=plugin_menu_groups groups=groups
) )
MENUS.append(plugins_menu)
MENUS.append(PLUGIN_MENU)

View File

@ -1,18 +1,19 @@
import hashlib import hashlib
import importlib import importlib
import logging import importlib.util
import os import os
import platform import platform
import re
import socket
import sys import sys
import warnings import warnings
from urllib.parse import urlsplit from urllib.parse import urlsplit
import django
import sentry_sdk import sentry_sdk
from django.contrib.messages import constants as messages from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.utils.encoding import force_str
from extras.plugins import PluginConfig
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from netbox.config import PARAMS from netbox.config import PARAMS
@ -20,9 +21,7 @@ from netbox.config import PARAMS
# Monkey patch to fix Django 4.0 support for graphene-django (see # Monkey patch to fix Django 4.0 support for graphene-django (see
# https://github.com/graphql-python/graphene-django/issues/1284) # https://github.com/graphql-python/graphene-django/issues/1284)
# TODO: Remove this when graphene-django 2.16 becomes available # TODO: Remove this when graphene-django 2.16 becomes available
import django django.utils.encoding.force_text = force_str # type: ignore
from django.utils.encoding import force_str
django.utils.encoding.force_text = force_str
# #
@ -186,7 +185,7 @@ if STORAGE_BACKEND is not None:
if STORAGE_BACKEND.startswith('storages.'): if STORAGE_BACKEND.startswith('storages.'):
try: try:
import storages.utils import storages.utils # type: ignore
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
if getattr(e, 'name') == 'storages': if getattr(e, 'name') == 'storages':
raise ImproperlyConfigured( raise ImproperlyConfigured(
@ -663,14 +662,42 @@ for plugin_name in PLUGINS:
# Determine plugin config and add to INSTALLED_APPS. # Determine plugin config and add to INSTALLED_APPS.
try: try:
plugin_config = plugin.config plugin_config: PluginConfig = plugin.config
INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__))
except AttributeError: except AttributeError:
raise ImproperlyConfigured( raise ImproperlyConfigured(
"Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file " "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file "
"and point to the PluginConfig subclass.".format(plugin_name) "and point to the PluginConfig subclass.".format(plugin_name)
) )
plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore
# Gather additional apps to load alongside this plugin
django_apps = plugin_config.django_apps
if plugin_name in django_apps:
django_apps.pop(plugin_name)
if plugin_module not in django_apps:
django_apps.append(plugin_module)
# Test if we can import all modules (or its parent, for PluginConfigs and AppConfigs)
for app in django_apps:
if "." in app:
parts = app.split(".")
spec = importlib.util.find_spec(".".join(parts[:-1]))
else:
spec = importlib.util.find_spec(app)
if spec is None:
raise ImproperlyConfigured(
f"Failed to load django_apps specified by plugin {plugin_name}: {django_apps} "
f"The module {app} cannot be imported. Check that the necessary package has been "
"installed within the correct Python environment."
)
INSTALLED_APPS.extend(django_apps)
# Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence
sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS))))
INSTALLED_APPS = list(sorted_apps)
# Validate user-provided configuration settings and assign defaults # Validate user-provided configuration settings and assign defaults
if plugin_name not in PLUGINS_CONFIG: if plugin_name not in PLUGINS_CONFIG:
PLUGINS_CONFIG[plugin_name] = {} PLUGINS_CONFIG[plugin_name] = {}

View File

@ -19,17 +19,6 @@
<h5 class="card-header">Provider</h5> <h5 class="card-header">Provider</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr>
<th scope="row">ASN</th>
<td>
{% if object.asn %}
<div class="float-end text-warning">
<i class="mdi mdi-alert" title="This field will be removed in a future release. Please migrate this data to ASN objects."></i>
</div>
{% endif %}
{{ object.asn|placeholder }}
</td>
</tr>
<tr> <tr>
<th scope="row">ASNs</th> <th scope="row">ASNs</th>
<td> <td>
@ -44,24 +33,6 @@
<th scope="row">Account</th> <th scope="row">Account</th>
<td>{{ object.account|placeholder }}</td> <td>{{ object.account|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">Customer Portal</th>
<td>
{% if object.portal_url %}
<a href="{{ object.portal_url }}">{{ object.portal_url }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">NOC Contact</th>
<td>{{ object.noc_contact|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Admin Contact</th>
<td>{{ object.admin_contact|markdown|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">Circuits</th> <th scope="row">Circuits</th>
<td> <td>

View File

@ -66,7 +66,7 @@
{% with object.parent_bay.device as parent %} {% with object.parent_bay.device as parent %}
{{ parent|linkify }} / {{ object.parent_bay }} {{ parent|linkify }} / {{ object.parent_bay }}
{% if parent.position %} {% if parent.position %}
(U{{ parent.position }} / {{ parent.get_face_display }}) (U{{ parent.position|floatformat }} / {{ parent.get_face_display }})
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% elif object.rack and object.position %} {% elif object.rack and object.position %}
@ -90,7 +90,7 @@
<tr> <tr>
<th scope="row">Device Type</th> <th scope="row">Device Type</th>
<td> <td>
{{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U) {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U)
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -29,12 +29,22 @@
</tr> </tr>
<tr> <tr>
<td>Height (U)</td> <td>Height (U)</td>
<td>{{ object.u_height }}</td> <td>{{ object.u_height|floatformat }}</td>
</tr> </tr>
<tr> <tr>
<td>Full Depth</td> <td>Full Depth</td>
<td>{% checkmark object.is_full_depth %}</td> <td>{% checkmark object.is_full_depth %}</td>
</tr> </tr>
<tr>
<td>Weight</td>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Parent/Child</td> <td>Parent/Child</td>
<td> <td>

View File

@ -22,6 +22,16 @@
<td>Part Number</td> <td>Part Number</td>
<td>{{ object.part_number|placeholder }}</td> <td>{{ object.part_number|placeholder }}</td>
</tr> </tr>
<tr>
<td>Weight</td>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Instances</td> <td>Instances</td>
<td><a href="{% url 'dcim:module_list' %}?module_type_id={{ object.pk }}">{{ instance_count }}</a></td> <td><a href="{% url 'dcim:module_list' %}?module_type_id={{ object.pk }}">{{ instance_count }}</a></td>

View File

@ -104,9 +104,7 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Dimensions</h5>
Dimensions
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
@ -147,6 +145,20 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Rack Weight</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Total Weight</th>
<td>{{ object.total_weight|floatformat }} Kilograms</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
@ -186,6 +198,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">

View File

@ -57,6 +57,14 @@
</div> </div>
{% render_field form.desc_units %} {% render_field form.desc_units %}
</div> </div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Weight</h5>
</div>
{% render_field form.weight %}
{% render_field form.weight_unit %}
</div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="field-group my-5"> <div class="field-group my-5">

View File

@ -3,6 +3,9 @@
{% load form_helpers %} {% load form_helpers %}
{% block form %} {% block form %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="field-group my-5"> <div class="field-group my-5">
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">Contact Assignment</h5> <h5 class="offset-sm-3">Contact Assignment</h5>

View File

@ -119,8 +119,10 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ContactAssignment model = ContactAssignment
fields = ( fields = (
'group', 'contact', 'role', 'priority', 'content_type', 'object_id', 'group', 'contact', 'role', 'priority',
) )
widgets = { widgets = {
'content_type': forms.HiddenInput(),
'object_id': forms.HiddenInput(),
'priority': StaticSelect(), 'priority': StaticSelect(),
} }

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import mptt.fields import mptt.fields
@ -34,7 +34,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -54,7 +54,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import mptt.fields import mptt.fields
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -34,7 +34,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)), ('slug', models.SlugField(max_length=100)),
@ -55,7 +55,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('title', models.CharField(blank=True, max_length=100)), ('title', models.CharField(blank=True, max_length=100)),

View File

@ -3,8 +3,6 @@ from django import forms
from django.conf import settings from django.conf import settings
from django_filters.constants import EMPTY_VALUES from django_filters.constants import EMPTY_VALUES
from utilities.forms import MACAddressField
def multivalue_field_factory(field_class): def multivalue_field_factory(field_class):
""" """
@ -23,7 +21,15 @@ def multivalue_field_factory(field_class):
field.to_python(v) for v in value if v field.to_python(v) for v in value if v
] ]
return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) def run_validators(self, value):
for v in value:
super().run_validators(v)
def validate(self, value):
for v in value:
super().validate(v)
return type(f'MultiValue{field_class.__name__}', (NewField,), dict())
# #
@ -46,6 +52,10 @@ class MultiValueNumberFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.IntegerField) field_class = multivalue_field_factory(forms.IntegerField)
class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.DecimalField)
class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.TimeField) field_class = multivalue_field_factory(forms.TimeField)

17
netbox/utilities/json.py Normal file
View File

@ -0,0 +1,17 @@
import decimal
from django.core.serializers.json import DjangoJSONEncoder
__all__ = (
'CustomFieldJSONEncoder',
)
class CustomFieldJSONEncoder(DjangoJSONEncoder):
"""
Override Django's built-in JSON encoder to save decimal values as JSON numbers.
"""
def default(self, o):
if isinstance(o, decimal.Decimal):
return float(o)
return super().default(o)

View File

@ -73,9 +73,9 @@ def humanize_megabytes(mb):
""" """
if not mb: if not mb:
return '' return ''
if mb >= 1048576: if not mb % 1048576: # 1024^2
return f'{int(mb / 1048576)} TB' return f'{int(mb / 1048576)} TB'
if mb >= 1024: if not mb % 1024:
return f'{int(mb / 1024)} GB' return f'{int(mb / 1024)} GB'
return f'{mb} MB' return f'{mb} MB'

View File

@ -2,7 +2,7 @@ from typing import Dict
from django import template from django import template
from django.template import Context from django.template import Context
from netbox.navigation_menu import MENUS from netbox.navigation.menu import MENUS
register = template.Library() register = template.Library()

View File

@ -5,8 +5,6 @@ from django.test import TestCase
from mptt.fields import TreeForeignKey from mptt.fields import TreeForeignKey
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet
from circuits.models import Circuit, Provider
from dcim.choices import * from dcim.choices import *
from dcim.fields import MACAddressField from dcim.fields import MACAddressField
from dcim.filtersets import DeviceFilterSet, SiteFilterSet from dcim.filtersets import DeviceFilterSet, SiteFilterSet
@ -15,6 +13,7 @@ from dcim.models import (
) )
from extras.filters import TagFilter from extras.filters import TagFilter
from extras.models import TaggedItem from extras.models import TaggedItem
from ipam.filtersets import ASNFilterSet
from ipam.models import RIR, ASN from ipam.models import RIR, ASN
from netbox.filtersets import BaseFilterSet from netbox.filtersets import BaseFilterSet
from utilities.filters import ( from utilities.filters import (
@ -338,13 +337,14 @@ class DynamicFilterLookupExpressionTest(TestCase):
""" """
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
providers = ( asns = (
Provider(name='Provider 1', slug='provider-1', asn=65001), ASN(asn=65001, rir=rir),
Provider(name='Provider 2', slug='provider-2', asn=65101), ASN(asn=65101, rir=rir),
Provider(name='Provider 3', slug='provider-3', asn=65201), ASN(asn=65201, rir=rir),
) )
Provider.objects.bulk_create(providers) ASN.objects.bulk_create(asns)
manufacturers = ( manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
@ -389,15 +389,6 @@ class DynamicFilterLookupExpressionTest(TestCase):
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
rir = RIR.objects.create(name='RFC 6996', is_private=True)
asns = [
ASN(asn=65001, rir=rir),
ASN(asn=65101, rir=rir),
ASN(asn=65201, rir=rir)
]
ASN.objects.bulk_create(asns)
asns[0].sites.add(sites[0]) asns[0].sites.add(sites[0])
asns[1].sites.add(sites[1]) asns[1].sites.add(sites[1])
asns[2].sites.add(sites[2]) asns[2].sites.add(sites[2])
@ -456,19 +447,19 @@ class DynamicFilterLookupExpressionTest(TestCase):
def test_provider_asn_lt(self): def test_provider_asn_lt(self):
params = {'asn__lt': [65101]} params = {'asn__lt': [65101]}
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1)
def test_provider_asn_lte(self): def test_provider_asn_lte(self):
params = {'asn__lte': [65101]} params = {'asn__lte': [65101]}
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 2)
def test_provider_asn_gt(self): def test_provider_asn_gt(self):
params = {'asn__lt': [65101]} params = {'asn__lt': [65101]}
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 1)
def test_provider_asn_gte(self): def test_provider_asn_gte(self):
params = {'asn__gte': [65101]} params = {'asn__gte': [65101]}
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) self.assertEqual(ASNFilterSet(params, ASN.objects.all()).qs.count(), 2)
def test_site_region_negation(self): def test_site_region_negation(self):
params = {'region__n': ['region-1']} params = {'region__n': ['region-1']}

View File

@ -12,7 +12,7 @@ from django.http import QueryDict
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel from mptt.models import MPTTModel
from dcim.choices import CableLengthUnitChoices from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
from extras.plugins import PluginConfig from extras.plugins import PluginConfig
from extras.utils import is_taggable from extras.utils import is_taggable
from netbox.config import get_config from netbox.config import get_config
@ -270,6 +270,31 @@ def to_meters(length, unit):
raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.") raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
def to_grams(weight, unit):
"""
Convert the given weight to kilograms.
"""
try:
if weight < 0:
raise ValueError("Weight must be a positive number")
except TypeError:
raise TypeError(f"Invalid value '{weight}' for weight (must be a number)")
valid_units = WeightUnitChoices.values()
if unit not in valid_units:
raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}")
if unit == WeightUnitChoices.UNIT_KILOGRAM:
return weight * 1000
if unit == WeightUnitChoices.UNIT_GRAM:
return weight
if unit == WeightUnitChoices.UNIT_POUND:
return weight * Decimal(453.592)
if unit == WeightUnitChoices.UNIT_OUNCE:
return weight * Decimal(28.3495)
raise ValueError(f"Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.")
def render_jinja2(template_code, context): def render_jinja2(template_code, context):
""" """
Render a Jinja2 template with the provided context. Return the rendered content. Render a Jinja2 template with the provided context. Return the rendered content.

View File

@ -6,7 +6,7 @@ from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF from ipam.models import VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from .choices import * from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -196,6 +196,9 @@ class VirtualMachineFilterSet(
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
name = MultiValueCharFilter(
lookup_expr='iexact'
)
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
label='Role (ID)', label='Role (ID)',
@ -227,7 +230,7 @@ class VirtualMachineFilterSet(
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = ['id', 'name', 'cluster', 'vcpus', 'memory', 'disk'] fields = ['id', 'cluster', 'vcpus', 'memory', 'disk']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -1,5 +1,5 @@
import dcim.fields import dcim.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -51,7 +51,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('comments', models.TextField(blank=True)), ('comments', models.TextField(blank=True)),
@ -65,7 +65,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -80,7 +80,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -95,7 +95,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('local_context_data', models.JSONField(blank=True, null=True)), ('local_context_data', models.JSONField(blank=True, null=True)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
@ -147,7 +147,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('enabled', models.BooleanField(default=True)), ('enabled', models.BooleanField(default=True)),
('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)),

View File

@ -1,4 +1,5 @@
from django.db import migrations, models from django.db import migrations, models
import django.db.models.functions.text
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -30,11 +31,11 @@ class Migration(migrations.Migration):
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='virtualmachine', model_name='virtualmachine',
constraint=models.UniqueConstraint(fields=('name', 'cluster', 'tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'), constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), models.F('tenant'), name='virtualization_virtualmachine_unique_name_cluster_tenant'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='virtualmachine', model_name='virtualmachine',
constraint=models.UniqueConstraint(condition=models.Q(('tenant__isnull', True)), fields=('name', 'cluster'), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per site.'), constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('cluster'), condition=models.Q(('tenant__isnull', True)), name='virtualization_virtualmachine_unique_name_cluster', violation_error_message='Virtual machine name must be unique per cluster.'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='vminterface', model_name='vminterface',

View File

@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Lower
from django.urls import reverse from django.urls import reverse
from dcim.models import BaseInterface, Device from dcim.models import BaseInterface, Device
@ -318,14 +319,14 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
ordering = ('_name', 'pk') # Name may be non-unique ordering = ('_name', 'pk') # Name may be non-unique
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('name', 'cluster', 'tenant'), Lower('name'), 'cluster', 'tenant',
name='%(app_label)s_%(class)s_unique_name_cluster_tenant' name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('name', 'cluster'), Lower('name'), 'cluster',
name='%(app_label)s_%(class)s_unique_name_cluster', name='%(app_label)s_%(class)s_unique_name_cluster',
condition=Q(tenant__isnull=True), condition=Q(tenant__isnull=True),
violation_error_message="Virtual machine name must be unique per site." violation_error_message="Virtual machine name must be unique per cluster."
), ),
) )

View File

@ -299,6 +299,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_name(self): def test_name(self):
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# Test case insensitivity
params = {'name': ['VIRTUAL MACHINE 1', 'VIRTUAL MACHINE 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vcpus(self): def test_vcpus(self):
params = {'vcpus': [1, 2]} params = {'vcpus': [1, 2]}

View File

@ -8,12 +8,14 @@ from tenancy.models import Tenant
class VirtualMachineTestCase(TestCase): class VirtualMachineTestCase(TestCase):
def test_vm_duplicate_name_per_cluster(self): @classmethod
def setUpTestData(cls):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) Cluster.objects.create(name='Cluster 1', type=cluster_type)
def test_vm_duplicate_name_per_cluster(self):
vm1 = VirtualMachine( vm1 = VirtualMachine(
cluster=cluster, cluster=Cluster.objects.first(),
name='Test VM 1' name='Test VM 1'
) )
vm1.save() vm1.save()
@ -43,7 +45,7 @@ class VirtualMachineTestCase(TestCase):
vm2.save() vm2.save()
def test_vm_mismatched_site_cluster(self): def test_vm_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster_type = ClusterType.objects.first()
sites = ( sites = (
Site(name='Site 1', slug='site-1'), Site(name='Site 1', slug='site-1'),
@ -71,3 +73,19 @@ class VirtualMachineTestCase(TestCase):
# VM with cluster site but no direct site should fail # VM with cluster site but no direct site should fail
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean()
def test_vm_name_case_sensitivity(self):
vm1 = VirtualMachine(
cluster=Cluster.objects.first(),
name='virtual machine 1'
)
vm1.save()
vm2 = VirtualMachine(
cluster=vm1.cluster,
name='VIRTUAL MACHINE 1'
)
# Uniqueness validation for name should ignore case
with self.assertRaises(ValidationError):
vm2.full_clean()

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import mptt.fields import mptt.fields
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -44,7 +44,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('ssid', models.CharField(max_length=32)), ('ssid', models.CharField(max_length=32)),
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')), ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')),
@ -65,7 +65,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('ssid', models.CharField(blank=True, max_length=32)), ('ssid', models.CharField(blank=True, max_length=32)),
('status', models.CharField(default='connected', max_length=50)), ('status', models.CharField(default='connected', max_length=50)),