Merge branch 'feature' into oob_ip

This commit is contained in:
Jamie (Bear) Murphy 2023-07-24 21:10:18 +01:00 committed by GitHub
commit b92360faee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
189 changed files with 4615 additions and 1476 deletions

View File

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

View File

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

View File

@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
## Project Stats
<div align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</div>
@ -66,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations.
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
<br />
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com)
</div>

View File

@ -8,7 +8,7 @@ boto3
# The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/
Django<4.2
Django<5.0
# Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@ -121,8 +121,8 @@ netaddr
Pillow
# PostgreSQL database adapter for Python
# https://www.psycopg.org/docs/news.html
psycopg2-binary
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg[binary,pool]
# YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES

View File

@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## DATABASE
NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* `NAME` - Database name
* `USER` - PostgreSQL username

View File

@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.

View File

@ -18,6 +18,10 @@ The `tag` filter can be specified multiple times to match only objects which hav
GET /api/dcim/devices/?tag=monitored&tag=deprecated
```
## Bookmarks
Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard.
## Custom Fields
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.

View File

@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning "PostgreSQL 11 or later required"
NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported.
!!! warning "PostgreSQL 12 or later required"
NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation
@ -35,7 +35,7 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo systemctl enable postgresql
```
Before continuing, verify that you have installed PostgreSQL 11 or later:
Before continuing, verify that you have installed PostgreSQL 12 or later:
```no-highlight
psql -V

View File

@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.8 |
| PostgreSQL | 11 |
| PostgreSQL | 12 |
| Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference:

View File

@ -15,12 +15,12 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
## 2. Update Dependencies to Required Versions
NetBox v3.0 and later require the following:
NetBox requires the following dependencies:
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.8 |
| PostgreSQL | 11 |
| PostgreSQL | 12 |
| Redis | 4.0 |
## 3. Install the Latest Release

View File

@ -75,5 +75,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL 11+ |
| Database | PostgreSQL 12+ |
| Task queuing | Redis/django-rq |

View File

@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev
!!! tip
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
### Latitude & Longitude
GPS coordinates of the device for geolocation.
### Status
The device's operational status.

View File

@ -0,0 +1,13 @@
# Bookmarks
A user can bookmark individual objects for convenient access. Bookmarks are listed under a user's profile and can be displayed using a dashboard widget.
## Fields
### User
The user to whom the bookmark belongs.
### Object
The bookmarked object.

View File

@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
### Choices
### Choice Set
For choice and multi-choice custom fields only. A comma-delimited list of the available choices.
For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
### Cloneable

View File

@ -0,0 +1,17 @@
# Custom Field Choice Sets
Single- and multi-selection [custom fields documentation](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
## Fields
### Name
The human-friendly name of the choice set.
### Extra Choices
The list of valid choices, entered as a comma-separated list.
### Order Alphabetically
If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

View File

@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
### Color
The color to use when displaying the tag in the NetBox UI.
### Object Types
!!! info "This feature was introduced in NetBox v3.6."
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.
If no object types are specified, the tag will be assignable to any type of object.

View File

@ -165,19 +165,6 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
options:
members: false
## Choice Fields
!!! warning "Obsolete Fields"
NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
::: utilities.forms.fields.ChoiceField
options:
members: false
::: utilities.forms.fields.MultipleChoiceField
options:
members: false
## Dynamic Object Fields
::: utilities.forms.fields.DynamicModelChoiceField

View File

@ -19,6 +19,9 @@ class MyModel(models.Model):
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
!!! note
Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions.
## Enabling NetBox Features
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:

View File

@ -1,15 +1,53 @@
# NetBox v3.5
## v3.5.5 (FUTURE)
## v3.5.7 (FUTURE)
---
## v3.5.6 (2023-07-10)
### Bug Fixes
* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
---
## v3.5.5 (2023-07-06)
### Enhancements
* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
### Bug Fixes
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
---

View File

@ -0,0 +1,22 @@
# NetBox v3.6
## v3.6.0 (FUTURE)
### Breaking Changes
* PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model.
### Enhancements
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
### Other Changes
* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL

View File

@ -206,6 +206,7 @@ nav:
- VirtualChassis: 'models/dcim/virtualchassis.md'
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
- Extras:
- Bookmark: 'models/extras/bookmark.md'
- Branch: 'models/extras/branch.md'
- ConfigContext: 'models/extras/configcontext.md'
- ConfigTemplate: 'models/extras/configtemplate.md'
@ -273,6 +274,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 3.6: 'release-notes/version-3.6.md'
- Version 3.5: 'release-notes/version-3.5.md'
- Version 3.4: 'release-notes/version-3.4.md'
- Version 3.3: 'release-notes/version-3.3.md'

View File

@ -1,5 +1,5 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
@ -105,7 +105,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
widget=DateTimePicker()
)
user = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(

View File

@ -5,7 +5,7 @@ import sys
from django import get_version
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
@ -60,7 +60,7 @@ class Command(BaseCommand):
# Additional objects to include
namespace['ContentType'] = ContentType
namespace['User'] = User
namespace['User'] = get_user_model()
# Load convenience commands
namespace.update({

View File

@ -200,6 +200,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Emit the post_sync signal
post_sync.send(sender=self.__class__, instance=self)
sync.alters_data = True
def _walk(self, root):
"""
@ -289,8 +290,10 @@ class DataFile(models.Model):
@property
def data_as_string(self):
if not self.data:
return None
try:
return self.data.tobytes().decode('utf-8')
return bytes(self.data, 'utf-8')
except UnicodeDecodeError:
return None

View File

@ -1,7 +1,7 @@
import uuid
import django_rq
from django.contrib.auth.models import User
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator
@ -69,7 +69,7 @@ class Job(models.Model):
blank=True
)
user = models.ForeignKey(
to=User,
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name='+',
blank=True,

View File

@ -635,8 +635,8 @@ class PlatformSerializer(NetBoxModelSerializer):
class Meta:
model = Platform
fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
@ -676,10 +676,10 @@ class DeviceSerializer(NetBoxModelSerializer):
model = Device
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
'oob_ip', 'oob_ip4', 'oob_ip6',
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created',
'oob_ip', 'last_updated',
]
@extend_schema_field(NestedDeviceSerializer)
@ -702,8 +702,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'oob_ip', 'oob_ip4', 'oob_ip6'
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', 'oob_ip',
'created', 'last_updated',
]
@extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -810,6 +810,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_CXP = '100gbase-x-cxp'
TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
@ -959,6 +960,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),

View File

@ -17,6 +17,8 @@ RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
RACK_STARTING_UNIT_DEFAULT = 1
#
# RearPorts

View File

@ -1,5 +1,5 @@
import django_filters
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet
@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
label=_('Location (slug)'),
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
@ -811,7 +811,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
fields = ['id', 'name', 'slug', 'description']
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
@ -1013,7 +1013,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
class Meta:
model = Device
fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority']
fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
def search(self, queryset, name, value):
if not value.strip():
@ -1097,10 +1097,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(identifier=value.strip())
).distinct()
qs_filter = Q(name__icontains=value)
try:
qs_filter |= Q(identifier=int(value))
except ValueError:
pass
return queryset.filter(qs_filter).distinct()
def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)

View File

@ -1,6 +1,6 @@
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField
@ -322,7 +322,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
user = forms.ModelChoiceField(
queryset=User.objects.order_by(
queryset=get_user_model().objects.order_by(
'username'
),
required=False
@ -472,10 +472,6 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
queryset=Manufacturer.objects.all(),
required=False
)
napalm_driver = forms.CharField(
max_length=50,
required=False
)
config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(),
required=False
@ -487,9 +483,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
model = Platform
fieldsets = (
(None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
(None, ('manufacturer', 'config_template', 'description')),
)
nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
nullable_fields = ('manufacturer', 'config_template', 'description')
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
@ -1106,7 +1102,7 @@ class PowerPortBulkEditForm(
(None, ('module', 'type', 'label', 'description', 'mark_connected')),
('Power', ('maximum_draw', 'allocated_draw')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
class PowerOutletBulkEditForm(

View File

@ -365,7 +365,7 @@ class PlatformImportForm(NetBoxModelImportForm):
class Meta:
model = Platform
fields = (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
)
@ -478,8 +478,9 @@ class DeviceImportForm(BaseDeviceImportForm):
class Meta(BaseDeviceImportForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags',
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments',
'tags',
]
def __init__(self, data=None, *args, **kwargs):

View File

@ -1,5 +1,5 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from dcim.choices import *
@ -376,7 +376,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Rack')
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(

View File

@ -1,5 +1,5 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField
@ -221,8 +221,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
model = Rack
fields = [
'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',
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
]
@ -236,7 +236,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.")
)
user = forms.ModelChoiceField(
queryset=User.objects.order_by(
queryset=get_user_model().objects.order_by(
'username'
)
)
@ -360,19 +360,14 @@ class PlatformForm(NetBoxModelForm):
)
fieldsets = (
('Platform', (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
)),
('Platform', ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')),
)
class Meta:
model = Platform
fields = [
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
]
widgets = {
'napalm_args': forms.Textarea(),
}
class DeviceForm(TenancyForm, NetBoxModelForm):
@ -454,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
model = Device
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags',
'local_context_data', 'oob_ip4', 'oob_ip6'
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster',
'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'comments', 'tags', 'local_context_data' 'oob_ip',
]
def __init__(self, *args, **kwargs):

View File

@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0172_larger_power_draw_values'),
]
operations = [
migrations.RemoveField(
model_name='platform',
name='napalm_args',
),
migrations.RemoveField(
model_name='platform',
name='napalm_driver',
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.1.9 on 2023-05-31 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0173_remove_napalm_fields'),
]
operations = [
migrations.AddField(
model_name='device',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
),
migrations.AddField(
model_name='device',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.9 on 2023-05-31 15:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0174_device_latitude_device_longitude'),
]
operations = [
migrations.AddField(
model_name='rack',
name='starting_unit',
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@ -359,6 +359,7 @@ class CableTermination(ChangeLoggedModel):
# Circuit terminations
elif getattr(self.termination, 'site', None):
self._site = self.termination.site
cache_related_objects.alters_data = True
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
@ -637,6 +638,7 @@ class CablePath(models.Model):
self.save()
else:
self.delete()
retrace.alters_data = True
def _get_path(self):
"""

View File

@ -213,6 +213,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
type=self.type,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@ -256,6 +257,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
allocated_draw=self.allocated_draw,
**kwargs
)
instantiate.do_not_call_in_templates = True
def clean(self):
super().clean()
@ -330,6 +332,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
feed_leg=self.feed_leg,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@ -413,6 +416,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
poe_type=self.poe_type,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@ -507,6 +511,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
rear_port_position=self.rear_port_position,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@ -550,6 +555,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
positions=self.positions,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@ -581,6 +587,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
label=self.label,
position=self.position
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@ -603,6 +610,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
name=self.name,
label=self.label
)
instantiate.do_not_call_in_templates = True
def clean(self):
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
@ -696,3 +704,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
part_id=self.part_id,
**kwargs
)
instantiate.do_not_call_in_templates = True

View File

@ -432,9 +432,8 @@ class DeviceRole(OrganizationalModel):
class Platform(OrganizationalModel):
"""
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
specifying a NAPALM driver.
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
Platform may optionally be associated with a particular Manufacturer.
"""
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
@ -451,18 +450,6 @@ class Platform(OrganizationalModel):
blank=True,
null=True
)
napalm_driver = models.CharField(
max_length=50,
blank=True,
verbose_name='NAPALM driver',
help_text=_('The name of the NAPALM driver to use when interacting with devices')
)
napalm_args = models.JSONField(
blank=True,
null=True,
verbose_name='NAPALM arguments',
help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)')
)
def get_absolute_url(self):
return reverse('dcim:platform', args=[self.pk])
@ -653,6 +640,20 @@ class Device(PrimaryModel, ConfigContextModel):
blank=True,
null=True
)
latitude = models.DecimalField(
max_digits=8,
decimal_places=6,
blank=True,
null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
# Generic relations
contacts = GenericRelation(

View File

@ -1,7 +1,7 @@
import decimal
from functools import cached_property
from django.contrib.auth.models import User
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
@ -129,6 +129,11 @@ class Rack(PrimaryModel, WeightMixin):
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units')
)
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name='Starting unit',
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(
default=False,
verbose_name='Descending units',
@ -228,20 +233,24 @@ class Rack(PrimaryModel, WeightMixin):
raise ValidationError("Must specify a unit when setting a maximum weight")
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(
rack=self
).exclude(
position__isnull=True
).order_by('-position').first()
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
# Validate that Rack is tall enough to house the highest mounted Device
if top_device := mounted_devices.last():
min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
if self.u_height < min_height:
raise ValidationError({
'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
min_height
)
'u_height': f"Rack must be at least {min_height}U tall to house currently installed devices."
})
# Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device
if last_device := mounted_devices.first():
if self.starting_unit > last_device.position:
raise ValidationError({
'starting_unit': f"Rack unit numbering must begin at {last_device.position} or less to house "
f"currently installed devices."
})
# Validate that Rack was assigned a Location of its same site, if applicable
if self.location:
if self.location.site != self.site:
@ -269,8 +278,8 @@ class Rack(PrimaryModel, WeightMixin):
Return a list of unit numbers, top to bottom.
"""
if self.desc_units:
return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5)
return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5)
def get_status_color(self):
return RackStatusChoices.colors.get(self.status)
@ -505,7 +514,7 @@ class RackReservation(PrimaryModel):
null=True
)
user = models.ForeignKey(
to=User,
to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT
)
description = models.CharField(

View File

@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
fields = (
('name', 100),
('slug', 110),
('napalm_driver', 300),
('description', 500),
)

View File

@ -150,9 +150,9 @@ class RackElevationSVG:
x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
y = RACK_ELEVATION_BORDER_WIDTH
if self.rack.desc_units:
y += int((position - 1) * self.unit_height)
y += int((position - self.rack.starting_unit) * self.unit_height)
else:
y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
y += int((self.rack.u_height - position + self.rack.starting_unit) * self.unit_height) - int(height * self.unit_height)
return x, y
@ -237,6 +237,7 @@ class RackElevationSVG:
start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
unit = unit + self.rack.starting_unit - 1
self.drawing.add(
Text(str(unit), position_coordinates, class_='unit')
)
@ -278,6 +279,7 @@ class RackElevationSVG:
for ru in range(0, self.rack.u_height):
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
unit = unit + self.rack.starting_unit - 1
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
text_coords = (
x_offset + self.unit_width / 2,

View File

@ -137,11 +137,11 @@ class PlatformTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Platform
fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
)
@ -249,9 +249,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
'tags', 'created', 'last_updated', 'oob_ip', 'oob_ip4', 'oob_ip6',
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'comments', 'contacts', 'oob_ip', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@ -1,4 +1,4 @@
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@ -14,6 +14,9 @@ from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN
User = get_user_model()
class AppTest(APITestCase):
def test_root(self):

View File

@ -1,4 +1,4 @@
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.test import TestCase
from dcim.choices import *
@ -12,6 +12,9 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()
class DeviceComponentFilterSetTests:
def test_device_type(self):
@ -1515,9 +1518,9 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers)
platforms = (
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'),
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='A'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='B'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='C'),
)
Platform.objects.bulk_create(platforms)
@ -1533,10 +1536,6 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_napalm_driver(self):
params = {'napalm_driver': ['driver-1', 'driver-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_manufacturer(self):
manufacturers = Manufacturer.objects.all()[:2]
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
@ -1642,9 +1641,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
devices = (
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]),
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, latitude=10, longitude=10, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, latitude=20, longitude=20, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, latitude=30, longitude=30, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]),
)
Device.objects.bulk_create(devices)
@ -1725,6 +1724,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'position': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_latitude(self):
params = {'latitude': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_longitude(self):
params = {'longitude': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vc_position(self):
params = {'vc_position': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -6,7 +6,7 @@ except ImportError:
from backports.zoneinfo import ZoneInfo
import yaml
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
@ -22,6 +22,9 @@ from utilities.testing import ViewTestCases, create_tags, create_test_device, po
from wireless.models import WirelessLAN
User = get_user_model()
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Region
@ -389,6 +392,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 500,
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'starting_unit': 1,
'weight': 100,
'max_weight': 2000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
@ -1609,8 +1613,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'name': 'Platform X',
'slug': 'platform-x',
'manufacturer': manufacturer.pk,
'napalm_driver': 'junos',
'napalm_args': None,
'description': 'A new platform',
'tags': [t.pk for t in tags],
}
@ -1630,7 +1632,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
)
cls.bulk_edit_data = {
'napalm_driver': 'ios',
'description': 'New description',
}
@ -1699,6 +1700,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'rack': racks[1].pk,
'position': 1,
'face': DeviceFaceChoices.FACE_FRONT,
'latitude': Decimal('35.780000'),
'longitude': Decimal('-78.642000'),
'status': DeviceStatusChoices.STATUS_PLANNED,
'primary_ip4': None,
'primary_ip6': None,

View File

@ -3131,6 +3131,19 @@ class CableEditView(generic.ObjectEditView):
return obj
def get_extra_addanother_params(self, request):
params = {
'a_terminations_type': request.GET.get('a_terminations_type'),
'b_terminations_type': request.GET.get('b_terminations_type')
}
for key in request.POST:
if 'device' in key or 'power_panel' in key or 'circuit' in key:
params.update({key: request.POST.get(key)})
return params
@register_model_view(Cable, 'delete')
class CableDeleteView(generic.ObjectDeleteView):

View File

@ -1,129 +1,2 @@
from django.contrib import admin
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from netbox.config import get_config, PARAMS
# TODO: Removing this import triggers an import loop due to how form mixins are currently organized
from .forms import ConfigRevisionForm
from .models import ConfigRevision
@admin.register(ConfigRevision)
class ConfigRevisionAdmin(admin.ModelAdmin):
fieldsets = [
('Rack Elevations', {
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
}),
('Power', {
'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
}),
('IPAM', {
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
}),
('Security', {
'fields': ('ALLOWED_URL_SCHEMES',),
}),
('Banners', {
'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
'classes': ('monospace',),
}),
('Pagination', {
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
}),
('Validation', {
'fields': ('CUSTOM_VALIDATORS',),
'classes': ('monospace',),
}),
('User Preferences', {
'fields': ('DEFAULT_USER_PREFERENCES',),
}),
('Miscellaneous', {
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL'),
}),
('Config Revision', {
'fields': ('comment',),
})
]
form = ConfigRevisionForm
list_display = ('id', 'is_active', 'created', 'comment', 'restore_link')
ordering = ('-id',)
readonly_fields = ('data',)
def get_changeform_initial_data(self, request):
"""
Populate initial form data from the most recent ConfigRevision.
"""
latest_revision = ConfigRevision.objects.last()
initial = latest_revision.data if latest_revision else {}
initial.update(super().get_changeform_initial_data(request))
return initial
# Permissions
def has_add_permission(self, request):
# Only superusers may modify the configuration.
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
# ConfigRevisions cannot be modified once created.
return False
def has_delete_permission(self, request, obj=None):
# Only inactive ConfigRevisions may be deleted (must be superuser).
return request.user.is_superuser and (
obj is None or not obj.is_active()
)
# List display methods
def restore_link(self, obj):
if obj.is_active():
return ''
return format_html(
'<a href="{url}" class="button">Restore</a>',
url=reverse('admin:extras_configrevision_restore', args=(obj.pk,))
)
restore_link.short_description = "Actions"
# URLs
def get_urls(self):
urls = [
path('<int:pk>/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'),
]
return urls + super().get_urls()
# Views
def restore(self, request, pk):
# Get the ConfigRevision being restored
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
if request.method == 'POST':
candidate_config.activate()
self.message_user(request, f"Restored configuration revision #{pk}")
return redirect(reverse('admin:extras_configrevision_changelist'))
# Get the current ConfigRevision
config_version = get_config().version
current_config = ConfigRevision.objects.filter(pk=config_version).first()
params = []
for param in PARAMS:
params.append((
param.name,
current_config.data.get(param.name, None),
candidate_config.data.get(param.name, None)
))
context = self.admin_site.each_context(request)
context.update({
'object': candidate_config,
'params': params,
})
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)

View File

@ -4,8 +4,10 @@ from extras import models
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
__all__ = [
'NestedBookmarkSerializer',
'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer',
'NestedCustomFieldChoiceSetSerializer',
'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer',
@ -33,6 +35,14 @@ class NestedCustomFieldSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name']
class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
class Meta:
model = models.CustomFieldChoiceSet
fields = ['id', 'url', 'display', 'name', 'choices_count']
class NestedCustomLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
@ -73,6 +83,14 @@ class NestedSavedFilterSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'slug']
class NestedBookmarkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
class Meta:
model = models.Bookmark
fields = ['id', 'url', 'display', 'object_id', 'object_type']
class NestedImageAttachmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')

View File

@ -1,4 +1,4 @@
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
@ -31,9 +31,11 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
from .nested_serializers import *
__all__ = (
'BookmarkSerializer',
'ConfigContextSerializer',
'ConfigTemplateSerializer',
'ContentTypeSerializer',
'CustomFieldChoiceSetSerializer',
'CustomFieldSerializer',
'CustomLinkSerializer',
'DashboardSerializer',
@ -93,6 +95,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
class Meta:
@ -100,7 +103,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created',
'last_updated',
]
@ -126,6 +129,17 @@ class CustomFieldSerializer(ValidatedModelSerializer):
return 'string'
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
'created', 'last_updated',
]
#
# Custom links
#
@ -190,18 +204,48 @@ class SavedFilterSerializer(ValidatedModelSerializer):
]
#
# Bookmarks
#
class BookmarkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
object_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
)
object = serializers.SerializerMethodField(read_only=True)
user = NestedUserSerializer()
class Meta:
model = Bookmark
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, instance):
serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX)
return serializer(instance.object, context={'request': self.context['request']}).data
#
# Tags
#
class TagSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
object_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
many=True,
required=False
)
tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = Tag
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
'last_updated',
]
@ -256,7 +300,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
assigned_object = serializers.SerializerMethodField(read_only=True)
created_by = serializers.PrimaryKeyRelatedField(
allow_null=True,
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
required=False,
default=serializers.CurrentUserDefault()
)

View File

@ -9,9 +9,11 @@ router.APIRootView = views.ExtrasRootView
router.register('webhooks', views.WebhookViewSet)
router.register('custom-fields', views.CustomFieldViewSet)
router.register('custom-field-choices', views.CustomFieldChoiceSetViewSet)
router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet)
router.register('bookmarks', views.BookmarkViewSet)
router.register('tags', views.TagViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet)

View File

@ -1,6 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
@ -55,11 +54,17 @@ class WebhookViewSet(NetBoxModelViewSet):
class CustomFieldViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
serializer_class = serializers.CustomFieldSerializer
filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
queryset = CustomFieldChoiceSet.objects.all()
serializer_class = serializers.CustomFieldChoiceSetSerializer
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
#
# Custom links
#
@ -93,6 +98,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
filterset_class = filtersets.SavedFilterFilterSet
#
# Bookmarks
#
class BookmarkViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Bookmark.objects.all()
serializer_class = serializers.BookmarkSerializer
filterset_class = filtersets.BookmarkFilterSet
#
# Tags
#
@ -368,7 +384,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.prefetch_related('user')
queryset = ObjectChange.objects.valid_models().prefetch_related('user')
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet

View File

@ -79,6 +79,21 @@ class CustomLinkButtonClassChoices(ButtonColorChoices):
(LINK, 'Link'),
)
#
# Bookmarks
#
class BookmarkOrderingChoices(ChoiceSet):
ORDERING_NEWEST = '-created'
ORDERING_OLDEST = 'created'
CHOICES = (
(ORDERING_NEWEST, 'Newest'),
(ORDERING_OLDEST, 'Oldest'),
)
#
# ObjectChanges
#
@ -98,7 +113,7 @@ class ObjectChangeActionChoices(ChoiceSet):
#
# Jounral entries
# Journal entries
#
class JournalEntryKindChoices(ChoiceSet):

View File

@ -14,6 +14,7 @@ from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _
from extras.choices import BookmarkOrderingChoices
from extras.utils import FeatureQuery
from utilities.forms import BootstrapMixin
from utilities.permissions import get_permission_for_model
@ -22,6 +23,7 @@ from utilities.utils import content_type_identifier, content_type_name, dict_to_
from .utils import register_widget
__all__ = (
'BookmarksWidget',
'DashboardWidget',
'NoteWidget',
'ObjectCountsWidget',
@ -316,3 +318,42 @@ class RSSFeedWidget(DashboardWidget):
return {
'feed': feed,
}
@register_widget
class BookmarksWidget(DashboardWidget):
default_title = _('Bookmarks')
default_config = {
'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
}
description = _('Show your personal bookmarks')
template_name = 'extras/dashboard/widgets/bookmarks.html'
class ConfigForm(WidgetConfigForm):
object_types = forms.MultipleChoiceField(
# TODO: Restrict the choices by FeatureQuery('bookmarks')
choices=get_content_type_labels,
required=False
)
order_by = forms.ChoiceField(
choices=BookmarkOrderingChoices
)
max_items = forms.IntegerField(
min_value=1,
required=False
)
def render(self, request):
from extras.models import Bookmark
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types)
conent_types = ContentType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=conent_types)
if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items]
return render_to_string(self.template_name, {
'bookmarks': bookmarks,
})

View File

@ -1,5 +1,5 @@
import django_filters
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
@ -15,9 +15,12 @@ from .filters import TagFilter
from .models import *
__all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet',
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet',
'ContentTypeFilterSet',
'CustomFieldChoiceSetFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
@ -72,6 +75,14 @@ class CustomFieldFilterSet(BaseFilterSet):
field_name='content_types__id'
)
content_types = ContentTypeFilter()
choice_set_id = django_filters.ModelMultipleChoiceFilter(
queryset=CustomFieldChoiceSet.objects.all()
)
choice_set = django_filters.ModelMultipleChoiceFilter(
field_name='choice_set__name',
queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name'
)
class Meta:
model = CustomField
@ -91,6 +102,35 @@ class CustomFieldFilterSet(BaseFilterSet):
)
class CustomFieldChoiceSetFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
choice = MultiValueCharFilter(
method='filter_by_choice'
)
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'name', 'description', 'order_alphabetically',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(extra_choices__contains=value)
)
def filter_by_choice(self, queryset, name, value):
# TODO: Support case-insensitive matching
return queryset.filter(extra_choices__overlap=value)
class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@ -159,12 +199,12 @@ class SavedFilterFilterSet(BaseFilterSet):
)
content_types = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
@ -198,6 +238,26 @@ class SavedFilterFilterSet(BaseFilterSet):
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
class BookmarkFilterSet(BaseFilterSet):
created = django_filters.DateTimeFilter()
object_type_id = MultiValueNumberFilter()
object_type = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
class Meta:
model = Bookmark
fields = ['id', 'object_id']
class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
@ -223,12 +283,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
queryset=ContentType.objects.all()
)
created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
created_by = django_filters.ModelMultipleChoiceFilter(
field_name='created_by__username',
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
@ -257,10 +317,13 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
content_type_id = MultiValueNumberFilter(
method='_content_type_id'
)
for_object_type_id = MultiValueNumberFilter(
method='_for_object_type'
)
class Meta:
model = Tag
fields = ['id', 'name', 'slug', 'color', 'description']
fields = ['id', 'name', 'slug', 'color', 'description', 'object_types']
def search(self, queryset, name, value):
if not value.strip():
@ -297,6 +360,11 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
def _for_object_type(self, queryset, name, values):
return queryset.filter(
Q(object_types__id__in=values) | Q(object_types__isnull=True)
)
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
@ -510,12 +578,12 @@ class ObjectChangeFilterSet(BaseFilterSet):
queryset=ContentType.objects.all()
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User name'),
)
@ -557,3 +625,27 @@ class ContentTypeFilterSet(django_filters.FilterSet):
Q(app_label__icontains=value) |
Q(model__icontains=value)
)
#
# ConfigRevisions
#
class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ConfigRevision
fields = [
'id',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(comment__icontains=value)
)

View File

@ -4,5 +4,4 @@ from .bulk_edit import *
from .bulk_import import *
from .misc import *
from .mixins import *
from .config import *
from .scripts import *

View File

@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
from extras.choices import *
from extras.models import *
from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField
from utilities.forms.fields import ColorField, DynamicModelChoiceField
from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = (
'ConfigContextBulkEditForm',
'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm',
'CustomFieldChoiceSetBulkEditForm',
'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm',
@ -38,6 +39,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
weight = forms.IntegerField(
required=False
)
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
ui_visibility = forms.ChoiceField(
label=_("UI visibility"),
choices=add_blank_choice(CustomFieldVisibilityChoices),
@ -49,7 +54,23 @@ class CustomFieldBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('group_name', 'description',)
nullable_fields = ('group_name', 'description', 'choice_set')
class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
required=False
)
order_alphabetically = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
class CustomLinkBulkEditForm(BulkEditForm):

View File

@ -9,10 +9,13 @@ from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
)
__all__ = (
'ConfigTemplateImportForm',
'CustomFieldChoiceSetImportForm',
'CustomFieldImportForm',
'CustomLinkImportForm',
'ExportTemplateImportForm',
@ -39,10 +42,11 @@ class CustomFieldImportForm(CSVModelForm):
required=False,
help_text=_("Object type (for object or multi-object fields)")
)
choices = SimpleArrayField(
base_field=forms.CharField(),
choice_set = CSVModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
to_field_name='name',
required=False,
help_text=_('Comma-separated list of field choices')
help_text=_('Choice set (for selection fields)')
)
ui_visibility = CSVChoiceField(
choices=CustomFieldVisibilityChoices,
@ -53,8 +57,22 @@ class CustomFieldImportForm(CSVModelForm):
model = CustomField
fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'ui_visibility', 'is_cloneable',
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable',
)
class CustomFieldChoiceSetImportForm(CSVModelForm):
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
)
class Meta:
model = CustomFieldChoiceSet
fields = (
'name', 'description', 'extra_choices', 'order_alphabetically',
)

View File

@ -1,82 +0,0 @@
from django import forms
from django.conf import settings
from netbox.config import get_config, PARAMS
__all__ = (
'ConfigRevisionForm',
)
EMPTY_VALUES = ('', None, [], ())
class FormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported configuration parameter
param_fields = {}
for param in PARAMS:
field_kwargs = {
'required': False,
'label': param.label,
'help_text': param.description,
}
field_kwargs.update(**param.field_kwargs)
param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields)
return super().__new__(mcs, name, bases, attrs)
class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass):
"""
Form for creating a new ConfigRevision.
"""
class Meta:
widgets = {
'comment': forms.Textarea(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Append current parameter values to form field help texts and check for static configurations
config = get_config()
for param in PARAMS:
value = getattr(config, param.name)
is_static = hasattr(settings, param.name)
if value:
help_text = self.fields[param.name].help_text
if help_text:
help_text += '<br />' # Line break
help_text += f'Current value: <strong>{value}</strong>'
if is_static:
help_text += ' (defined statically)'
elif value == param.default:
help_text += ' (default)'
self.fields[param.name].help_text = help_text
if is_static:
self.fields[param.name].disabled = True
def save(self, commit=True):
instance = super().save(commit=False)
# Populate JSON data on the instance
instance.data = self.render_json()
if commit:
instance.save()
return instance
def render_json(self):
json = {}
# Iterate through each field and populate non-empty values
for field_name in self.declared_fields:
if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
json[field_name] = self.cleaned_data[field_name]
return json

View File

@ -1,5 +1,5 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
@ -18,7 +18,9 @@ from .mixins import SavedFiltersMixin
__all__ = (
'ConfigContextFilterForm',
'ConfigRevisionFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldChoiceSetFilterForm',
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
@ -36,7 +38,8 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', (
'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable',
'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility',
'is_cloneable',
)),
)
content_type_id = ContentTypeMultipleChoiceField(
@ -61,6 +64,11 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
choice_set_id = DynamicModelMultipleChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False,
label=_('Choice set')
)
ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
@ -74,10 +82,19 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
)
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'choice')),
)
choice = forms.CharField(
required=False
)
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
(_('Attributes'), ('content_types', 'enabled', 'new_window', 'weight')),
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
@ -244,6 +261,11 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Tagged object type')
)
for_object_type_id = ContentTypeChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
required=False,
label=_('Allowed object type')
)
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
@ -385,7 +407,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
widget=DateTimePicker()
)
created_by_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
@ -429,7 +451,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
required=False
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
@ -444,3 +466,9 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
api_url='/api/extras/content-types/',
)
)
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
)

View File

@ -1,6 +1,7 @@
import json
from django import forms
from django.conf import settings
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
@ -10,18 +11,24 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
SlugField,
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
)
from utilities.forms.widgets import ArrayWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'BookmarkForm',
'ConfigContextForm',
'ConfigRevisionForm',
'ConfigTemplateForm',
'CustomFieldChoiceSetForm',
'CustomFieldForm',
'CustomLinkForm',
'ExportTemplateForm',
@ -45,13 +52,17 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
required=False,
help_text=_("Type of the related object (for object/multi-object fields only)")
)
choice_set = DynamicModelChoiceField(
queryset=CustomFieldChoiceSet.objects.all(),
required=False
)
fieldsets = (
('Custom Field', (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
)),
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
('Values', ('default', 'choices')),
('Values', ('default', 'choice_set')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
@ -73,6 +84,20 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
self.fields['type'].disabled = True
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ArrayWidget(),
help_text=_('Enter one choice per line.')
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'extra_choices', 'order_alphabetically')
def clean_extra_choices(self):
return self.cleaned_data['extra_choices'].splitlines()
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
@ -165,6 +190,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
super().__init__(*args, initial=initial, **kwargs)
class BookmarkForm(BootstrapMixin, forms.ModelForm):
object_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('bookmarks').get_query()
)
class Meta:
model = Bookmark
fields = ('object_type', 'object_id')
class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
@ -200,15 +236,20 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
object_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('tags'),
required=False
)
fieldsets = (
('Tag', ('name', 'slug', 'color', 'description')),
('Tag', ('name', 'slug', 'color', 'description', 'object_types')),
)
class Meta:
model = Tag
fields = [
'name', 'slug', 'color', 'description'
'name', 'slug', 'color', 'description', 'object_types',
]
@ -374,3 +415,99 @@ class JournalEntryForm(NetBoxModelForm):
'assigned_object_type': forms.HiddenInput,
'assigned_object_id': forms.HiddenInput,
}
EMPTY_VALUES = ('', None, [], ())
class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported configuration parameter
param_fields = {}
for param in PARAMS:
field_kwargs = {
'required': False,
'label': param.label,
'help_text': param.description,
}
field_kwargs.update(**param.field_kwargs)
param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields)
return super().__new__(mcs, name, bases, attrs)
class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass):
"""
Form for creating a new ConfigRevision.
"""
fieldsets = (
('Rack Elevations', ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')),
('Power', ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')),
('IPAM', ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')),
('Security', ('ALLOWED_URL_SCHEMES',)),
('Banners', ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
('Pagination', ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
('Validation', ('CUSTOM_VALIDATORS',)),
('User Preferences', ('DEFAULT_USER_PREFERENCES',)),
('Miscellaneous', ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')),
('Config Revision', ('comment',))
)
class Meta:
model = ConfigRevision
fields = '__all__'
widgets = {
'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
'comment': forms.Textarea(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Append current parameter values to form field help texts and check for static configurations
config = get_config()
for param in PARAMS:
value = getattr(config, param.name)
is_static = hasattr(settings, param.name)
if value:
help_text = self.fields[param.name].help_text
if help_text:
help_text += '<br />' # Line break
help_text += f'Current value: <strong>{value}</strong>'
if is_static:
help_text += ' (defined statically)'
elif value == param.default:
help_text += ' (default)'
self.fields[param.name].help_text = help_text
self.fields[param.name].initial = value
if is_static:
self.fields[param.name].disabled = True
def save(self, commit=True):
instance = super().save(commit=False)
# Populate JSON data on the instance
instance.data = self.render_json()
if commit:
instance.save()
return instance
def render_json(self):
json = {}
# Iterate through each field and populate non-empty values
for field_name in self.declared_fields:
if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
json[field_name] = self.cleaned_data[field_name]
return json

View File

@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form):
self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the built-in fields).
"""
return bool(len(self.fields) > 3)

View File

@ -25,6 +25,12 @@ class ExtrasQuery(graphene.ObjectType):
def resolve_custom_field_list(root, info, **kwargs):
return gql_query_optimizer(models.CustomField.objects.all(), info)
custom_field_choice_set = ObjectField(CustomFieldChoiceSetType)
custom_field_choice_set_list = ObjectListField(CustomFieldChoiceSetType)
def resolve_custom_field_choices_list(root, info, **kwargs):
return gql_query_optimizer(models.CustomFieldChoiceSet.objects.all(), info)
custom_link = ObjectField(CustomLinkType)
custom_link_list = ObjectListField(CustomLinkType)

View File

@ -5,6 +5,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
__all__ = (
'ConfigContextType',
'ConfigTemplateType',
'CustomFieldChoiceSetType',
'CustomFieldType',
'CustomLinkType',
'ExportTemplateType',
@ -41,6 +42,14 @@ class CustomFieldType(ObjectType):
filterset_class = filtersets.CustomFieldFilterSet
class CustomFieldChoiceSetType(ObjectType):
class Meta:
model = models.CustomFieldChoiceSet
fields = '__all__'
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
class CustomLinkType(ObjectType):
class Meta:

View File

@ -4,7 +4,7 @@ import sys
import traceback
import uuid
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
@ -63,6 +63,8 @@ class Command(BaseCommand):
logger.info(f"Script completed in {job.duration}")
User = get_user_model()
# Params
script = options['script']
loglevel = options['loglevel']

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.9 on 2023-06-22 14:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0092_delete_jobresult'),
]
operations = [
migrations.AlterModelOptions(
name='configrevision',
options={'ordering': ['-created']},
),
]

View File

@ -0,0 +1,23 @@
from django.db import migrations, models
import extras.utils
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0093_configrevision_ordering'),
]
operations = [
migrations.AddField(
model_name='tag',
name='object_types',
field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'),
),
migrations.RenameIndex(
model_name='taggeditem',
new_name='extras_tagg_content_717743_idx',
old_fields=('content_type', 'object_id'),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.9 on 2023-06-29 14:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0094_tag_object_types'),
]
operations = [
migrations.CreateModel(
name='Bookmark',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('object_id', models.PositiveBigIntegerField()),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('created', 'pk'),
},
),
migrations.AddConstraint(
model_name='bookmark',
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_bookmark_unique_per_object_and_user'),
),
]

View File

@ -0,0 +1,61 @@
import django.contrib.postgres.fields
from django.db import migrations, models
from extras.choices import CustomFieldTypeChoices
def create_choice_sets(apps, schema_editor):
"""
Create a CustomFieldChoiceSet for each CustomField with choices defined.
"""
CustomField = apps.get_model('extras', 'CustomField')
CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet')
# Create custom field choice sets
choice_fields = CustomField.objects.filter(
type__in=(CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT),
choices__len__gt=0
)
for cf in choice_fields:
choiceset = CustomFieldChoiceSet.objects.create(
name=f'{cf.name} Choices',
extra_choices=cf.choices
)
cf.choice_set = choiceset
# Update custom fields to point to new choice sets
CustomField.objects.bulk_update(choice_fields, ['choice_set'])
class Migration(migrations.Migration):
dependencies = [
('extras', '0095_bookmarks'),
]
operations = [
migrations.CreateModel(
name='CustomFieldChoiceSet',
fields=[
('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)),
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)),
('order_alphabetically', models.BooleanField(default=False)),
],
options={
'ordering': ('name',),
},
),
migrations.AddField(
model_name='customfield',
name='choice_set',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='choices_for', to='extras.customfieldchoiceset'),
),
migrations.RunPython(
code=create_choice_sets,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.10 on 2023-07-17 15:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0096_customfieldchoiceset'),
]
operations = [
migrations.RemoveField(
model_name='customfield',
name='choices',
),
]

View File

@ -1,6 +1,6 @@
from .change_logging import *
from .configs import *
from .customfields import CustomField
from .customfields import *
from .dashboard import *
from .models import *
from .reports import *

View File

@ -1,11 +1,11 @@
from django.contrib.auth.models import User
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from extras.choices import *
from utilities.querysets import RestrictedQuerySet
from ..querysets import ObjectChangeQuerySet
__all__ = (
'ObjectChange',
@ -24,7 +24,7 @@ class ObjectChange(models.Model):
db_index=True
)
user = models.ForeignKey(
to=User,
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name='changes',
blank=True,
@ -82,7 +82,7 @@ class ObjectChange(models.Model):
null=True
)
objects = RestrictedQuerySet.as_manager()
objects = ObjectChangeQuerySet.as_manager()
class Meta:
ordering = ['-time']

View File

@ -146,6 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
Synchronize context data from the designated DataFile (if any).
"""
self.data = self.data_file.get_data()
sync_data.alters_data = True
class ConfigContextModel(models.Model):
@ -236,6 +237,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
Synchronize template content from the designated DataFile (if any).
"""
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def render(self, context=None):
"""

View File

@ -31,6 +31,7 @@ from utilities.validators import validate_regex
__all__ = (
'CustomField',
'CustomFieldChoiceSet',
'CustomFieldManager',
)
@ -158,11 +159,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
)
)
choices = ArrayField(
base_field=models.CharField(max_length=100),
choice_set = models.ForeignKey(
to='CustomFieldChoiceSet',
on_delete=models.PROTECT,
related_name='choices_for',
blank=True,
null=True,
help_text=_('Comma-separated list of available choices (for selection fields)')
null=True
)
ui_visibility = models.CharField(
max_length=50,
@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
'ui_visibility', 'is_cloneable',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
'choice_set', 'ui_visibility', 'is_cloneable',
)
class Meta:
@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
def search_type(self):
return SEARCH_TYPES.get(self.type)
@property
def choices(self):
if self.choice_set:
return self.choice_set.choices
return []
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or
@ -278,22 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'validation_regex': "Regular expression validation is supported only for text and URL fields"
})
# Choices can be set only on selection fields
if self.choices and self.type not in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
):
raise ValidationError({
'choices': "Choices may be set only for custom selection fields."
})
# Selection fields must have at least one choice defined
# Choice set must be set on selection fields, and *only* on selection fields
if self.type in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
) and not self.choices:
):
if not self.choice_set:
raise ValidationError({
'choices': "Selection fields must specify at least one choice."
'choice_set': "Selection fields must specify a set of choices."
})
elif self.choice_set:
raise ValidationError({
'choice_set': "Choices may be set only on selection fields."
})
# A selection field's default (if any) must be present in its available choices
@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
elif self.required:
raise ValidationError("Required field cannot be empty.")
class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
Represents a set of choices available for choice and multi-choice custom fields.
"""
name = models.CharField(
max_length=100,
unique=True
)
description = models.CharField(
max_length=200,
blank=True
)
extra_choices = ArrayField(
base_field=models.CharField(max_length=100),
help_text=_('List of field choices')
)
order_alphabetically = models.BooleanField(
default=False,
help_text=_('Choices are automatically ordered alphabetically on save')
)
clone_fields = ('extra_choices', 'order_alphabetically')
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:customfieldchoiceset', args=[self.pk])
@property
def choices(self):
return self.extra_choices
@property
def choices_count(self):
return len(self.choices)
def save(self, *args, **kwargs):
# Sort choices if alphabetical ordering is enforced
if self.order_alphabetically:
self.extra_choices = sorted(self.choices)
return super().save(*args, **kwargs)

View File

@ -1,9 +1,8 @@
import json
import urllib.parse
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.models import User
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
@ -29,6 +28,7 @@ from utilities.querysets import RestrictedQuerySet
from utilities.utils import clean_html, dict_to_querydict, render_jinja2
__all__ = (
'Bookmark',
'ConfigRevision',
'CustomLink',
'ExportTemplate',
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes)
# Sanitize link
link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
# Verify link scheme is allowed
result = urllib.parse.urlparse(link)
@ -362,6 +362,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
Synchronize template content from the designated DataFile (if any).
"""
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def render(self, queryset):
"""
@ -418,7 +419,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
blank=True
)
user = models.ForeignKey(
to=User,
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True
@ -558,7 +559,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
fk_field='assigned_object_id'
)
created_by = models.ForeignKey(
to=User,
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True
@ -593,6 +594,44 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
return JournalEntryKindChoices.colors.get(self.kind)
class Bookmark(models.Model):
"""
An object bookmarked by a User.
"""
created = models.DateTimeField(
auto_now_add=True
)
object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT
)
object_id = models.PositiveBigIntegerField()
object = GenericForeignKey(
ct_field='object_type',
fk_field='object_id'
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('created', 'pk')
constraints = (
models.UniqueConstraint(
fields=('object_type', 'object_id', 'user'),
name='%(app_label)s_%(class)s_unique_per_object_and_user'
),
)
def __str__(self):
if self.object:
return str(self.object)
return super().__str__()
class ConfigRevision(models.Model):
"""
An atomic revision of NetBox's configuration.
@ -610,6 +649,11 @@ class ConfigRevision(models.Model):
verbose_name='Configuration data'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['-created']
def __str__(self):
return f'Config revision #{self.pk} ({self.created})'
@ -618,12 +662,16 @@ class ConfigRevision(models.Model):
return self.data[item]
return super().__getattribute__(item)
def get_absolute_url(self):
return reverse('extras:configrevision', args=[self.pk])
def activate(self):
"""
Cache the configuration data.
"""
cache.set('config', self.data, None)
cache.set('config_version', self.pk, None)
activate.alters_data = True
@admin.display(boolean=True)
def is_active(self):

View File

@ -1,7 +1,7 @@
import inspect
import logging
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin
from utilities.querysets import RestrictedQuerySet
from .mixins import PythonModuleMixin
logger = logging.getLogger('netbox.reports')
__all__ = (
'Report',
'ReportModule',
@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
try:
module = self.get_module()
except ImportError:
except (ImportError, SyntaxError) as e:
logger.error(f"Unable to load report module {self.name}, exception: {e}")
return {}
reports = {}
ordered = getattr(module, 'report_order', [])

View File

@ -112,6 +112,7 @@ class StagedChange(ChangeLoggedModel):
instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete()
apply.alters_data = True
def get_action_color(self):
return ChangeActionChoices.colors.get(self.action)

View File

@ -1,9 +1,13 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext as _
from taggit.models import TagBase, GenericTaggedItemBase
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.choices import ColorChoices
@ -30,9 +34,16 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
max_length=200,
blank=True,
)
object_types = models.ManyToManyField(
to=ContentType,
related_name='+',
limit_choices_to=FeatureQuery('tags'),
blank=True,
help_text=_("The object type(s) to which this this tag can be applied.")
)
clone_fields = (
'color', 'description',
'color', 'description', 'object_types',
)
class Meta:
@ -61,6 +72,4 @@ class TaggedItem(GenericTaggedItemBase):
)
class Meta:
index_together = (
("content_type", "object_id")
)
indexes = [models.Index(fields=["content_type", "object_id"])]

View File

@ -1,5 +1,8 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.aggregates import JSONBAgg
from django.db.models import OuterRef, Subquery, Q
from django.db.utils import ProgrammingError
from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg
@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
)
return base_query
class ObjectChangeQuerySet(RestrictedQuerySet):
def valid_models(self):
# Exclude any change records which refer to an instance of a model that's no longer installed. This
# can happen when a plugin is removed but its data remains in the database, for example.
try:
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
except ProgrammingError:
# Handle the case where the database schema has not yet been initialized
content_types = ContentType.objects.none()
content_type_ids = set(
ct.pk for ct in content_types
)
return self.filter(changed_object_type_id__in=content_type_ids)

View File

@ -366,7 +366,7 @@ class BaseScript:
if self.fieldsets:
fieldsets.extend(self.fieldsets)
else:
fields = (name for name, _ in self._get_vars().items())
fields = list(name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields))
# Append the default fieldset if defined in the Meta class
@ -390,6 +390,11 @@ class BaseScript:
# Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = self.commit_default
# Hide fields if scheduling has been disabled
if not self.scheduling_enabled:
form.fields['_schedule_at'].widget = forms.HiddenInput()
form.fields['_interval'].widget = forms.HiddenInput()
return form
# Logging

View File

@ -10,8 +10,9 @@ from extras.validators import CustomValidator
from netbox.config import get_config
from netbox.context import current_request, webhooks_queue
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange
from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
#
@ -207,3 +208,21 @@ def update_config(sender, instance, **kwargs):
Update the cached NetBox configuration when a new ConfigRevision is created.
"""
instance.activate()
#
# Tags
#
@receiver(m2m_changed, sender=TaggedItem)
def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
"""
Validate that any Tags being assigned to the instance are not restricted to non-applicable object types.
"""
if action != 'pre_add':
return
ct = ContentType.objects.get_for_model(instance)
# Retrieve any applied Tags that are restricted to certain object_types
for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'):
if ct not in tag.object_types.all():
raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")

View File

@ -2,14 +2,18 @@ import json
import django_tables2 as tables
from django.conf import settings
from django.utils.translation import gettext as _
from extras.models import *
from netbox.tables import NetBoxTable, columns
from .template_code import *
__all__ = (
'BookmarkTable',
'ConfigContextTable',
'ConfigRevisionTable',
'ConfigTemplateTable',
'CustomFieldChoiceSetTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
@ -30,6 +34,29 @@ IMAGEATTACHMENT_IMAGE = '''
{% endif %}
'''
REVISION_BUTTONS = """
{% if not record.is_active %}
<a href="{% url 'extras:configrevision_restore' pk=record.pk %}" class="btn btn-sm btn-primary" title="Restore config">
<i class="mdi mdi-file-restore"></i>
</a>
{% endif %}
"""
class ConfigRevisionTable(NetBoxTable):
is_active = columns.BooleanColumn()
actions = columns.ActionsColumn(
actions=('delete',),
extra_buttons=REVISION_BUTTONS
)
class Meta(NetBoxTable.Meta):
model = ConfigRevision
fields = (
'pk', 'id', 'is_active', 'created', 'comment',
)
default_columns = ('pk', 'id', 'is_active', 'created', 'comment')
class CustomFieldTable(NetBoxTable):
name = tables.Column(
@ -39,6 +66,11 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn()
choices = columns.ArrayColumn(
max_items=10,
orderable=False,
verbose_name=_('Choices')
)
is_cloneable = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
@ -51,6 +83,33 @@ class CustomFieldTable(NetBoxTable):
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
class CustomFieldChoiceSetTable(NetBoxTable):
name = tables.Column(
linkify=True
)
choices = columns.ArrayColumn(
max_items=10,
accessor=tables.A('extra_choices'),
orderable=False,
verbose_name=_('Choices')
)
choice_count = tables.TemplateColumn(
accessor=tables.A('extra_choices'),
template_code='{{ value|length }}',
orderable=False,
verbose_name=_('Count')
)
order_alphabetically = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
model = CustomFieldChoiceSet
fields = (
'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'choice_count', 'description')
class CustomLinkTable(NetBoxTable):
name = tables.Column(
linkify=True
@ -143,6 +202,21 @@ class SavedFilterTable(NetBoxTable):
)
class BookmarkTable(NetBoxTable):
object_type = columns.ContentTypeColumn()
object = tables.Column(
linkify=True
)
actions = columns.ActionsColumn(
actions=('delete',)
)
class Meta(NetBoxTable.Meta):
model = Bookmark
fields = ('pk', 'object', 'object_type', 'created')
default_columns = ('object', 'object_type', 'created')
class WebhookTable(NetBoxTable):
name = tables.Column(
linkify=True
@ -186,10 +260,14 @@ class TagTable(NetBoxTable):
linkify=True
)
color = columns.ColorColumn()
object_types = columns.ContentTypesColumn()
class Meta(NetBoxTable.Meta):
model = Tag
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions')
fields = (
'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated',
'actions',
)
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')

View File

@ -1,6 +1,6 @@
import datetime
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware
@ -8,13 +8,15 @@ from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet
from extras.models import *
from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
User = get_user_model()
class AppTest(APITestCase):
def test_root(self):
@ -96,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
{
'content_types': ['dcim.site'],
'name': 'cf6',
'type': 'select',
'choices': ['A', 'B', 'C']
'type': 'text',
},
]
bulk_update_data = {
@ -132,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
cf.content_types.add(site_ct)
class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
model = CustomFieldChoiceSet
brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
create_data = [
{
'name': 'Choice Set 4',
'extra_choices': ['4A', '4B', '4C'],
},
{
'name': 'Choice Set 5',
'extra_choices': ['5A', '5B', '5C'],
},
{
'name': 'Choice Set 6',
'extra_choices': ['6A', '6B', '6C'],
},
]
bulk_update_data = {
'description': 'New description',
}
update_data = {
'name': 'Choice Set X',
'extra_choices': ['X1', 'X2', 'X3'],
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink
brief_fields = ['display', 'id', 'name', 'url']
@ -265,6 +302,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
savedfilter.content_types.set([site_ct])
class BookmarkTest(
APIViewTestCases.GetObjectViewTestCase,
APIViewTestCases.ListObjectsViewTestCase,
APIViewTestCases.CreateObjectViewTestCase,
APIViewTestCases.DeleteObjectViewTestCase
):
model = Bookmark
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url']
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
Site(name='Site 5', slug='site-5'),
Site(name='Site 6', slug='site-6'),
)
Site.objects.bulk_create(sites)
def setUp(self):
super().setUp()
sites = Site.objects.all()
bookmarks = (
Bookmark(object=sites[0], user=self.user),
Bookmark(object=sites[1], user=self.user),
Bookmark(object=sites[2], user=self.user),
)
Bookmark.objects.bulk_create(bookmarks)
self.create_data = [
{
'object_type': 'dcim.site',
'object_id': sites[3].pk,
'user': self.user.pk,
},
{
'object_type': 'dcim.site',
'object_id': sites[4].pk,
'user': self.user.pk,
},
{
'object_type': 'dcim.site',
'object_id': sites[5].pk,
'user': self.user.pk,
},
]
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate
brief_fields = ['display', 'id', 'name', 'url']
@ -579,6 +668,7 @@ class ReportTest(APITestCase):
super().setUp()
# Monkey-patch the API viewset's _get_report() method to return our test Report above
from extras.api.views import ReportViewSet
ReportViewSet._get_report = self.get_test_report
def test_get_report(self):
@ -621,6 +711,7 @@ class ScriptTest(APITestCase):
super().setUp()
# Monkey-patch the API viewset's _get_script() method to return our test Script above
from extras.api.views import ScriptViewSet
ScriptViewSet._get_script = self.get_test_script
def test_get_script(self):

View File

@ -5,7 +5,7 @@ from rest_framework import status
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, ObjectChange, Tag
from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase
@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase):
@classmethod
def setUpTestData(cls):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['Bar', 'Foo']
)
# Create a custom field on the Site model
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT,
name='my_field',
name='cf1',
required=False
)
cf.save()
@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase):
# Create a select custom field on the Site model
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
name='cf2',
required=False,
choices=['Bar', 'Foo']
choice_set=choice_set
)
cf_select.save()
cf_select.content_types.set([ct])
@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site 1',
'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_my_field': 'ABC',
'cf_my_field_select': 'Bar',
'cf_cf1': 'ABC',
'cf_cf2': 'Bar',
'tags': [tag.pk for tag in tags],
}
@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None)
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site X',
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_my_field': 'DEF',
'cf_my_field_select': 'Foo',
'cf_cf1': 'DEF',
'cf_cf2': 'Foo',
'tags': [tags[2].pk],
}
@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.prechange_data['name'], 'Site 1')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
def test_delete_object(self):
@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase):
name='Site 1',
slug='site-1',
custom_field_data={
'my_field': 'ABC',
'my_field_select': 'Bar'
'cf1': 'ABC',
'cf2': 'Bar'
}
)
site.save()
@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase):
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT,
name='my_field',
name='cf1',
required=False
)
cf.save()
cf.content_types.set([ct])
# Create a select custom field on the Site model
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=['Bar', 'Foo']
)
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field_select',
name='cf2',
required=False,
choices=['Bar', 'Foo']
choice_set=choice_set
)
cf_select.save()
cf_select.content_types.set([ct])
@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site 1',
'slug': 'site-1',
'custom_fields': {
'my_field': 'ABC',
'my_field_select': 'Bar',
'cf1': 'ABC',
'cf2': 'Bar',
},
'tags': [
{'name': 'Tag 1'},
@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site X',
'slug': 'site-x',
'custom_fields': {
'my_field': 'DEF',
'my_field_select': 'Foo',
'cf1': 'DEF',
'cf2': 'Foo',
},
'tags': [
{'name': 'Tag 3'}
@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase):
name='Site 1',
slug='site-1',
custom_field_data={
'my_field': 'ABC',
'my_field_select': 'Bar'
'cf1': 'ABC',
'cf2': 'Bar'
}
)
site.save()
@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)

View File

@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet
from dcim.forms import SiteImportForm
from dcim.models import Manufacturer, Rack, Site
from extras.choices import *
from extras.models import CustomField
from extras.models import CustomField, CustomFieldChoiceSet
from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
@ -272,12 +272,18 @@ class CustomFieldTest(TestCase):
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=CHOICES
)
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
required=False,
choices=CHOICES
choice_set=choice_set
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
@ -299,12 +305,18 @@ class CustomFieldTest(TestCase):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=CHOICES
)
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
choices=CHOICES
choice_set=choice_set
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase):
)
VLAN.objects.bulk_create(vlans)
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Foo', 'Bar', 'Baz')
)
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase):
type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field',
default='Foo',
choices=(
'Foo', 'Bar', 'Baz'
)
choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field',
default=['Foo'],
choices=(
'Foo', 'Bar', 'Baz'
)
choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_OBJECT,
@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase):
@classmethod
def setUpTestData(cls):
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Choice A', 'Choice B', 'Choice C')
)
custom_fields = (
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase):
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
)
for cf in custom_fields:
cf.save()
@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase):
Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
))
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['A', 'B', 'C', 'X']
)
# Integer filtering
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf.save()
@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField(
name='cf9',
type=CustomFieldTypeChoices.TYPE_SELECT,
choices=['Foo', 'Bar', 'Baz']
choice_set=choice_set
)
cf.save()
cf.content_types.set([obj_type])
@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField(
name='cf10',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=['A', 'B', 'C', 'X']
choice_set=choice_set
)
cf.save()
cf.content_types.set([obj_type])
@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-26',
'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com',
'cf9': 'Foo',
'cf9': 'A',
'cf10': ['A', 'X'],
'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-27',
'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com',
'cf9': 'Bar',
'cf9': 'B',
'cf10': ['B', 'X'],
'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-28',
'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com',
'cf9': 'Baz',
'cf9': 'C',
'cf10': ['C', 'X'],
'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
@ -1399,7 +1420,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)

View File

@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
@ -18,13 +18,20 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr
from virtualization.models import Cluster, ClusterGroup, ClusterType
User = get_user_model()
class CustomFieldTestCase(TestCase, BaseFilterSetTests):
queryset = CustomField.objects.all()
filterset = CustomFieldFilterSet
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
custom_fields = (
CustomField(
@ -51,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
),
CustomField(
name='Custom Field 4',
type=CustomFieldTypeChoices.TYPE_SELECT,
required=False,
weight=400,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
choice_set=choice_sets[0]
),
CustomField(
name='Custom Field 5',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
weight=500,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
choice_set=choice_sets[1]
),
)
CustomField.objects.bulk_create(custom_fields)
custom_fields[0].content_types.add(content_types[0])
custom_fields[1].content_types.add(content_types[1])
custom_fields[2].content_types.add(content_types[2])
custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
def test_name(self):
params = {'name': ['Custom Field 1', 'Custom Field 2']}
@ -64,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_required(self):
@ -83,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_choice_set(self):
params = {'choice_set': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
queryset = CustomFieldChoiceSet.objects.all()
filterset = CustomFieldChoiceSetFilterSet
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
def test_name(self):
params = {'name': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_choice(self):
params = {'choice': ['A', 'D']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all()
@ -362,6 +417,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class BookmarkTestCase(TestCase, BaseFilterSetTests):
queryset = Bookmark.objects.all()
filterset = BookmarkFilterSet
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
)
User.objects.bulk_create(users)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
bookmarks = (
Bookmark(
object=sites[0],
user=users[0],
),
Bookmark(
object=sites[1],
user=users[1],
),
Bookmark(
object=sites[2],
user=users[2],
),
Bookmark(
object=tenants[0],
user=users[0],
),
Bookmark(
object=tenants[1],
user=users[1],
),
Bookmark(
object=tenants[2],
user=users[2],
),
)
Bookmark.objects.bulk_create(bookmarks)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_user(self):
users = User.objects.filter(username__startswith='User')
params = {'user': [users[0].username, users[1].username]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'user_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet
@ -818,6 +944,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
content_types = {
'site': ContentType.objects.get_by_natural_key('dcim', 'site'),
'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'),
}
tags = (
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
@ -825,6 +955,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
Tag(name='Tag 3', slug='tag-3', color='0000ff'),
)
Tag.objects.bulk_create(tags)
tags[0].object_types.add(content_types['site'])
tags[1].object_types.add(content_types['provider'])
# Apply some tags so we can filter by content type
site = Site.objects.create(name='Site 1', slug='site-1')
@ -857,6 +989,18 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'content_type_id': [site_ct, provider_ct]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_object_types(self):
params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(
list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
['Tag 1', 'Tag 3']
)
params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]}
self.assertEqual(
list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)),
['Tag 2', 'Tag 3']
)
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all()

View File

@ -5,7 +5,7 @@ from dcim.forms import SiteForm
from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.forms import SavedFilterForm
from extras.models import CustomField
from extras.models import CustomField, CustomFieldChoiceSet
class CustomFieldModelFormTest(TestCase):
@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase):
@classmethod
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
CHOICES = ('A', 'B', 'C')
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('A', 'B', 'C')
)
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
cf_text.content_types.set([obj_type])
@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase):
cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
cf_json.content_types.set([obj_type])
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
cf_select = CustomField.objects.create(
name='select',
type=CustomFieldTypeChoices.TYPE_SELECT,
choice_set=choice_set
)
cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create(
name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES
choice_set=choice_set
)
cf_multiselect.content_types.set([obj_type])

View File

@ -1,8 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, Tag
from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -14,6 +16,22 @@ class TagTest(TestCase):
self.assertEqual(tag.slug, 'testing-unicode-台灣')
def test_object_type_validation(self):
region = Region.objects.create(name='Region 1', slug='region-1')
sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
# Create a Tag that can only be applied to Regions
tag = Tag.objects.create(name='Tag 1', slug='tag-1')
tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region'))
# Apply the Tag to a Region
region.tags.add(tag)
self.assertIn(tag, region.tags.all())
# Apply the Tag to a SiteGroup
with self.assertRaises(AbortRequest):
sitegroup.tags.add(tag)
class ConfigContextTest(TestCase):
"""

View File

@ -1,7 +1,7 @@
import urllib.parse
import uuid
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@ -11,6 +11,9 @@ from extras.models import *
from utilities.testing import ViewTestCases, TestCase
User = get_user_model()
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomField
@ -18,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=('A', 'B', 'C')
)
custom_fields = (
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
@ -41,10 +49,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write',
'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write',
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
)
@ -61,6 +69,43 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomFieldChoiceSet
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': 'X1,X2,X3,X4,X5',
}
cls.csv_data = (
'name,extra_choices',
'Choice Set 4,"4A,4B,4C,4D,4E"',
'Choice Set 5,"5A,5B,5C,5D,5E"',
'Choice Set 6,"6A,6B,6C,6D,6E"',
)
cls.csv_update_data = (
'id,extra_choices',
f'{choice_sets[0].pk},"1X,1Y,1Z"',
f'{choice_sets[1].pk},"2X,2Y,2Z"',
f'{choice_sets[2].pk},"3X,3Y,3Z"',
)
cls.bulk_edit_data = {
'description': 'New description',
}
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink
@ -178,6 +223,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class BookmarkTestCase(
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Bookmark
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
)
Site.objects.bulk_create(sites)
cls.form_data = {
'object_type': site_ct.pk,
'object_id': sites[3].pk,
}
def setUp(self):
super().setUp()
sites = Site.objects.all()
user = self.user
bookmarks = (
Bookmark(object=sites[0], user=user),
Bookmark(object=sites[1], user=user),
Bookmark(object=sites[2], user=user),
)
Bookmark.objects.bulk_create(bookmarks)
def _get_url(self, action, instance=None):
if action == 'list':
return reverse('users:bookmarks')
return super()._get_url(action, instance)
def test_list_objects_anonymous(self):
return
def test_list_objects_with_constrained_permission(self):
return
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ExportTemplate

View File

@ -1,4 +1,4 @@
from django.urls import include, path, re_path
from django.urls import include, path
from extras import views
from utilities.urls import get_model_urls
@ -15,6 +15,14 @@ urlpatterns = [
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))),
# Custom field choices
path('custom-field-choices/', views.CustomFieldChoiceSetListView.as_view(), name='customfieldchoiceset_list'),
path('custom-field-choices/add/', views.CustomFieldChoiceSetEditView.as_view(), name='customfieldchoiceset_add'),
path('custom-field-choices/import/', views.CustomFieldChoiceSetBulkImportView.as_view(), name='customfieldchoiceset_import'),
path('custom-field-choices/edit/', views.CustomFieldChoiceSetBulkEditView.as_view(), name='customfieldchoiceset_bulk_edit'),
path('custom-field-choices/delete/', views.CustomFieldChoiceSetBulkDeleteView.as_view(), name='customfieldchoiceset_bulk_delete'),
path('custom-field-choices/<int:pk>/', include(get_model_urls('extras', 'customfieldchoiceset'))),
# Custom links
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
path('custom-links/add/', views.CustomLinkEditView.as_view(), name='customlink_add'),
@ -40,6 +48,11 @@ urlpatterns = [
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
# Bookmarks
path('bookmarks/add/', views.BookmarkCreateView.as_view(), name='bookmark_add'),
path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
# Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
@ -85,6 +98,13 @@ urlpatterns = [
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
# Config revisions
path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'),
path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'),
path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'),
path('config-revisions/<int:pk>/restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'),
path('config-revisions/<int:pk>/', include(get_model_urls('extras', 'configrevision'))),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
@ -114,5 +134,5 @@ urlpatterns = [
path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
# Markdown
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),
]

View File

@ -14,6 +14,7 @@ from core.models import Job
from core.tables import JobTable
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from netbox.config import get_config, PARAMS
from netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import is_htmx
@ -33,7 +34,7 @@ from .scripts import run_script
#
class CustomFieldListView(generic.ObjectListView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet
filterset_form = forms.CustomFieldFilterForm
table = tables.CustomFieldTable
@ -41,38 +42,83 @@ class CustomFieldListView(generic.ObjectListView):
@register_model_view(CustomField)
class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
@register_model_view(CustomField, 'edit')
class CustomFieldEditView(generic.ObjectEditView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
form = forms.CustomFieldForm
@register_model_view(CustomField, 'delete')
class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm
class CustomFieldBulkEditView(generic.BulkEditView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
form = forms.CustomFieldBulkEditForm
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.all()
queryset = CustomField.objects.select_related('choice_set')
filterset = filtersets.CustomFieldFilterSet
table = tables.CustomFieldTable
#
# Custom field choices
#
class CustomFieldChoiceSetListView(generic.ObjectListView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
filterset_form = forms.CustomFieldChoiceSetFilterForm
table = tables.CustomFieldChoiceSetTable
@register_model_view(CustomFieldChoiceSet)
class CustomFieldChoiceSetView(generic.ObjectView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'edit')
class CustomFieldChoiceSetEditView(generic.ObjectEditView):
queryset = CustomFieldChoiceSet.objects.all()
form = forms.CustomFieldChoiceSetForm
@register_model_view(CustomFieldChoiceSet, 'delete')
class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
queryset = CustomFieldChoiceSet.objects.all()
model_form = forms.CustomFieldChoiceSetImportForm
class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
table = tables.CustomFieldChoiceSetTable
form = forms.CustomFieldChoiceSetBulkEditForm
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
filterset = filtersets.CustomFieldChoiceSetFilterSet
table = tables.CustomFieldChoiceSetTable
#
# Custom links
#
@ -236,6 +282,35 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
table = tables.SavedFilterTable
#
# Bookmarks
#
class BookmarkCreateView(generic.ObjectEditView):
form = forms.BookmarkForm
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
def alter_object(self, obj, request, url_args, url_kwargs):
obj.user = request.user
return obj
@register_model_view(Bookmark, 'delete')
class BookmarkDeleteView(generic.ObjectDeleteView):
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
class BookmarkBulkDeleteView(generic.BulkDeleteView):
table = tables.BookmarkTable
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
#
# Webhooks
#
@ -511,7 +586,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
#
class ObjectChangeListView(generic.ObjectListView):
queryset = ObjectChange.objects.all()
queryset = ObjectChange.objects.valid_models()
filterset = filtersets.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
@ -521,10 +596,10 @@ class ObjectChangeListView(generic.ObjectListView):
@register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView):
queryset = ObjectChange.objects.all()
queryset = ObjectChange.objects.valid_models()
def get_extra_context(self, request, instance):
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
request_id=instance.request_id
).exclude(
pk=instance.pk
@ -534,7 +609,7 @@ class ObjectChangeView(generic.ObjectView):
orderable=False
)
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
changed_object_type=instance.changed_object_type,
changed_object_id=instance.changed_object_id,
)
@ -1176,6 +1251,74 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
})
#
# Config Revisions
#
class ConfigRevisionListView(generic.ObjectListView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable
@register_model_view(ConfigRevision)
class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
class ConfigRevisionEditView(generic.ObjectEditView):
queryset = ConfigRevision.objects.all()
form = forms.ConfigRevisionForm
@register_model_view(ConfigRevision, 'delete')
class ConfigRevisionDeleteView(generic.ObjectDeleteView):
queryset = ConfigRevision.objects.all()
class ConfigRevisionBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigRevision.objects.all()
filterset = filtersets.ConfigRevisionFilterSet
table = tables.ConfigRevisionTable
class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.configrevision_edit'
def get(self, request, pk):
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
# Get the current ConfigRevision
config_version = get_config().version
current_config = ConfigRevision.objects.filter(pk=config_version).first()
params = []
for param in PARAMS:
params.append((
param.name,
current_config.data.get(param.name, None),
candidate_config.data.get(param.name, None)
))
return render(request, 'extras/configrevision_restore.html', {
'object': candidate_config,
'params': params,
})
def post(self, request, pk):
if not request.user.has_perm('extras.configrevision_edit'):
return HttpResponseForbidden()
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
candidate_config.activate()
messages.success(request, f"Restored configuration revision #{pk}")
return redirect(candidate_config.get_absolute_url())
#
# Markdown
#

View File

@ -58,6 +58,7 @@ class AvailableASNSerializer(serializers.Serializer):
Representation of an ASN which does not exist in the database.
"""
asn = serializers.IntegerField(read_only=True)
description = serializers.CharField(required=False)
def to_representation(self, asn):
rir = NestedRIRSerializer(self.context['range'].rir, context={
@ -218,12 +219,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
utilization = serializers.CharField(read_only=True)
class Meta:
model = VLANGroup
fields = [
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
]
validators = []
@ -432,6 +434,7 @@ class AvailableIPSerializer(serializers.Serializer):
family = serializers.IntegerField(read_only=True)
address = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True)
description = serializers.CharField(required=False)
def to_representation(self, instance):
if self.context.get('vrf'):

View File

@ -1,9 +1,13 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import F
from django.db.models.functions import Round
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema
from netaddr import IPSet
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.views import APIView
@ -12,10 +16,12 @@ from circuits.models import Provider
from dcim.models import Site
from ipam import filtersets
from ipam.models import *
from ipam.utils import get_next_available_prefix
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import ObjectValidationMixin
from netbox.config import get_config
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
from . import serializers
from ipam.models import L2VPN, L2VPNTermination
@ -145,9 +151,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
class VLANGroupViewSet(NetBoxModelViewSet):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
).prefetch_related('tags')
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
serializer_class = serializers.VLANGroupSerializer
filterset_class = filtersets.VLANGroupFilterSet
@ -207,237 +211,233 @@ def get_results_limit(request):
return limit
class AvailableASNsView(ObjectValidationMixin, APIView):
queryset = ASN.objects.all()
class AvailableObjectsView(ObjectValidationMixin, APIView):
"""
Return a list of dicts representing child objects that have not yet been created for a parent object.
"""
read_serializer_class = None
write_serializer_class = None
advisory_lock_key = None
def get_parent(self, request, pk):
"""
Return the parent object.
"""
raise NotImplemented()
def get_available_objects(self, parent, limit=None):
"""
Return all available objects for the parent.
"""
raise NotImplemented()
def get_extra_context(self, parent):
"""
Return any extra context data for the serializer.
"""
return {}
def check_sufficient_available(self, requested_objects, available_objects):
"""
Check if there exist a sufficient number of available objects to satisfy the request.
"""
return len(requested_objects) <= len(available_objects)
def prep_object_data(self, requested_objects, available_objects, parent):
"""
Prepare data by setting any programmatically determined object attributes (e.g. next available VLAN ID)
on the request data.
"""
return requested_objects
@extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)})
def get(self, request, pk):
asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
parent = self.get_parent(request, pk)
limit = get_results_limit(request)
available_objects = self.get_available_objects(parent, limit)
available_asns = asnrange.get_available_asns()[:limit]
serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={
serializer = self.read_serializer_class(available_objects, many=True, context={
'request': request,
'range': asnrange,
**self.get_extra_context(parent),
})
return Response(serializer.data)
@extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-asns'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
parent = self.get_parent(request, pk)
# Normalize to a list of objects
requested_asns = request.data if isinstance(request.data, list) else [request.data]
# Normalize request data to a list of objects
requested_objects = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_asns = asnrange.get_available_asns()
if len(available_asns) < len(requested_asns):
return Response(
{
"detail": f"An insufficient number of ASNs are available within {asnrange} "
f"({len(requested_asns)} requested, {len(available_asns)} available)"
},
status=status.HTTP_409_CONFLICT
)
# Assign ASNs from the list of available IPs and copy VRF assignment from the parent
for i, requested_asn in enumerate(requested_asns):
requested_asn.update({
'rir': asnrange.rir.pk,
'range': asnrange.pk,
'asn': available_asns[i],
})
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context)
else:
serializer = serializers.ASNSerializer(data=requested_asns[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get_serializer_class(self):
if self.request.method == "GET":
return serializers.AvailableASNSerializer
return serializers.ASNSerializer
class AvailablePrefixesView(ObjectValidationMixin, APIView):
queryset = Prefix.objects.all()
@extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
available_prefixes = prefix.get_available_prefixes()
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
# Serialize and validate the request data
serializer = self.write_serializer_class(data=requested_objects, many=True, context={
'request': request,
'vrf': prefix.vrf,
**self.get_extra_context(parent),
})
return Response(serializer.data)
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
available_prefixes = prefix.get_available_prefixes()
# Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data],
many=True,
context={
'request': request,
'prefix': prefix,
}
)
if not serializer.is_valid():
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
requested_prefixes = serializer.validated_data
# Allocate prefixes to the requested objects based on availability within the parent
for i, requested_prefix in enumerate(requested_prefixes):
with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
available_objects = self.get_available_objects(parent)
# Find the first available prefix equal to or larger than the requested size
for available_prefix in available_prefixes.iter_cidrs():
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
requested_prefix['prefix'] = allocated_prefix
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
break
else:
# Determine if the requested number of objects is available
if not self.check_sufficient_available(serializer.validated_data, available_objects):
return Response(
{
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
{"detail": f"Insufficient resources are available to satisfy the request"},
status=status.HTTP_409_CONFLICT
)
# Remove the allocated prefix from the list of available prefixes
available_prefixes.remove(allocated_prefix)
# Prepare object data for deserialization
requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent)
# Initialize the serializer with a list or a single object depending on what was requested
serializer_class = get_serializer_for_model(self.queryset.model)
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
serializer = serializer_class(data=requested_objects, many=True, context=context)
else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
serializer = serializer_class(data=requested_objects[0], context=context)
# Create the new Prefix(es)
if serializer.is_valid():
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Create the new IP address(es)
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get_serializer_class(self):
if self.request.method == "GET":
return serializers.AvailablePrefixSerializer
return serializers.PrefixLengthSerializer
class AvailableIPAddressesView(ObjectValidationMixin, APIView):
queryset = IPAddress.objects.all()
class AvailableASNsView(AvailableObjectsView):
queryset = ASN.objects.all()
read_serializer_class = serializers.AvailableASNSerializer
write_serializer_class = serializers.AvailableASNSerializer
advisory_lock_key = 'available-asns'
def get_parent(self, request, pk):
raise NotImplemented()
return get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
@extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)})
def get_available_objects(self, parent, limit=None):
return parent.get_available_asns()[:limit]
def get_extra_context(self, parent):
return {
'range': parent,
}
def prep_object_data(self, requested_objects, available_objects, parent):
for i, request_data in enumerate(requested_objects):
request_data.update({
'rir': parent.rir.pk,
'range': parent.pk,
'asn': available_objects[i],
})
return requested_objects
@extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)})
def get(self, request, pk):
parent = self.get_parent(request, pk)
limit = get_results_limit(request)
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)})
def post(self, request, pk):
return super().post(request, pk)
class AvailablePrefixesView(AvailableObjectsView):
queryset = Prefix.objects.all()
read_serializer_class = serializers.AvailablePrefixSerializer
write_serializer_class = serializers.PrefixLengthSerializer
advisory_lock_key = 'available-prefixes'
def get_parent(self, request, pk):
return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
def get_available_objects(self, parent, limit=None):
return parent.get_available_prefixes().iter_cidrs()
def check_sufficient_available(self, requested_objects, available_objects):
available_prefixes = IPSet(available_objects)
for requested_object in requested_objects:
if not get_next_available_prefix(available_prefixes, requested_object['prefix_length']):
return False
return True
def get_extra_context(self, parent):
return {
'prefix': parent,
'vrf': parent.vrf,
}
def prep_object_data(self, requested_objects, available_objects, parent):
available_prefixes = IPSet(available_objects)
for i, request_data in enumerate(requested_objects):
# Find the first available prefix equal to or larger than the requested size
if allocated_prefix := get_next_available_prefix(available_prefixes, request_data['prefix_length']):
request_data.update({
'prefix': allocated_prefix,
'vrf': parent.vrf.pk if parent.vrf else None,
})
else:
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
return requested_objects
@extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
def get(self, request, pk):
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
def post(self, request, pk):
return super().post(request, pk)
class AvailableIPAddressesView(AvailableObjectsView):
queryset = IPAddress.objects.all()
read_serializer_class = serializers.AvailableIPSerializer
write_serializer_class = serializers.AvailableIPSerializer
advisory_lock_key = 'available-ips'
def get_available_objects(self, parent, limit=None):
# Calculate available IPs within the parent
ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1):
ip_list.append(ip)
if index == limit:
break
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
'request': request,
return ip_list
def get_extra_context(self, parent):
return {
'parent': parent,
'vrf': parent.vrf,
}
def prep_object_data(self, requested_objects, available_objects, parent):
available_ips = iter(available_objects)
for i, request_data in enumerate(requested_objects):
request_data.update({
'address': f'{next(available_ips)}/{parent.mask_length}',
'vrf': parent.vrf.pk if parent.vrf else None,
})
return Response(serializer.data)
return requested_objects
@extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)})
def get(self, request, pk):
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
parent = self.get_parent(request, pk)
# Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_ips = parent.get_available_ips()
if available_ips.size < len(requested_ips):
return Response(
{
"detail": f"An insufficient number of IP addresses are available within {parent} "
f"({len(requested_ips)} requested, {len(available_ips)} available)"
},
status=status.HTTP_409_CONFLICT
)
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
available_ips = iter(available_ips)
for requested_ip in requested_ips:
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get_serializer_class(self):
if self.request.method == "GET":
return serializers.AvailableIPSerializer
return serializers.IPAddressSerializer
return super().post(request, pk)
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
class AvailableVLANsView(ObjectValidationMixin, APIView):
class AvailableVLANsView(AvailableObjectsView):
queryset = VLAN.objects.all()
read_serializer_class = serializers.AvailableVLANSerializer
write_serializer_class = serializers.CreateAvailableVLANSerializer
advisory_lock_key = 'available-vlans'
def get_parent(self, request, pk):
return get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
def get_available_objects(self, parent, limit=None):
return parent.get_available_vids()[:limit]
def get_extra_context(self, parent):
return {
'group': parent,
}
def prep_object_data(self, requested_objects, available_objects, parent):
for i, request_data in enumerate(requested_objects):
request_data.update({
'vid': available_objects.pop(0),
'group': parent.pk,
})
return requested_objects
@extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
def get(self, request, pk):
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
limit = get_results_limit(request)
available_vlans = vlangroup.get_available_vids()[:limit]
serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={
'request': request,
'group': vlangroup,
})
return Response(serializer.data)
return super().get(request, pk)
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
available_vlans = vlangroup.get_available_vids()
many = isinstance(request.data, list)
# Validate requested VLANs
serializer = serializers.CreateAvailableVLANSerializer(
data=request.data if many else [request.data],
many=True,
context={
'request': request,
'group': vlangroup,
}
)
if not serializer.is_valid():
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
requested_vlans = serializer.validated_data
for i, requested_vlan in enumerate(requested_vlans):
try:
requested_vlan['vid'] = available_vlans.pop(0)
requested_vlan['group'] = vlangroup.pk
except IndexError:
return Response({
"detail": "The requested number of VLANs is not available"
}, status=status.HTTP_409_CONFLICT)
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if many:
serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context)
else:
serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context)
# Create the new VLAN(s)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get_serializer_class(self):
if self.request.method == "GET":
return serializers.AvailableVLANSerializer
return serializers.VLANSerializer
return super().post(request, pk)

View File

@ -379,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
interface = self.instance.assigned_object
if type(interface) in (Interface, VMInterface):
parent = interface.parent_object
parent.snapshot()
if self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress

View File

@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from ipam.fields import ASNField
from ipam.querysets import ASNRangeQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
__all__ = (
@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
null=True
)
objects = ASNRangeQuerySet.as_manager()
class Meta:
ordering = ('name',)
verbose_name = 'ASN range'

View File

@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
Return all available IPs within this prefix as an IPSet.
"""
if self.mark_utilized:
return list()
return netaddr.IPSet()
prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])

View File

@ -9,7 +9,7 @@ from django.utils.translation import gettext as _
from dcim.models import Interface
from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
from virtualization.models import VMInterface
@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel):
help_text=_('Highest permissible ID of a child VLAN')
)
objects = VLANGroupQuerySet.as_manager()
class Meta:
ordering = ('name', 'pk') # Name may be non-unique
constraints = (

View File

@ -1,8 +1,34 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.db.models import Count, F, OuterRef, Q, Subquery, Value
from django.db.models.expressions import RawSQL
from django.db.models.functions import Round
from utilities.querysets import RestrictedQuerySet
from utilities.utils import count_related
__all__ = (
'ASNRangeQuerySet',
'PrefixQuerySet',
'VLANQuerySet',
)
class ASNRangeQuerySet(RestrictedQuerySet):
def annotate_asn_counts(self):
"""
Annotate the number of ASNs which appear within each range.
"""
from .models import ASN
# Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
# that we can use to count ASNs and return a single value per ASNRange.
asns = ASN.objects.filter(
asn__gte=OuterRef('start'),
asn__lte=OuterRef('end')
).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
return self.annotate(asn_count=Subquery(asns))
class PrefixQuerySet(RestrictedQuerySet):
@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet):
)
class VLANGroupQuerySet(RestrictedQuerySet):
def annotate_utilization(self):
from .models import VLAN
return self.annotate(
vlan_count=count_related(VLAN, 'group'),
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
)
class VLANQuerySet(RestrictedQuerySet):
def get_for_device(self, device):

View File

@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:asnrange_list'
)
asn_count = columns.LinkedCountColumn(
viewname='ipam:asn_list',
url_params={'asn_id': 'pk'},
verbose_name=_('ASN Count')
asn_count = tables.Column(
verbose_name=_('ASNs')
)
class Meta(NetBoxTable.Meta):
@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Provider Count')
)
sites = columns.ManyToManyColumn(
linkify_item=True
linkify_item=True,
verbose_name=_('Sites')
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(

View File

@ -19,14 +19,22 @@ __all__ = (
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
AGGREGATE_COPY_BUTTON = """
{% copy_content record.pk prefix="aggregate_" %}
"""
PREFIX_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
{% else %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
{% endif %}
"""
PREFIX_COPY_BUTTON = """
{% copy_content record.pk prefix="prefix_" %}
"""
PREFIX_LINK_WITH_DEPTH = """
{% load helpers %}
{% if record.depth %}
@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %}
@ -48,6 +56,10 @@ IPADDRESS_LINK = """
{% endif %}
"""
IPADDRESS_COPY_BUTTON = """
{% copy_content record.pk prefix="ipaddress_" %}
"""
IPADDRESS_ASSIGN_LINK = """
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
"""
@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
prefix = tables.Column(
linkify=True,
verbose_name='Aggregate'
verbose_name='Aggregate',
attrs={
# Allow the aggregate to be copied to the clipboard
'a': {'id': lambda record: f"aggregate_{record.pk}"}
}
)
date_added = tables.DateColumn(
format="Y-m-d",
@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:aggregate_list'
)
actions = columns.ActionsColumn(
extra_buttons=AGGREGATE_COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = Aggregate
@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:prefix_list'
)
actions = columns.ActionsColumn(
extra_buttons=PREFIX_COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = Prefix
@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:ipaddress_list'
)
actions = columns.ActionsColumn(
extra_buttons=IPADDRESS_COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = IPAddress

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