Update to latest feature

This commit is contained in:
Daniel Sheppard 2024-07-01 22:27:15 -05:00
commit b12ce97474
228 changed files with 6089 additions and 170699 deletions

View File

@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.0.5
placeholder: v4.0.6
validations:
required: true
- type: dropdown

View File

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

View File

@ -5,10 +5,12 @@ on:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
pull_request:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
permissions:
contents: read

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ netbox.pid
.idea
.coverage
.vscode
.python-version

View File

@ -17,7 +17,6 @@ NetBox exists to empower network engineers. Since its release in 2016, it has be
<a href="#why-netbox">Why NetBox?</a> |
<a href="#getting-started">Getting Started</a> |
<a href="#get-involved">Get Involved</a> |
<a href="#project-stats">Project Stats</a> |
<a href="#screenshots">Screenshots</a>
</p>

View File

@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
### Bug Bounties

View File

@ -8,7 +8,9 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
django-debug-toolbar
# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
# and https://github.com/jazzband/django-debug-toolbar/issues/1927
django-debug-toolbar==4.3.0
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@ -108,7 +110,7 @@ Pillow
# PostgreSQL database adapter for Python
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg[binary,pool]
psycopg[c,pool]
# YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -138,11 +138,11 @@ These two methods will load data in YAML or JSON format, respectively, from file
The Script object provides a set of convenient functions for recording messages at different severity levels:
* `log_debug(message, object=None)`
* `log_success(message, object=None)`
* `log_info(message, object=None)`
* `log_warning(message, object=None)`
* `log_failure(message, object=None)`
* `log_debug(message=None, obj=None)`
* `log_success(message=None, obj=None)`
* `log_info(message=None, obj=None)`
* `log_warning(message=None, obj=None)`
* `log_failure(message=None, obj=None)`
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method.
@ -152,6 +152,8 @@ A script can define one or more test methods to report on certain conditions. Al
These methods are detected and run automatically when the script is executed, unless its `run()` method has been overridden. (When overriding `run()`, `run_tests()` can be called to run all test methods present in the script.)
Calling any of these logging methods without a message will increment the relevant counter, but will not generate an output line in the script's log.
!!! info
This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0.

View File

@ -126,3 +126,13 @@ VERSION = 'v3.3.2-dev'
```
Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream.
### Update the Public Documentation
After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository.
First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at <https://netboxlabs.com/docs>. The job should take about two minutes.
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.

View File

@ -107,3 +107,7 @@ For numeric custom fields only. The maximum valid value (optional).
### Validation Regex
For string-based custom fields only. A regular expression used to validate the field's value (optional).
### Uniqueness Validation
If enabled, each object must have a unique value set for this custom field (per object type).

View File

@ -40,3 +40,7 @@ The security cipher used to apply wireless authentication. Options include:
### Pre-Shared Key
The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types.
### Distance
The numeric distance of the link, including a unit designation (e.g. 100 meters or 25 feet).

View File

@ -27,7 +27,7 @@ Serializers are responsible for converting Python objects to JSON data suitable
#### Example
To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class. It is generally advisable to include a `url` attribute on each serializer. This will render the direct link to access the object being rendered.
To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class.
```python
# api/serializers.py
@ -36,9 +36,7 @@ from netbox.api.serializers import NetBoxModelSerializer
from my_plugin.models import MyModel
class MyModelSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='plugins-api:myplugin-api:mymodel-detail'
)
foo = SiteSerializer(nested=True, allow_null=True)
class Meta:
model = MyModel
@ -63,9 +61,7 @@ from netbox.api.serializers import WritableNestedSerializer
from my_plugin.models import MyModel
class NestedMyModelSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='plugins-api:myplugin-api:mymodel-detail'
)
foo = SiteSerializer(nested=True, allow_null=True)
class Meta:
model = MyModel

View File

