Merge remote-tracking branch 'origin/feature' into feat/12882-contact-assignments-tags

# Conflicts:
#	requirements.txt
This commit is contained in:
Abhimanyu Saharan 2023-07-08 12:52:43 +05:30
commit 73dcae2fd1
107 changed files with 1898 additions and 734 deletions

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
@ -121,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

@ -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

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

View File

@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). 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

@ -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

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

View File

@ -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

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

View File

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

View File

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

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
@ -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,

View File

@ -635,8 +635,8 @@ class PlatformSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
@ -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

@ -17,6 +17,8 @@ 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)'),
) )
@ -811,7 +811,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = Platform model = Platform
fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] fields = ['id', 'name', 'slug', 'description']
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
@ -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():

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
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.utils.translation import gettext as _ from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -322,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
@ -472,10 +472,6 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
napalm_driver = forms.CharField(
max_length=50,
required=False
)
config_template = DynamicModelChoiceField( config_template = DynamicModelChoiceField(
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
required=False required=False
@ -487,9 +483,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
model = Platform model = Platform
fieldsets = ( fieldsets = (
(None, ('manufacturer', 'config_template', 'napalm_driver', 'description')), (None, ('manufacturer', 'config_template', 'description')),
) )
nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description') nullable_fields = ('manufacturer', 'config_template', 'description')
class DeviceBulkEditForm(NetBoxModelBulkEditForm): class DeviceBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -365,7 +365,7 @@ class PlatformImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ( fields = (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
) )
@ -478,8 +478,9 @@ class DeviceImportForm(BaseDeviceImportForm):
class Meta(BaseDeviceImportForm.Meta): 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 *
@ -376,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(

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'
) )
) )
@ -360,19 +360,14 @@ class PlatformForm(NetBoxModelForm):
) )
fieldsets = ( fieldsets = (
('Platform', ( ('Platform', ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')),
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
)),
) )
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
] ]
widgets = {
'napalm_args': forms.Textarea(),
}
class DeviceForm(TenancyForm, NetBoxModelForm): class DeviceForm(TenancyForm, NetBoxModelForm):
@ -454,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

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

View File

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

View File

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

View File

@ -359,6 +359,7 @@ class CableTermination(ChangeLoggedModel):
# Circuit terminations # 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

@ -432,9 +432,8 @@ class DeviceRole(OrganizationalModel):
class Platform(OrganizationalModel): class Platform(OrganizationalModel):
""" """
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by Platform may optionally be associated with a particular Manufacturer.
specifying a NAPALM driver.
""" """
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
to='dcim.Manufacturer', to='dcim.Manufacturer',
@ -451,18 +450,6 @@ class Platform(OrganizationalModel):
blank=True, blank=True,
null=True null=True
) )
napalm_driver = models.CharField(
max_length=50,
blank=True,
verbose_name='NAPALM driver',
help_text=_('The name of the NAPALM driver to use when interacting with devices')
)
napalm_args = models.JSONField(
blank=True,
null=True,
verbose_name='NAPALM arguments',
help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)')
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:platform', args=[self.pk]) return reverse('dcim:platform', args=[self.pk])
@ -637,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
@ -129,6 +129,11 @@ class Rack(PrimaryModel, WeightMixin):
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], 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)
@ -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

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

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

@ -137,11 +137,11 @@ class PlatformTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = models.Platform model = models.Platform
fields = ( fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver', 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
) )
@ -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):

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,9 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()
class DeviceComponentFilterSetTests: class DeviceComponentFilterSetTests:
def test_device_type(self): def test_device_type(self):
@ -1515,9 +1518,9 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
platforms = ( platforms = (
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'), Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='A'),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'), Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='B'),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'), Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='C'),
) )
Platform.objects.bulk_create(platforms) Platform.objects.bulk_create(platforms)
@ -1533,10 +1536,6 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['A', 'B']} params = {'description': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_napalm_driver(self):
params = {'napalm_driver': ['driver-1', 'driver-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_manufacturer(self): def test_manufacturer(self):
manufacturers = Manufacturer.objects.all()[:2] manufacturers = Manufacturer.objects.all()[:2]
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
@ -1642,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)
@ -1725,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)

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,
@ -1609,8 +1613,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'name': 'Platform X', 'name': 'Platform X',
'slug': 'platform-x', 'slug': 'platform-x',
'manufacturer': manufacturer.pk, 'manufacturer': manufacturer.pk,
'napalm_driver': 'junos',
'napalm_args': None,
'description': 'A new platform', 'description': 'A new platform',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
@ -1630,7 +1632,6 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'napalm_driver': 'ios',
'description': 'New description', 'description': 'New description',
} }
@ -1699,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

