mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -06:00
Merge remote-tracking branch 'origin/feature' into feat/12882-contact-assignments-tags
# Conflicts: # requirements.txt
This commit is contained in:
commit
73dcae2fd1
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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 |
|
||||||
|
@ -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.
|
||||||
|
13
docs/models/extras/bookmark.md
Normal file
13
docs/models/extras/bookmark.md
Normal 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.
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
22
docs/release-notes/version-3.6.md
Normal file
22
docs/release-notes/version-3.6.md
Normal 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
|
@ -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'
|
||||||
|
@ -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(
|
||||||
|
@ -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({
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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():
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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(
|
||||||
|
@ -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):
|
||||||
|
19
netbox/dcim/migrations/0173_remove_napalm_fields.py
Normal file
19
netbox/dcim/migrations/0173_remove_napalm_fields.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
17
netbox/dcim/migrations/0174_rack_starting_unit.py
Normal file
17
netbox/dcim/migrations/0174_rack_starting_unit.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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(
|
||||||
|
@ -172,7 +172,6 @@ class PlatformIndex(SearchIndex):
|
|||||||
fields = (
|
fields = (
|
||||||
('name', 100),
|
('name', 100),
|
||||||
('slug', 110),
|
('slug', 110),
|
||||||
('napalm_driver', 300),
|
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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()
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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):
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
@ -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)
|
||||||
|
)
|
||||||
|
@ -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 *
|
||||||
|
@ -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
|
|
@ -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')),
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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']
|
||||||
|
17
netbox/extras/migrations/0093_configrevision_ordering.py
Normal file
17
netbox/extras/migrations/0093_configrevision_ordering.py
Normal 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']},
|
||||||
|
),
|
||||||
|
]
|
23
netbox/extras/migrations/0094_tag_object_types.py
Normal file
23
netbox/extras/migrations/0094_tag_object_types.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
34
netbox/extras/migrations/0095_bookmarks.py
Normal file
34
netbox/extras/migrations/0095_bookmarks.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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")
|
|
||||||
)
|
|
||||||
|
@ -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.")
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
@ -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']
|
||||||
|
@ -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()
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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'):
|
||||||
|
@ -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
|
||||||
|
serializer_class = get_serializer_for_model(self.queryset.model)
|
||||||
context = {'request': request}
|
context = {'request': request}
|
||||||
if isinstance(request.data, list):
|
if isinstance(request.data, list):
|
||||||
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
|
serializer = serializer_class(data=requested_objects, many=True, context=context)
|
||||||
else:
|
else:
|
||||||
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
|
serializer = serializer_class(data=requested_objects[0], context=context)
|
||||||
|
|
||||||
# Create the new Prefix(es)
|
if not serializer.is_valid():
|
||||||
if serializer.is_valid():
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Create the new IP address(es)
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
created = serializer.save()
|
created = serializer.save()
|
||||||
self._validate_objects(created)
|
self._validate_objects(created)
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
class AvailableASNsView(AvailableObjectsView):
|
||||||
if self.request.method == "GET":
|
queryset = ASN.objects.all()
|
||||||
return serializers.AvailablePrefixSerializer
|
read_serializer_class = serializers.AvailableASNSerializer
|
||||||
|
write_serializer_class = serializers.AvailableASNSerializer
|
||||||
return serializers.PrefixLengthSerializer
|
advisory_lock_key = 'available-asns'
|
||||||
|
|
||||||
|
|
||||||
class AvailableIPAddressesView(ObjectValidationMixin, APIView):
|
|
||||||
queryset = IPAddress.objects.all()
|
|
||||||
|
|
||||||
def get_parent(self, request, pk):
|
def get_parent(self, request, pk):
|
||||||
raise NotImplemented()
|
return get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
@extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)})
|
def get_available_objects(self, parent, limit=None):
|
||||||
|
return parent.get_available_asns()[:limit]
|
||||||
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
|
'range': parent,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
request_data.update({
|
||||||
|
'rir': parent.rir.pk,
|
||||||
|
'range': parent.pk,
|
||||||
|
'asn': available_objects[i],
|
||||||
|
})
|
||||||
|
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
|
@extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)})
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
parent = self.get_parent(request, pk)
|
return super().get(request, pk)
|
||||||
limit = get_results_limit(request)
|
|
||||||
|
|
||||||
|
@extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)})
|
||||||
|
def post(self, request, pk):
|
||||||
|
return super().post(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
class AvailablePrefixesView(AvailableObjectsView):
|
||||||
|
queryset = Prefix.objects.all()
|
||||||
|
read_serializer_class = serializers.AvailablePrefixSerializer
|
||||||
|
write_serializer_class = serializers.PrefixLengthSerializer
|
||||||
|
advisory_lock_key = 'available-prefixes'
|
||||||
|
|
||||||
|
def get_parent(self, request, pk):
|
||||||
|
return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
|
def get_available_objects(self, parent, limit=None):
|
||||||
|
return parent.get_available_prefixes().iter_cidrs()
|
||||||
|
|
||||||
|
def check_sufficient_available(self, requested_objects, available_objects):
|
||||||
|
available_prefixes = IPSet(available_objects)
|
||||||
|
for requested_object in requested_objects:
|
||||||
|
if not get_next_available_prefix(available_prefixes, requested_object['prefix_length']):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
|
'prefix': parent,
|
||||||
|
'vrf': parent.vrf,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
available_prefixes = IPSet(available_objects)
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
|
||||||
|
# Find the first available prefix equal to or larger than the requested size
|
||||||
|
if allocated_prefix := get_next_available_prefix(available_prefixes, request_data['prefix_length']):
|
||||||
|
request_data.update({
|
||||||
|
'prefix': allocated_prefix,
|
||||||
|
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
|
||||||
|
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
|
@extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||||
|
def get(self, request, pk):
|
||||||
|
return super().get(request, pk)
|
||||||
|
|
||||||
|
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
|
||||||
|
def post(self, request, pk):
|
||||||
|
return super().post(request, pk)
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableIPAddressesView(AvailableObjectsView):
|
||||||
|
queryset = IPAddress.objects.all()
|
||||||
|
read_serializer_class = serializers.AvailableIPSerializer
|
||||||
|
write_serializer_class = serializers.AvailableIPSerializer
|
||||||
|
advisory_lock_key = 'available-ips'
|
||||||
|
|
||||||
|
def get_available_objects(self, parent, limit=None):
|
||||||
# Calculate available IPs within the parent
|
# Calculate available IPs within the parent
|
||||||
ip_list = []
|
ip_list = []
|
||||||
for index, ip in enumerate(parent.get_available_ips(), start=1):
|
for index, ip in enumerate(parent.get_available_ips(), start=1):
|
||||||
ip_list.append(ip)
|
ip_list.append(ip)
|
||||||
if index == limit:
|
if index == limit:
|
||||||
break
|
break
|
||||||
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
|
return ip_list
|
||||||
'request': request,
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
'parent': parent,
|
'parent': parent,
|
||||||
'vrf': parent.vrf,
|
'vrf': parent.vrf,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
available_ips = iter(available_objects)
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
request_data.update({
|
||||||
|
'address': f'{next(available_ips)}/{parent.mask_length}',
|
||||||
|
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response(serializer.data)
|
return requested_objects
|
||||||
|
|
||||||
|
@extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)})
|
||||||
|
def get(self, request, pk):
|
||||||
|
return super().get(request, pk)
|
||||||
|
|
||||||
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
|
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
|
||||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
return super().post(request, pk)
|
||||||
parent = self.get_parent(request, pk)
|
|
||||||
|
|
||||||
# Normalize to a list of objects
|
|
||||||
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
|
||||||
|
|
||||||
# Determine if the requested number of IPs is available
|
|
||||||
available_ips = parent.get_available_ips()
|
|
||||||
if available_ips.size < len(requested_ips):
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"detail": f"An insufficient number of IP addresses are available within {parent} "
|
|
||||||
f"({len(requested_ips)} requested, {len(available_ips)} available)"
|
|
||||||
},
|
|
||||||
status=status.HTTP_409_CONFLICT
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
|
|
||||||
available_ips = iter(available_ips)
|
|
||||||
for requested_ip in requested_ips:
|
|
||||||
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
|
|
||||||
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
|
|
||||||
|
|
||||||
# Initialize the serializer with a list or a single object depending on what was requested
|
|
||||||
context = {'request': request}
|
|
||||||
if isinstance(request.data, list):
|
|
||||||
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
|
|
||||||
else:
|
|
||||||
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
|
|
||||||
|
|
||||||
# Create the new IP address(es)
|
|
||||||
if serializer.is_valid():
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
created = serializer.save()
|
|
||||||
self._validate_objects(created)
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
raise PermissionDenied()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "GET":
|
|
||||||
return serializers.AvailableIPSerializer
|
|
||||||
|
|
||||||
return serializers.IPAddressSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
|
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
|
||||||
@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
|
|||||||
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
|
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
|
|
||||||
class AvailableVLANsView(ObjectValidationMixin, APIView):
|
class AvailableVLANsView(AvailableObjectsView):
|
||||||
queryset = VLAN.objects.all()
|
queryset = VLAN.objects.all()
|
||||||
|
read_serializer_class = serializers.AvailableVLANSerializer
|
||||||
|
write_serializer_class = serializers.CreateAvailableVLANSerializer
|
||||||
|
advisory_lock_key = 'available-vlans'
|
||||||
|
|
||||||
|
def get_parent(self, request, pk):
|
||||||
|
return get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
|
||||||
|
|
||||||
|
def get_available_objects(self, parent, limit=None):
|
||||||
|
return parent.get_available_vids()[:limit]
|
||||||
|
|
||||||
|
def get_extra_context(self, parent):
|
||||||
|
return {
|
||||||
|
'group': parent,
|
||||||
|
}
|
||||||
|
|
||||||
|
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||||
|
for i, request_data in enumerate(requested_objects):
|
||||||
|
request_data.update({
|
||||||
|
'vid': available_objects.pop(0),
|
||||||
|
'group': parent.pk,
|
||||||
|
})
|
||||||
|
|
||||||
|
return requested_objects
|
||||||
|
|
||||||
@extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
|
@extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)})
|
||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
|
return super().get(request, pk)
|
||||||
limit = get_results_limit(request)
|
|
||||||
|
|
||||||
available_vlans = vlangroup.get_available_vids()[:limit]
|
|
||||||
serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={
|
|
||||||
'request': request,
|
|
||||||
'group': vlangroup,
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
|
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
|
||||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
return super().post(request, pk)
|
||||||
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
|
|
||||||
available_vlans = vlangroup.get_available_vids()
|
|
||||||
many = isinstance(request.data, list)
|
|
||||||
|
|
||||||
# Validate requested VLANs
|
|
||||||
serializer = serializers.CreateAvailableVLANSerializer(
|
|
||||||
data=request.data if many else [request.data],
|
|
||||||
many=True,
|
|
||||||
context={
|
|
||||||
'request': request,
|
|
||||||
'group': vlangroup,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not serializer.is_valid():
|
|
||||||
return Response(
|
|
||||||
serializer.errors,
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
requested_vlans = serializer.validated_data
|
|
||||||
|
|
||||||
for i, requested_vlan in enumerate(requested_vlans):
|
|
||||||
try:
|
|
||||||
requested_vlan['vid'] = available_vlans.pop(0)
|
|
||||||
requested_vlan['group'] = vlangroup.pk
|
|
||||||
except IndexError:
|
|
||||||
return Response({
|
|
||||||
"detail": "The requested number of VLANs is not available"
|
|
||||||
}, status=status.HTTP_409_CONFLICT)
|
|
||||||
|
|
||||||
# Initialize the serializer with a list or a single object depending on what was requested
|
|
||||||
context = {'request': request}
|
|
||||||
if many:
|
|
||||||
serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context)
|
|
||||||
else:
|
|
||||||
serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context)
|
|
||||||
|
|
||||||
# Create the new VLAN(s)
|
|
||||||
if serializer.is_valid():
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
created = serializer.save()
|
|
||||||
self._validate_objects(created)
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
raise PermissionDenied()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.request.method == "GET":
|
|
||||||
return serializers.AvailableVLANSerializer
|
|
||||||
|
|
||||||
return serializers.VLANSerializer
|
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
class NetBoxFeatureSet(
|
class NetBoxFeatureSet(
|
||||||
|
BookmarksMixin,
|
||||||
ChangeLoggingMixin,
|
ChangeLoggingMixin,
|
||||||
CustomFieldsMixin,
|
CustomFieldsMixin,
|
||||||
CustomLinksMixin,
|
CustomLinksMixin,
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
]
|
]
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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=['*'])
|
||||||
|
@ -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 %}
|
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
200
netbox/templates/extras/configrevision.html
Normal file
200
netbox/templates/extras/configrevision.html
Normal 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 %}
|
88
netbox/templates/extras/configrevision_restore.html
Normal file
88
netbox/templates/extras/configrevision_restore.html
Normal 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 %}
|
9
netbox/templates/extras/dashboard/widgets/bookmarks.html
Normal file
9
netbox/templates/extras/dashboard/widgets/bookmarks.html
Normal 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 %}
|
@ -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 %}
|
||||||
|
@ -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">·</span>
|
<span class="separator">·</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 %}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
34
netbox/templates/users/bookmarks.html
Normal file
34
netbox/templates/users/bookmarks.html
Normal 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 %}
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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)'),
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -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')
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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']
|
||||||
|
|
||||||
|
@ -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'),
|
||||||
|
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user