@ -191,7 +191,7 @@ class MyView(generic.ObjectView):
### Extra Template Content
Plugins can inject custom content into certain areas of core NetBox views. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired method(s) to render custom content. Five methods are available:
Plugins can inject custom content into certain areas of core NetBox views. This is accomplished by subclassing `PluginTemplateExtension`, optionally designating one or more particular NetBox models, and defining the desired method(s) to render custom content. Five methods are available:
| Method | View | Description |
|---------------------|-------------|-----------------------------------------------------|
@ -206,7 +206,9 @@ Plugins can inject custom content into certain areas of core NetBox views. This
Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include:
To control where the custom content is injected, plugin authors can specify an iterable of models by overriding the `models` attribute on the subclass. Extensions which do not specify a set of models will be invoked on every view, where supported.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data includes:
* `object` - The object being viewed (object views only)
* `model` - The model of the list view (list views only)
@ -223,7 +225,7 @@ from netbox.plugins import PluginTemplateExtension
from .models import Animal
class SiteAnimalCount(PluginTemplateExtension):
model = 'dcim.site'
models = ['dcim.site']
def right_page(self):
return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={

View File

@ -1,6 +1,36 @@
# NetBox v4.0
## v4.0.6 (FUTURE)
## v4.0.7 (FUTURE)
---
## v4.0.6 (2024-06-24)
### Enhancements
* [#15348](https://github.com/netbox-community/netbox/issues/15348) - Show saved filters alongside quick search on object list views
* [#15794](https://github.com/netbox-community/netbox/issues/15794) - Dynamically populate related objects in UI views
* [#16256](https://github.com/netbox-community/netbox/issues/16256) - Enable alphabetical ordering of bookmarks on dashboard
* [#16307](https://github.com/netbox-community/netbox/issues/16307) - Enable calling `log_*()` methods on Script without passing a message
### Bug Fixes
* [#13925](https://github.com/netbox-community/netbox/issues/13925) - Fix support for "zulu" (UTC) timestamps for custom fields
* [#14829](https://github.com/netbox-community/netbox/issues/14829) - Fix support for simple conditions (without AND/OR) in event rules
* [#15717](https://github.com/netbox-community/netbox/issues/15717) - Allow assigning a device/VM in a site to a cluster with no site assigned
* [#16143](https://github.com/netbox-community/netbox/issues/16143) - Display timestamps in tables in the configured timezone
* [#16149](https://github.com/netbox-community/netbox/issues/16149) - Fix object linking in custom script logs
* [#16252](https://github.com/netbox-community/netbox/issues/16252) - Fix total count in tab at top of rack elevations view
* [#16273](https://github.com/netbox-community/netbox/issues/16273) - Restore global search bar on mobile
* [#16416](https://github.com/netbox-community/netbox/issues/16416) - Retain dark/light mode toggle on mobile view
* [#16444](https://github.com/netbox-community/netbox/issues/16444) - Disable ordering circuits list by A/Z termination
* [#16450](https://github.com/netbox-community/netbox/issues/16450) - Searching for rack unit in form dropdown should be case-insensitive
* [#16452](https://github.com/netbox-community/netbox/issues/16452) - Fix sizing of buttons within object attribute panels
* [#16454](https://github.com/netbox-community/netbox/issues/16454) - Address DNS lookup bug in `django-debug-toolbar
* [#16460](https://github.com/netbox-community/netbox/issues/16460) - Omit spaces from telephone number URLs
* [#16512](https://github.com/netbox-community/netbox/issues/16512) - Restore a user's preferred language (if any) on login
* [#16542](https://github.com/netbox-community/netbox/issues/16542) - Fix bulk form operations when HTMX is enabled
* [#16702](https://github.com/netbox-community/netbox/issues/16702) - Fix validation of `return_url` query parameter
---

View File

@ -0,0 +1,30 @@
# NetBox v4.1
## v4.1.0 (FUTURE)
### Breaking Changes
* Several filters deprecated in v4.0 have been removed (see [#15410](https://github.com/netbox-community/netbox/issues/15410)).
* The unit size for virtual disk size has been changed from 1 gigabyte to 1 megabyte. Existing values have been updated accordingly.
### New Features
### Enhancements
* [#7537](https://github.com/netbox-community/netbox/issues/7537) - Add a serial number field for virtual machines
* [#16359](https://github.com/netbox-community/netbox/issues/16359) - Enable plugins to embed content in the top navigation bar
### Other Changes
* [#14692](https://github.com/netbox-community/netbox/issues/14692) - Change atomic unit for virtual disks from 1GB to 1MB
* [#15410](https://github.com/netbox-community/netbox/issues/15410) - Removed various deprecated filters
* [#15908](https://github.com/netbox-community/netbox/issues/15908) - Indicate product edition in release data
* [#16388](https://github.com/netbox-community/netbox/issues/16388) - Move all change logging resources from `extras` to `core`
### REST API Changes
* The `/api/extras/object-changes/` endpoint has moved to `/api/core/object-changes/`
* virtualization.VirtualMachine
* Added the optional `serial` field
* wireless.WirelessLink
* Added the optional `distance` and `distance_unit` fields

View File

@ -296,6 +296,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 4.1: 'release-notes/version-4.1.md'
- Version 4.0: 'release-notes/version-4.0.md'
- Version 3.7: 'release-notes/version-3.7.md'
- Version 3.6: 'release-notes/version-3.6.md'

View File

@ -19,8 +19,10 @@ from django.views.generic import View
from social_core.backends.utils import load_backends
from account.models import UserToken
from extras.models import Bookmark, ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable
from core.models import ObjectChange
from core.tables import ObjectChangeTable
from extras.models import Bookmark
from extras.tables import BookmarkTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.views import generic
@ -104,10 +106,16 @@ class LoginView(View):
# Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.)
if not hasattr(request.user, 'config'):
config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
request.user.config = get_config()
UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save()
return self.redirect_to_next(request, logger)
response = self.redirect_to_next(request, logger)
# Set the user's preferred language (if any)
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
return response
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
@ -145,9 +153,10 @@ class LogoutView(View):
logger.info(f"User {username} has logged out")
messages.info(request, "You have logged out.")
# Delete session key cookie (if set) upon logout
# Delete session key & language cookies (if set) upon logout
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key')
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response

View File

@ -20,11 +20,10 @@ __all__ = [
#
class NestedProviderNetworkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
class Meta:
model = ProviderNetwork
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
#
@ -35,12 +34,11 @@ class NestedProviderNetworkSerializer(WritableNestedSerializer):
exclude_fields=('circuit_count',),
)
class NestedProviderSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = RelatedObjectCountField('circuits')
class Meta:
model = Provider
fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'circuit_count']
#
@ -48,11 +46,10 @@ class NestedProviderSerializer(WritableNestedSerializer):
#
class NestedProviderAccountSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
class Meta:
model = ProviderAccount
fields = ['id', 'url', 'display', 'name', 'account']
fields = ['id', 'url', 'display_url', 'display', 'name', 'account']
#
@ -63,26 +60,23 @@ class NestedProviderAccountSerializer(WritableNestedSerializer):
exclude_fields=('circuit_count',),
)
class NestedCircuitTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = RelatedObjectCountField('circuits')
class Meta:
model = CircuitType
fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'circuit_count']
class NestedCircuitSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
class Meta:
model = Circuit
fields = ['id', 'url', 'display', 'cid']
fields = ['id', 'url', 'display_url', 'display', 'cid']
class NestedCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
class Meta:
model = CircuitTermination
fields = ['id', 'url', 'display', 'circuit', 'term_side', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'cable', '_occupied']

View File

@ -18,7 +18,6 @@ __all__ = (
class CircuitTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
# Related object counts
circuit_count = RelatedObjectCountField('circuits')
@ -26,27 +25,25 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
class Meta:
model = CircuitType
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'circuit_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = SiteSerializer(nested=True, allow_null=True)
provider_network = ProviderNetworkSerializer(nested=True, allow_null=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'description',
'id', 'url', 'display_url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'description',
]
class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = ProviderSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=CircuitStatusChoices, required=False)
@ -58,15 +55,14 @@ class CircuitSerializer(NetBoxModelSerializer):
class Meta:
model = Circuit
fields = [
'id', 'url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date',
'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant',
'install_date', 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'cid', 'description')
class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = CircuitSerializer(nested=True)
site = SiteSerializer(nested=True, required=False, allow_null=True)
provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True)
@ -74,8 +70,8 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
class Meta:
model = CircuitTermination
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed',
'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end',
'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')

View File

@ -15,7 +15,6 @@ __all__ = (
class ProviderSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
accounts = SerializedPKRelatedField(
queryset=ProviderAccount.objects.all(),
serializer=NestedProviderAccountSerializer,
@ -36,34 +35,32 @@ class ProviderSerializer(NetBoxModelSerializer):
class Meta:
model = Provider
fields = [
'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'accounts', 'description', 'comments',
'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
class ProviderAccountSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
provider = ProviderSerializer(nested=True)
name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
class Meta:
model = ProviderAccount
fields = [
'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
class ProviderNetworkSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail')
provider = ProviderSerializer(nested=True)
class Meta:
model = ProviderNetwork
fields = [
'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'id', 'url', 'display_url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -63,10 +63,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
status = columns.ChoiceFieldColumn()
termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side A')
)
termination_z = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side Z')
)
commit_rate = CommitRateColumn(

View File

@ -7,7 +7,7 @@ from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.query import count_related
from utilities.views import register_model_view
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView):
@register_model_view(Provider)
class ProviderView(generic.ObjectView):
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Provider.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
(Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -92,16 +87,12 @@ class ProviderAccountListView(generic.ObjectListView):
@register_model_view(ProviderAccount)
class ProviderAccountView(generic.ObjectView):
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderAccount.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -156,19 +147,21 @@ class ProviderNetworkListView(generic.ObjectListView):
@register_model_view(ProviderNetwork)
class ProviderNetworkView(generic.ObjectView):
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderNetwork.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'provider_network_id',
),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'provider_network_id',
),
),
),
}
@ -215,16 +208,12 @@ class CircuitTypeListView(generic.ObjectListView):
@register_model_view(CircuitType)
class CircuitTypeView(generic.ObjectView):
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}

View File

@ -14,23 +14,20 @@ __all__ = (
class NestedDataSourceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:datasource-detail')
class Meta:
model = DataSource
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedDataFileSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:datafile-detail')
class Meta:
model = DataFile
fields = ['id', 'url', 'display', 'path']
fields = ['id', 'url', 'display_url', 'display', 'path']
class NestedJobSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
status = ChoiceField(choices=JobStatusChoices)
user = UserSerializer(
nested=True,
@ -39,4 +36,4 @@ class NestedJobSerializer(serializers.ModelSerializer):
class Meta:
model = Job
fields = ['url', 'created', 'completed', 'user', 'status']
fields = ['url', 'display_url', 'created', 'completed', 'user', 'status']

View File

@ -1,3 +1,4 @@
from .serializers_.change_logging import *
from .serializers_.data import *
from .serializers_.jobs import *
from .nested_serializers import *

View File

@ -1,8 +1,8 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from extras.choices import *
from extras.models import ObjectChange
from core.choices import *
from core.models import ObjectChange
from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer
@ -15,7 +15,6 @@ __all__ = (
class ObjectChangeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
user = UserSerializer(
nested=True,
read_only=True
@ -44,8 +43,8 @@ class ObjectChangeSerializer(BaseModelSerializer):
class Meta:
model = ObjectChange
fields = [
'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
'changed_object_type', 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
]
@extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -13,9 +13,6 @@ __all__ = (
class DataSourceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='core-api:datasource-detail'
)
type = ChoiceField(
choices=get_data_backend_choices()
)
@ -30,16 +27,13 @@ class DataSourceSerializer(NetBoxModelSerializer):
class Meta:
model = DataSource
fields = [
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class DataFileSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='core-api:datafile-detail'
)
source = DataSourceSerializer(
nested=True,
read_only=True
@ -48,6 +42,6 @@ class DataFileSerializer(NetBoxModelSerializer):
class Meta:
model = DataFile
fields = [
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
]
brief_fields = ('id', 'url', 'display', 'path')

View File

@ -12,7 +12,6 @@ __all__ = (
class JobSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
user = UserSerializer(
nested=True,
read_only=True
@ -25,7 +24,7 @@ class JobSerializer(BaseModelSerializer):
class Meta:
model = Job
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
'started', 'completed', 'user', 'data', 'error', 'job_id',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@ -5,12 +5,10 @@ from . import views
router = NetBoxRouter()
router.APIRootView = views.CoreRootView
# Data sources
router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet)
# Jobs
router.register('jobs', views.JobViewSet)
router.register('object-changes', views.ObjectChangeViewSet)
app_name = 'core-api'
urlpatterns = router.urls

View File

@ -8,6 +8,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from core import filtersets
from core.models import *
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from . import serializers
@ -54,3 +55,13 @@ class JobViewSet(ReadOnlyModelViewSet):
queryset = Job.objects.all()
serializer_class = serializers.JobSerializer
filterset_class = filtersets.JobFilterSet
class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.valid_models()
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet

View File

@ -64,3 +64,20 @@ class JobStatusChoices(ChoiceSet):
STATUS_ERRORED,
STATUS_FAILED,
)
#
# ObjectChanges
#
class ObjectChangeActionChoices(ChoiceSet):
ACTION_CREATE = 'create'
ACTION_UPDATE = 'update'
ACTION_DELETE = 'delete'
CHOICES = (
(ACTION_CREATE, _('Created'), 'green'),
(ACTION_UPDATE, _('Updated'), 'blue'),
(ACTION_DELETE, _('Deleted'), 'red'),
)

View File

@ -1,3 +1,5 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
@ -5,6 +7,7 @@ import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices
from utilities.filters import ContentTypeFilter
from .choices import *
from .models import *
@ -13,6 +16,7 @@ __all__ = (
'DataFileFilterSet',
'DataSourceFilterSet',
'JobFilterSet',
'ObjectChangeFilterSet',
)
@ -126,6 +130,43 @@ class JobFilterSet(BaseFilterSet):
)
class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
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 = ObjectChange
fields = (
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
)
class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@ -7,8 +7,10 @@ from core.models import *
from netbox.forms import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker
@ -17,6 +19,7 @@ __all__ = (
'DataFileFilterForm',
'DataSourceFilterForm',
'JobFilterForm',
'ObjectChangeFilterForm',
)
@ -124,6 +127,40 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
)
class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
model = ObjectChange
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('time_before', 'time_after', name=_('Time')),
FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
)
time_after = forms.DateTimeField(
required=False,
label=_('After'),
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
action = forms.ChoiceField(
label=_('Action'),
choices=add_blank_choice(ObjectChangeActionChoices),
required=False
)
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
changed_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('change_logging'),
required=False,
label=_('Object Type'),
)
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id'),

View File

@ -6,6 +6,7 @@ from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
__all__ = (
'DataFileFilter',
'DataSourceFilter',
'ObjectChangeFilter',
)
@ -19,3 +20,9 @@ class DataFileFilter(BaseFilterMixin):
@autotype_decorator(filtersets.DataSourceFilterSet)
class DataSourceFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.ObjectChange, lookups=True)
@autotype_decorator(filtersets.ObjectChangeFilterSet)
class ObjectChangeFilter(BaseFilterMixin):
pass

View File

@ -0,0 +1,24 @@
from typing import Annotated, List
import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType
from core.models import ObjectChange
__all__ = (
'ChangelogMixin',
)
@strawberry.type
class ChangelogMixin:
@strawberry_django.field
def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]:
content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter(
changed_object_type=content_type,
changed_object_id=self.pk
)
return object_changes.restrict(info.context.request.user, 'view')

View File

@ -10,6 +10,7 @@ from .filters import *
__all__ = (
'DataFileType',
'DataSourceType',
'ObjectChangeType',
)
@ -30,3 +31,12 @@ class DataFileType(BaseObjectType):
class DataSourceType(NetBoxObjectType):
datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
@strawberry_django.type(
models.ObjectChange,
fields='__all__',
filters=ObjectChangeFilter
)
class ObjectChangeType(BaseObjectType):
pass

View File

@ -0,0 +1,45 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0010_gfk_indexes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name='ObjectChange',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('time', models.DateTimeField(auto_now_add=True, db_index=True)),
('user_name', models.CharField(editable=False, max_length=150)),
('request_id', models.UUIDField(db_index=True, editable=False)),
('action', models.CharField(max_length=50)),
('changed_object_id', models.PositiveBigIntegerField()),
('related_object_id', models.PositiveBigIntegerField(blank=True, null=True)),
('object_repr', models.CharField(editable=False, max_length=200)),
('prechange_data', models.JSONField(blank=True, editable=False, null=True)),
('postchange_data', models.JSONField(blank=True, editable=False, null=True)),
('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'object change',
'verbose_name_plural': 'object changes',
'ordering': ['-time'],
'indexes': [models.Index(fields=['changed_object_type', 'changed_object_id'], name='core_object_changed_c227ce_idx'), models.Index(fields=['related_object_type', 'related_object_id'], name='core_object_related_3375d6_idx')],
},
),
],
# Table has been renamed from 'extras' app
database_operations=[],
),
]

View File

@ -1,5 +1,6 @@
from .config import *
from .contenttypes import *
from .change_logging import *
from .config import *
from .data import *
from .files import *
from .jobs import *

View File

@ -8,11 +8,11 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from core.models import ObjectType
from extras.choices import *
from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet
from netbox.models.features import ChangeLoggingMixin
from utilities.data import shallow_compare_dict
from ..querysets import ObjectChangeQuerySet
from .contenttypes import ObjectType
__all__ = (
'ObjectChange',
@ -136,7 +136,7 @@ class ObjectChange(models.Model):
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('extras:objectchange', args=[self.pk])
return reverse('core:objectchange', args=[self.pk])
def get_action_color(self):
return ObjectChangeActionChoices.colors.get(self.action)

26
netbox/core/querysets.py Normal file
View File

@ -0,0 +1,26 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db.utils import ProgrammingError
from utilities.querysets import RestrictedQuerySet
__all__ = (
'ObjectChangeQuerySet',
)
class ObjectChangeQuerySet(RestrictedQuerySet):
def valid_models(self):
# Exclude any change records which refer to an instance of a model that's no longer installed. This
# can happen when a plugin is removed but its data remains in the database, for example.
try:
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
except ProgrammingError:
# Handle the case where the database schema has not yet been initialized
content_types = ContentType.objects.none()
content_type_ids = set(
ct.pk for ct in content_types
)
return self.filter(changed_object_type_id__in=content_type_ids)

View File

@ -1,3 +1,4 @@
from .change_logging import *
from .config import *
from .data import *
from .jobs import *

View File

@ -0,0 +1,53 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from core.models import ObjectChange
from netbox.tables import NetBoxTable, columns
from .template_code import *
__all__ = (
'ObjectChangeTable',
)
class ObjectChangeTable(NetBoxTable):
time = columns.DateTimeColumn(
verbose_name=_('Time'),
timespec='minutes',
linkify=True
)
user_name = tables.Column(
verbose_name=_('Username')
)
full_name = tables.TemplateColumn(
accessor=tables.A('user'),
template_code=OBJECTCHANGE_FULL_NAME,
verbose_name=_('Full Name'),
orderable=False
)
action = columns.ChoiceFieldColumn(
verbose_name=_('Action'),
)
changed_object_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
object_repr = tables.TemplateColumn(
accessor=tables.A('changed_object'),
template_code=OBJECTCHANGE_OBJECT,
verbose_name=_('Object'),
orderable=False
)
request_id = tables.TemplateColumn(
template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name=_('Request ID')
)
actions = columns.ActionsColumn(
actions=()
)
class Meta(NetBoxTable.Meta):
model = ObjectChange
fields = (
'pk', 'id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
'actions',
)

View File

@ -0,0 +1,16 @@
OBJECTCHANGE_FULL_NAME = """
{% load helpers %}
{{ value.get_full_name|placeholder }}
"""
OBJECTCHANGE_OBJECT = """
{% if value and value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}
"""
OBJECTCHANGE_REQUEST_ID = """
<a href="{% url 'core:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
"""

View File

@ -3,11 +3,12 @@ from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from core.models import ObjectType
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
from extras.models import CustomField, CustomFieldChoiceSet, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase

View File

@ -1,7 +1,13 @@
import uuid
from datetime import datetime, timezone
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from utilities.testing import ChangeLoggedFilterSetTests
from dcim.models import Site
from ipam.models import IPAddress
from users.models import User
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
from ..choices import *
from ..filtersets import *
from ..models import *
@ -132,3 +138,99 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all()
filterset = ObjectChangeFilterSet
ignore_fields = ('prechange_data', 'postchange_data')
@classmethod
def setUpTestData(cls):
users = (
User(username='user1'),
User(username='user2'),
User(username='user3'),
)
User.objects.bulk_create(users)
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
ipaddress = IPAddress.objects.create(address='192.0.2.1/24')
object_changes = (
ObjectChange(
user=users[0],
user_name=users[0].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=site,
object_repr=str(site),
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[0],
user_name=users[0].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=site,
object_repr=str(site),
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
user_name=users[1].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=site,
object_repr=str(site),
postchange_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
user_name=users[1].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
user_name=users[2].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
user_name=users[2].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=ipaddress,
object_repr=str(ipaddress),
postchange_data={'address': ipaddress.address, 'status': ipaddress.status}
),
)
ObjectChange.objects.bulk_create(object_changes)
def test_q(self):
params = {'q': 'Site 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_user(self):
params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'user': ['user1', 'user2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_user_name(self):
params = {'user_name': ['user1', 'user2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_changed_object_type(self):
params = {'changed_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@ -1,7 +1,7 @@
from django.test import TestCase
from core.models import DataSource
from extras.choices import ObjectChangeActionChoices
from core.choices import ObjectChangeActionChoices
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED

View File

@ -1,4 +1,4 @@
import logging
import urllib.parse
import uuid
from datetime import datetime
@ -10,8 +10,11 @@ from django_rq.workers import get_worker
from rq.job import Job as RQ_Job, JobStatus
from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
from core.choices import ObjectChangeActionChoices
from core.models import *
from dcim.models import Site
from users.models import User
from utilities.testing import TestCase, ViewTestCases, create_tags
from ..models import *
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@ -99,6 +102,43 @@ class DataFileTestCase(
DataFile.objects.bulk_create(data_files)
# TODO: Convert to StandardTestCases.Views
class ObjectChangeTestCase(TestCase):
user_permissions = (
'core.view_objectchange',
)
@classmethod
def setUpTestData(cls):
site = Site(name='Site 1', slug='site-1')
site.save()
# Create three ObjectChanges
user = User.objects.create_user(username='testuser2')
for i in range(1, 4):
oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
oc.user = user
oc.request_id = uuid.uuid4()
oc.save()
def test_objectchange_list(self):
url = reverse('core:objectchange_list')
params = {
"user": User.objects.first().pk,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertHttpStatus(response, 200)
def test_objectchange(self):
objectchange = ObjectChange.objects.first()
response = self.client.get(objectchange.get_absolute_url())
self.assertHttpStatus(response, 200)
class BackgroundTaskTestCase(TestCase):
user_permissions = ()

View File

@ -25,6 +25,10 @@ urlpatterns = (
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', include(get_model_urls('core', 'objectchange'))),
# Background Tasks
path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'),
path('background-queues/<int:queue_index>/<str:status>/', views.BackgroundTaskListView.as_view(), name='background_task_list'),

View File

@ -29,10 +29,11 @@ from netbox.config import get_config, PARAMS
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin
from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial
from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
@ -51,16 +52,12 @@ class DataSourceListView(generic.ObjectListView):
@register_model_view(DataSource)
class DataSourceView(generic.ObjectView):
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DataSource.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -176,6 +173,75 @@ class JobBulkDeleteView(generic.BulkDeleteView):
table = tables.JobTable
#
# Change logging
#
class ObjectChangeListView(generic.ObjectListView):
queryset = ObjectChange.objects.valid_models()
filterset = filtersets.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html'
actions = {
'export': {'view'},
}
@register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView):
queryset = ObjectChange.objects.valid_models()
def get_extra_context(self, request, instance):
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
request_id=instance.request_id
).exclude(
pk=instance.pk
)
related_changes_table = tables.ObjectChangeTable(
data=related_changes[:50],
orderable=False
)
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
changed_object_type=instance.changed_object_type,
changed_object_id=instance.changed_object_id,
)
next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first()
prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first()
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
non_atomic_change = True
prechange_data = prev_change.postchange_data_clean
else:
non_atomic_change = False
prechange_data = instance.prechange_data_clean
if prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict(
prechange_data or dict(),
instance.postchange_data_clean or dict(),
exclude=['last_updated'],
)
diff_removed = {
x: prechange_data.get(x) for x in diff_added
} if prechange_data else {}
else:
diff_added = None
diff_removed = None
return {
'diff_added': diff_added,
'diff_removed': diff_removed,
'next_change': next_change,
'prev_change': prev_change,
'related_changes_table': related_changes_table,
'related_changes_count': related_changes.count(),
'non_atomic_change': non_atomic_change
}
#
# Config Revisions
#

View File

@ -57,34 +57,31 @@ __all__ = [
exclude_fields=('site_count',),
)
class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
site_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.Region
fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'site_count', '_depth']
@extend_schema_serializer(
exclude_fields=('site_count',),
)
class NestedSiteGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
site_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.SiteGroup
fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'site_count', '_depth']
class NestedSiteSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta:
model = models.Site
fields = ['id', 'url', 'display', 'name', 'slug']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
#
@ -95,46 +92,42 @@ class NestedSiteSerializer(WritableNestedSerializer):
exclude_fields=('rack_count',),
)
class NestedLocationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
rack_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.Location
fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth']
@extend_schema_serializer(
exclude_fields=('rack_count',),
)
class NestedRackRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = RelatedObjectCountField('racks')
class Meta:
model = models.RackRole
fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count']
@extend_schema_serializer(
exclude_fields=('device_count',),
)
class NestedRackSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
device_count = RelatedObjectCountField('devices')
class Meta:
model = models.Rack
fields = ['id', 'url', 'display', 'name', 'device_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'device_count']
class NestedRackReservationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
user = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.RackReservation
fields = ['id', 'url', 'display', 'user', 'units']
fields = ['id', 'url', 'display_url', 'display', 'user', 'units']
def get_user(self, obj):
return obj.user.username
@ -148,34 +141,31 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
exclude_fields=('devicetype_count',),
)
class NestedManufacturerSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = RelatedObjectCountField('device_types')
class Meta:
model = models.Manufacturer
fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'devicetype_count']
@extend_schema_serializer(
exclude_fields=('device_count',),
)
class NestedDeviceTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True)
device_count = RelatedObjectCountField('instances')
class Meta:
model = models.DeviceType
fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
fields = ['id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
class NestedModuleTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True)
class Meta:
model = models.ModuleType
fields = ['id', 'url', 'display', 'manufacturer', 'model']
fields = ['id', 'url', 'display_url', 'display', 'manufacturer', 'model']
#
@ -183,84 +173,74 @@ class NestedModuleTypeSerializer(WritableNestedSerializer):
#
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
class Meta:
model = models.ConsolePortTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
class Meta:
model = models.ConsoleServerPortTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
class Meta:
model = models.PowerPortTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
class Meta:
model = models.PowerOutletTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
class Meta:
model = models.InterfaceTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
class Meta:
model = models.RearPortTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
class Meta:
model = models.FrontPortTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedModuleBayTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail')
class Meta:
model = models.ModuleBayTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
class Meta:
model = models.DeviceBayTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail')
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.InventoryItemTemplate
fields = ['id', 'url', 'display', 'name', '_depth']
fields = ['id', 'url', 'display_url', 'display', 'name', '_depth']
#
@ -271,171 +251,154 @@ class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
exclude_fields=('device_count', 'virtualmachine_count'),
)
class NestedDeviceRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta:
model = models.DeviceRole
fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
@extend_schema_serializer(
exclude_fields=('device_count', 'virtualmachine_count'),
)
class NestedPlatformSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
device_count = RelatedObjectCountField('devices')
virtualmachine_count = RelatedObjectCountField('virtual_machines')
class Meta:
model = models.Platform
fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
class NestedDeviceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
class Meta:
model = models.Device
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
class Meta:
model = models.ModuleBay
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
class Meta:
model = models.Module
fields = ['id', 'url', 'display', 'serial']
fields = ['id', 'url', 'display_url', 'display', 'serial']
class NestedModuleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
device = NestedDeviceSerializer(read_only=True)
module_bay = ModuleNestedModuleBaySerializer(read_only=True)
module_type = NestedModuleTypeSerializer(read_only=True)
class Meta:
model = models.Module
fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type']
fields = ['id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type']
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.ConsoleServerPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedConsolePortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.ConsolePort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerOutlet
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedPowerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedInterfaceSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.Interface
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedRearPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.RearPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedFrontPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.FrontPort
fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
class NestedModuleBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
class Meta:
model = models.ModuleBay
fields = ['id', 'url', 'display', 'installed_module', 'name']
fields = ['id', 'url', 'display_url', 'display', 'installed_module', 'name']
class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = models.DeviceBay
fields = ['id', 'url', 'display', 'device', 'name']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name']
class NestedInventoryItemSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.InventoryItem
fields = ['id', 'url', 'display', 'device', 'name', '_depth']
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', '_depth']
@extend_schema_serializer(
exclude_fields=('inventoryitem_count',),
)
class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
inventoryitem_count = RelatedObjectCountField('inventory_items')
class Meta:
model = models.InventoryItemRole
fields = ['id', 'url', 'display', 'name', 'slug', 'inventoryitem_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'inventoryitem_count']
#
@ -443,11 +406,10 @@ class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
#
class NestedCableSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = models.Cable
fields = ['id', 'url', 'display', 'label']
fields = ['id', 'url', 'display_url', 'display', 'label']
#
@ -458,13 +420,12 @@ class NestedCableSerializer(WritableNestedSerializer):
exclude_fields=('member_count',),
)
class NestedVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer()
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = models.VirtualChassis
fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'master', 'member_count']
#
@ -475,27 +436,24 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
exclude_fields=('powerfeed_count',),
)
class NestedPowerPanelSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
powerfeed_count = RelatedObjectCountField('powerfeeds')
class Meta:
model = models.PowerPanel
fields = ['id', 'url', 'display', 'name', 'powerfeed_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'powerfeed_count']
class NestedPowerFeedSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerFeed
fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']
fields = ['id', 'url', 'display_url', 'display', 'name', 'cable', '_occupied']
class NestedVirtualDeviceContextSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = NestedDeviceSerializer()
class Meta:
model = models.VirtualDeviceContext
fields = ['id', 'url', 'display', 'name', 'identifier', 'device']
fields = ['id', 'url', 'display_url', 'display', 'name', 'identifier', 'device']

View File

@ -7,7 +7,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import Cable, CablePath, CableTermination
from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import GenericObjectSerializer, NetBoxModelSerializer
from netbox.api.serializers import BaseModelSerializer, GenericObjectSerializer, NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
@ -21,7 +21,6 @@ __all__ = (
class CableSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
a_terminations = GenericObjectSerializer(many=True, required=False)
b_terminations = GenericObjectSerializer(many=True, required=False)
status = ChoiceField(choices=LinkStatusChoices, required=False)
@ -31,27 +30,26 @@ class CableSerializer(NetBoxModelSerializer):
class Meta:
model = Cable
fields = [
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant',
'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
brief_fields = ('id', 'url', 'display', 'label', 'description')
class TracedCableSerializer(serializers.ModelSerializer):
class TracedCableSerializer(BaseModelSerializer):
"""
Used only while tracing a cable path.
"""
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = Cable
fields = [
'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description',
'id', 'url', 'display_url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description',
]
class CableTerminationSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
termination_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
@ -60,8 +58,8 @@ class CableTerminationSerializer(NetBoxModelSerializer):
class Meta:
model = CableTermination
fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination',
'created', 'last_updated',
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
'termination', 'created', 'last_updated',
]
@extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -41,7 +41,6 @@ __all__ = (
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@ -63,7 +62,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
class Meta:
model = ConsoleServerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
@ -72,7 +71,6 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@ -94,7 +92,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
class Meta:
model = ConsolePort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
@ -103,7 +101,6 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@ -121,8 +118,8 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
class Meta:
model = PowerPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
@ -130,7 +127,6 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@ -159,8 +155,8 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port',
'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
@ -168,7 +164,6 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = DeviceSerializer(nested=True)
vdcs = SerializedPKRelatedField(
queryset=VirtualDeviceContext.objects.all(),
@ -224,11 +219,11 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
class Meta:
model = Interface
fields = [
'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge',
'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role',
'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description',
'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width',
'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link',
'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
@ -250,7 +245,6 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@ -263,9 +257,9 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
class Meta:
model = RearPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@ -274,15 +268,13 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
"""
NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
"""
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta:
model = RearPort
fields = ['id', 'url', 'display', 'name', 'label', 'description']
fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
@ -296,7 +288,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
class Meta:
model = FrontPort
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
@ -304,7 +296,6 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
class ModuleBaySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
device = DeviceSerializer(nested=True)
installed_module = ModuleSerializer(
nested=True,
@ -316,28 +307,26 @@ class ModuleBaySerializer(NetBoxModelSerializer):
class Meta:
model = ModuleBay
fields = [
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'device', 'name', 'installed_module', 'label', 'position',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = DeviceSerializer(nested=True)
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
class Meta:
model = DeviceBay
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = DeviceSerializer(nested=True)
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
@ -353,9 +342,9 @@ class InventoryItemSerializer(NetBoxModelSerializer):
class Meta:
model = InventoryItem
fields = [
'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial',
'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
'custom_fields', 'created', 'last_updated', '_depth',
'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer',
'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type', 'component_id',
'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')

View File

@ -29,7 +29,6 @@ __all__ = (
class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = DeviceTypeSerializer(nested=True)
role = DeviceRoleSerializer(nested=True)
tenant = TenantSerializer(
@ -78,13 +77,13 @@ class DeviceSerializer(NetBoxModelSerializer):
class Meta:
model = Device
fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
'module_bay_count', 'inventory_item_count',
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@ -105,13 +104,13 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@ -120,7 +119,6 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = DeviceSerializer(nested=True)
identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
@ -135,15 +133,14 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
class Meta:
model = VirtualDeviceContext
fields = [
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count',
'id', 'url', 'display_url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip',
'primary_ip4', 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'interface_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
class ModuleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
device = DeviceSerializer(nested=True)
module_bay = NestedModuleBaySerializer()
module_type = ModuleTypeSerializer(nested=True)
@ -152,7 +149,7 @@ class ModuleSerializer(NetBoxModelSerializer):
class Meta:
model = Module
fields = [
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial',
'asset_tag', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')

View File

@ -32,7 +32,6 @@ __all__ = (
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
device_type = DeviceTypeSerializer(
nested=True,
required=False,
@ -54,14 +53,13 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
device_type = DeviceTypeSerializer(
nested=True,
required=False,
@ -83,14 +81,13 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
device_type = DeviceTypeSerializer(
nested=True,
required=False,
@ -113,14 +110,13 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
'allocated_draw', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
'maximum_draw', 'allocated_draw', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
device_type = DeviceTypeSerializer(
nested=True,
required=False,
@ -154,14 +150,13 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
'power_port', 'feed_leg', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class InterfaceTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
device_type = DeviceTypeSerializer(
nested=True,
required=False,
@ -201,14 +196,13 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled',
'mgmt_only', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class RearPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
device_type = DeviceTypeSerializer(
required=False,
nested=True,
@ -226,14 +220,13 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = RearPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
'positions', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class FrontPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
device_type = DeviceTypeSerializer(
nested=True,
required=False,
@ -252,14 +245,13 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = FrontPortTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
'rear_port', 'rear_port_position', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ModuleBayTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail')
device_type = DeviceTypeSerializer(
nested=True
)
@ -267,26 +259,27 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ModuleBayTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
'last_updated',
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
device_type = DeviceTypeSerializer(
nested=True
)
class Meta:
model = DeviceBayTemplate
fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'description',
'created', 'last_updated'
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class InventoryItemTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail')
device_type = DeviceTypeSerializer(
nested=True
)
@ -313,8 +306,9 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = InventoryItemTemplate
fields = [
'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer',
'part_id', 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated',
'_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')

View File

@ -17,7 +17,6 @@ __all__ = (
class DeviceTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = ManufacturerSerializer(nested=True)
default_platform = PlatformSerializer(nested=True, required=False, allow_null=True)
u_height = serializers.DecimalField(
@ -51,26 +50,25 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
class Meta:
model = DeviceType
fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'device_count', 'console_port_template_count', 'console_server_port_template_count',
'power_port_template_count', 'power_outlet_template_count', 'interface_template_count',
'front_port_template_count', 'rear_port_template_count', 'device_bay_template_count',
'module_bay_template_count', 'inventory_item_template_count',
'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'console_port_template_count',
'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
'device_bay_template_count', 'module_bay_template_count', 'inventory_item_template_count',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
class ModuleTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = ManufacturerSerializer(nested=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
class Meta:
model = ModuleType
fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')

View File

@ -10,7 +10,6 @@ __all__ = (
class ManufacturerSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
# Related object counts
devicetype_count = RelatedObjectCountField('device_types')
@ -20,7 +19,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
class Meta:
model = Manufacturer
fields = [
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'devicetype_count', 'inventoryitem_count', 'platform_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')

View File

@ -12,7 +12,6 @@ __all__ = (
class PlatformSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = ManufacturerSerializer(nested=True, required=False, allow_null=True)
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
@ -23,7 +22,7 @@ class PlatformSerializer(NetBoxModelSerializer):
class Meta:
model = Platform
fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description',
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')

View File

@ -17,7 +17,6 @@ __all__ = (
class PowerPanelSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
site = SiteSerializer(nested=True)
location = LocationSerializer(
nested=True,
@ -32,14 +31,13 @@ class PowerPanelSerializer(NetBoxModelSerializer):
class Meta:
model = PowerPanel
fields = [
'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
'powerfeed_count', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags',
'custom_fields', 'powerfeed_count', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = PowerPanelSerializer(nested=True)
rack = RackSerializer(
nested=True,
@ -72,9 +70,9 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
class Meta:
model = PowerFeed
fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'id', 'url', 'display_url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply',
'phase', 'voltage', 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
'description', 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

View File

@ -20,7 +20,6 @@ __all__ = (
class RackRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
# Related object counts
rack_count = RelatedObjectCountField('racks')
@ -28,14 +27,13 @@ class RackRoleSerializer(NetBoxModelSerializer):
class Meta:
model = RackRole
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'rack_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'rack_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
site = SiteSerializer(nested=True)
location = LocationSerializer(nested=True, required=False, allow_null=True, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
@ -55,16 +53,15 @@ class RackSerializer(NetBoxModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit',
'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight',
'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
class RackReservationSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
rack = RackSerializer(nested=True)
user = UserSerializer(nested=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
@ -72,8 +69,8 @@ class RackReservationSerializer(NetBoxModelSerializer):
class Meta:
model = RackReservation
fields = [
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
'comments', 'tags', 'custom_fields',
'id', 'url', 'display_url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant',
'description', 'comments', 'tags', 'custom_fields',
]
brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')

View File

@ -12,7 +12,6 @@ __all__ = (
class DeviceRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
# Related object counts
@ -22,14 +21,13 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
class Meta:
model = DeviceRole
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
class InventoryItemRoleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
# Related object counts
inventoryitem_count = RelatedObjectCountField('inventory_items')
@ -37,7 +35,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
class Meta:
model = InventoryItemRole
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'inventoryitem_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'inventoryitem_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')

View File

@ -19,35 +19,32 @@ __all__ = (
class RegionSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Region
fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = SiteGroup
fields = [
'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'site_count', '_depth',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
'created', 'last_updated', 'site_count', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
class SiteSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = RegionSerializer(nested=True, required=False, allow_null=True)
group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
@ -72,16 +69,15 @@ class SiteSerializer(NetBoxModelSerializer):
class Meta:
model = Site
fields = [
'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
'virtualmachine_count', 'vlan_count',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count',
'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = SiteSerializer(nested=True)
parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=LocationStatusChoices, required=False)
@ -92,7 +88,7 @@ class LocationSerializer(NestedGroupModelSerializer):
class Meta:
model = Location
fields = [
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description',
'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')

View File

@ -10,7 +10,6 @@ __all__ = (
class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
members = NestedDeviceSerializer(many=True, read_only=True)
@ -20,7 +19,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
class Meta:
model = VirtualChassis
fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'member_count', 'members',
'id', 'url', 'display_url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'member_count', 'members',
]
brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')

View File

@ -219,9 +219,9 @@ class RackViewSet(NetBoxModelViewSet):
)
# Enable filtering rack units by ID
q = data['q']
if q:
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])]
if q := data['q']:
q = q.lower()
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name']).lower()]
page = self.paginate_queryset(elevation)
if page is not None:

View File

@ -465,7 +465,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
selector=True
selector=True,
query_params={
'site_id': ['$site', 'null']
},
)
comments = CommentField()
local_context_data = JSONField(
@ -656,11 +659,6 @@ class CableForm(TenancyForm, NetBoxModelForm):
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'description', 'comments', 'tags',
]
error_messages = {
'length': {
'max_value': _('Maximum length is 32767 (any unit)')
}
}
class PowerPanelForm(NetBoxModelForm):

View File

@ -3,14 +3,10 @@ from typing import Annotated, List, Union
import strawberry
import strawberry_django
from core.graphql.mixins import ChangelogMixin
from dcim import models
from extras.graphql.mixins import (
ChangelogMixin,
ConfigContextMixin,
ContactsMixin,
CustomFieldsMixin,
ImageAttachmentsMixin,
TagsMixin,
ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
)
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt

View File

@ -393,6 +393,8 @@ class CableTraceSVG:
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
if cable.ssid:
description.append(f"{cable.ssid}")
if cable.distance and cable.distance_unit:
description.append(f"{cable.distance} {cable.get_distance_unit_display()}")
near = [term for term in near_terminations if term.object == cable.interface_a]
far = [term for term in far_terminations if term.object == cable.interface_b]
if not (near and far):

View File

@ -1,6 +1,7 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from django_tables2.utils import Accessor
from django.utils.html import escape
from django.utils.safestring import mark_safe
from dcim.models import Cable
@ -35,7 +36,7 @@ class CableTerminationsColumn(tables.Column):
def render(self, value):
links = [
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
f'<a href="{term.get_absolute_url()}">{escape(term)}</a>' for term in self._get_terminations(value)
]
return mark_safe('<br />'.join(links) or '&mdash;')
@ -109,7 +110,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
status = columns.ChoiceFieldColumn()
length = columns.TemplateColumn(
template_code=CABLE_LENGTH,
order_by=('_abs_length', 'length_unit')
order_by=('_abs_length')
)
color = columns.ColorColumn()
comments = columns.MarkdownColumn()

View File

@ -8,6 +8,7 @@ from dcim.models import *
from extras.models import CustomField
from tenancy.models import Tenant
from utilities.data import drange
from virtualization.models import Cluster, ClusterType
class LocationTestCase(TestCase):
@ -533,6 +534,36 @@ class DeviceTestCase(TestCase):
device2.full_clean()
device2.save()
def test_device_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
clusters = (
Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, site=None),
)
Cluster.objects.bulk_create(clusters)
device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
# Device with site only should pass
Device(name='device1', site=sites[0], device_type=device_type, role=device_role).full_clean()
# Device with site, cluster non-site should pass
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[2]).full_clean()
# Device with mismatched site & cluster should fail
with self.assertRaises(ValidationError):
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean()
class CableTestCase(TestCase):

View File

@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.models import ASN, IPAddress, VLANGroup
from ipam.tables import InterfaceVLANTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
@ -27,7 +27,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
)
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
@ -226,19 +228,21 @@ class RegionListView(generic.ObjectListView):
@register_model_view(Region)
class RegionView(generic.ObjectView):
class RegionView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Region.objects.all()
def get_extra_context(self, request, instance):
regions = instance.get_descendants(include_self=True)
related_models = (
(Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'),
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
regions,
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
),
),
}
@ -306,19 +310,21 @@ class SiteGroupListView(generic.ObjectListView):
@register_model_view(SiteGroup)
class SiteGroupView(generic.ObjectView):
class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = SiteGroup.objects.all()
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
related_models = (
(Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
groups,
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
),
),
}
@ -380,31 +386,25 @@ class SiteListView(generic.ObjectListView):
@register_model_view(Site)
class SiteView(generic.ObjectView):
class SiteView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group')
def get_extra_context(self, request, instance):
related_models = (
# DCIM
(Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
# Virtualization
(VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'),
# IPAM
(Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
), 'site'),
(VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
# Circuits
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
[CableTermination, CircuitTermination],
(
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
), 'site'),
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(),
'site_id'),
),
),
}
@ -466,18 +466,13 @@ class LocationListView(generic.ObjectListView):
@register_model_view(Location)
class LocationView(generic.ObjectView):
class LocationView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Location.objects.all()
def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True)
related_models = (
(Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, locations, [CableTermination]),
}
@ -541,16 +536,12 @@ class RackRoleListView(generic.ObjectListView):
@register_model_view(RackRole)
class RackRoleView(generic.ObjectView):
class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackRole.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -655,15 +646,10 @@ class RackElevationListView(generic.ObjectListView):
@register_model_view(Rack)
class RackView(generic.ObjectView):
class RackView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'),
(PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
)
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
if instance.location:
@ -679,7 +665,7 @@ class RackView(generic.ObjectView):
])
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, [CableTermination]),
'next_rack': next_rack,
'prev_rack': prev_rack,
'svg_extra': svg_extra,
@ -838,19 +824,12 @@ class ManufacturerListView(generic.ObjectListView):
@register_model_view(Manufacturer)
class ManufacturerView(generic.ObjectView):
class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Manufacturer.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
(ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
(InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
(Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, [InventoryItemTemplate]),
}
@ -912,16 +891,16 @@ class DeviceTypeListView(generic.ObjectListView):
@register_model_view(DeviceType)
class DeviceTypeView(generic.ObjectView):
class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, omit=[
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
RearPortTemplate,
]),
}
@ -1151,16 +1130,16 @@ class ModuleTypeListView(generic.ObjectListView):
@register_model_view(ModuleType)
class ModuleTypeView(generic.ObjectView):
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ModuleType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, omit=[
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
RearPortTemplate,
]),
}
@ -1711,17 +1690,12 @@ class DeviceRoleListView(generic.ObjectListView):
@register_model_view(DeviceRole)
class DeviceRoleView(generic.ObjectView):
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceRole.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
(VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -1775,17 +1749,12 @@ class PlatformListView(generic.ObjectListView):
@register_model_view(Platform)
class PlatformView(generic.ObjectView):
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Platform.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
(VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -2157,22 +2126,12 @@ class ModuleListView(generic.ObjectListView):
@register_model_view(Module)
class ModuleView(generic.ObjectView):
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Module.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -3451,8 +3410,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
if membership_form.is_valid():
membership_form.save()
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
messages.success(request, mark_safe(msg))
messages.success(request, mark_safe(
f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
))
if '_addanother' in request.POST:
return redirect(request.get_full_path())
@ -3552,16 +3512,12 @@ class PowerPanelListView(generic.ObjectListView):
@register_model_view(PowerPanel)
class PowerPanelView(generic.ObjectView):
class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
queryset = PowerPanel.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@ -3665,16 +3621,18 @@ class VirtualDeviceContextListView(generic.ObjectListView):
@register_model_view(VirtualDeviceContext)
class VirtualDeviceContextView(generic.ObjectView):
class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualDeviceContext.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
extra=(
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
),
),
}

View File

@ -6,6 +6,7 @@ from rest_framework.serializers import ValidationError
from core.models import ObjectType
from extras.choices import CustomFieldTypeChoices
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.models import CustomField
from utilities.api import get_serializer_for_model
@ -75,7 +76,7 @@ class CustomFieldsDataField(Field):
# Serialize object and multi-object values
for cf in self._get_custom_fields():
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
if cf.name in data and data[cf.name] not in CUSTOMFIELD_EMPTY_VALUES and cf.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT
):

View File

@ -22,79 +22,69 @@ __all__ = [
class NestedEventRuleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
class Meta:
model = models.EventRule
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedWebhookSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
class Meta:
model = models.Webhook
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedCustomFieldSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
class Meta:
model = models.CustomField
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
class Meta:
model = models.CustomFieldChoiceSet
fields = ['id', 'url', 'display', 'name', 'choices_count']
fields = ['id', 'url', 'display_url', 'display', 'name', 'choices_count']
class NestedCustomLinkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
class Meta:
model = models.CustomLink
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedConfigContextSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
class Meta:
model = models.ConfigContext
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedConfigTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
class Meta:
model = models.ConfigTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedExportTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
class Meta:
model = models.ExportTemplate
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
class NestedSavedFilterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
class Meta:
model = models.SavedFilter
fields = ['id', 'url', 'display', 'name', 'slug']
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
class NestedBookmarkSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
class Meta:
model = models.Bookmark
@ -102,7 +92,6 @@ class NestedBookmarkSerializer(WritableNestedSerializer):
class NestedImageAttachmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
class Meta:
model = models.ImageAttachment
@ -110,11 +99,10 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer):
class NestedJournalEntrySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
class Meta:
model = models.JournalEntry
fields = ['id', 'url', 'display', 'created']
fields = ['id', 'url', 'display_url', 'display', 'created']
class NestedScriptSerializer(WritableNestedSerializer):
@ -123,12 +111,17 @@ class NestedScriptSerializer(WritableNestedSerializer):
lookup_field='full_name',
lookup_url_kwarg='pk'
)
display_url = serializers.HyperlinkedIdentityField(
view_name='extras:script',
lookup_field='full_name',
lookup_url_kwarg='pk'
)
name = serializers.CharField(read_only=True)
display = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Script
fields = ['id', 'url', 'display', 'name']
fields = ['id', 'url', 'display_url', 'display', 'name']
def get_display(self, obj):
return f'{obj.name} ({obj.module})'

View File

@ -1,7 +1,6 @@
from .serializers_.objecttypes import *
from .serializers_.attachments import *
from .serializers_.bookmarks import *
from .serializers_.change_logging import *
from .serializers_.customfields import *
from .serializers_.customlinks import *
from .serializers_.dashboard import *

View File

@ -14,7 +14,6 @@ __all__ = (
class ImageAttachmentSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
object_type = ContentTypeField(
queryset=ObjectType.objects.all()
)
@ -23,8 +22,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
class Meta:
model = ImageAttachment
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'image_height',
'image_width', 'created', 'last_updated',
'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image',
'image_height', 'image_width', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'image')

View File

@ -14,7 +14,6 @@ __all__ = (
class BookmarkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
object_type = ContentTypeField(
queryset=ObjectType.objects.with_feature('bookmarks'),
)

View File

@ -20,7 +20,6 @@ __all__ = (
class ConfigContextSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
regions = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=RegionSerializer,
@ -123,9 +122,9 @@ class ConfigContextSerializer(ValidatedModelSerializer):
class Meta:
model = ConfigContext
fields = [
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'name', 'weight', 'description', 'is_active', 'regions',
'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path',
'data_file', 'data_synced', 'data', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -11,7 +11,6 @@ __all__ = (
class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
data_source = DataSourceSerializer(
nested=True,
required=False
@ -24,7 +23,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
class Meta:
model = ConfigTemplate
fields = [
'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
'data_source', 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -16,7 +16,6 @@ __all__ = (
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
base_choices = ChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False
@ -31,14 +30,13 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
'choices_count', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'name', 'description', 'base_choices', 'extra_choices',
'order_alphabetically', 'choices_count', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
class CustomFieldSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
object_types = ContentTypeField(
queryset=ObjectType.objects.with_feature('custom_fields'),
many=True
@ -62,10 +60,10 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class Meta:
model = CustomField
fields = [
'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label',
'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
'choice_set', 'comments', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'object_types', 'type', 'related_object_type', 'data_type',
'name', 'label', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible',
'ui_editable', 'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'validation_unique', 'choice_set', 'comments', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -11,7 +11,6 @@ __all__ = (
class CustomLinkSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
object_types = ContentTypeField(
queryset=ObjectType.objects.with_feature('custom_links'),
many=True
@ -20,7 +19,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class Meta:
model = CustomLink
fields = [
'id', 'url', 'display', 'object_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'enabled', 'link_text', 'link_url',
'weight', 'group_name', 'button_class', 'new_window', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name')

View File

@ -21,7 +21,6 @@ __all__ = (
#
class EventRuleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:eventrule-detail')
object_types = ContentTypeField(
queryset=ObjectType.objects.with_feature('event_rules'),
many=True
@ -35,7 +34,7 @@ class EventRuleSerializer(NetBoxModelSerializer):
class Meta:
model = EventRule
fields = [
'id', 'url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete',
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete',
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
]
@ -58,13 +57,12 @@ class EventRuleSerializer(NetBoxModelSerializer):
#
class WebhookSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
class Meta:
model = Webhook
fields = [
'id', 'url', 'display', 'name', 'description', 'payload_url', 'http_method', 'http_content_type',
'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
'tags', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'name', 'description', 'payload_url', 'http_method',
'http_content_type', 'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path',
'custom_fields', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -12,7 +12,6 @@ __all__ = (
class ExportTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
object_types = ContentTypeField(
queryset=ObjectType.objects.with_feature('export_templates'),
many=True
@ -29,7 +28,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
fields = [
'id', 'url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
'last_updated',
]

View File

@ -16,7 +16,6 @@ __all__ = (
class JournalEntrySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail')
assigned_object_type = ContentTypeField(
queryset=ObjectType.objects.all()
)
@ -35,8 +34,8 @@ class JournalEntrySerializer(NetBoxModelSerializer):
class Meta:
model = JournalEntry
fields = [
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
'id', 'url', 'display_url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object',
'created', 'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'created')

View File

@ -11,7 +11,6 @@ __all__ = (
class SavedFilterSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
object_types = ContentTypeField(
queryset=ObjectType.objects.all(),
many=True
@ -20,7 +19,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
class Meta:
model = SavedFilter
fields = [
'id', 'url', 'display', 'object_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
'shared', 'parameters', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'slug', 'description', 'user', 'weight',
'enabled', 'shared', 'parameters', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')

View File

@ -14,7 +14,6 @@ __all__ = (
class ScriptSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
description = serializers.SerializerMethodField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)
result = JobSerializer(nested=True, read_only=True)
@ -22,7 +21,7 @@ class ScriptSerializer(ValidatedModelSerializer):
class Meta:
model = Script
fields = [
'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
'id', 'url', 'display_url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -11,7 +11,6 @@ __all__ = (
class TagSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
object_types = ContentTypeField(
queryset=ObjectType.objects.with_feature('tags'),
many=True,
@ -24,7 +23,7 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
'last_updated',
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types',
'tagged_items', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')

View File

@ -21,7 +21,6 @@ router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-templates', views.ConfigTemplateViewSet)
router.register('scripts', views.ScriptViewSet, basename='script')
router.register('object-changes', views.ObjectChangeViewSet)
router.register('object-types', views.ObjectTypeViewSet)
app_name = 'extras-api'

View File

@ -271,20 +271,6 @@ class ScriptViewSet(ModelViewSet):
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
#
# Change logging
#
class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.valid_models()
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet
#
# Object types
#

View File

@ -117,27 +117,14 @@ class BookmarkOrderingChoices(ChoiceSet):
ORDERING_NEWEST = '-created'
ORDERING_OLDEST = 'created'
ORDERING_ALPHABETICAL_AZ = 'name'
ORDERING_ALPHABETICAL_ZA = '-name'
CHOICES = (
(ORDERING_NEWEST, _('Newest')),
(ORDERING_OLDEST, _('Oldest')),
)
#
# ObjectChanges
#
class ObjectChangeActionChoices(ChoiceSet):
ACTION_CREATE = 'create'
ACTION_UPDATE = 'update'
ACTION_DELETE = 'delete'
CHOICES = (
(ACTION_CREATE, _('Created'), 'green'),
(ACTION_UPDATE, _('Updated'), 'blue'),
(ACTION_DELETE, _('Deleted'), 'red'),
(ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')),
(ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')),
)

View File

@ -135,23 +135,23 @@ class ConditionSet:
def __init__(self, ruleset):
if type(ruleset) is not dict:
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
if len(ruleset) != 1:
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
ruleset=len(ruleset)))
# Determine the logic type
logic = list(ruleset.keys())[0]
if type(logic) is not str or logic.lower() not in (AND, OR):
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
logic=logic, op_and=AND, op_or=OR
))
self.logic = logic.lower()
if len(ruleset) == 1:
self.logic = (list(ruleset.keys())[0]).lower()
if self.logic not in (AND, OR):
raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation."))
# Compile the set of Conditions
self.conditions = [
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
for rule in ruleset[self.logic]
]
# Compile the set of Conditions
self.conditions = [
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
for rule in ruleset[self.logic]
]
else:
try:
self.logic = None
self.conditions = [Condition(**ruleset)]
except TypeError:
raise ValueError(_("Incorrect key(s) informed. Please check documentation."))
def eval(self, data):
"""

View File

@ -1,3 +1,5 @@
from extras.choices import LogLevelChoices
# Events
EVENT_CREATE = 'create'
EVENT_UPDATE = 'update'
@ -5,6 +7,8 @@ EVENT_DELETE = 'delete'
EVENT_JOB_START = 'job_start'
EVENT_JOB_END = 'job_end'
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
# Webhooks
HTTP_CONTENT_TYPE_JSON = 'application/json'
@ -128,8 +132,17 @@ DEFAULT_DASHBOARD = [
'title': 'Change Log',
'color': 'blue',
'config': {
'model': 'extras.objectchange',
'model': 'core.objectchange',
'page_size': 25,
}
},
]
LOG_LEVEL_RANK = {
LogLevelChoices.LOG_DEFAULT: 0,
LogLevelChoices.LOG_DEBUG: 1,
LogLevelChoices.LOG_SUCCESS: 2,
LogLevelChoices.LOG_INFO: 3,
LogLevelChoices.LOG_WARNING: 4,
LogLevelChoices.LOG_FAILURE: 5,
}

View File

@ -381,11 +381,17 @@ class BookmarksWidget(DashboardWidget):
if request.user.is_anonymous:
bookmarks = list()
else:
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
user_bookmarks = Bookmark.objects.filter(user=request.user)
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower())
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
else:
bookmarks = user_bookmarks.order_by(self.config['order_by'])
if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types)
conent_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=conent_types)
content_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=content_types)
if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items]

View File

@ -1,3 +1,5 @@
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
@ -6,6 +8,7 @@ from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from django_rq import get_queue
from core.choices import ObjectChangeActionChoices
from core.models import Job
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
@ -13,7 +16,7 @@ from netbox.registry import registry
from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.serialization import serialize_object
from .choices import *
from .choices import EventRuleActionChoices
from .models import EventRule
logger = logging.getLogger('netbox.events_processor')

View File

@ -26,7 +26,6 @@ __all__ = (
'ImageAttachmentFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'ObjectTypeFilterSet',
'SavedFilterFilterSet',
'ScriptFilterSet',
@ -155,7 +154,7 @@ class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
fields = (
'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible',
'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum',
'validation_regex',
'validation_regex', 'validation_unique',
)
def search(self, queryset, name, value):
@ -645,43 +644,6 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
return queryset.exclude(local_context_data__isnull=value)
class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
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 = ObjectChange
fields = (
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
)
#
# ContentTypes
#

View File

@ -6,6 +6,7 @@ from extras.models import *
from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = (
@ -64,8 +65,32 @@ class CustomFieldBulkEditForm(BulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect()
)
validation_minimum = forms.IntegerField(
label=_('Minimum value'),
required=False,
)
validation_maximum = forms.IntegerField(
label=_('Maximum value'),
required=False,
)
validation_regex = forms.CharField(
label=_('Validation regex'),
required=False
)
validation_unique = forms.NullBooleanField(
label=_('Must be unique'),
required=False,
widget=BulkEditNullBooleanSelect()
)
comments = CommentField()
fieldsets = (
FieldSet('group_name', 'description', 'weight', 'choice_set', name=_('Attributes')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet(
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation')
),
)
nullable_fields = ('group_name', 'description', 'choice_set')

View File

@ -71,7 +71,8 @@ class CustomFieldImportForm(CSVModelForm):
fields = (
'name', 'label', 'group_name', 'type', 'object_types', 'related_object_type', 'required', 'description',
'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum',
'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', 'comments',
'validation_maximum', 'validation_regex', 'validation_unique', 'ui_visible', 'ui_editable', 'is_cloneable',
'comments',
)

View File

@ -14,7 +14,7 @@ from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
from utilities.forms.widgets import DateTimePicker
from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
@ -28,7 +28,6 @@ __all__ = (
'ImageAttachmentFilterForm',
'JournalEntryFilterForm',
'LocalConfigContextFilterForm',
'ObjectChangeFilterForm',
'SavedFilterFilterForm',
'TagFilterForm',
'WebhookFilterForm',
@ -42,6 +41,9 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible',
'ui_editable', 'is_cloneable', name=_('Attributes')
),
FieldSet(
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation')
),
)
related_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('custom_fields'),
@ -90,6 +92,25 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
validation_minimum = forms.IntegerField(
label=_('Minimum value'),
required=False
)
validation_maximum = forms.IntegerField(
label=_('Maximum value'),
required=False
)
validation_regex = forms.CharField(
label=_('Validation regex'),
required=False
)
validation_unique = forms.NullBooleanField(
label=_('Must be unique'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
@ -475,37 +496,3 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
required=False
)
tag = TagFilterField(model)
class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
model = ObjectChange
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('time_before', 'time_after', name=_('Time')),
FieldSet('action', 'user_id', 'changed_object_type_id', name=_('Attributes')),
)
time_after = forms.DateTimeField(
required=False,
label=_('After'),
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
required=False,
label=_('Before'),
widget=DateTimePicker()
)
action = forms.ChoiceField(
label=_('Action'),
choices=add_blank_choice(ObjectChangeActionChoices),
required=False
)
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
changed_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('change_logging'),
required=False,
label=_('Object Type'),
)

View File

@ -64,7 +64,9 @@ class CustomFieldForm(forms.ModelForm):
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior')
),
FieldSet('default', 'choice_set', name=_('Values')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
FieldSet(
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation')
),
)
class Meta:

View File

@ -13,7 +13,6 @@ __all__ = (
'ExportTemplateFilter',
'ImageAttachmentFilter',
'JournalEntryFilter',
'ObjectChangeFilter',
'SavedFilterFilter',
'TagFilter',
'WebhookFilter',
@ -68,12 +67,6 @@ class JournalEntryFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.ObjectChange, lookups=True)
@autotype_decorator(filtersets.ObjectChangeFilterSet)
class ObjectChangeFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.SavedFilter, lookups=True)
@autotype_decorator(filtersets.SavedFilterFilterSet)
class SavedFilterFilter(BaseFilterMixin):

View File

@ -2,12 +2,8 @@ from typing import TYPE_CHECKING, Annotated, List
import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType
from extras.models import ObjectChange
__all__ = (
'ChangelogMixin',
'ConfigContextMixin',
'ContactsMixin',
'CustomFieldsMixin',
@ -17,23 +13,10 @@ __all__ = (
)
if TYPE_CHECKING:
from .types import ImageAttachmentType, JournalEntryType, ObjectChangeType, TagType
from .types import ImageAttachmentType, JournalEntryType, TagType
from tenancy.graphql.types import ContactAssignmentType
@strawberry.type
class ChangelogMixin:
@strawberry_django.field
def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]:
content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter(
changed_object_type=content_type,
changed_object_id=self.pk
)
return object_changes.restrict(info.context.request.user, 'view')
@strawberry.type
class ConfigContextMixin:

View File

@ -18,7 +18,6 @@ __all__ = (
'ExportTemplateType',
'ImageAttachmentType',
'JournalEntryType',
'ObjectChangeType',
'SavedFilterType',
'TagType',
'WebhookType',
@ -123,15 +122,6 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
models.ObjectChange,
fields='__all__',
filters=ObjectChangeFilter
)
class ObjectChangeType(BaseObjectType):
pass
@strawberry_django.type(
models.SavedFilter,
exclude=['content_types',],

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