@ -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

@ -4,6 +4,7 @@ from extras import models
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
__all__ = [ __all__ = [
'NestedBookmarkSerializer',
'NestedConfigContextSerializer', 'NestedConfigContextSerializer',
'NestedConfigTemplateSerializer', 'NestedConfigTemplateSerializer',
'NestedCustomFieldSerializer', 'NestedCustomFieldSerializer',
@ -73,6 +74,14 @@ class NestedSavedFilterSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name', 'slug'] fields = ['id', 'url', 'display', 'name', 'slug']
class NestedBookmarkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
class Meta:
model = models.Bookmark
fields = ['id', 'url', 'display', 'object_id', 'object_type']
class NestedImageAttachmentSerializer(WritableNestedSerializer): class NestedImageAttachmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')

View File

@ -1,4 +1,4 @@
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.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
@ -31,6 +31,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
from .nested_serializers import * from .nested_serializers import *
__all__ = ( __all__ = (
'BookmarkSerializer',
'ConfigContextSerializer', 'ConfigContextSerializer',
'ConfigTemplateSerializer', 'ConfigTemplateSerializer',
'ContentTypeSerializer', 'ContentTypeSerializer',
@ -190,18 +191,48 @@ class SavedFilterSerializer(ValidatedModelSerializer):
] ]
#
# Bookmarks
#
class BookmarkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
object_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
)
object = serializers.SerializerMethodField(read_only=True)
user = NestedUserSerializer()
class Meta:
model = Bookmark
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, instance):
serializer = get_serializer_for_model(instance.object, prefix=NESTED_SERIALIZER_PREFIX)
return serializer(instance.object, context={'request': self.context['request']}).data
# #
# Tags # Tags
# #
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 +287,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

@ -12,6 +12,7 @@ router.register('custom-fields', views.CustomFieldViewSet)
router.register('custom-links', views.CustomLinkViewSet) router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet) router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet) router.register('saved-filters', views.SavedFilterViewSet)
router.register('bookmarks', views.BookmarkViewSet)
router.register('tags', views.TagViewSet) router.register('tags', views.TagViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet) router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet) router.register('journal-entries', views.JournalEntryViewSet)

View File

@ -93,6 +93,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
filterset_class = filtersets.SavedFilterFilterSet filterset_class = filtersets.SavedFilterFilterSet
#
# Bookmarks
#
class BookmarkViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Bookmark.objects.all()
serializer_class = serializers.BookmarkSerializer
filterset_class = filtersets.BookmarkFilterSet
# #
# Tags # Tags
# #

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import django_filters 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 _
@ -15,7 +15,9 @@ from .filters import TagFilter
from .models import * from .models import *
__all__ = ( __all__ = (
'BookmarkFilterSet',
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ConfigRevisionFilterSet',
'ConfigTemplateFilterSet', 'ConfigTemplateFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldFilterSet', 'CustomFieldFilterSet',
@ -159,12 +161,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)'),
) )
@ -198,6 +200,26 @@ class SavedFilterFilterSet(BaseFilterSet):
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
class BookmarkFilterSet(BaseFilterSet):
created = django_filters.DateTimeFilter()
object_type_id = MultiValueNumberFilter()
object_type = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
class Meta:
model = Bookmark
fields = ['id', 'object_id']
class ImageAttachmentFilterSet(BaseFilterSet): class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
@ -223,12 +245,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 +279,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 +322,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 +540,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 +587,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,6 +11,7 @@ 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 BootstrapMixin, add_blank_choice
@ -19,8 +21,11 @@ from utilities.forms.fields import (
) )
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = ( __all__ = (
'BookmarkForm',
'ConfigContextForm', 'ConfigContextForm',
'ConfigRevisionForm',
'ConfigTemplateForm', 'ConfigTemplateForm',
'CustomFieldForm', 'CustomFieldForm',
'CustomLinkForm', 'CustomLinkForm',
@ -165,6 +170,17 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
super().__init__(*args, initial=initial, **kwargs) super().__init__(*args, initial=initial, **kwargs)
class BookmarkForm(BootstrapMixin, forms.ModelForm):
object_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('bookmarks').get_query()
)
class Meta:
model = Bookmark
fields = ('object_type', 'object_id')
class WebhookForm(BootstrapMixin, forms.ModelForm): class WebhookForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
@ -200,15 +216,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 +395,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

@ -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

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

View File

@ -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

@ -1,9 +1,8 @@
import json import json
import urllib.parse import urllib.parse
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
@ -29,6 +28,7 @@ from utilities.querysets import RestrictedQuerySet
from utilities.utils import clean_html, dict_to_querydict, render_jinja2 from utilities.utils import clean_html, dict_to_querydict, render_jinja2
__all__ = ( __all__ = (
'Bookmark',
'ConfigRevision', 'ConfigRevision',
'CustomLink', 'CustomLink',
'ExportTemplate', 'ExportTemplate',
@ -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
@ -558,7 +559,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
@ -593,6 +594,44 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
return JournalEntryKindChoices.colors.get(self.kind) return JournalEntryKindChoices.colors.get(self.kind)
class Bookmark(models.Model):
"""
An object bookmarked by a User.
"""
created = models.DateTimeField(
auto_now_add=True
)
object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT
)
object_id = models.PositiveBigIntegerField()
object = GenericForeignKey(
ct_field='object_type',
fk_field='object_id'
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.PROTECT
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('created', 'pk')
constraints = (
models.UniqueConstraint(
fields=('object_type', 'object_id', 'user'),
name='%(app_label)s_%(class)s_unique_per_object_and_user'
),
)
def __str__(self):
if self.object:
return str(self.object)
return super().__str__()
class ConfigRevision(models.Model): class ConfigRevision(models.Model):
""" """
An atomic revision of NetBox's configuration. An atomic revision of NetBox's configuration.
@ -610,6 +649,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})'
@ -618,12 +662,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,6 +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): def get_action_color(self):
return ChangeActionChoices.colors.get(self.action) 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

