Merge branch 'feature' into 8424-device-location

This commit is contained in:
Arthur 2022-09-30 16:26:32 -07:00
commit 503288ee7e
68 changed files with 873 additions and 298 deletions

View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@
{% block site_meta %}
{{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs #}
{% if not config.extra.readthedocs %}
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
{% if page.canonical_url != 'https://docs.netbox.dev/' %}
<meta name="robots" content="noindex">
{% endif %}
{% 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)
* Long text: Free-form of any length; supports Markdown rendering
* Integer: A whole number (positive or negative)
* Decimal: A fixed-precision decimal number (4 decimal places)
* Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI

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).
### Weight
The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
### 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.

View File

@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu
### Part Number
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.
### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### 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.)

View File

@ -14,6 +14,7 @@ Plugins can do a lot, including:
* Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links
* Extend NetBox's REST and GraphQL APIs
* Load additional Django apps
* 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.
@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig):
default_settings = {
'baz': True
}
django_apps = ["foo", "bar", "baz"]
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. |
| `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 |
| `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 |
| `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 |
@ -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.
#### 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
`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

@ -2,17 +2,27 @@
## v3.3.5 (FUTURE)
### Enhancements
* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions
### Bug Fixes
* [#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
* [#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)
### Bug Fixes
* [#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

View File

@ -18,11 +18,14 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
### 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
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
### 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
### Other Changes
@ -35,5 +38,11 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
* 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
* Added optional `name` field

View File

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

View File

@ -1,5 +1,5 @@
import dcim.fields
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('cid', models.CharField(max_length=100)),
('status', models.CharField(default='active', max_length=50)),
@ -58,7 +58,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -73,7 +73,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -93,7 +93,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
('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
import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='circuittermination',
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(
model_name='circuittermination',

View File

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

View File

@ -320,7 +320,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = [
'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):
@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = DeviceType
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):
@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = ModuleType
fields = ['id', 'model', 'part_number']
fields = ['id', 'model', 'part_number', 'weight', 'weight_unit']
def search(self, queryset, name, value):
if not value.strip():

View File

@ -285,15 +285,26 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
widget=SmallTextarea,
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
fieldsets = (
('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')),
('Location', ('region', 'site_group', 'site', 'location')),
('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')),
('Weight', ('weight', 'weight_unit')),
)
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,
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
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):
@ -371,12 +393,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
part_number = forms.CharField(
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
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):
@ -553,17 +586,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
'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):
domain = forms.CharField(

View File

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

View File

@ -260,7 +260,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [
'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',
'outer_unit', 'comments', 'tags',
'outer_unit', 'weight', 'weight_unit', 'comments', 'tags',
]
help_texts = {
'site': "The site at which the rack exists",
@ -273,6 +273,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
'type': StaticSelect(),
'width': StaticSelect(),
'outer_unit': StaticSelect(),
'weight_unit': StaticSelect(),
}
@ -363,6 +364,7 @@ class DeviceTypeForm(NetBoxModelForm):
('Chassis', (
'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
)),
('Attributes', ('weight', 'weight_unit')),
('Images', ('front_image', 'rear_image')),
)
@ -370,7 +372,7 @@ class DeviceTypeForm(NetBoxModelForm):
model = DeviceType
fields = [
'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 = {
'airflow': StaticSelect(),
@ -380,7 +382,8 @@ class DeviceTypeForm(NetBoxModelForm):
}),
'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
})
}),
'weight_unit': StaticSelect(),
}
@ -392,16 +395,20 @@ class ModuleTypeForm(NetBoxModelForm):
fieldsets = (
('Module Type', (
'manufacturer', 'model', 'part_number', 'tags',
'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit'
)),
)
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'comments', 'tags',
'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
]
widgets = {
'weight_unit': StaticSelect(),
}
class DeviceRoleForm(NetBoxModelForm):
slug = SlugField()

View File

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

View File

