diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f5028cd..d75f98fbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ name: CI on: [push, pull_request] +permissions: + contents: read jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 9df4bc441..6019cef5d 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -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' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 57666417a..ab259af2a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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 diff --git a/docs/_theme/main.html b/docs/_theme/main.html index 4dfc4e14e..3ff44b9cb 100644 --- a/docs/_theme/main.html +++ b/docs/_theme/main.html @@ -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/' %} {% endif %} {% endblock %} diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index c443fa9f6..81aaa5247 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -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 diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index 050f93244..6dc4aa13e 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -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. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index b8ec0ac6e..3122d2e00 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -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). diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 57e7bec98..e88c36fad 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -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.) diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 98db9e0bb..d5aea0591 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -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: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 2955e17d5..e91e825f5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -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 diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 24e5a0ea9..537a4968d 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 8f6e2930a..4e2cb73dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py index 851f40a22..971233162 100644 --- a/netbox/circuits/migrations/0001_squashed.py +++ b/netbox/circuits/migrations/0001_squashed.py @@ -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)), diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py index c686bf042..96b2a9d97 100644 --- a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py +++ b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py @@ -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', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 897ee4ca3..22d56565e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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', ] diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 7d35a40f9..8466d4861 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3a66e6c30..a0c5e545c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 396f7e59b..d033d3a67 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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( diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 96b0d1319..818da83e1 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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): diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index b33023ece..4faefb623 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -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() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 52a98278a..78cabbcd1 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -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): diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py index 374d3bf45..fca7d8eb9 100644 --- a/netbox/dcim/migrations/0001_squashed.py +++ b/netbox/dcim/migrations/0001_squashed.py @@ -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)), diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py index 11324fc58..821cf6119 100644 --- a/netbox/dcim/migrations/0146_modules.py +++ b/netbox/dcim/migrations/0146_modules.py @@ -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)), diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py index f5e1f23f5..cbdd36c08 100644 --- a/netbox/dcim/migrations/0147_inventoryitemrole.py +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -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)), diff --git a/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py new file mode 100644 index 000000000..09bef5736 --- /dev/null +++ b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py @@ -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), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d0d9001ad..b7c4abd32 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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): """ diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py new file mode 100644 index 000000000..b5449332b --- /dev/null +++ b/netbox/dcim/models/mixins.py @@ -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 = '' diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 10550e906..6da48b65c 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -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): """ diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 3872bc4fe..9a847acc9 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -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 diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 573fc966c..6c57e6023 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -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 '' ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3ed4d8c08..c48e93ca7 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -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', diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index e40d7bd80..b644e6ba6 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -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', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 39553bac0..ffca07145 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -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', diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index dfc77b854..9b8fb8fd6 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -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 = """ {{ record.name|default:'Unnamed device' }} diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7a745721b..d4922fb1d 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -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() diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 764c7750a..fd774f8ff 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 123fd2cd4..5afe9f33f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -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'), diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 7574f4f2b..40d068450 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -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 += '
' - self.fields[field_name].help_text += ' ' \ - '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: diff --git a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py index 73a3e466c..5f2d7f7f3 100644 --- a/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py +++ b/netbox/extras/migrations/0073_journalentry_tags_custom_fields.py @@ -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', diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 43c4f9671..c3c298a44 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -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 = '
' if field.help_text else '' + field.help_text += f'{prepend} 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)}" diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index ef1106aea..3efa9aaa7 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -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' diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 946999bc2..6080ce2e5 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -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) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index 1ec50b7dd..35402bda3 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -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]) diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py index 545fd46c6..b5d68439a 100644 --- a/netbox/ipam/migrations/0001_squashed.py +++ b/netbox/ipam/migrations/0001_squashed.py @@ -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)), diff --git a/netbox/ipam/migrations/0050_iprange.py b/netbox/ipam/migrations/0050_iprange.py index 5b8861f29..374b2547c 100644 --- a/netbox/ipam/migrations/0050_iprange.py +++ b/netbox/ipam/migrations/0050_iprange.py @@ -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()), diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 70219543f..e69e49d48 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -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)), diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py index 1c7ee8e23..3b074634c 100644 --- a/netbox/ipam/migrations/0053_asn_model.py +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -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)), diff --git a/netbox/ipam/migrations/0055_servicetemplate.py b/netbox/ipam/migrations/0055_servicetemplate.py index 738317907..c8ba6645c 100644 --- a/netbox/ipam/migrations/0055_servicetemplate.py +++ b/netbox/ipam/migrations/0055_servicetemplate.py @@ -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)), diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py index bd4761593..59dbab632 100644 --- a/netbox/ipam/migrations/0059_l2vpn.py +++ b/netbox/ipam/migrations/0059_l2vpn.py @@ -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')), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index b6776e3c1..6a8f5d0d3 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -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 diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2676e4cde..2cbc67971 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -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) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 9fa1c5cef..ce80cec3e 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -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 ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cfd4d231c..a2a6f57a6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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] = {} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2be860c62..5916f8b22 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -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 @@ Device Type - {{ 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) diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index bb3ec9d2e..458c74ac1 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -29,12 +29,22 @@ Height (U) - {{ object.u_height }} + {{ object.u_height|floatformat }} Full Depth {% checkmark object.is_full_depth %} + + Weight + + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + Parent/Child diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 2c8e77be3..8128e64be 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -22,6 +22,16 @@ Part Number {{ object.part_number|placeholder }} + + Weight + + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + Instances
{{ instance_count }} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 51e873ffa..e30ce7a62 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -104,9 +104,7 @@
-
- Dimensions -
+
Dimensions
@@ -147,6 +145,20 @@ {% endif %} + + + + + + + +
Rack Weight + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Total Weight{{ object.total_weight|floatformat }} Kilograms
@@ -186,6 +198,7 @@ {% endif %} + {% include 'inc/panels/image_attachments.html' %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index ca97be34d..4a340c147 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -57,6 +57,14 @@
{% render_field form.desc_units %} +
+
+
Weight
+
+ {% render_field form.weight %} + {% render_field form.weight_unit %} +
+ {% if form.custom_fields %}
diff --git a/netbox/templates/tenancy/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html index 4d1747e72..d904deead 100644 --- a/netbox/templates/tenancy/contactassignment_edit.html +++ b/netbox/templates/tenancy/contactassignment_edit.html @@ -3,6 +3,9 @@ {% load form_helpers %} {% block form %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
Contact Assignment
diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 021e36a5b..eabcb1d0f 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -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(), } diff --git a/netbox/tenancy/migrations/0001_squashed_0012.py b/netbox/tenancy/migrations/0001_squashed_0012.py index 77297b982..e8a028a92 100644 --- a/netbox/tenancy/migrations/0001_squashed_0012.py +++ b/netbox/tenancy/migrations/0001_squashed_0012.py @@ -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)), diff --git a/netbox/tenancy/migrations/0003_contacts.py b/netbox/tenancy/migrations/0003_contacts.py index 35e568ab1..ba9bef50f 100644 --- a/netbox/tenancy/migrations/0003_contacts.py +++ b/netbox/tenancy/migrations/0003_contacts.py @@ -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)), diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 543449b73..d41eff498 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -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) diff --git a/netbox/utilities/json.py b/netbox/utilities/json.py new file mode 100644 index 000000000..5574ff36f --- /dev/null +++ b/netbox/utilities/json.py @@ -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) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 67ed553b2..462b37feb 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -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' diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 69ab615fc..9f587e88d 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -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. diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index d00bae2e2..29eda8a50 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -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)), diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 10b6e585b..9369df8a5 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -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)),