12589 merge feature

This commit is contained in:
Arthur 2023-06-26 10:01:17 -07:00
commit 12cd1af564
135 changed files with 2091 additions and 862 deletions

View File

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

View File

@ -3,10 +3,13 @@ blank_issues_enabled: false
contact_links: contact_links:
- name: 📖 Contributing Policy - name: 📖 Contributing Policy
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
about: "Please read through our contributing policy before opening an issue or pull request" about: "Please read through our contributing policy before opening an issue or pull request."
- name: ❓ Discussion - name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions url: https://github.com/netbox-community/netbox/discussions
about: "If you're just looking for help, try starting a discussion instead" about: "If you're just looking for help, try starting a discussion instead."
- name: 💡 Plugin Idea
url: https://plugin-ideas.netbox.dev
about: "Have an idea for a plugin? Head over to the ideas board!"
- name: 💬 Community Slack - name: 💬 Community Slack
url: https://netdev.chat/ url: https://netdev.chat
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems."

View File

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

View File

@ -1,11 +1,10 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premiere source of truth powering network automation</p>
The premiere source of truth powering network automation <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p>
</div> </div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions, datacenter infrastructure management (DCIM) with powerful APIs and extensions,

View File

@ -8,7 +8,7 @@ boto3
# The Python web framework on which NetBox is built # The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/ # https://docs.djangoproject.com/en/stable/releases/
Django<4.2 Django<5.0
# Django middleware which permits cross-domain API requests # Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@ -84,7 +84,8 @@ feedparser
# Django wrapper for Graphene (GraphQL support) # Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django/releases # https://github.com/graphql-python/graphene-django/releases
graphene_django # Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
graphene_django==3.0.0
# WSGI HTTP server # WSGI HTTP server
# https://docs.gunicorn.org/en/latest/news.html # https://docs.gunicorn.org/en/latest/news.html
@ -120,8 +121,8 @@ netaddr
Pillow Pillow
# PostgreSQL database adapter for Python # PostgreSQL database adapter for Python
# https://www.psycopg.org/docs/news.html # https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg2-binary psycopg[binary,pool]
# YAML rendering library # YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES # https://github.com/yaml/pyyaml/blob/master/CHANGES

View File

@ -204,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
Default: `300` Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds. The maximum execution time of a background task (such as running a custom script), in seconds.
---
## RQ_RETRY_INTERVAL
!!! note
This parameter was added in NetBox v3.5.
Default: `60`
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
---
## RQ_RETRY_MAX
!!! note
This parameter was added in NetBox v3.5.
Default: `0` (retries disabled)
The maximum number of times a background task will be retried before being marked as failed.

View File