@ -1,6 +1,6 @@
import dcim.fields
import django.contrib.postgres.fields
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
@ -28,7 +28,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('termination_a_id', models.PositiveIntegerField()),
('termination_b_id', models.PositiveIntegerField()),
@ -60,7 +60,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -96,7 +96,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -132,7 +132,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('local_context_data', models.JSONField(blank=True, null=True)),
('name', models.CharField(blank=True, max_length=64, null=True)),
@ -155,7 +155,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -186,7 +186,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -203,7 +203,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('model', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
@ -224,7 +224,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -261,7 +261,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('label', models.CharField(blank=True, max_length=64)),
@ -302,7 +302,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -326,7 +326,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
@ -345,7 +345,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -360,7 +360,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -377,7 +377,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)),
('mark_connected', models.BooleanField(default=False)),
@ -401,7 +401,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -438,7 +438,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
],
@ -451,7 +451,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -490,7 +490,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -516,7 +516,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
('description', models.CharField(max_length=200)),
@ -530,7 +530,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -546,7 +546,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -583,7 +583,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -602,7 +602,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -630,7 +630,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -649,7 +649,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('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
import django.db.models.deletion
import taggit.managers
@ -107,7 +107,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('model', models.CharField(max_length=100)),
('part_number', models.CharField(blank=True, max_length=50)),
@ -125,7 +125,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -145,7 +145,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('local_context_data', models.JSONField(blank=True, null=True)),
('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
import django.db.models.deletion
import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),

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,7 +1,8 @@
import decimal
import yaml
from functools import cached_property
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
@ -21,6 +22,7 @@ from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from .device_components import *
from .mixins import WeightMixin
__all__ = (
@ -71,7 +73,7 @@ class Manufacturer(OrganizationalModel):
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
well as high-level functional role(s).
@ -139,7 +141,7 @@ class DeviceType(NetBoxModel):
)
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:
@ -315,7 +317,7 @@ class DeviceType(NetBoxModel):
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
components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
@ -344,7 +346,7 @@ class ModuleType(NetBoxModel):
to='extras.ImageAttachment'
)
clone_fields = ('manufacturer',)
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
class Meta:
ordering = ('manufacturer', 'model')
@ -946,6 +948,18 @@ class Device(NetBoxModel, ConfigContextModel):
def get_status_color(self):
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):
"""

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
from functools import cached_property
from django.apps import apps
from django.contrib.auth.models import User
@ -18,7 +19,8 @@ from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange
from .device_components import PowerPort
from .devices import Device
from .devices import Device, Module
from .mixins import WeightMixin
from .power import PowerFeed
__all__ = (
@ -62,7 +64,7 @@ class RackRole(OrganizationalModel):
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.
Each Rack is assigned to a Site and (optionally) a Location.
@ -185,7 +187,7 @@ class Rack(NetBoxModel):
clone_fields = (
'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:
@ -454,6 +456,22 @@ class Rack(NetBoxModel):
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):
"""

View File