@ -8,7 +8,9 @@ from netbox.tables import NetBoxTable, columns
from .template_code import * from .template_code import *
__all__ = ( __all__ = (
'BookmarkTable',
'ConfigContextTable', 'ConfigContextTable',
'ConfigRevisionTable',
'ConfigTemplateTable', 'ConfigTemplateTable',
'CustomFieldTable', 'CustomFieldTable',
'CustomLinkTable', 'CustomLinkTable',
@ -30,6 +32,29 @@ IMAGEATTACHMENT_IMAGE = '''
{% endif %} {% 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(
@ -143,6 +168,21 @@ class SavedFilterTable(NetBoxTable):
) )
class BookmarkTable(NetBoxTable):
object_type = columns.ContentTypeColumn()
object = tables.Column(
linkify=True
)
actions = columns.ActionsColumn(
actions=('delete',)
)
class Meta(NetBoxTable.Meta):
model = Bookmark
fields = ('pk', 'object', 'object_type', 'created')
default_columns = ('object', 'object_type', 'created')
class WebhookTable(NetBoxTable): class WebhookTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
@ -186,10 +226,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
@ -14,6 +14,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):
@ -264,6 +267,58 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
savedfilter.content_types.set([site_ct]) savedfilter.content_types.set([site_ct])
class BookmarkTest(
APIViewTestCases.GetObjectViewTestCase,
APIViewTestCases.ListObjectsViewTestCase,
APIViewTestCases.CreateObjectViewTestCase,
APIViewTestCases.DeleteObjectViewTestCase
):
model = Bookmark
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url']
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
Site(name='Site 5', slug='site-5'),
Site(name='Site 6', slug='site-6'),
)
Site.objects.bulk_create(sites)
def setUp(self):
super().setUp()
sites = Site.objects.all()
bookmarks = (
Bookmark(object=sites[0], user=self.user),
Bookmark(object=sites[1], user=self.user),
Bookmark(object=sites[2], user=self.user),
)
Bookmark.objects.bulk_create(bookmarks)
self.create_data = [
{
'object_type': 'dcim.site',
'object_id': sites[3].pk,
'user': self.user.pk,
},
{
'object_type': 'dcim.site',
'object_id': sites[4].pk,
'user': self.user.pk,
},
{
'object_type': 'dcim.site',
'object_id': sites[5].pk,
'user': self.user.pk,
},
]
class ExportTemplateTest(APIViewTestCases.APIViewTestCase): class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate model = ExportTemplate
brief_fields = ['display', 'id', 'name', 'url'] brief_fields = ['display', 'id', 'name', 'url']

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
@ -362,6 +365,77 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class BookmarkTestCase(TestCase, BaseFilterSetTests):
queryset = Bookmark.objects.all()
filterset = BookmarkFilterSet
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
)
User.objects.bulk_create(users)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
bookmarks = (
Bookmark(
object=sites[0],
user=users[0],
),
Bookmark(
object=sites[1],
user=users[1],
),
Bookmark(
object=sites[2],
user=users[2],
),
Bookmark(
object=tenants[0],
user=users[0],
),
Bookmark(
object=tenants[1],
user=users[1],
),
Bookmark(
object=tenants[2],
user=users[2],
),
)
Bookmark.objects.bulk_create(bookmarks)
def test_object_type(self):
params = {'object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'object_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_user(self):
users = User.objects.filter(username__startswith='User')
params = {'user': [users[0].username, users[1].username]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'user_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ExportTemplateTestCase(TestCase, BaseFilterSetTests): class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet filterset = ExportTemplateFilterSet
@ -818,6 +892,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 +903,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 +937,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
@ -178,6 +181,54 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class BookmarkTestCase(
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Bookmark
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
)
Site.objects.bulk_create(sites)
cls.form_data = {
'object_type': site_ct.pk,
'object_id': sites[3].pk,
}
def setUp(self):
super().setUp()
sites = Site.objects.all()
user = self.user
bookmarks = (
Bookmark(object=sites[0], user=user),
Bookmark(object=sites[1], user=user),
Bookmark(object=sites[2], user=user),
)
Bookmark.objects.bulk_create(bookmarks)
def _get_url(self, action, instance=None):
if action == 'list':
return reverse('users:bookmarks')
return super()._get_url(action, instance)
def test_list_objects_anonymous(self):
return
def test_list_objects_with_constrained_permission(self):
return
class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ExportTemplate model = ExportTemplate

View File

@ -1,4 +1,4 @@
from django.urls import include, path, re_path from django.urls import include, path
from extras import views from extras import views
from utilities.urls import get_model_urls from utilities.urls import get_model_urls
@ -40,6 +40,11 @@ urlpatterns = [
path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'), path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))), path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
# Bookmarks
path('bookmarks/add/', views.BookmarkCreateView.as_view(), name='bookmark_add'),
path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
# Webhooks # Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'), path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
@ -85,6 +90,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 +126,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
@ -236,6 +237,35 @@ class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
table = tables.SavedFilterTable table = tables.SavedFilterTable
#
# Bookmarks
#
class BookmarkCreateView(generic.ObjectEditView):
form = forms.BookmarkForm
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
def alter_object(self, obj, request, url_args, url_kwargs):
obj.user = request.user
return obj
@register_model_view(Bookmark, 'delete')
class BookmarkDeleteView(generic.ObjectDeleteView):
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
class BookmarkBulkDeleteView(generic.BulkDeleteView):
table = tables.BookmarkTable
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
# #
# Webhooks # Webhooks
# #
@ -1176,6 +1206,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

@ -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={
@ -433,6 +434,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

@ -5,7 +5,9 @@ from django.db.models.functions import Round
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
@ -14,10 +16,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
context = {'request': request} serializer_class = get_serializer_for_model(self.queryset.model)
if isinstance(request.data, list): context = {'request': request}
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context) if isinstance(request.data, list):
else: serializer = serializer_class(data=requested_objects, many=True, context=context)
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context) else:
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.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.data, status=status.HTTP_201_CREATED)
def get_serializer_class(self):
if self.request.method == "GET":
return serializers.AvailablePrefixSerializer
return serializers.PrefixLengthSerializer
class AvailableIPAddressesView(ObjectValidationMixin, APIView): class AvailableASNsView(AvailableObjectsView):
queryset = IPAddress.objects.all() queryset = ASN.objects.all()
read_serializer_class = serializers.AvailableASNSerializer
write_serializer_class = serializers.AvailableASNSerializer
advisory_lock_key = 'available-asns'
def get_parent(self, request, pk): 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,
}) }
return Response(serializer.data) 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 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,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

@ -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

@ -18,6 +18,7 @@ __all__ = (
class NetBoxFeatureSet( class NetBoxFeatureSet(
BookmarksMixin,
ChangeLoggingMixin, ChangeLoggingMixin,
CustomFieldsMixin, CustomFieldsMixin,
CustomLinksMixin, CustomLinksMixin,

View File

@ -22,6 +22,7 @@ from utilities.utils import serialize_object
from utilities.views import register_model_view from utilities.views import register_model_view
__all__ = ( __all__ = (
'BookmarksMixin',
'ChangeLoggingMixin', 'ChangeLoggingMixin',
'CloningMixin', 'CloningMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
@ -71,6 +72,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):
""" """
@ -244,6 +246,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()
@ -302,6 +305,20 @@ class ExportTemplatesMixin(models.Model):
abstract = True abstract = True
class BookmarksMixin(models.Model):
"""
Enables support for user bookmarks.
"""
bookmarks = GenericRelation(
to='extras.Bookmark',
content_type_field='object_type',
object_id_field='object_id'
)
class Meta:
abstract = True
class JobsMixin(models.Model): class JobsMixin(models.Model):
""" """
Enables support for job results. Enables support for job results.
@ -419,6 +436,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
@ -466,6 +484,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):
""" """
@ -476,6 +495,7 @@ class SyncedDataMixin(models.Model):
FEATURES_MAP = { FEATURES_MAP = {
'bookmarks': BookmarksMixin,
'custom_fields': CustomFieldsMixin, 'custom_fields': CustomFieldsMixin,
'custom_links': CustomLinksMixin, 'custom_links': CustomLinksMixin,
'export_templates': ExportTemplatesMixin, 'export_templates': ExportTemplatesMixin,

View File

@ -346,6 +346,22 @@ OPERATIONS_MENU = Menu(
), ),
) )
ADMIN_MENU = Menu(
label=_('Admin'),
icon_class='mdi mdi-account-multiple',
groups=(
MenuGroup(
label=_('Configuration'),
items=(
MenuItem(
link='extras:configrevision_list',
link_text=_('Config Revisions'),
permissions=['extras.view_configrevision']
),
),
),
),
)
MENUS = [ MENUS = [
ORGANIZATION_MENU, ORGANIZATION_MENU,
@ -360,6 +376,7 @@ MENUS = [
PROVISIONING_MENU, PROVISIONING_MENU,
CUSTOMIZATION_MENU, CUSTOMIZATION_MENU,
OPERATIONS_MENU, OPERATIONS_MENU,
ADMIN_MENU,
] ]
# #

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=['*'])

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

@ -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>

View File

@ -53,26 +53,11 @@
title="This field has been deprecated, and will be removed in NetBox v3.6." title="This field has been deprecated, and will be removed in NetBox v3.6."
></i> ></i>
</th> </th>
<td>{{ object.napalm_driver|placeholder }}</td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
<div class="card">
<h5 class="card-header">
NAPALM Arguments
<i
class="mdi mdi-alert-box text-warning"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="This field has been deprecated, and will be removed in NetBox v3.6."
></i>
</h5>
<div class="card-body">
<pre>{{ object.napalm_args|json }}</pre>
</div>
</div>
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -101,6 +101,12 @@
<th scope="row">Height</th> <th scope="row">Height</th>
<td>{{ object.u_height }}U ({% if object.desc_units %}descending{% else %}ascending{% endif %})</td> <td>{{ object.u_height }}U ({% if object.desc_units %}descending{% else %}ascending{% endif %})</td>
</tr> </tr>
<tr>
<th scope="row">Starting Unit</th>
<td>
{{ object.starting_unit }}
</td>
</tr>
<tr> <tr>
<th scope="row">Outer Width</th> <th scope="row">Outer Width</th>
<td> <td>

View File

@ -71,6 +71,7 @@
</div> </div>
{% render_field form.mounting_depth %} {% render_field form.mounting_depth %}
{% render_field form.desc_units %} {% render_field form.desc_units %}
{% render_field form.starting_unit %}
</div> </div>
{% if form.custom_fields %} {% if form.custom_fields %}

View File

@ -0,0 +1,200 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
{% load perms %}
{% load plugins %}
{% load static %}
{% block breadcrumbs %}
{% endblock %}
{% block controls %}
<div class="controls">
<div class="control-group">
{% plugin_buttons object %}
</div>
<div class="control-group">
{% custom_links object %}
</div>
</div>
{% endblock controls %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Rack Elevation</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Rack elevation default unit height:</th>
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}</td>
</tr>
<tr>
<th scope="row">Rack elevation default unit width:</th>
<td>{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Power</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Powerfeed default voltage:</th>
<td>{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}</td>
</tr>
<tr>
<th scope="row">Powerfeed default amperage:</th>
<td>{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}</td>
</tr>
<tr>
<th scope="row">Powerfeed default max utilization:</th>
<td>{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">IPAM</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">IPAM enforce global unique:</th>
<td>{{ object.data.ENFORCE_GLOBAL_UNIQUE }}</td>
</tr>
<tr>
<th scope="row">IPAM prefer IPV4:</th>
<td>{{ object.data.PREFER_IPV4 }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Security</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Allowed URL schemes:</th>
<td>{{ object.data.ALLOWED_URL_SCHEMES }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Banners</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Login banner:</th>
<td>{{ object.data.BANNER_LOGIN }}</td>
</tr>
<tr>
<th scope="row">Maintenance banner:</th>
<td>{{ object.data.BANNER_MAINTENANCE }}</td>
</tr>
<tr>
<th scope="row">Top banner:</th>
<td>{{ object.data.BANNER_TOP }}</td>
</tr>
<tr>
<th scope="row">Bottom banner:</th>
<td>{{ object.data.BANNER_BOTTOM }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Pagination</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Paginate count:</th>
<td>{{ object.data.PAGINATE_COUNT }}</td>
</tr>
<tr>
<th scope="row">Max page size:</th>
<td>{{ object.data.MAX_PAGE_SIZE }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Validation</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Custom validators:</th>
<td>{{ object.data.CUSTOM_VALIDATORS }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">User Preferences</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Default user preferences:</th>
<td>{{ object.data.DEFAULT_USER_PREFERENCES }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Miscellaneous</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Maintenance mode:</th>
<td>{{ object.data.MAINTENANCE_MODE }}</td>
</tr>
<tr>
<th scope="row">GraphQL enabled:</th>
<td>{{ object.data.GRAPHQL_ENABLED }}</td>
</tr>
<tr>
<th scope="row">Changelog retention:</th>
<td>{{ object.data.CHANGELOG_RETENTION }}</td>
</tr>
<tr>
<th scope="row">Job retention:</th>
<td>{{ object.data.JOB_RETENTION }}</td>
</tr>
<tr>
<th scope="row">Maps URL:</th>
<td>{{ object.data.MAPS_URL }}</td>
</tr>
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Config Revision</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Comment:</th>
<td>{{ object.comment }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,88 @@
{% extends 'base/layout.html' %}
{% load helpers %}
{% load buttons %}
{% load perms %}
{% load static %}
{% block title %}Restore: {{ object }}{% endblock %}
{% block subtitle %}
<div class="object-subtitle">
<span>Created {{ object.created|annotated_date }}</span>
</div>
{% endblock %}
{% block header %}
<div class="row noprint">
<div class="col col-md-12">
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision_list' %}">Config revisions</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:configrevision' pk=object.pk %}">{{ object }}</a></li>
</ol>
</nav>
</div>
</div>
{{ block.super }}
{% endblock header %}
{% block controls %}
<div class="controls">
<div class="control-group">
{% if request.user|can_delete:job %}
{% delete_button job %}
{% endif %}
</div>
</div>
{% endblock controls %}
{% block tabs %}
<ul class="nav nav-tabs px-3" role="tablist">
<li class="nav-item" role="presentation">
<a href="#log" role="tab" data-bs-toggle="tab" class="nav-link active">Restore</a>
</li>
</ul>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">Parameter</th>
<th scope="col">Current Value</th>
<th scope="col">New Value</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for param, current, new in params %}
<tr{% if current != new %} class="table-warning"{% 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>
</div>
</div>
<form method="post">
{% csrf_token %}
<div class="submit-row" style="margin-top: 20px">
<div class="controls">
<div class="control-group">
<button type="submit" name="restore" class="btn btn-primary">Restore</button>
<a href="{% url 'extras:configrevision_list' %}" id="cancel" name="cancel" class="btn btn-outline-danger">Cancel</a>
</div>
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{% endblock modals %}

View File

@ -0,0 +1,9 @@
{% if bookmarks %}
<div class="list-group list-group-flush">
{% for bookmark in bookmarks %}
<a href="{{ bookmark.object.get_absolute_url }}" class="list-group-item list-group-item-action">
{{ bookmark.object }}
</a>
{% endfor %}
</div>
{% endif %}

View File

@ -43,9 +43,23 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Allowed Object Types</h5>
Tagged Item Types <div class="card-body">
</h5> <table class="table table-hover attr-table">
{% for ct in object.object_types.all %}
<tr>
<td>{{ ct }}</td>
</tr>
{% empty %}
<tr>
<td class="text-muted">Any</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="card">
<h5 class="card-header">Tagged Item Types</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover panel-body attr-table"> <table class="table table-hover panel-body attr-table">
{% for object_type in object_types %} {% for object_type in object_types %}

View File

@ -38,7 +38,7 @@ Context:
</div> </div>
</div> </div>
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock header %}
{% block title %}{{ object }}{% endblock %} {% block title %}{{ object }}{% endblock %}
@ -48,7 +48,7 @@ Context:
<span class="separator">&middot;</span> <span class="separator">&middot;</span>
<span>Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</span> <span>Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</span>
</div> </div>
{% endblock %} {% endblock subtitle %}
{% block controls %} {% block controls %}
{# Clone/Edit/Delete Buttons #} {# Clone/Edit/Delete Buttons #}
@ -59,6 +59,9 @@ Context:
{# Extra buttons #} {# Extra buttons #}
{% block extra_controls %}{% endblock %} {% block extra_controls %}{% endblock %}
{% if perms.extras.add_bookmark %}
{% bookmark_button object %}
{% endif %}
{% if request.user|can_add:object %} {% if request.user|can_add:object %}
{% clone_button object %} {% clone_button object %}
{% endif %} {% endif %}

View File

@ -23,6 +23,11 @@
<i class="mdi mdi-account"></i> Profile <i class="mdi mdi-account"></i> Profile
</a> </a>
</li> </li>
<li>
<a class="dropdown-item" href="{% url 'users:bookmarks' %}">
<i class="mdi mdi-bookmark"></i> Bookmarks
</a>
</li>
<li> <li>
<a class="dropdown-item" href="{% url 'users:preferences' %}"> <a class="dropdown-item" href="{% url 'users:preferences' %}">
<i class="mdi mdi-wrench"></i> Preferences <i class="mdi mdi-wrench"></i> Preferences

View File

@ -5,6 +5,9 @@
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a> <a class="nav-link{% if active_tab == 'profile' %} active{% endif %}" href="{% url 'users:profile' %}">Profile</a>
</li> </li>
<li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'bookmarks' %} active{% endif %}" href="{% url 'users:bookmarks' %}">Bookmarks</a>
</li>
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a> <a class="nav-link{% if active_tab == 'preferences' %} active{% endif %}" href="{% url 'users:preferences' %}">Preferences</a>
</li> </li>

View File

@ -0,0 +1,34 @@
{% extends 'users/base.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}Bookmarks{% endblock %}
{% block content %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'users:bookmarks' %}" />
{# Table #}
<div class="row">
<div class="col col-md-12">
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
</div>
{# Form buttons #}
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
</div>
</form>
{% endblock %}

View File

@ -1,4 +1,5 @@
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 drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -28,7 +29,7 @@ class NestedUserSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail')
class Meta: class Meta:
model = User model = get_user_model()
fields = ['id', 'url', 'display', 'username'] fields = ['id', 'url', 'display', 'username']
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)

View File

@ -1,5 +1,6 @@
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 drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -30,7 +31,7 @@ class UserSerializer(ValidatedModelSerializer):
) )
class Meta: class Meta:
model = User model = get_user_model()
fields = ( fields = (
'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
'date_joined', 'groups', 'date_joined', 'groups',
@ -124,7 +125,7 @@ class ObjectPermissionSerializer(ValidatedModelSerializer):
many=True many=True
) )
users = SerializedPKRelatedField( users = SerializedPKRelatedField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
serializer=NestedUserSerializer, serializer=NestedUserSerializer,
required=False, required=False,
many=True many=True

View File

@ -1,5 +1,6 @@
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
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.db.models import Count from django.db.models import Count
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -32,7 +33,7 @@ class UsersRootView(APIRootView):
# #
class UserViewSet(NetBoxModelViewSet): class UserViewSet(NetBoxModelViewSet):
queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') queryset = RestrictedQuerySet(model=get_user_model()).prefetch_related('groups').order_by('username')
serializer_class = serializers.UserSerializer serializer_class = serializers.UserSerializer
filterset_class = filtersets.UserFilterSet filterset_class = filtersets.UserFilterSet

View File

@ -1,5 +1,6 @@
import django_filters import django_filters
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.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -47,7 +48,7 @@ class UserFilterSet(BaseFilterSet):
) )
class Meta: class Meta:
model = User model = get_user_model()
fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active']
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -68,12 +69,12 @@ class TokenFilterSet(BaseFilterSet):
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='user', field_name='user',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User'), label=_('User'),
) )
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)'),
) )
@ -116,12 +117,12 @@ class ObjectPermissionFilterSet(BaseFilterSet):
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users', field_name='users',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User'), label=_('User'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='users__username', field_name='users__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)'),
) )

View File

@ -1,6 +1,7 @@
import graphene import graphene
from django.contrib.auth.models import Group, User from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from netbox.graphql.fields import ObjectField, ObjectListField from netbox.graphql.fields import ObjectField, ObjectListField
from .types import * from .types import *
from utilities.graphql_optimizer import gql_query_optimizer from utilities.graphql_optimizer import gql_query_optimizer
@ -17,4 +18,4 @@ class UsersQuery(graphene.ObjectType):
user_list = ObjectListField(UserType) user_list = ObjectListField(UserType)
def resolve_user_list(root, info, **kwargs): def resolve_user_list(root, info, **kwargs):
return gql_query_optimizer(User.objects.all(), info) return gql_query_optimizer(get_user_model().objects.all(), info)

View File

@ -1,4 +1,5 @@
from django.contrib.auth.models import Group, User from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from users import filtersets from users import filtersets
@ -25,7 +26,7 @@ class GroupType(DjangoObjectType):
class UserType(DjangoObjectType): class UserType(DjangoObjectType):
class Meta: class Meta:
model = User model = get_user_model()
fields = ( fields = (
'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined',
'groups', 'groups',
@ -34,4 +35,4 @@ class UserType(DjangoObjectType):
@classmethod @classmethod
def get_queryset(cls, queryset, info): def get_queryset(cls, queryset, info):
return RestrictedQuerySet(model=User).restrict(info.context.user, 'view') return RestrictedQuerySet(model=get_user_model()).restrict(info.context.user, 'view')

View File

@ -1,4 +1,5 @@
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.urls import reverse from django.urls import reverse
@ -7,6 +8,9 @@ from utilities.testing import APIViewTestCases, APITestCase
from utilities.utils import deepmerge from utilities.utils import deepmerge
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):

View File

@ -1,6 +1,7 @@
import datetime import datetime
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 TestCase from django.test import TestCase
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
@ -10,6 +11,9 @@ from users.models import ObjectPermission, Token
from utilities.testing import BaseFilterSetTests from utilities.testing import BaseFilterSetTests
User = get_user_model()
class UserTestCase(TestCase, BaseFilterSetTests): class UserTestCase(TestCase, BaseFilterSetTests):
queryset = User.objects.all() queryset = User.objects.all()
filterset = filtersets.UserFilterSet filterset = filtersets.UserFilterSet

View File

@ -1,7 +1,10 @@
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
User = get_user_model()
class UserConfigTest(TestCase): class UserConfigTest(TestCase):
@classmethod @classmethod

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.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
@ -16,6 +16,9 @@ DEFAULT_USER_PREFERENCES = {
} }
User = get_user_model()
class UserPreferencesTest(TestCase): class UserPreferencesTest(TestCase):
user_permissions = ['dcim.view_site'] user_permissions = ['dcim.view_site']

View File

@ -8,6 +8,7 @@ urlpatterns = [
# User # User
path('profile/', views.ProfileView.as_view(), name='profile'), path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'), path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'), path('password/', views.ChangePasswordView.as_view(), name='change_password'),

View File

@ -15,10 +15,11 @@ from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View from django.views.generic import View
from social_core.backends.utils import load_backends from social_core.backends.utils import load_backends
from extras.models import ObjectChange from extras.models import Bookmark, ObjectChange
from extras.tables import ObjectChangeTable from extras.tables import BookmarkTable, ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config from netbox.config import get_config
from netbox.views.generic import ObjectListView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import register_model_view from utilities.views import register_model_view
from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
@ -230,6 +231,23 @@ class ChangePasswordView(LoginRequiredMixin, View):
}) })
#
# Bookmarks
#
class BookmarkListView(LoginRequiredMixin, ObjectListView):
table = BookmarkTable
template_name = 'users/bookmarks.html'
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
def get_extra_context(self, request):
return {
'active_tab': 'bookmarks',
}
# #
# API tokens # API tokens
# #

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