@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## DATABASE ## 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 * `NAME` - Database name
* `USER` - PostgreSQL username * `USER` - PostgreSQL username

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). 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" !!! warning "PostgreSQL 12 or later required"
NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported. NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation ## Installation
@ -35,7 +35,7 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo systemctl enable postgresql 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 ```no-highlight
psql -V psql -V

View File

@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Minimum Version | | Dependency | Minimum Version |
|------------|-----------------| |------------|-----------------|
| Python | 3.8 | | Python | 3.8 |
| PostgreSQL | 11 | | PostgreSQL | 12 |
| Redis | 4.0 | | Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference: 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 ## 2. Update Dependencies to Required Versions
NetBox v3.0 and later require the following: NetBox requires the following dependencies:
| Dependency | Minimum Version | | Dependency | Minimum Version |
|------------|-----------------| |------------|-----------------|
| Python | 3.8 | | Python | 3.8 |
| PostgreSQL | 11 | | PostgreSQL | 12 |
| Redis | 4.0 | | Redis | 4.0 |
## 3. Install the Latest Release ## 3. Install the Latest Release

View File

@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
## Interactive Documentation ## Interactive Documentation
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
## Endpoint Hierarchy ## Endpoint Hierarchy

View File

@ -75,5 +75,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache | | HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI | | WSGI service | gunicorn or uWSGI |
| Application | Django/Python | | Application | Django/Python |
| Database | PostgreSQL 11+ | | Database | PostgreSQL 12+ |
| Task queuing | Redis/django-rq | | 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 !!! tip
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy. 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 ### Status
The device's operational status. The device's operational status.

View File

@ -69,10 +69,11 @@ Defines how filters are evaluated against custom field values.
Controls how and whether the custom field is displayed within the NetBox user interface. Controls how and whether the custom field is displayed within the NetBox user interface.
| Option | Description | | Option | Description |
|------------|--------------------------------------| |-------------------|--------------------------------------------------|
| Read/write | Display and permit editing (default) | | Read/write | Display and permit editing (default) |
| Read-only | Display field but disallow editing | | Read-only | Display field but disallow editing |
| Hidden | Do not display field in the UI | | Hidden | Do not display field in the UI |
| Hidden (if unset) | Display in the UI only when a value has been set |
### Default ### Default

View File

@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
### Color ### Color
The color to use when displaying the tag in the NetBox UI. 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

@ -1,10 +1,70 @@
# NetBox v3.5 # NetBox v3.5
## v3.5.2 (FUTURE) ## v3.5.5 (FUTURE)
---
## v3.5.4 (2023-06-20)
### Enhancements
* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices
* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views
* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly
* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu
### Bug Fixes
* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site
* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site
* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint
* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces
* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job
* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs
* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values
* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table
* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100
* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request
---
## v3.5.3 (2023-06-02)
### Enhancements
* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
### Bug Fixes
* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
---
## v3.5.2 (2023-05-22)
### Enhancements ### Enhancements
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use * [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled * [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views * [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import * [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
@ -14,14 +74,23 @@
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab * [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view * [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type * [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty * [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner * [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
### Bug Fixes ### Bug Fixes
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables * [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form * [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute * [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
--- ---

View File

@ -4,9 +4,19 @@
### Breaking Changes ### 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. * 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 ### 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 * [#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 * [#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

@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet):
""" """
Enqueue a job to synchronize the DataSource. Enqueue a job to synchronize the DataSource.
""" """
if not request.user.has_perm('extras.sync_datasource'): if not request.user.has_perm('core.sync_datasource'):
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.") raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
datasource = get_object_or_404(DataSource, pk=pk) datasource = get_object_or_404(DataSource, pk=pk)

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import uuid import uuid
import django_rq 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.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model from utilities.rqworker import get_queue_for_model, get_rq_retry
__all__ = ( __all__ = (
'Job', 'Job',
@ -69,7 +69,7 @@ class Job(models.Model):
blank=True blank=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='+', related_name='+',
blank=True, blank=True,
@ -219,5 +219,6 @@ class Job(models.Model):
event=event, event=event,
data=self.data, data=self.data,
timestamp=str(timezone.now()), timestamp=str(timezone.now()),
username=self.user.username username=self.user.username,
retry=get_rq_retry()
) )

View File

@ -673,9 +673,10 @@ class DeviceSerializer(NetBoxModelSerializer):
model = Device model = Device
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created',
'last_updated',
] ]
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)

View File

@ -1,12 +1,12 @@
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from circuits.models import Circuit from circuits.models import Circuit
@ -14,7 +14,6 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import * from dcim.models import *
from dcim.svg import CableTraceSVG from dcim.svg import CableTraceSVG
from extras.api.nested_serializers import NestedConfigTemplateSerializer
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related from utilities.utils import count_related
@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
# Devices/modules # Devices/modules
# #
class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
ConfigTemplateRenderMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
@ -493,7 +498,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
'vdcs',
) )
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet
@ -640,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet):
def get_view_name(self): def get_view_name(self):
return "Connected Device Locator" return "Connected Device Locator"
@extend_schema(responses={200: OpenApiTypes.OBJECT}) @extend_schema(
parameters=[_device_param, _interface_param],
responses={200: serializers.DeviceSerializer}
)
def list(self, request): def list(self, request):
peer_device_name = request.query_params.get(self._device_param.name) peer_device_name = request.query_params.get(self._device_param.name)

View File

@ -812,8 +812,11 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp' TYPE_800GE_OSFP = '800gbase-x-osfp'
@ -957,8 +960,11 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'), (TYPE_800GE_OSFP, 'OSFP (800GE)'),
) )
@ -1223,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH_PC = 'lsh-pc' TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc' TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc' TYPE_LSH_APC = 'lsh-apc'
TYPE_LX5 = 'lx5'
TYPE_LX5_PC = 'lx5-pc'
TYPE_LX5_UPC = 'lx5-upc'
TYPE_LX5_APC = 'lx5-apc'
TYPE_SPLICE = 'splice' TYPE_SPLICE = 'splice'
TYPE_CS = 'cs' TYPE_CS = 'cs'
TYPE_SN = 'sn' TYPE_SN = 'sn'
@ -1269,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
(TYPE_LSH_PC, 'LSH/PC'), (TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'), (TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'), (TYPE_LSH_APC, 'LSH/APC'),
(TYPE_LX5, 'LX.5'),
(TYPE_LX5_PC, 'LX.5/PC'),
(TYPE_LX5_UPC, 'LX.5/UPC'),
(TYPE_LX5_APC, 'LX.5/APC'),
(TYPE_MPO, 'MPO'), (TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'), (TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'), (TYPE_SC, 'SC'),

View File

@ -11,11 +11,14 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
# #
RACK_U_HEIGHT_DEFAULT = 42 RACK_U_HEIGHT_DEFAULT = 42
RACK_U_HEIGHT_MAX = 100
RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
RACK_STARTING_UNIT_DEFAULT = 1
# #
# RearPorts # RearPorts

View File

@ -1,5 +1,5 @@
import django_filters 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 django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
label=_('Location (slug)'), label=_('Location (slug)'),
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -999,7 +999,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
class Meta: class Meta:
model = Device 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -1219,6 +1219,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name', to_field_name='name',
label=_('Device (name)'), label=_('Device (name)'),
) )
device_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type',
queryset=DeviceType.objects.all(),
label=_('Device type (ID)'),
)
device_type = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type__model',
queryset=DeviceType.objects.all(),
to_field_name='model',
label=_('Device type (model)'),
)
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis', field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),

View File

@ -1,5 +1,6 @@
from django import forms from django import forms
from django.contrib.auth.models import User from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -321,7 +322,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
class RackReservationBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=User.objects.order_by( queryset=get_user_model().objects.order_by(
'username' 'username'
), ),
required=False required=False
@ -1288,8 +1289,13 @@ class InterfaceBulkEditForm(
break break
if site is not None: if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) # Query for VLANs assigned to the same site and VLANs with no site assigned (null).
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) self.fields['untagged_vlan'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = () self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True self.fields['parent'].widget.attrs['disabled'] = True

View File

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

View File

@ -1,5 +1,5 @@
from django import forms 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 django.utils.translation import gettext as _
from dcim.choices import * from dcim.choices import *
@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Virtual Chassis') label=_('Virtual Chassis')
) )
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Device role')
)
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
query_params={ query_params={
'site_id': '$site_id', 'site_id': '$site_id',
'location_id': '$location_id', 'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id' 'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id',
'role_id': '$device_role_id'
}, },
label=_('Device') label=_('Device')
) )
@ -364,7 +376,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Rack') label=_('Rack')
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(
@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), ('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), ('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')), ('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')), ('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')), ('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
'device_id', 'vdc_id')), ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
('Connection', ('cabled', 'connected', 'occupied')), ('Connection', ('cabled', 'connected', 'occupied')),
) )
vdc_id = DynamicModelMultipleChoiceField( vdc_id = DynamicModelMultipleChoiceField(
@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), ('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')), ('Cable', ('cabled', 'occupied')),
) )
model = FrontPort model = FrontPort
@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), ('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')), ('Cable', ('cabled', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'position')), ('Attributes', ('name', 'label', 'position')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
position = forms.CharField( position = forms.CharField(
@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label')), ('Attributes', ('name', 'label')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
) )
role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),

View File

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

View File

@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
choices=[], choices=[],
label=_('Rear ports'), label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'), help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
) )
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position # Override fieldsets from FrontPortTemplateForm to omit rear_port_position

View File

@ -18,6 +18,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='device', model_name='device',
name='position', name='position',
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]),
), ),
] ]

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

View File

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

View File

@ -555,7 +555,7 @@ class Device(PrimaryModel, ConfigContextModel):
decimal_places=1, decimal_places=1,
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1), MaxValueValidator(99.5)], validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
verbose_name='Position (U)', verbose_name='Position (U)',
help_text=_('The lowest-numbered unit occupied by the device') help_text=_('The lowest-numbered unit occupied by the device')
) )
@ -624,6 +624,20 @@ class Device(PrimaryModel, ConfigContextModel):
blank=True, blank=True,
null=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 # Generic relations
contacts = GenericRelation( contacts = GenericRelation(

View File

@ -1,7 +1,7 @@
import decimal import decimal
from functools import cached_property 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.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -126,9 +126,14 @@ class Rack(PrimaryModel, WeightMixin):
u_height = models.PositiveSmallIntegerField( u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT, default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)', verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)], validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units') 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( desc_units = models.BooleanField(
default=False, default=False,
verbose_name='Descending units', verbose_name='Descending units',
@ -228,20 +233,24 @@ class Rack(PrimaryModel, WeightMixin):
raise ValidationError("Must specify a unit when setting a maximum weight") raise ValidationError("Must specify a unit when setting a maximum weight")
if self.pk: if self.pk:
# Validate that Rack is tall enough to house the installed Devices mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position')
top_device = Device.objects.filter(
rack=self # Validate that Rack is tall enough to house the highest mounted Device
).exclude( if top_device := mounted_devices.last():
position__isnull=True min_height = top_device.position + top_device.device_type.u_height - self.starting_unit
).order_by('-position').first()
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
if self.u_height < min_height: if self.u_height < min_height:
raise ValidationError({ raise ValidationError({
'u_height': "Rack must be at least {}U tall to house currently installed devices.".format( 'u_height': f"Rack must be at least {min_height}U tall to house currently installed devices."
min_height
)
}) })
# 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 # Validate that Rack was assigned a Location of its same site, if applicable
if self.location: if self.location:
if self.location.site != self.site: if self.location.site != self.site:
@ -269,8 +278,8 @@ class Rack(PrimaryModel, WeightMixin):
Return a list of unit numbers, top to bottom. Return a list of unit numbers, top to bottom.
""" """
if self.desc_units: if self.desc_units:
return drange(decimal.Decimal(1.0), self.u_height + 1, 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), 0.5, -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): def get_status_color(self):
return RackStatusChoices.colors.get(self.status) return RackStatusChoices.colors.get(self.status)
@ -466,7 +475,7 @@ class Rack(PrimaryModel, WeightMixin):
powerport.get_power_draw()['allocated'] for powerport in powerports powerport.get_power_draw()['allocated'] for powerport in powerports
]) ])
return int(allocated_draw / available_power_total * 100) return round(allocated_draw / available_power_total * 100, 1)
@cached_property @cached_property
def total_weight(self): def total_weight(self):
@ -505,7 +514,7 @@ class RackReservation(PrimaryModel):
null=True null=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT on_delete=models.PROTECT
) )
description = models.CharField( description = models.CharField(

View File

@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs):
Rack.objects.filter(location__in=locations).update(site=instance.site) Rack.objects.filter(location__in=locations).update(site=instance.site)
Device.objects.filter(location__in=locations).update(site=instance.site) Device.objects.filter(location__in=locations).update(site=instance.site)
PowerPanel.objects.filter(location__in=locations).update(site=instance.site) PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
@receiver(post_save, sender=Rack) @receiver(post_save, sender=Rack)

View File

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

View File

@ -236,9 +236,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
fields = ( fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', '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', 'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'tags', 'created', 'last_updated', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', '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.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -14,6 +14,9 @@ from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):
@ -1115,7 +1118,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
device_types = ( device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
) )
DeviceType.objects.bulk_create(device_types) DeviceType.objects.bulk_create(device_types)
@ -1229,6 +1232,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_rack_fit(self):
"""
Check that creating multiple devices with overlapping position fails.
"""
device = Device.objects.first()
device_type = DeviceType.objects.all()[1]
data = [
{
'device_type': device_type.pk,
'device_role': device.device_role.pk,
'site': device.site.pk,
'name': 'Test Device 7',
'rack': device.rack.pk,
'face': 'front',
'position': 1
},
{
'device_type': device_type.pk,
'device_role': device.device_role.pk,
'site': device.site.pk,
'name': 'Test Device 8',
'rack': device.rack.pk,
'face': 'front',
'position': 2
}
]
self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ModuleTest(APIViewTestCases.APIViewTestCase): class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module model = Module

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 django.test import TestCase
from dcim.choices import * from dcim.choices import *
@ -12,6 +12,26 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()
class DeviceComponentFilterSetTests:
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Region.objects.all() queryset = Region.objects.all()
filterset = RegionFilterSet filterset = RegionFilterSet
@ -1621,9 +1641,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
devices = ( 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 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, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), 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, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), 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) Device.objects.bulk_create(devices)
@ -1704,6 +1724,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'position': [1, 2]} params = {'position': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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): def test_vc_position(self):
params = {'vc_position': [1, 2]} params = {'vc_position': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -1994,7 +2022,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
filterset = ConsolePortFilterSet filterset = ConsolePortFilterSet
@ -2023,10 +2051,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2044,10 +2085,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2161,7 +2202,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
filterset = ConsoleServerPortFilterSet filterset = ConsoleServerPortFilterSet
@ -2190,10 +2231,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2211,10 +2265,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2328,7 +2382,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
filterset = PowerPortFilterSet filterset = PowerPortFilterSet
@ -2357,10 +2411,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2378,10 +2445,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2503,7 +2570,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
filterset = PowerOutletFilterSet filterset = PowerOutletFilterSet
@ -2532,10 +2599,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2553,10 +2633,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -2674,7 +2754,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all() queryset = Interface.objects.all()
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
@ -2703,10 +2783,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -2724,10 +2817,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3097,7 +3190,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
filterset = FrontPortFilterSet filterset = FrontPortFilterSet
@ -3126,10 +3219,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3147,10 +3253,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3273,7 +3379,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
filterset = RearPortFilterSet filterset = RearPortFilterSet
@ -3302,10 +3408,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3323,10 +3442,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3443,7 +3562,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ModuleBay.objects.all() queryset = ModuleBay.objects.all()
filterset = ModuleBayFilterSet filterset = ModuleBayFilterSet
@ -3472,9 +3591,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3492,9 +3623,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3560,7 +3691,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
filterset = DeviceBayFilterSet filterset = DeviceBayFilterSet
@ -3589,9 +3720,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'), Site(name='Site X', slug='site-x'),
)) ))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = ( locations = (
Location(name='Location 1', slug='location-1', site=sites[0]), Location(name='Location 1', slug='location-1', site=sites[0]),
@ -3609,9 +3752,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3690,8 +3833,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1') device_types = (
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
regions = ( regions = (
Region(name='Region 1', slug='region-1'), Region(name='Region 1', slug='region-1'),
@ -3732,9 +3886,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
@ -3825,6 +3979,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]} params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]} params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@ -6,7 +6,7 @@ except ImportError:
from backports.zoneinfo import ZoneInfo from backports.zoneinfo import ZoneInfo
import yaml 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.contrib.contenttypes.models import ContentType
from django.test import override_settings from django.test import override_settings
from django.urls import reverse 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 from wireless.models import WirelessLAN
User = get_user_model()
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Region model = Region
@ -389,6 +392,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 500, 'outer_width': 500,
'outer_depth': 500, 'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'starting_unit': 1,
'weight': 100, 'weight': 100,
'max_weight': 2000, 'max_weight': 2000,
'weight_unit': WeightUnitChoices.UNIT_POUND, 'weight_unit': WeightUnitChoices.UNIT_POUND,
@ -1696,6 +1700,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'rack': racks[1].pk, 'rack': racks[1].pk,
'position': 1, 'position': 1,
'face': DeviceFaceChoices.FACE_FRONT, 'face': DeviceFaceChoices.FACE_FRONT,
'latitude': Decimal('35.780000'),
'longitude': Decimal('-78.642000'),
'status': DeviceStatusChoices.STATUS_PLANNED, 'status': DeviceStatusChoices.STATUS_PLANNED,
'primary_ip4': None, 'primary_ip4': None,
'primary_ip6': None, 'primary_ip6': None,

View File

@ -2193,7 +2193,6 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable table = tables.ConsolePortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsolePort) @register_model_view(ConsolePort)
@ -2257,7 +2256,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable table = tables.ConsoleServerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsoleServerPort) @register_model_view(ConsoleServerPort)
@ -2321,7 +2319,6 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable table = tables.PowerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerPort) @register_model_view(PowerPort)
@ -2385,7 +2382,6 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable table = tables.PowerOutletTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerOutlet) @register_model_view(PowerOutlet)
@ -2449,7 +2445,6 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable table = tables.InterfaceTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(Interface) @register_model_view(Interface)
@ -2559,7 +2554,6 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable table = tables.FrontPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(FrontPort) @register_model_view(FrontPort)
@ -2623,7 +2617,6 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable table = tables.RearPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(RearPort) @register_model_view(RearPort)
@ -2687,7 +2680,6 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable table = tables.ModuleBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ModuleBay) @register_model_view(ModuleBay)
@ -2743,7 +2735,6 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable table = tables.DeviceBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(DeviceBay) @register_model_view(DeviceBay)
@ -2868,7 +2859,6 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(InventoryItem) @register_model_view(InventoryItem)

View File

@ -1,129 +1,2 @@
from django.contrib import admin # TODO: Removing this import triggers an import loop due to how form mixins are currently organized
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
from .forms import ConfigRevisionForm 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

@ -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.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
@ -196,12 +196,18 @@ class SavedFilterSerializer(ValidatedModelSerializer):
class TagSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') 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) tagged_items = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Tag model = Tag
fields = [ 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 +262,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
assigned_object = serializers.SerializerMethodField(read_only=True) assigned_object = serializers.SerializerMethodField(read_only=True)
created_by = serializers.PrimaryKeyRelatedField( created_by = serializers.PrimaryKeyRelatedField(
allow_null=True, allow_null=True,
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
default=serializers.CurrentUserDefault() default=serializers.CurrentUserDefault()
) )

View File

@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
VISIBILITY_READ_WRITE = 'read-write' VISIBILITY_READ_WRITE = 'read-write'
VISIBILITY_READ_ONLY = 'read-only' VISIBILITY_READ_ONLY = 'read-only'
VISIBILITY_HIDDEN = 'hidden' VISIBILITY_HIDDEN = 'hidden'
VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
CHOICES = ( CHOICES = (
(VISIBILITY_READ_WRITE, 'Read/Write'), (VISIBILITY_READ_WRITE, 'Read/Write'),
(VISIBILITY_READ_ONLY, 'Read-only'), (VISIBILITY_READ_ONLY, 'Read-only'),
(VISIBILITY_HIDDEN, 'Hidden'), (VISIBILITY_HIDDEN, 'Hidden'),
(VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
) )
@ -208,7 +210,7 @@ class ChangeActionChoices(ChoiceSet):
ACTION_DELETE = 'delete' ACTION_DELETE = 'delete'
CHOICES = ( CHOICES = (
(ACTION_CREATE, 'Create'), (ACTION_CREATE, 'Create', 'green'),
(ACTION_UPDATE, 'Update'), (ACTION_UPDATE, 'Update', 'blue'),
(ACTION_DELETE, 'Delete'), (ACTION_DELETE, 'Delete', 'red'),
) )

View File

@ -65,8 +65,14 @@ class Condition:
""" """
Evaluate the provided data to determine whether it matches the condition. Evaluate the provided data to determine whether it matches the condition.
""" """
def _get(obj, key):
if isinstance(obj, list):
return [dict.get(i, key) for i in obj]
return dict.get(obj, key)
try: try:
value = functools.reduce(dict.get, self.attr.split('.'), data) value = functools.reduce(_get, self.attr.split('.'), data)
except TypeError: except TypeError:
# Invalid key path # Invalid key path
value = None value = None

View File

@ -10,8 +10,9 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Q from django.db.models import Q
from django.http import QueryDict
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
@ -35,7 +36,8 @@ def get_content_type_labels():
return [ return [
(content_type_identifier(ct), content_type_name(ct)) (content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.filter( for ct in ContentType.objects.filter(
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
Q(app_label='extras', model='configcontext')
).order_by('app_label', 'model') ).order_by('app_label', 'model')
] ]
@ -148,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget):
filters = forms.JSONField( filters = forms.JSONField(
required=False, required=False,
label='Object filters', label='Object filters',
help_text=_("Only objects matching the specified filters will be counted") help_text=_("Filters to apply when counting the number of objects")
) )
def clean_filters(self): def clean_filters(self):
@ -157,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget):
dict(data) dict(data)
except TypeError: except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.") raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
for model in get_models_from_content_types(self.cleaned_data.get('models')):
try:
# Validate the filters by creating a QuerySet
model.objects.filter(**data).none()
except Exception:
model_name = model._meta.verbose_name_plural
raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
return data return data
def render(self, request): def render(self, request):
@ -171,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget):
for model in get_models_from_content_types(self.config['models']): for model in get_models_from_content_types(self.config['models']):
permission = get_permission_for_model(model, 'view') permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission): if request.user.has_perm(permission):
url = reverse(get_viewname(model, 'list'))
qs = model.objects.restrict(request.user, 'view') qs = model.objects.restrict(request.user, 'view')
# Apply any specified filters
if filters := self.config.get('filters'): if filters := self.config.get('filters'):
qs = qs.filter(**filters) params = QueryDict(mutable=True)
params.update(filters)
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
qs = filterset(params, qs).qs
url = f'{url}?{params.urlencode()}'
object_count = qs.count object_count = qs.count
counts.append((model, object_count)) counts.append((model, object_count, url))
else: else:
counts.append((model, None)) counts.append((model, None, None))
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
'counts': counts, 'counts': counts,

View File

@ -1,5 +1,5 @@
import django_filters 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.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -16,6 +16,7 @@ from .models import *
__all__ = ( __all__ = (
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet', 'ConfigTemplateFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldFilterSet', 'CustomFieldFilterSet',
@ -159,12 +160,12 @@ class SavedFilterFilterSet(BaseFilterSet):
) )
content_types = ContentTypeFilter() content_types = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -223,12 +224,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
queryset=ContentType.objects.all() queryset=ContentType.objects.all()
) )
created_by_id = django_filters.ModelMultipleChoiceFilter( created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
created_by = django_filters.ModelMultipleChoiceFilter( created_by = django_filters.ModelMultipleChoiceFilter(
field_name='created_by__username', field_name='created_by__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -257,10 +258,13 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
content_type_id = MultiValueNumberFilter( content_type_id = MultiValueNumberFilter(
method='_content_type_id' method='_content_type_id'
) )
for_object_type_id = MultiValueNumberFilter(
method='_for_object_type'
)
class Meta: class Meta:
model = Tag model = Tag
fields = ['id', 'name', 'slug', 'color', 'description'] fields = ['id', 'name', 'slug', 'color', 'description', 'object_types']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -297,6 +301,11 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct() 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): class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
@ -510,12 +519,12 @@ class ObjectChangeFilterSet(BaseFilterSet):
queryset=ContentType.objects.all() queryset=ContentType.objects.all()
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User name'), label=_('User name'),
) )
@ -557,3 +566,27 @@ class ContentTypeFilterSet(django_filters.FilterSet):
Q(app_label__icontains=value) | Q(app_label__icontains=value) |
Q(model__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 .bulk_import import *
from .misc import * from .misc import *
from .mixins import * from .mixins import *
from .config import *
from .scripts import * from .scripts import *

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 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.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -18,6 +18,7 @@ from .mixins import SavedFiltersMixin
__all__ = ( __all__ = (
'ConfigContextFilterForm', 'ConfigContextFilterForm',
'ConfigRevisionFilterForm',
'ConfigTemplateFilterForm', 'ConfigTemplateFilterForm',
'CustomFieldFilterForm', 'CustomFieldFilterForm',
'CustomLinkFilterForm', 'CustomLinkFilterForm',
@ -244,6 +245,11 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
required=False, required=False,
label=_('Tagged object type') 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): class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
@ -385,7 +391,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
widget=DateTimePicker() widget=DateTimePicker()
) )
created_by_id = DynamicModelMultipleChoiceField( created_by_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(
@ -429,7 +435,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
required=False required=False
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(
@ -444,3 +450,9 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
api_url='/api/extras/content-types/', api_url='/api/extras/content-types/',
) )
) )
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
)

View File

@ -1,6 +1,7 @@
import json import json
from django import forms from django import forms
from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -10,17 +11,20 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.config import get_config, PARAMS
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import BootstrapMixin, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField,
SlugField, SlugField,
) )
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = ( __all__ = (
'ConfigContextForm', 'ConfigContextForm',
'ConfigRevisionForm',
'ConfigTemplateForm', 'ConfigTemplateForm',
'CustomFieldForm', 'CustomFieldForm',
'CustomLinkForm', 'CustomLinkForm',
@ -200,15 +204,20 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
class TagForm(BootstrapMixin, forms.ModelForm): class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField() slug = SlugField()
object_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('tags'),
required=False
)
fieldsets = ( fieldsets = (
('Tag', ('name', 'slug', 'color', 'description')), ('Tag', ('name', 'slug', 'color', 'description', 'object_types')),
) )
class Meta: class Meta:
model = Tag model = Tag
fields = [ fields = [
'name', 'slug', 'color', 'description' 'name', 'slug', 'color', 'description', 'object_types',
] ]
@ -374,3 +383,99 @@ class JournalEntryForm(NetBoxModelForm):
'assigned_object_type': forms.HiddenInput, 'assigned_object_type': forms.HiddenInput,
'assigned_object_id': 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

@ -7,12 +7,14 @@ class Empty(Lookup):
Filter on whether a string is empty. Filter on whether a string is empty.
""" """
lookup_name = 'empty' lookup_name = 'empty'
prepare_rhs = False
def as_sql(self, qn, connection): def as_sql(self, compiler, connection):
lhs, lhs_params = self.process_lhs(qn, connection) sql, params = compiler.compile(self.lhs)
rhs, rhs_params = self.process_rhs(qn, connection) if self.rhs:
params = lhs_params + rhs_params return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params else:
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
class NetContainsOrEquals(Lookup): class NetContainsOrEquals(Lookup):

View File

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

@ -1,4 +1,4 @@
from django.contrib.auth.models import User from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
@ -24,7 +24,7 @@ class ObjectChange(models.Model):
db_index=True db_index=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='changes', related_name='changes',
blank=True, blank=True,

View File

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

View File

@ -3,7 +3,7 @@ import urllib.parse
from django.conf import settings from django.conf import settings
from django.contrib import admin 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.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
:param context: The context passed to Jinja2 :param context: The context passed to Jinja2
""" """
text = render_jinja2(self.link_text, context) text = render_jinja2(self.link_text, context).strip()
if not text: if not text:
return {} return {}
link = render_jinja2(self.link_url, context) link = render_jinja2(self.link_url, context).strip()
link_target = ' target="_blank"' if self.new_window else '' link_target = ' target="_blank"' if self.new_window else ''
# Sanitize link text # Sanitize link text
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes) text = clean_html(text, allowed_schemes)
# Sanitize link # Sanitize link
link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#') link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
# Verify link scheme is allowed # Verify link scheme is allowed
result = urllib.parse.urlparse(link) result = urllib.parse.urlparse(link)
@ -362,6 +362,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
Synchronize template content from the designated DataFile (if any). Synchronize template content from the designated DataFile (if any).
""" """
self.template_code = self.data_file.data_as_string self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def render(self, queryset): def render(self, queryset):
""" """
@ -418,7 +419,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
blank=True blank=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, blank=True,
null=True null=True
@ -559,7 +560,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
fk_field='assigned_object_id' fk_field='assigned_object_id'
) )
created_by = models.ForeignKey( created_by = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, blank=True,
null=True null=True
@ -611,6 +612,11 @@ class ConfigRevision(models.Model):
verbose_name='Configuration data' verbose_name='Configuration data'
) )
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['-created']
def __str__(self): def __str__(self):
return f'Config revision #{self.pk} ({self.created})' return f'Config revision #{self.pk} ({self.created})'
@ -619,12 +625,16 @@ class ConfigRevision(models.Model):
return self.data[item] return self.data[item]
return super().__getattribute__(item) return super().__getattribute__(item)
def get_absolute_url(self):
return reverse('extras:configrevision', args=[self.pk])
def activate(self): def activate(self):
""" """
Cache the configuration data. Cache the configuration data.
""" """
cache.set('config', self.data, None) cache.set('config', self.data, None)
cache.set('config_version', self.pk, None) cache.set('config_version', self.pk, None)
activate.alters_data = True
@admin.display(boolean=True) @admin.display(boolean=True)
def is_active(self): def is_active(self):

View File

@ -112,3 +112,7 @@ class StagedChange(ChangeLoggedModel):
instance = self.model.objects.get(pk=self.object_id) instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete() 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.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _
from taggit.models import TagBase, GenericTaggedItemBase from taggit.models import TagBase, GenericTaggedItemBase
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
@ -30,9 +34,16 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
max_length=200, max_length=200,
blank=True, 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 = ( clone_fields = (
'color', 'description', 'color', 'description', 'object_types',
) )
class Meta: class Meta:
@ -61,6 +72,4 @@ class TaggedItem(GenericTaggedItemBase):
) )
class Meta: class Meta:
index_together = ( indexes = [models.Index(fields=["content_type", "object_id"])]
("content_type", "object_id")
)

View File

@ -10,8 +10,9 @@ from extras.validators import CustomValidator
from netbox.config import get_config from netbox.config import get_config
from netbox.context import current_request, webhooks_queue from netbox.context import current_request, webhooks_queue
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices 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 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. Update the cached NetBox configuration when a new ConfigRevision is created.
""" """
instance.activate() 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

@ -9,6 +9,7 @@ from .template_code import *
__all__ = ( __all__ = (
'ConfigContextTable', 'ConfigContextTable',
'ConfigRevisionTable',
'ConfigTemplateTable', 'ConfigTemplateTable',
'CustomFieldTable', 'CustomFieldTable',
'CustomLinkTable', 'CustomLinkTable',
@ -22,6 +23,37 @@ __all__ = (
'WebhookTable', 'WebhookTable',
) )
IMAGEATTACHMENT_IMAGE = '''
{% if record.image %}
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
{% else %}
&mdash;
{% 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): class CustomFieldTable(NetBoxTable):
name = tables.Column( name = tables.Column(
@ -96,6 +128,9 @@ class ImageAttachmentTable(NetBoxTable):
parent = tables.Column( parent = tables.Column(
linkify=True linkify=True
) )
image = tables.TemplateColumn(
template_code=IMAGEATTACHMENT_IMAGE,
)
size = tables.Column( size = tables.Column(
orderable=False, orderable=False,
verbose_name='Size (bytes)' verbose_name='Size (bytes)'
@ -175,10 +210,14 @@ class TagTable(NetBoxTable):
linkify=True linkify=True
) )
color = columns.ColorColumn() color = columns.ColorColumn()
object_types = columns.ContentTypesColumn()
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Tag 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') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')

View File

@ -1,6 +1,6 @@
import datetime 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.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
@ -15,6 +15,9 @@ from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):

View File

@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, timezone 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.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
@ -18,6 +18,9 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
User = get_user_model()
class CustomFieldTestCase(TestCase, BaseFilterSetTests): class CustomFieldTestCase(TestCase, BaseFilterSetTests):
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
filterset = CustomFieldFilterSet filterset = CustomFieldFilterSet
@ -818,6 +821,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
content_types = {
'site': ContentType.objects.get_by_natural_key('dcim', 'site'),
'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'),
}
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
@ -825,6 +832,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
Tag(name='Tag 3', slug='tag-3', color='0000ff'), Tag(name='Tag 3', slug='tag-3', color='0000ff'),
) )
Tag.objects.bulk_create(tags) 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 # Apply some tags so we can filter by content type
site = Site.objects.create(name='Site 1', slug='site-1') site = Site.objects.create(name='Site 1', slug='site-1')
@ -857,6 +866,18 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'content_type_id': [site_ct, provider_ct]} params = {'content_type_id': [site_ct, provider_ct]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.all()

View File

@ -1,8 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, Tag from extras.models import ConfigContext, Tag
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.exceptions import AbortRequest
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -14,6 +16,22 @@ class TagTest(TestCase):
self.assertEqual(tag.slug, 'testing-unicode-台灣') 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): class ConfigContextTest(TestCase):
""" """

View File

@ -1,7 +1,7 @@
import urllib.parse import urllib.parse
import uuid 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.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
@ -11,6 +11,9 @@ from extras.models import *
from utilities.testing import ViewTestCases, TestCase from utilities.testing import ViewTestCases, TestCase
User = get_user_model()
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomField model = CustomField

View File

@ -85,6 +85,13 @@ urlpatterns = [
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))), 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 # Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))), path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
@ -114,5 +121,5 @@ urlpatterns = [
path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'), path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
# Markdown # 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 core.tables import JobTable
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from netbox.config import get_config, PARAMS
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
@ -1176,6 +1177,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 # Markdown
# #

View File

@ -9,6 +9,7 @@ from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry from netbox.registry import registry
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.utils import serialize_object from utilities.utils import serialize_object
from .choices import * from .choices import *
from .models import Webhook from .models import Webhook
@ -116,5 +117,6 @@ def flush_webhooks(queue):
snapshots=data['snapshots'], snapshots=data['snapshots'],
timestamp=str(timezone.now()), timestamp=str(timezone.now()),
username=data['username'], username=data['username'],
request_id=data['request_id'] request_id=data['request_id'],
retry=get_rq_retry()
) )

View File

@ -58,6 +58,7 @@ class AvailableASNSerializer(serializers.Serializer):
Representation of an ASN which does not exist in the database. Representation of an ASN which does not exist in the database.
""" """
asn = serializers.IntegerField(read_only=True) asn = serializers.IntegerField(read_only=True)
description = serializers.CharField(required=False)
def to_representation(self, asn): def to_representation(self, asn):
rir = NestedRIRSerializer(self.context['range'].rir, context={ rir = NestedRIRSerializer(self.context['range'].rir, context={
@ -432,6 +433,7 @@ class AvailableIPSerializer(serializers.Serializer):
family = serializers.IntegerField(read_only=True) family = serializers.IntegerField(read_only=True)
address = serializers.CharField(read_only=True) address = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True)
description = serializers.CharField(required=False)
def to_representation(self, instance): def to_representation(self, instance):
if self.context.get('vrf'): if self.context.get('vrf'):

View File

@ -3,7 +3,9 @@ from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from netaddr import IPSet
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.views import APIView from rest_framework.views import APIView
@ -12,10 +14,12 @@ from circuits.models import Provider
from dcim.models import Site from dcim.models import Site
from ipam import filtersets from ipam import filtersets
from ipam.models import * from ipam.models import *
from ipam.utils import get_next_available_prefix
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.api.viewsets.mixins import ObjectValidationMixin
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_serializer_for_model
from utilities.utils import count_related from utilities.utils import count_related
from . import serializers from . import serializers
from ipam.models import L2VPN, L2VPNTermination from ipam.models import L2VPN, L2VPNTermination
@ -207,237 +211,233 @@ def get_results_limit(request):
return limit return limit
class AvailableASNsView(ObjectValidationMixin, APIView): class AvailableObjectsView(ObjectValidationMixin, APIView):
queryset = ASN.objects.all() """
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): 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) limit = get_results_limit(request)
available_objects = self.get_available_objects(parent, limit)
available_asns = asnrange.get_available_asns()[:limit] serializer = self.read_serializer_class(available_objects, many=True, context={
serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={
'request': request, 'request': request,
'range': asnrange, **self.get_extra_context(parent),
}) })
return Response(serializer.data) 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): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') 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 # Normalize request data to a list of objects
requested_asns = request.data if isinstance(request.data, list) else [request.data] requested_objects = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available # Serialize and validate the request data
available_asns = asnrange.get_available_asns() serializer = self.write_serializer_class(data=requested_objects, many=True, context={
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={
'request': request, '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(): if not serializer.is_valid():
return Response( return Response(
serializer.errors, serializer.errors,
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
requested_prefixes = serializer.validated_data with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
# Allocate prefixes to the requested objects based on availability within the parent available_objects = self.get_available_objects(parent)
for i, requested_prefix in enumerate(requested_prefixes):
# Find the first available prefix equal to or larger than the requested size # Determine if the requested number of objects is available
for available_prefix in available_prefixes.iter_cidrs(): if not self.check_sufficient_available(serializer.validated_data, available_objects):
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:
return Response( return Response(
{ {"detail": f"Insufficient resources are available to satisfy the request"},
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_409_CONFLICT status=status.HTTP_409_CONFLICT
) )
# Remove the allocated prefix from the list of available prefixes # Prepare object data for deserialization
available_prefixes.remove(allocated_prefix) 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 # 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} context = {'request': request}
if isinstance(request.data, list): 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: 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 not serializer.is_valid():
if serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Create the new IP address(es)
try: try:
with transaction.atomic(): with transaction.atomic():
created = serializer.save() created = serializer.save()
self._validate_objects(created) self._validate_objects(created)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise PermissionDenied() raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get_serializer_class(self): class AvailableASNsView(AvailableObjectsView):
if self.request.method == "GET": queryset = ASN.objects.all()
return serializers.AvailablePrefixSerializer read_serializer_class = serializers.AvailableASNSerializer
write_serializer_class = serializers.AvailableASNSerializer
return serializers.PrefixLengthSerializer advisory_lock_key = 'available-asns'
class AvailableIPAddressesView(ObjectValidationMixin, APIView):
queryset = IPAddress.objects.all()
def get_parent(self, request, pk): 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): def get(self, request, pk):
parent = self.get_parent(request, pk) return super().get(request, pk)
limit = get_results_limit(request)
@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 # Calculate available IPs within the parent
ip_list = [] ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1): for index, ip in enumerate(parent.get_available_ips(), start=1):
ip_list.append(ip) ip_list.append(ip)
if index == limit: if index == limit:
break break
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ return ip_list
'request': request,
def get_extra_context(self, parent):
return {
'parent': parent, 'parent': parent,
'vrf': parent.vrf, '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)}) @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') return super().post(request, pk)
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
class PrefixAvailableIPAddressesView(AvailableIPAddressesView): class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk) return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
class AvailableVLANsView(ObjectValidationMixin, APIView): class AvailableVLANsView(AvailableObjectsView):
queryset = VLAN.objects.all() 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)}) @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
def get(self, request, pk): def get(self, request, pk):
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) return super().get(request, 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)
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
def post(self, request, pk): def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add') return super().post(request, pk)
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

View File

@ -1,6 +1,7 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
@ -181,16 +182,31 @@ class PrefixImportForm(NetBoxModelImportForm):
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs) super().__init__(data, *args, **kwargs)
if data: if not data:
return
site = data.get('site')
vlan_group = data.get('vlan_group')
# Limit VLAN queryset by assigned site and/or group (if specified) # Limit VLAN queryset by assigned site and/or group (if specified)
params = {} query = Q()
if data.get('site'):
params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') if site:
if data.get('vlan_group'): query |= Q(**{
params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') f"site__{self.fields['site'].to_field_name}": site
if params: })
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) # Don't Forget to include VLANs without a site in the filter
query |= Q(**{
f"site__{self.fields['site'].to_field_name}__isnull": True
})
if vlan_group:
query &= Q(**{
f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
})
queryset = self.fields['vlan'].queryset.filter(query)
self.fields['vlan'].queryset = queryset
class IPRangeImportForm(NetBoxModelImportForm): class IPRangeImportForm(NetBoxModelImportForm):

View File

@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
vlan = DynamicModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
selector=True,
label=_('VLAN'), label=_('VLAN'),
query_params={
'site_id': '$site',
}
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
@ -328,6 +326,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
): ):
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True
self.fields['vminterface'].disabled = True
self.fields['fhrpgroup'].disabled = True
def clean(self): def clean(self):
super().clean() super().clean()
@ -340,7 +344,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
selected_objects[1]: "An IP address can only be assigned to a single object." selected_objects[1]: "An IP address can only be assigned to a single object."
}) })
elif selected_objects: elif selected_objects:
self.instance.assigned_object = self.cleaned_data[selected_objects[0]] assigned_object = self.cleaned_data[selected_objects[0]]
if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
raise ValidationError(
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
)
self.instance.assigned_object = assigned_object
else: else:
self.instance.assigned_object = None self.instance.assigned_object = None
@ -351,6 +360,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
) )
# Do not allow assigning a network ID or broadcast address to an interface.
if interface and (address := self.cleaned_data.get('address')):
if address.ip == address.network:
msg = f"{address} is a network ID, which may not be assigned to an interface."
if address.version == 4 and address.prefixlen not in (31, 32):
raise ValidationError(msg)
if address.version == 6 and address.prefixlen not in (127, 128):
raise ValidationError(msg)
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
raise ValidationError(msg)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs) ipaddress = super().save(*args, **kwargs)

View File

@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200) self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import(self):
"""
Custom import test for YAML-based imports (versus CSV)
"""
IMPORT_DATA = """
prefix: 10.1.1.0/24
status: active
vlan: 101
site: Site 1
"""
# Note, a site is not tied to the VLAN to verify the fix for #12622
VLAN.objects.create(vid=101, name='VLAN101')
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.1.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.vlan.vid, 101)
self.assertEqual(prefix.site.name, "Site 1")
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_group(self):
"""
This test covers a unique import edge case where VLAN group is specified during the import.
"""
IMPORT_DATA = """
prefix: 10.1.2.0/24
status: active
vlan: 102
site: Site 1
vlan_group: Group 1
"""
vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.2.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.vlan.vid, 102)
self.assertEqual(prefix.site.name, "Site 1")
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange model = IPRange

View File

@ -1,7 +1,15 @@
import netaddr import netaddr
from .constants import * from .constants import *
from .models import ASN, Prefix, VLAN from .models import Prefix, VLAN
__all__ = (
'add_available_ipaddresses',
'add_available_vlans',
'add_requested_prefixes',
'get_next_available_prefix',
'rebuild_prefixes',
)
def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True): def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True):
@ -184,3 +192,15 @@ def rebuild_prefixes(vrf):
# Final flush of any remaining Prefixes # Final flush of any remaining Prefixes
Prefix.objects.bulk_update(update_queue, ['_depth', '_children']) Prefix.objects.bulk_update(update_queue, ['_depth', '_children'])
def get_next_available_prefix(ipset, prefix_size):
"""
Given a prefix length, allocate the next available prefix from an IPSet.
"""
for available_prefix in ipset.iter_cidrs():
if prefix_size >= available_prefix.prefixlen:
allocated_prefix = f"{available_prefix.network}/{prefix_size}"
ipset.remove(allocated_prefix)
return allocated_prefix
return None

View File

@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model
__all__ = ( __all__ = (
'BriefModeMixin', 'BriefModeMixin',
'BulkDestroyModelMixin',
'BulkUpdateModelMixin', 'BulkUpdateModelMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
'ExportTemplatesMixin', 'ExportTemplatesMixin',
'BulkDestroyModelMixin',
'ObjectValidationMixin', 'ObjectValidationMixin',
'SequentialBulkCreatesMixin',
) )
@ -94,6 +95,30 @@ class ExportTemplatesMixin:
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
class SequentialBulkCreatesMixin:
"""
Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
appropriately.
"""
@transaction.atomic
def create(self, request, *args, **kwargs):
if not isinstance(request.data, list):
# Creating a single object
return super().create(request, *args, **kwargs)
return_data = []
for data in request.data:
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return_data.append(serializer.data)
headers = self.get_success_headers(serializer.data)
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
class BulkUpdateModelMixin: class BulkUpdateModelMixin:
""" """
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one

View File

@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet):
# create the new filter with the same type because there is no guarantee the defined type # create the new filter with the same type because there is no guarantee the defined type
# is the same as the default type for the field # is the same as the default type for the field
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
new_filter = type(existing_filter)( filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
new_filter = filter_cls(
field_name=field_name, field_name=field_name,
lookup_expr=lookup_expr, lookup_expr=lookup_expr,
label=existing_filter.label, label=existing_filter.label,
@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
return filters return filters
@classmethod
def filter_for_lookup(cls, field, lookup_type):
if lookup_type == 'empty':
return django_filters.BooleanFilter, {}
return super().filter_for_lookup(field, lookup_type)
class ChangeLoggedModelFilterSet(BaseFilterSet): class ChangeLoggedModelFilterSet(BaseFilterSet):
""" """

View File

@ -31,6 +31,13 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
required=False required=False
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit tags to those applicable to the object type
if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'):
self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk)
def _get_content_type(self): def _get_content_type(self):
return ContentType.objects.get_for_model(self._meta.model) return ContentType.objects.get_for_model(self._meta.model)

View File

@ -181,19 +181,23 @@ class MaintenanceModeMiddleware:
def __call__(self, request): def __call__(self, request):
if get_config().MAINTENANCE_MODE: if get_config().MAINTENANCE_MODE:
self._prevent_db_write_operations() self._set_session_type(
allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
)
return self.get_response(request) return self.get_response(request)
@staticmethod @staticmethod
def _prevent_db_write_operations(): def _set_session_type(allow_write):
""" """
Prevent any write-related database operations. Prevent any write-related database operations.
Args:
allow_write (bool): If True, write operations will be permitted.
""" """
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( mode = 'READ WRITE' if allow_write else 'READ ONLY'
'SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;' cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
)
def process_exception(self, request, exception): def process_exception(self, request, exception):
""" """

View File

@ -71,6 +71,7 @@ class ChangeLoggingMixin(models.Model):
`_prechange_snapshot` on the instance. `_prechange_snapshot` on the instance.
""" """
self._prechange_snapshot = self.serialize_object() self._prechange_snapshot = self.serialize_object()
snapshot.alters_data = True
def to_objectchange(self, action): def to_objectchange(self, action):
""" """
@ -197,11 +198,15 @@ class CustomFieldsMixin(models.Model):
data = {} data = {}
for field in CustomField.objects.get_for_model(self): for field in CustomField.objects.get_for_model(self):
value = self.custom_field_data.get(field.name)
# Skip fields that are hidden if 'omit_hidden' is set # Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: if omit_hidden:
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
continue continue
value = self.custom_field_data.get(field.name)
data[field] = field.deserialize(value) data[field] = field.deserialize(value)
return data return data
@ -227,6 +232,8 @@ class CustomFieldsMixin(models.Model):
for cf in visible_custom_fields: for cf in visible_custom_fields:
value = self.custom_field_data.get(cf.name) value = self.custom_field_data.get(cf.name)
if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
continue
value = cf.deserialize(value) value = cf.deserialize(value)
groups[cf.group_name][cf] = value groups[cf.group_name][cf] = value
@ -238,6 +245,7 @@ class CustomFieldsMixin(models.Model):
""" """
for cf in self.custom_fields: for cf in self.custom_fields:
self.custom_field_data[cf.name] = cf.default self.custom_field_data[cf.name] = cf.default
populate_custom_field_defaults.alters_data = True
def clean(self): def clean(self):
super().clean() super().clean()
@ -413,6 +421,7 @@ class SyncedDataMixin(models.Model):
self.data_synced = None self.data_synced = None
super().clean() super().clean()
clean.alters_data = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
from core.models import AutoSyncRecord from core.models import AutoSyncRecord
@ -460,6 +469,7 @@ class SyncedDataMixin(models.Model):
self.data_synced = timezone.now() self.data_synced = timezone.now()
if save: if save:
self.save() self.save()
sync.alters_data = True
def sync_data(self): def sync_data(self):
""" """

View File

@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
label=_('Connections'), label=_('Connections'),
items=( items=(
get_model_item('dcim', 'cable', _('Cables'), actions=['import']), get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
MenuItem( MenuItem(
link='dcim:interface_connections_list', link='dcim:interface_connections_list',
link_text=_('Interface Connections'), link_text=_('Interface Connections'),
@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu(
MenuItem( MenuItem(
link='extras:report_list', link='extras:report_list',
link_text=_('Reports'), link_text=_('Reports'),
permissions=['extras.view_report'] permissions=['extras.view_report'],
buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
), ),
MenuItem( MenuItem(
link='extras:script_list', link='extras:script_list',
link_text=_('Scripts'), link_text=_('Scripts'),
permissions=['extras.view_script'] permissions=['extras.view_script'],
buttons=get_model_buttons('extras', "scriptmodule", actions=['add'])
), ),
), ),
), ),
@ -356,10 +358,19 @@ ADMIN_MENU = Menu(
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
), ),
), ),
MenuGroup(
label=_('Configuration'),
items=(
MenuItem(
link='extras:configrevision_list',
link_text=_('Config Revisions'),
permissions=['extras.view_configrevision']
),
),
),
), ),
) )
MENUS = [ MENUS = [
ORGANIZATION_MENU, ORGANIZATION_MENU,
DEVICES_MENU, DEVICES_MENU,

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup # Environment setup
# #
VERSION = '3.5.2-dev' VERSION = '3.5.5-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -140,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
@ -478,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
f'/{BASE_PATH}metrics', f'/{BASE_PATH}metrics',
) )
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/',
)
SERIALIZATION_MODULES = { SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json', 'json': 'utilities.serializers.json',
} }

View File

@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
return '' return ''
model = table.Meta.model model = table.Meta.model
request = getattr(table, 'context', {}).get('request') if request := getattr(table, 'context', {}).get('request'):
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else '' return_url = request.GET.get('return_url', request.get_full_path())
url_appendix = f'?return_url={quote(return_url)}'
else:
url_appendix = ''
html = '' html = ''
# Compile actions menu # Compile actions menu

View File

@ -140,10 +140,14 @@ class BaseTable(tables.Table):
if request.user.is_authenticated: if request.user.is_authenticated:
table_name = self.__class__.__name__ table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET: if self.prefixed_order_by_field in request.GET:
if request.GET[self.prefixed_order_by_field]:
# If an ordering has been specified as a query parameter, save it as the # If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table. # user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field) ordering = request.GET.getlist(self.prefixed_order_by_field)
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
else:
# If the ordering has been set to none (empty), clear any existing preference.
request.user.config.clear(f'tables.{table_name}.ordering', commit=True)
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any). # If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering self.order_by = ordering

View File

@ -1,7 +1,8 @@
import datetime import datetime
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, User from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import Client from django.test import Client
from django.test.utils import override_settings from django.test.utils import override_settings
@ -16,6 +17,9 @@ from utilities.testing import TestCase
from utilities.testing.api import APITestCase from utilities.testing.api import APITestCase
User = get_user_model()
class TokenAuthenticationTestCase(APITestCase): class TokenAuthenticationTestCase(APITestCase):
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])

Binary file not shown.

Binary file not shown.

View File

@ -30,6 +30,7 @@
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"gridstack": "^7.2.3", "gridstack": "^7.2.3",
"html-entities": "^2.3.3",
"htmx.org": "^1.8.0", "htmx.org": "^1.8.0",
"just-debounce-it": "^3.1.1", "just-debounce-it": "^3.1.1",
"query-string": "^7.1.1", "query-string": "^7.1.1",

View File

@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util';
import { initButtons } from './buttons'; import { initButtons } from './buttons';
import { initSelect } from './select'; import { initSelect } from './select';
import { initObjectSelector } from './objectSelector'; import { initObjectSelector } from './objectSelector';
import { initBootstrap } from './bs';
function initDepedencies(): void { function initDepedencies(): void {
for (const init of [initButtons, initSelect, initObjectSelector]) { for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
init(); init();
} }
} }
@ -22,4 +23,8 @@ export function initHtmx(): void {
} }
} }
} }
for (const element of getElements('[hx-trigger=load]')) {
element.addEventListener('htmx:afterSettle', initDepedencies);
}
} }

View File

@ -1,5 +1,6 @@
import { readableColor } from 'color2k'; import { readableColor } from 'color2k';
import debounce from 'just-debounce-it'; import debounce from 'just-debounce-it';
import { encode } from 'html-entities';
import queryString from 'query-string'; import queryString from 'query-string';
import SlimSelect from 'slim-select'; import SlimSelect from 'slim-select';
import { createToast } from '../../bs'; import { createToast } from '../../bs';
@ -446,7 +447,7 @@ export class APISelect {
// Build SlimSelect options from all already-selected options. // Build SlimSelect options from all already-selected options.
const preSelectedOptions = preSelected.map(option => ({ const preSelectedOptions = preSelected.map(option => ({
value: option.value, value: option.value,
text: option.innerText, text: encode(option.innerText),
selected: true, selected: true,
disabled: false, disabled: false,
})) as Option[]; })) as Option[];
@ -454,7 +455,7 @@ export class APISelect {
let options = [] as Option[]; let options = [] as Option[];
for (const result of data.results) { for (const result of data.results) {
let text = result.display; let text = encode(result.display);
if (typeof result._depth === 'number' && result._depth > 0) { if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text. // If the object has a `_depth` property, indent its display text.

View File

@ -1818,6 +1818,11 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
html-entities@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46"
integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==
htmx.org@^1.8.0: htmx.org@^1.8.0:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3" resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3"

View File

@ -1,37 +0,0 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block content %}
<p>Restore configuration #{{ object.pk }} from <strong>{{ object.created }}</strong>?</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Current Value</th>
<th>New Value</th>
<th></th>
</tr>
</thead>
<tbody>
{% for param, current, new in params %}
<tr{% if current != new %} style="color: #d7a50d"{% endif %}>
<td>{{ param }}</td>
<td>{{ current }}</td>
<td>{{ new }}</td>
<td>{% if current != new %}<img src="{% static 'admin/img/icon-changelink.svg' %}" alt="*" title="Changed">{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<form method="post">
{% csrf_token %}
<div class="submit-row" style="margin-top: 20px">
<input type="submit" name="restore" value="Restore" class="default" style="float: left" />
<a href="{% url 'admin:extras_configrevision_changelist' %}" style="float: left; margin: 2px 0; padding: 10px 15px">Cancel</a>
</div>
</form>
{% endblock content %}

View File

@ -76,6 +76,23 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">GPS Coordinates</th>
<td class="position-relative">
{% if object.latitude and object.longitude %}
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
{% endif %}
<span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr> <tr>
<th scope="row">Tenant</th> <th scope="row">Tenant</th>
<td> <td>

View File

@ -28,11 +28,25 @@
</div> </div>
<div class="col-7"> <div class="col-7">
<div class="card"> <div class="card">
<h5 class="card-header">Context Data</h5> <div class="accordion accordion-flush" id="renderConfig">
<div class="card-body">
<div class="accordion-item">
<h2 class="accordion-header" id="renderConfigHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
Context Data
</button>
</h2>
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
<div class="accordion-body">
<pre class="card-body">{{ context_data|pprint }}</pre> <pre class="card-body">{{ context_data|pprint }}</pre>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="card"> <div class="card">

View File

@ -53,6 +53,8 @@
{% else %} {% else %}
{% render_field form.face %} {% render_field form.face %}
{% render_field form.position %} {% render_field form.position %}
{% render_field form.latitude %}
{% render_field form.longitude %}
{% endif %} {% endif %}
</div> </div>

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