@ -35,7 +35,7 @@ class Node(Hyperlink):
"""
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

View File

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

View File

@ -5,7 +5,7 @@ from dcim.models import (
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
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__ = (
'ConsolePortTemplateTable',
@ -85,12 +85,22 @@ class DeviceTypeTable(NetBoxTable):
tags = columns.TagColumn(
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):
model = DeviceType
fields = (
'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 = (
'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 netbox.tables import NetBoxTable, columns
from .template_code import DEVICE_WEIGHT
__all__ = (
'ModuleTable',
@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:moduletype_list'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta):
model = ModuleType
fields = (
'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags',
'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags',
)
default_columns = (
'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 netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from .template_code import DEVICE_WEIGHT
__all__ = (
'RackTable',
@ -82,13 +83,17 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name='Outer Depth'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments',
'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'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 %}
"""
DEVICE_WEIGHT = """
{% load helpers %}
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ 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)
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 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 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 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, 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, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
Rack.objects.bulk_create(racks)
@ -517,6 +517,14 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
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):
queryset = RackReservation.objects.all()
@ -688,9 +696,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers)
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[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[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[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, 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, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
DeviceType.objects.bulk_create(device_types)
@ -839,6 +847,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'inventory_items': 'false'}
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):
queryset = ModuleType.objects.all()
@ -855,9 +871,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers)
module_types = (
ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'),
ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'),
ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'),
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', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
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)
@ -943,6 +959,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'pass_through_ports': 'false'}
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):
queryset = ConsolePortTemplate.objects.all()

View File

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

View File

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

View File

@ -34,7 +34,9 @@ class CustomFieldsMixin:
return ContentType.objects.get_for_model(self.model)
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):
return customfield.to_form_field()
@ -50,13 +52,6 @@ class CustomFieldsMixin:
field_name = f'cf_{customfield.name}'
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
self.custom_fields[field_name] = customfield
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
import taggit.managers
@ -13,7 +13,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='journalentry',
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(
model_name='journalentry',

View File

@ -1,5 +1,6 @@
import re
from datetime import datetime, date
import decimal
import django_filters
from django import forms
@ -219,14 +220,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
})
# Minimum/maximum values can be set only for numeric fields
if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
raise ValidationError({
'validation_minimum': "A minimum value may be set only for numeric fields"
})
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"
})
if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
if self.validation_minimum:
raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"})
if self.validation_maximum:
raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"})
# Regex validation can be set only for text fields
regex_types = (
@ -297,12 +295,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
return model.objects.filter(pk__in=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.
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_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.
"""
initial = self.default if set_initial else None
@ -317,6 +316,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
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
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
@ -398,6 +408,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
if 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
def to_filter(self, lookup_expr=None):
@ -426,6 +442,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
filter_class = filters.MultiValueNumberFilter
# Decimal
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
filter_class = filters.MultiValueDecimalFilter
# Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
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}'")
# Validate integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
if type(value) is not int:
raise ValidationError("Value must be an integer.")
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:
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
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.")
# Validate date
if self.type == CustomFieldTypeChoices.TYPE_DATE:
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
if type(value) is not date:
try:
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.")
# Validate selected choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
if value not in self.choices:
raise ValidationError(
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
)
# Validate all selected choices
if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
if not set(value).issubset(self.choices):
raise ValidationError(
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"

View File

@ -55,6 +55,9 @@ class PluginConfig(AppConfig):
# Django-rq queues dedicated to the plugin
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
# integrated components.
graphql_schema = 'graphql.schema'

View File

@ -1,3 +1,5 @@
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.urls import reverse
@ -102,6 +104,32 @@ class CustomFieldTest(TestCase):
instance.refresh_from_db()
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):
# Create a custom field & check that initial value is null
@ -373,7 +401,8 @@ class CustomFieldAPITest(APITestCase):
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
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_DATE, name='date_field', default='2020-01-01'),
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[1].name: 'DEF',
custom_fields[2].name: 456,
custom_fields[3].name: True,
custom_fields[4].name: '2020-01-02',
custom_fields[5].name: 'http://example.com/2',
custom_fields[6].name: '{"foo": 1, "bar": 2}',
custom_fields[7].name: 'Bar',
custom_fields[8].name: ['Bar', 'Baz'],
custom_fields[9].name: vlans[1].pk,
custom_fields[10].name: [vlans[2].pk, vlans[3].pk],
custom_fields[3].name: Decimal('456.78'),
custom_fields[4].name: True,
custom_fields[5].name: '2020-01-02',
custom_fields[6].name: 'http://example.com/2',
custom_fields[7].name: '{"foo": 1, "bar": 2}',
custom_fields[8].name: 'Bar',
custom_fields[9].name: ['Bar', 'Baz'],
custom_fields[10].name: vlans[1].pk,
custom_fields[11].name: [vlans[2].pk, vlans[3].pk],
}
sites[1].save()
@ -440,6 +470,7 @@ class CustomFieldAPITest(APITestCase):
CustomFieldTypeChoices.TYPE_TEXT: 'string',
CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
CustomFieldTypeChoices.TYPE_DATE: 'string',
CustomFieldTypeChoices.TYPE_URL: 'string',
@ -473,7 +504,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields'], {
'text_field': None,
'longtext_field': None,
'number_field': None,
'integer_field': None,
'decimal_field': None,
'boolean_field': None,
'date_field': None,
'url_field': None,
@ -497,7 +529,8 @@ class CustomFieldAPITest(APITestCase):
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']['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']['date_field'], site2_cfvs['date_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']
self.assertEqual(response_cf['text_field'], cf_defaults['text_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['date_field'], cf_defaults['date_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'])
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['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(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@ -568,7 +603,8 @@ class CustomFieldAPITest(APITestCase):
'custom_fields': {
'text_field': 'bar',
'longtext_field': 'blah blah blah',
'number_field': 456,
'integer_field': 456,
'decimal_field': 456.78,
'boolean_field': True,
'date_field': '2020-01-02',
'url_field': 'http://example.com/2',
@ -590,7 +626,8 @@ class CustomFieldAPITest(APITestCase):
data_cf = data['custom_fields']
self.assertEqual(response_cf['text_field'], data_cf['text_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['date_field'], data_cf['date_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'])
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['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(str(site.custom_field_data['date_field']), data_cf['date_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']
self.assertEqual(response_cf['text_field'], cf_defaults['text_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['date_field'], cf_defaults['date_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'])
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['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(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@ -686,7 +726,8 @@ class CustomFieldAPITest(APITestCase):
custom_field_data = {
'text_field': 'bar',
'longtext_field': 'abcdefghij',
'number_field': 456,
'integer_field': 456,
'decimal_field': 456.78,
'boolean_field': True,
'date_field': '2020-01-02',
'url_field': 'http://example.com/2',
@ -726,7 +767,8 @@ class CustomFieldAPITest(APITestCase):
response_cf = response.data[i]['custom_fields']
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['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['date_field'], custom_field_data['date_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'])
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['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(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'])
@ -763,7 +806,7 @@ class CustomFieldAPITest(APITestCase):
data = {
'custom_fields': {
'text_field': 'ABCD',
'number_field': 1234,
'integer_field': 1234,
},
}
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
@ -775,8 +818,9 @@ class CustomFieldAPITest(APITestCase):
# Validate response data
response_cf = response.data['custom_fields']
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['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['date_field'], original_cfvs['date_field'])
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
@ -792,8 +836,9 @@ class CustomFieldAPITest(APITestCase):
# Validate database data
site2.refresh_from_db()
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['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['date_field'], original_cfvs['date_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})
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_maximum = 20
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)
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)
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)
self.assertHttpStatus(response, status.HTTP_200_OK)
@ -860,6 +905,7 @@ class CustomFieldImportTest(TestCase):
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
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.
"""
data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', '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 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
('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', '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', '456.78', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
@ -893,10 +939,11 @@ class CustomFieldImportTest(TestCase):
# Validate data for 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['longtext'], 'Foo')
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['date'], '2020-01-01')
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
@ -906,10 +953,11 @@ class CustomFieldImportTest(TestCase):
# Validate data for 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['longtext'], 'Bar')
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['date'], '2020-01-02')
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
@ -1034,53 +1082,78 @@ class CustomFieldModelFilterTest(TestCase):
cf.save()
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
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf.save()
cf.content_types.set([obj_type])
# Exact text filtering
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
cf = CustomField(
name='cf4',
type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
)
cf.save()
cf.content_types.set([obj_type])
# Loose text filtering
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
cf = CustomField(
name='cf5',
type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
)
cf.save()
cf.content_types.set([obj_type])
# Date filtering
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE)
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
cf.save()
cf.content_types.set([obj_type])
# Exact URL filtering
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
cf = CustomField(
name='cf7',
type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
)
cf.save()
cf.content_types.set([obj_type])
# Loose URL filtering
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
cf = CustomField(
name='cf8',
type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
)
cf.save()
cf.content_types.set([obj_type])
# 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.content_types.set([obj_type])
# 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.content_types.set([obj_type])
# Object filtering
cf = CustomField(
name='cf10',
name='cf11',
type=CustomFieldTypeChoices.TYPE_OBJECT,
object_type=ContentType.objects.get_for_model(Manufacturer)
)
@ -1089,7 +1162,7 @@ class CustomFieldModelFilterTest(TestCase):
# Multi-object filtering
cf = CustomField(
name='cf11',
name='cf12',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
object_type=ContentType.objects.get_for_model(Manufacturer)
)
@ -1099,42 +1172,45 @@ class CustomFieldModelFilterTest(TestCase):
Site.objects.bulk_create([
Site(name='Site 1', slug='site-1', custom_field_data={
'cf1': 100,
'cf2': True,
'cf3': 'foo',
'cf2': 100.1,
'cf3': True,
'cf4': 'foo',
'cf5': '2016-06-26',
'cf6': 'http://a.example.com',
'cf5': 'foo',
'cf6': '2016-06-26',
'cf7': 'http://a.example.com',
'cf8': 'Foo',
'cf9': ['A', 'X'],
'cf10': manufacturers[0].pk,
'cf11': [manufacturers[0].pk, manufacturers[3].pk],
'cf8': 'http://a.example.com',
'cf9': 'Foo',
'cf10': ['A', 'X'],
'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
}),
Site(name='Site 2', slug='site-2', custom_field_data={
'cf1': 200,
'cf2': True,
'cf3': 'foobar',
'cf2': 200.2,
'cf3': True,
'cf4': 'foobar',
'cf5': '2016-06-27',
'cf6': 'http://b.example.com',
'cf5': 'foobar',
'cf6': '2016-06-27',
'cf7': 'http://b.example.com',
'cf8': 'Bar',
'cf9': ['B', 'X'],
'cf10': manufacturers[1].pk,
'cf11': [manufacturers[1].pk, manufacturers[3].pk],
'cf8': 'http://b.example.com',
'cf9': 'Bar',
'cf10': ['B', 'X'],
'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
}),
Site(name='Site 3', slug='site-3', custom_field_data={
'cf1': 300,
'cf2': False,
'cf3': 'bar',
'cf2': 300.3,
'cf3': False,
'cf4': 'bar',
'cf5': '2016-06-28',
'cf6': 'http://c.example.com',
'cf5': 'bar',
'cf6': '2016-06-28',
'cf7': 'http://c.example.com',
'cf8': 'Baz',
'cf9': ['C', 'X'],
'cf10': manufacturers[2].pk,
'cf11': [manufacturers[2].pk, manufacturers[3].pk],
'cf8': 'http://c.example.com',
'cf9': 'Baz',
'cf10': ['C', 'X'],
'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__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):
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
def test_filter_text_strict(self):
self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__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_cf3__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_cf3__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_cf3__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_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
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):
self.assertEqual(self.filterset({'cf_cf5': ['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_cf5__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_cf5__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': ['2016-06-26', '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_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
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_cf6__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_cf6__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_cf6__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_cf6__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_cf6__nie': ['HTTP://A.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_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0)
self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
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):
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):
self.assertEqual(self.filterset({'cf_cf9': ['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': ['A', 'B']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
def test_filter_object(self):
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):
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_cf11': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3)

View File

@ -23,6 +23,9 @@ class CustomFieldModelFormTest(TestCase):
cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
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.content_types.set([obj_type])

View File

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

View File

@ -1,6 +1,6 @@
# 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
import django.db.models.deletion
import django.db.models.expressions
@ -22,7 +22,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('start_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
from django.db import migrations, models
import django.db.models.deletion
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('group_id', models.PositiveSmallIntegerField()),
('protocol', models.CharField(max_length=50)),

View File

@ -1,7 +1,7 @@
# Generated by Django 3.2.8 on 2021-11-02 16:16
import dcim.fields
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('asn', dcim.fields.ASNField(unique=True)),
('description', models.CharField(blank=True, max_length=200)),

View File

@ -1,5 +1,5 @@
import django.contrib.postgres.fields
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
import django.core.validators
from django.db import migrations, models
import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('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)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
@ -20,7 +20,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=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)),
('slug', models.SlugField()),
('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)),
('created', models.DateTimeField(auto_now_add=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_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')),

View File

@ -46,7 +46,7 @@ class BaseFilterSet(django_filters.FilterSet):
'filter_class': filters.MultiValueDateTimeFilter
},
models.DecimalField: {
'filter_class': filters.MultiValueNumberFilter
'filter_class': filters.MultiValueDecimalFilter
},
models.EmailField: {
'filter_class': filters.MultiValueCharFilter
@ -95,6 +95,7 @@ class BaseFilterSet(django_filters.FilterSet):
filters.MultiValueDateFilter,
filters.MultiValueDateTimeFilter,
filters.MultiValueNumberFilter,
filters.MultiValueDecimalFilter,
filters.MultiValueTimeFilter
)):
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.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.models import CustomField, Tag
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)
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):
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):
return CustomField.objects.filter(content_types=content_type).exclude(
return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
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.dispatch import receiver
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import ValidationError
from django.db import models
from taggit.managers import TaggableManager
@ -12,6 +11,7 @@ from taggit.managers import TaggableManager
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
from extras.utils import is_taggable, register_features
from netbox.signals import post_clean
from utilities.json import CustomFieldJSONEncoder
from utilities.utils import serialize_object
__all__ = (
@ -124,7 +124,7 @@ class CustomFieldsMixin(models.Model):
Enables support for custom fields.
"""
custom_field_data = models.JSONField(
encoder=DjangoJSONEncoder,
encoder=CustomFieldJSONEncoder,
blank=True,
default=dict
)

View File

@ -1,18 +1,19 @@
import hashlib
import importlib
import logging
import importlib.util
import os
import platform
import re
import socket
import sys
import warnings
from urllib.parse import urlsplit
import django
import sentry_sdk
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
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 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
# https://github.com/graphql-python/graphene-django/issues/1284)
# TODO: Remove this when graphene-django 2.16 becomes available
import django
from django.utils.encoding import force_str
django.utils.encoding.force_text = force_str
django.utils.encoding.force_text = force_str # type: ignore
#
@ -186,7 +185,7 @@ if STORAGE_BACKEND is not None:
if STORAGE_BACKEND.startswith('storages.'):
try:
import storages.utils
import storages.utils # type: ignore
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'storages':
raise ImproperlyConfigured(
@ -663,14 +662,42 @@ for plugin_name in PLUGINS:
# Determine plugin config and add to INSTALLED_APPS.
try:
plugin_config = plugin.config
INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__))
plugin_config: PluginConfig = plugin.config
except AttributeError:
raise ImproperlyConfigured(
"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)
)
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
if plugin_name not in PLUGINS_CONFIG:
PLUGINS_CONFIG[plugin_name] = {}

View File

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

View File

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

View File

@ -22,6 +22,16 @@
<td>Part Number</td>
<td>{{ object.part_number|placeholder }}</td>
</tr>
<tr>
<td>Weight</td>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<td>Instances</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 class="card">
<h5 class="card-header">
Dimensions
</h5>
<h5 class="card-header">Dimensions</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
@ -147,6 +145,20 @@
{% endif %}
</td>
</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>
</div>
</div>
@ -186,6 +198,7 @@
</div>
</div>
{% endif %}
{% include 'inc/panels/image_attachments.html' %}
<div class="card">
<h5 class="card-header">

View File

@ -57,6 +57,14 @@
</div>
{% render_field form.desc_units %}
</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 %}
<div class="field-group my-5">

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
@ -34,7 +34,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -54,7 +54,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(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
import django.db.models.deletion
import mptt.fields
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -34,7 +34,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
@ -55,7 +55,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(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_filters.constants import EMPTY_VALUES
from utilities.forms import MACAddressField
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
]
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)
class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.DecimalField)
class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
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:
return ''
if mb >= 1048576:
if not mb % 1048576: # 1024^2
return f'{int(mb / 1048576)} TB'
if mb >= 1024:
if not mb % 1024:
return f'{int(mb / 1024)} GB'
return f'{mb} MB'

View File

@ -12,7 +12,7 @@ from django.http import QueryDict
from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel
from dcim.choices import CableLengthUnitChoices
from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
from extras.plugins import PluginConfig
from extras.utils import is_taggable
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'.")
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):
"""
Render a Jinja2 template with the provided context. Return the rendered content.

View File

@ -1,5 +1,5 @@
import dcim.fields
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
@ -51,7 +51,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('comments', models.TextField(blank=True)),
@ -65,7 +65,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -80,7 +80,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -95,7 +95,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('local_context_data', models.JSONField(blank=True, null=True)),
('name', models.CharField(max_length=64)),
@ -147,7 +147,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('enabled', models.BooleanField(default=True)),
('mac_address', dcim.fields.MACAddressField(blank=True, null=True)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json
from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@ -44,7 +44,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('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')),
@ -65,7 +65,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('ssid', models.CharField(blank=True, max_length=32)),
('status', models.CharField(default='connected', max_length=50)),