mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-24 08:25:17 -06:00
Merge branch 'feature' into 8424-device-location
This commit is contained in:
commit
503288ee7e
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -1,5 +1,7 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
6
.github/workflows/lock.yml
vendored
6
.github/workflows/lock.yml
vendored
@ -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'
|
||||
|
9
.github/workflows/stale.yml
vendored
9
.github/workflows/stale.yml
vendored
@ -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
|
||||
|
4
docs/_theme/main.html
vendored
4
docs/_theme/main.html
vendored
@ -2,8 +2,8 @@
|
||||
|
||||
{% block site_meta %}
|
||||
{{ super() }}
|
||||
{# Disable search indexing unless we're building for ReadTheDocs #}
|
||||
{% if not config.extra.readthedocs %}
|
||||
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
|
||||
{% if page.canonical_url != 'https://docs.netbox.dev/' %}
|
||||
<meta name="robots" content="noindex">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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).
|
||||
|
@ -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.)
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)),
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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():
|
||||
|
@ -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(
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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)),
|
||||
|
@ -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)),
|
||||
|
@ -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)),
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -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):
|
||||
"""
|
||||
|
45
netbox/dcim/models/mixins.py
Normal file
45
netbox/dcim/models/mixins.py
Normal file
@ -0,0 +1,45 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from dcim.choices import *
|
||||
from utilities.utils import to_grams
|
||||
|
||||
|
||||
class WeightMixin(models.Model):
|
||||
weight = models.DecimalField(
|
||||
max_digits=8,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
weight_unit = models.CharField(
|
||||
max_length=50,
|
||||
choices=WeightUnitChoices,
|
||||
blank=True,
|
||||
)
|
||||
# Stores the normalized weight (in grams) for database ordering
|
||||
_abs_weight = models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Store the given weight (if any) in grams for use in database ordering
|
||||
if self.weight and self.weight_unit:
|
||||
self._abs_weight = to_grams(self.weight, self.weight_unit)
|
||||
else:
|
||||
self._abs_weight = None
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate weight and weight_unit
|
||||
if self.weight is not None and not self.weight_unit:
|
||||
raise ValidationError("Must specify a unit when setting a weight")
|
||||
elif self.weight is None:
|
||||
self.weight_unit = ''
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 ''
|
||||
)
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -15,6 +15,11 @@ CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_WEIGHT = """
|
||||
{% load helpers %}
|
||||
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
<a href="{% url 'dcim:device' pk=record.pk %}">
|
||||
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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'),
|
||||
|
@ -34,7 +34,9 @@ class CustomFieldsMixin:
|
||||
return ContentType.objects.get_for_model(self.model)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type)
|
||||
return CustomField.objects.filter(content_types=content_type).exclude(
|
||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
|
||||
)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field()
|
||||
@ -50,13 +52,6 @@ class CustomFieldsMixin:
|
||||
field_name = f'cf_{customfield.name}'
|
||||
self.fields[field_name] = self._get_form_field(customfield)
|
||||
|
||||
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
self.fields[field_name].disabled = True
|
||||
if self.fields[field_name].help_text:
|
||||
self.fields[field_name].help_text += '<br />'
|
||||
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
|
||||
'Field is set to read-only.'
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields[field_name] = customfield
|
||||
if customfield.group_name not in self.custom_field_groups:
|
||||
|
@ -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',
|
||||
|
@ -1,5 +1,6 @@
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
import decimal
|
||||
|
||||
import django_filters
|
||||
from django import forms
|
||||
@ -219,14 +220,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
})
|
||||
|
||||
# Minimum/maximum values can be set only for numeric fields
|
||||
if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
raise ValidationError({
|
||||
'validation_minimum': "A minimum value may be set only for numeric fields"
|
||||
})
|
||||
if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
raise ValidationError({
|
||||
'validation_maximum': "A maximum value may be set only for numeric fields"
|
||||
})
|
||||
if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
|
||||
if self.validation_minimum:
|
||||
raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"})
|
||||
if self.validation_maximum:
|
||||
raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"})
|
||||
|
||||
# Regex validation can be set only for text fields
|
||||
regex_types = (
|
||||
@ -297,12 +295,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
return model.objects.filter(pk__in=value)
|
||||
return value
|
||||
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
"""
|
||||
initial = self.default if set_initial else None
|
||||
@ -317,6 +316,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
max_value=self.validation_maximum
|
||||
)
|
||||
|
||||
# Decimal
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
|
||||
field = forms.DecimalField(
|
||||
required=required,
|
||||
initial=initial,
|
||||
max_digits=12,
|
||||
decimal_places=4,
|
||||
min_value=self.validation_minimum,
|
||||
max_value=self.validation_maximum
|
||||
)
|
||||
|
||||
# Boolean
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
choices = (
|
||||
@ -398,6 +408,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
if self.description:
|
||||
field.help_text = escape(self.description)
|
||||
|
||||
# Annotate read-only fields
|
||||
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
field.disabled = True
|
||||
prepend = '<br />' if field.help_text else ''
|
||||
field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
|
||||
|
||||
return field
|
||||
|
||||
def to_filter(self, lookup_expr=None):
|
||||
@ -426,6 +442,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
filter_class = filters.MultiValueNumberFilter
|
||||
|
||||
# Decimal
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
|
||||
filter_class = filters.MultiValueDecimalFilter
|
||||
|
||||
# Boolean
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
filter_class = django_filters.BooleanFilter
|
||||
@ -475,7 +495,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
raise ValidationError(f"Value must match regex '{self.validation_regex}'")
|
||||
|
||||
# Validate integer
|
||||
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
if type(value) is not int:
|
||||
raise ValidationError("Value must be an integer.")
|
||||
if self.validation_minimum is not None and value < self.validation_minimum:
|
||||
@ -483,12 +503,23 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
if self.validation_maximum is not None and value > self.validation_maximum:
|
||||
raise ValidationError(f"Value must not exceed {self.validation_maximum}")
|
||||
|
||||
# Validate decimal
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
|
||||
try:
|
||||
decimal.Decimal(value)
|
||||
except decimal.InvalidOperation:
|
||||
raise ValidationError("Value must be a decimal.")
|
||||
if self.validation_minimum is not None and value < self.validation_minimum:
|
||||
raise ValidationError(f"Value must be at least {self.validation_minimum}")
|
||||
if self.validation_maximum is not None and value > self.validation_maximum:
|
||||
raise ValidationError(f"Value must not exceed {self.validation_maximum}")
|
||||
|
||||
# Validate boolean
|
||||
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
|
||||
raise ValidationError("Value must be true or false.")
|
||||
|
||||
# Validate date
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
if type(value) is not date:
|
||||
try:
|
||||
datetime.strptime(value, '%Y-%m-%d')
|
||||
@ -496,14 +527,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
raise ValidationError("Date values must be in the format YYYY-MM-DD.")
|
||||
|
||||
# Validate selected choice
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
if value not in self.choices:
|
||||
raise ValidationError(
|
||||
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
|
||||
)
|
||||
|
||||
# Validate all selected choices
|
||||
if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
if not set(value).issubset(self.choices):
|
||||
raise ValidationError(
|
||||
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
|
||||
|
@ -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'
|
||||
|
@ -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)
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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)),
|
||||
|
@ -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()),
|
||||
|
@ -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)),
|
||||
|
@ -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)),
|
||||
|
@ -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)),
|
||||
|
@ -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')),
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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] = {}
|
||||
|
@ -66,7 +66,7 @@
|
||||
{% with object.parent_bay.device as parent %}
|
||||
{{ parent|linkify }} / {{ object.parent_bay }}
|
||||
{% if parent.position %}
|
||||
(U{{ parent.position }} / {{ parent.get_face_display }})
|
||||
(U{{ parent.position|floatformat }} / {{ parent.get_face_display }})
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% elif object.rack and object.position %}
|
||||
@ -90,7 +90,7 @@
|
||||
<tr>
|
||||
<th scope="row">Device Type</th>
|
||||
<td>
|
||||
{{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U)
|
||||
{{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -29,12 +29,22 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Height (U)</td>
|
||||
<td>{{ object.u_height }}</td>
|
||||
<td>{{ object.u_height|floatformat }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Full Depth</td>
|
||||
<td>{% checkmark object.is_full_depth %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Weight</td>
|
||||
<td>
|
||||
{% if object.weight %}
|
||||
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Parent/Child</td>
|
||||
<td>
|
||||
|
@ -22,6 +22,16 @@
|
||||
<td>Part Number</td>
|
||||
<td>{{ object.part_number|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Weight</td>
|
||||
<td>
|
||||
{% if object.weight %}
|
||||
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Instances</td>
|
||||
<td><a href="{% url 'dcim:module_list' %}?module_type_id={{ object.pk }}">{{ instance_count }}</a></td>
|
||||
|
@ -104,9 +104,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Dimensions
|
||||
</h5>
|
||||
<h5 class="card-header">Dimensions</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
@ -147,6 +145,20 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Rack Weight</th>
|
||||
<td>
|
||||
{% if object.weight %}
|
||||
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Total Weight</th>
|
||||
<td>{{ object.total_weight|floatformat }} Kilograms</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@ -186,6 +198,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
|
@ -57,6 +57,14 @@
|
||||
</div>
|
||||
{% render_field form.desc_units %}
|
||||
</div>
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Weight</h5>
|
||||
</div>
|
||||
{% render_field form.weight %}
|
||||
{% render_field form.weight_unit %}
|
||||
</div>
|
||||
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group my-5">
|
||||
|
@ -3,6 +3,9 @@
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Contact Assignment</h5>
|
||||
|
@ -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(),
|
||||
}
|
||||
|
@ -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)),
|
||||
|
@ -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)),
|
||||
|
@ -3,8 +3,6 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django_filters.constants import EMPTY_VALUES
|
||||
|
||||
from utilities.forms import MACAddressField
|
||||
|
||||
|
||||
def multivalue_field_factory(field_class):
|
||||
"""
|
||||
@ -23,7 +21,15 @@ def multivalue_field_factory(field_class):
|
||||
field.to_python(v) for v in value if v
|
||||
]
|
||||
|
||||
return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict())
|
||||
def run_validators(self, value):
|
||||
for v in value:
|
||||
super().run_validators(v)
|
||||
|
||||
def validate(self, value):
|
||||
for v in value:
|
||||
super().validate(v)
|
||||
|
||||
return type(f'MultiValue{field_class.__name__}', (NewField,), dict())
|
||||
|
||||
|
||||
#
|
||||
@ -46,6 +52,10 @@ class MultiValueNumberFilter(django_filters.MultipleChoiceFilter):
|
||||
field_class = multivalue_field_factory(forms.IntegerField)
|
||||
|
||||
|
||||
class MultiValueDecimalFilter(django_filters.MultipleChoiceFilter):
|
||||
field_class = multivalue_field_factory(forms.DecimalField)
|
||||
|
||||
|
||||
class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
|
||||
field_class = multivalue_field_factory(forms.TimeField)
|
||||
|
||||
|
17
netbox/utilities/json.py
Normal file
17
netbox/utilities/json.py
Normal file
@ -0,0 +1,17 @@
|
||||
import decimal
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldJSONEncoder',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldJSONEncoder(DjangoJSONEncoder):
|
||||
"""
|
||||
Override Django's built-in JSON encoder to save decimal values as JSON numbers.
|
||||
"""
|
||||
def default(self, o):
|
||||
if isinstance(o, decimal.Decimal):
|
||||
return float(o)
|
||||
return super().default(o)
|
@ -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'
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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)),
|
||||
|
@ -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)),
|
||||
|
Loading…
Reference in New Issue
Block a user