mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-08 16:48:16 -06:00
Merge branch 'feature' into 8984-script-log
This commit is contained in:
commit
ae28d32135
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
1
.gitignore
vendored
@ -28,3 +28,4 @@ netbox.pid
|
||||
.idea
|
||||
.coverage
|
||||
.vscode
|
||||
.python-version
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
95605
contrib/openapi2.json
95605
contrib/openapi2.json
File diff suppressed because it is too large
Load Diff
69695
contrib/openapi2.yaml
69695
contrib/openapi2.yaml
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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).
|
||||
|
@ -51,3 +51,8 @@ The amount of running memory provisioned, in megabytes.
|
||||
### Disk
|
||||
|
||||
The amount of disk storage provisioned, in gigabytes.
|
||||
|
||||
### Serial Number
|
||||
|
||||
Optional serial number assigned to this VM.
|
||||
|
||||
|
@ -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).
|
||||
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
30
docs/release-notes/version-4.1.md
Normal file
30
docs/release-notes/version-4.1.md
Normal 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
|
@ -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'
|
||||
|
@ -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
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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 = (
|
||||
return {
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
extra=(
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
||||
'provider_network_id',
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
from .serializers_.change_logging import *
|
||||
from .serializers_.data import *
|
||||
from .serializers_.jobs import *
|
||||
from .nested_serializers import *
|
||||
|
@ -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,7 @@ __all__ = (
|
||||
|
||||
|
||||
class ObjectChangeSerializer(BaseModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
|
||||
url = serializers.HyperlinkedIdentityField(view_name='core-api:objectchange-detail')
|
||||
user = UserSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
)
|
||||
|
@ -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',
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
|
24
netbox/core/graphql/mixins.py
Normal file
24
netbox/core/graphql/mixins.py
Normal 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')
|
@ -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
|
||||
|
45
netbox/core/migrations/0011_move_objectchange.py
Normal file
45
netbox/core/migrations/0011_move_objectchange.py
Normal 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=[],
|
||||
),
|
||||
]
|
@ -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 *
|
||||
|
@ -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
26
netbox/core/querysets.py
Normal 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)
|
@ -1,3 +1,4 @@
|
||||
from .change_logging import *
|
||||
from .config import *
|
||||
from .data import *
|
||||
from .jobs import *
|
||||
|
53
netbox/core/tables/change_logging.py
Normal file
53
netbox/core/tables/change_logging.py
Normal 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',
|
||||
)
|
16
netbox/core/tables/template_code.py
Normal file
16
netbox/core/tables/template_code.py
Normal 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>
|
||||
"""
|
@ -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
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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 = ()
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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 '—')
|
||||
|
||||
@ -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()
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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'),
|
||||
return {
|
||||
'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'),
|
||||
(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,
|
||||
(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'),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
):
|
||||
|
@ -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 *
|
||||
|
@ -65,7 +65,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
'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',
|
||||
'validation_unique', 'choice_set', 'comments', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)')),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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]
|
||||
]
|
||||
else:
|
||||
try:
|
||||
self.logic = None
|
||||
self.conditions = [Condition(**ruleset)]
|
||||
except TypeError:
|
||||
raise ValueError(_("Incorrect key(s) informed. Please check documentation."))
|
||||
|
||||
def eval(self, data):
|
||||
"""
|
||||
|
@ -7,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'
|
||||
@ -130,7 +132,7 @@ DEFAULT_DASHBOARD = [
|
||||
'title': 'Change Log',
|
||||
'color': 'blue',
|
||||
'config': {
|
||||
'model': 'extras.objectchange',
|
||||
'model': 'core.objectchange',
|
||||
'page_size': 25,
|
||||
}
|
||||
},
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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')
|
||||
|
||||
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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'),
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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',],
|
||||
|
@ -9,8 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
from core.models import Job
|
||||
from extras.models import ObjectChange
|
||||
from core.models import Job, ObjectChange
|
||||
from netbox.config import Config
|
||||
|
||||
|
||||
|
@ -10,9 +10,9 @@ from django.db import transaction
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras.context_managers import event_tracking
|
||||
from extras.scripts import get_module_and_script
|
||||
from extras.signals import clear_events
|
||||
from netbox.context_managers import event_tracking
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.request import NetBoxFakeRequest
|
||||
|
||||
|
124
netbox/extras/migrations/0116_move_objectchange.py
Normal file
124
netbox/extras/migrations/0116_move_objectchange.py
Normal file
@ -0,0 +1,124 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_content_types(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
# Delete the new ContentTypes effected by the new model in the core app
|
||||
ContentType.objects.filter(app_label='core', model='objectchange').delete()
|
||||
|
||||
# Update the app labels of the original ContentTypes for extras.ObjectChange to ensure that any
|
||||
# foreign key references are preserved
|
||||
ContentType.objects.filter(app_label='extras', model='objectchange').update(app_label='core')
|
||||
|
||||
|
||||
def update_dashboard_widgets(apps, schema_editor):
|
||||
Dashboard = apps.get_model('extras', 'Dashboard')
|
||||
|
||||
for dashboard in Dashboard.objects.all():
|
||||
for key, widget in dashboard.config.items():
|
||||
if widget['config'].get('model') == 'extras.objectchange':
|
||||
widget['config']['model'] = 'core.objectchange'
|
||||
elif models := widget['config'].get('models'):
|
||||
models = list(map(lambda x: x.replace('extras.objectchange', 'core.objectchange'), models))
|
||||
dashboard.config[key]['config']['models'] = models
|
||||
dashboard.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0115_convert_dashboard_widgets'),
|
||||
('core', '0011_move_objectchange'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
state_operations=[
|
||||
migrations.DeleteModel(
|
||||
name='ObjectChange',
|
||||
),
|
||||
],
|
||||
database_operations=[
|
||||
migrations.AlterModelTable(
|
||||
name='ObjectChange',
|
||||
table='core_objectchange',
|
||||
),
|
||||
|
||||
# Rename PK sequence
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE extras_objectchange_id_seq"
|
||||
" RENAME TO core_objectchange_id_seq"
|
||||
),
|
||||
|
||||
# Rename indexes. Hashes generated by schema_editor._create_index_name()
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_objectchange_pkey"
|
||||
" RENAME TO core_objectchange_pkey"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_obje_changed_927fe5_idx"
|
||||
" RENAME TO core_objectchange_changed_object_type_id_cha_79a9ed1e"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_obje_related_bfcdef_idx"
|
||||
" RENAME TO core_objectchange_related_object_type_id_rel_a71d604a"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_objectchange_changed_object_type_id_b755bb60"
|
||||
" RENAME TO core_objectchange_changed_object_type_id_2070ade6"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_objectchange_related_object_type_id_fe6e521f"
|
||||
" RENAME TO core_objectchange_related_object_type_id_b80958af"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_objectchange_request_id_4ae21e90"
|
||||
" RENAME TO core_objectchange_request_id_d9d160ac"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_objectchange_time_224380ea"
|
||||
" RENAME TO core_objectchange_time_800f60a5"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER INDEX extras_objectchange_user_id_7fdf8186"
|
||||
" RENAME TO core_objectchange_user_id_2b2142be"
|
||||
),
|
||||
|
||||
# Rename constraints
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE core_objectchange RENAME CONSTRAINT "
|
||||
"extras_objectchange_changed_object_id_check TO "
|
||||
"core_objectchange_changed_object_id_check"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE core_objectchange RENAME CONSTRAINT "
|
||||
"extras_objectchange_related_object_id_check TO "
|
||||
"core_objectchange_related_object_id_check"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE core_objectchange RENAME CONSTRAINT "
|
||||
"extras_objectchange_changed_object_type__b755bb60_fk_django_co TO "
|
||||
"core_objectchange_changed_object_type_id_2070ade6"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE core_objectchange RENAME CONSTRAINT "
|
||||
"extras_objectchange_related_object_type__fe6e521f_fk_django_co TO "
|
||||
"core_objectchange_related_object_type_id_b80958af"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE core_objectchange RENAME CONSTRAINT "
|
||||
"extras_objectchange_user_id_7fdf8186_fk_auth_user_id TO "
|
||||
"core_objectchange_user_id_2b2142be"
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=update_content_types,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=update_dashboard_widgets,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
16
netbox/extras/migrations/0117_customfield_uniqueness.py
Normal file
16
netbox/extras/migrations/0117_customfield_uniqueness.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0116_move_objectchange'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='validation_unique',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -1,4 +1,3 @@
|
||||
from .change_logging import *
|
||||
from .configs import *
|
||||
from .customfields import *
|
||||
from .dashboard import *
|
||||
|
@ -10,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -179,6 +180,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
'example, <code>^[A-Z]{3}$</code> will limit values to exactly three uppercase letters.'
|
||||
)
|
||||
)
|
||||
validation_unique = models.BooleanField(
|
||||
verbose_name=_('must be unique'),
|
||||
default=False,
|
||||
help_text=_('The value of this field must be unique for the assigned object')
|
||||
)
|
||||
choice_set = models.ForeignKey(
|
||||
to='CustomFieldChoiceSet',
|
||||
on_delete=models.PROTECT,
|
||||
@ -216,7 +222,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
clone_fields = (
|
||||
'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
'validation_unique', 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -333,6 +339,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
'validation_regex': _("Regular expression validation is supported only for text and URL fields")
|
||||
})
|
||||
|
||||
# Uniqueness can not be enforced for boolean fields
|
||||
if self.validation_unique and self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
raise ValidationError({
|
||||
'validation_unique': _("Uniqueness cannot be enforced for boolean fields")
|
||||
})
|
||||
|
||||
# Choice set must be set on selection fields, and *only* on selection fields
|
||||
if self.type in (
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
@ -520,7 +532,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
RegexValidator(
|
||||
regex=self.validation_regex,
|
||||
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
|
||||
regex=self.validation_regex
|
||||
regex=escape(self.validation_regex)
|
||||
))
|
||||
)
|
||||
]
|
||||
@ -660,6 +672,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
# Validate date & time
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
||||
if type(value) is not datetime:
|
||||
# Work around UTC issue for Python < 3.11; see
|
||||
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
|
||||
if type(value) is str and value.endswith('Z'):
|
||||
value = f'{value[:-1]}+00:00'
|
||||
try:
|
||||
datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
|
@ -8,7 +8,6 @@ from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
@ -23,9 +22,9 @@ from netbox.models.features import (
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
|
||||
)
|
||||
from utilities.html import clean_html
|
||||
from utilities.jinja2 import render_jinja2
|
||||
from utilities.querydict import dict_to_querydict
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.jinja2 import render_jinja2
|
||||
|
||||
__all__ = (
|
||||
'Bookmark',
|
||||
|
@ -1,8 +1,5 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.aggregates import JSONBAgg
|
||||
from django.db.models import OuterRef, Subquery, Q
|
||||
from django.db.utils import ProgrammingError
|
||||
|
||||
from extras.models.tags import TaggedItem
|
||||
from utilities.query_functions import EmptyGroupByJSONBAgg
|
||||
@ -148,20 +145,3 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
)
|
||||
|
||||
return base_query
|
||||
|
||||
|
||||
class ObjectChangeQuerySet(RestrictedQuerySet):
|
||||
|
||||
def valid_models(self):
|
||||
# Exclude any change records which refer to an instance of a model that's no longer installed. This
|
||||
# can happen when a plugin is removed but its data remains in the database, for example.
|
||||
try:
|
||||
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
|
||||
except ProgrammingError:
|
||||
# Handle the case where the database schema has not yet been initialized
|
||||
content_types = ContentType.objects.none()
|
||||
|
||||
content_type_ids = set(
|
||||
ct.pk for ct in content_types
|
||||
)
|
||||
return self.filter(changed_object_type_id__in=content_type_ids)
|
||||
|
@ -21,11 +21,11 @@ from extras.models import ScriptModule, Script as ScriptModel
|
||||
from extras.signals import clear_events
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from netbox.context_managers import event_tracking
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import DatePicker, DateTimePicker
|
||||
from .context_managers import event_tracking
|
||||
from .forms import ScriptForm
|
||||
from .utils import is_report
|
||||
|
||||
@ -480,6 +480,12 @@ class BaseScript:
|
||||
# A test method is currently active, so log the message using legacy Report logging
|
||||
if self._current_test:
|
||||
|
||||
# Increment the event counter for this level
|
||||
if level in self.tests[self._current_test]:
|
||||
self.tests[self._current_test][level] += 1
|
||||
|
||||
# Record message (if any) to the report log
|
||||
if message:
|
||||
# TODO: Use a dataclass for test method logs
|
||||
self.tests[self._current_test]['log'].append((
|
||||
timezone.now().isoformat(),
|
||||
@ -489,10 +495,6 @@ class BaseScript:
|
||||
str(message),
|
||||
))
|
||||
|
||||
# Increment the event counter for this level
|
||||
if level in self.tests[self._current_test]:
|
||||
self.tests[self._current_test][level] += 1
|
||||
|
||||
elif message:
|
||||
|
||||
# Record to the script's log
|
||||
@ -500,6 +502,8 @@ class BaseScript:
|
||||
'time': timezone.now().isoformat(),
|
||||
'status': level,
|
||||
'message': str(message),
|
||||
'obj': str(obj) if obj else None,
|
||||
'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
})
|
||||
|
||||
# Record to the system log
|
||||
@ -507,19 +511,19 @@ class BaseScript:
|
||||
message = f"{obj}: {message}"
|
||||
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
|
||||
|
||||
def log_debug(self, message, obj=None):
|
||||
def log_debug(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
|
||||
|
||||
def log_success(self, message, obj=None):
|
||||
def log_success(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
|
||||
|
||||
def log_info(self, message, obj=None):
|
||||
def log_info(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_INFO)
|
||||
|
||||
def log_warning(self, message, obj=None):
|
||||
def log_warning(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
|
||||
|
||||
def log_failure(self, message, obj=None):
|
||||
def log_failure(self, message=None, obj=None):
|
||||
self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
|
||||
self.failed = True
|
||||
|
||||
|
@ -9,7 +9,8 @@ from django.dispatch import receiver, Signal
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from core.models import ObjectType
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from core.signals import job_end, job_start
|
||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from extras.events import process_event_rules
|
||||
@ -19,9 +20,8 @@ from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin
|
||||
from netbox.signals import post_clean
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .events import enqueue_object, get_snapshots, serialize_for_event
|
||||
from .models import CustomField, ObjectChange, TaggedItem
|
||||
from .models import CustomField, TaggedItem
|
||||
from .validators import CustomValidator
|
||||
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import json
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.models import *
|
||||
from netbox.constants import EMPTY_TABLE_TEXT
|
||||
from netbox.tables import BaseTable, NetBoxTable, columns
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
'BookmarkTable',
|
||||
@ -19,7 +19,6 @@ __all__ = (
|
||||
'ExportTemplateTable',
|
||||
'ImageAttachmentTable',
|
||||
'JournalEntryTable',
|
||||
'ObjectChangeTable',
|
||||
'SavedFilterTable',
|
||||
'ReportResultsTable',
|
||||
'ScriptResultsTable',
|
||||
@ -72,13 +71,26 @@ class CustomFieldTable(NetBoxTable):
|
||||
is_cloneable = columns.BooleanColumn(
|
||||
verbose_name=_('Is Cloneable'),
|
||||
)
|
||||
validation_minimum = tables.Column(
|
||||
verbose_name=_('Minimum Value'),
|
||||
)
|
||||
validation_maximum = tables.Column(
|
||||
verbose_name=_('Maximum Value'),
|
||||
)
|
||||
validation_regex = tables.Column(
|
||||
verbose_name=_('Validation Regex'),
|
||||
)
|
||||
validation_unique = columns.BooleanColumn(
|
||||
verbose_name=_('Validate Uniqueness'),
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required',
|
||||
'default', 'description', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable',
|
||||
'weight', 'choice_set', 'choices', 'comments', 'created', 'last_updated',
|
||||
'weight', 'choice_set', 'choices', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'validation_unique', 'comments', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'object_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||
|
||||
@ -451,49 +463,6 @@ class ConfigTemplateTable(NetBoxTable):
|
||||
)
|
||||
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
|
||||
class JournalEntryTable(NetBoxTable):
|
||||
created = columns.DateTimeColumn(
|
||||
verbose_name=_('Created'),
|
||||
@ -545,6 +514,9 @@ class ScriptResultsTable(BaseTable):
|
||||
template_code="""{% load log_levels %}{% log_level record.status %}""",
|
||||
verbose_name=_('Level')
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object')
|
||||
)
|
||||
message = columns.MarkdownColumn(
|
||||
verbose_name=_('Message')
|
||||
)
|
||||
@ -552,8 +524,17 @@ class ScriptResultsTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
empty_text = _(EMPTY_TABLE_TEXT)
|
||||
fields = (
|
||||
'index', 'time', 'status', 'message',
|
||||
'index', 'time', 'status', 'object', 'message',
|
||||
)
|
||||
default_columns = (
|
||||
'index', 'time', 'status', 'object', 'message',
|
||||
)
|
||||
|
||||
def render_object(self, value, record):
|
||||
return format_html("<a href='{}'>{}</a>", record['url'], value)
|
||||
|
||||
def render_url(self, value):
|
||||
return format_html("<a href='{}'>{}</a>", value, value)
|
||||
|
||||
|
||||
class ReportResultsTable(BaseTable):
|
||||
@ -585,3 +566,9 @@ class ReportResultsTable(BaseTable):
|
||||
fields = (
|
||||
'index', 'method', 'time', 'status', 'object', 'url', 'message',
|
||||
)
|
||||
|
||||
def render_object(self, value, record):
|
||||
return format_html("<a href='{}'>{}</a>", record['url'], value)
|
||||
|
||||
def render_url(self, value):
|
||||
return format_html("<a href='{}'>{}</a>", value, value)
|
||||
|
@ -1,25 +0,0 @@
|
||||
CONFIGCONTEXT_ACTIONS = """
|
||||
{% if perms.extras.change_configcontext %}
|
||||
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% if perms.extras.delete_configcontext %}
|
||||
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-sm btn-danger"><i class="mdi mdi-trash-can-outline" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
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 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
|
||||
"""
|
@ -1,4 +1,5 @@
|
||||
from django import template
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from core.models import ObjectType
|
||||
@ -59,8 +60,7 @@ def custom_links(context, obj):
|
||||
# Add non-grouped links
|
||||
else:
|
||||
try:
|
||||
rendered = cl.render(link_context)
|
||||
if rendered:
|
||||
if rendered := cl.render(link_context):
|
||||
template_code += LINK_BUTTON.format(
|
||||
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
|
||||
)
|
||||
@ -75,8 +75,7 @@ def custom_links(context, obj):
|
||||
|
||||
for cl in links:
|
||||
try:
|
||||
rendered = cl.render(link_context)
|
||||
if rendered:
|
||||
if rendered := cl.render(link_context):
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
|
||||
)
|
||||
@ -88,7 +87,7 @@ def custom_links(context, obj):
|
||||
|
||||
if links_rendered:
|
||||
template_code += GROUP_BUTTON.format(
|
||||
links[0].button_class, group, ''.join(links_rendered)
|
||||
links[0].button_class, escape(group), ''.join(links_rendered)
|
||||
)
|
||||
|
||||
return mark_safe(template_code)
|
||||
|
@ -1,6 +1,12 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.conditions import Condition, ConditionSet
|
||||
from extras.events import serialize_for_event
|
||||
from extras.forms import EventRuleForm
|
||||
from extras.models import EventRule, Webhook
|
||||
|
||||
|
||||
class ConditionTestCase(TestCase):
|
||||
@ -217,3 +223,93 @@ class ConditionSetTest(TestCase):
|
||||
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
|
||||
self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
|
||||
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
|
||||
|
||||
def test_event_rule_conditions_without_logic_operator(self):
|
||||
"""
|
||||
Test evaluation of EventRule conditions without logic operator.
|
||||
"""
|
||||
event_rule = EventRule(
|
||||
name='Event Rule 1',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
conditions={
|
||||
'attr': 'status.value',
|
||||
'value': 'active',
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate - Status = active
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status='active')
|
||||
self.assertTrue(event_rule.eval_conditions(data))
|
||||
|
||||
def test_event_rule_conditions_with_logical_operation(self):
|
||||
"""
|
||||
Test evaluation of EventRule conditions without logic operator, but with logical operation (in).
|
||||
"""
|
||||
event_rule = EventRule(
|
||||
name='Event Rule 1',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
conditions={
|
||||
"attr": "status.value",
|
||||
"value": ["planned", "staging"],
|
||||
"op": "in",
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate - Status = active
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status in ['planned, 'staging'])
|
||||
self.assertFalse(event_rule.eval_conditions(data))
|
||||
|
||||
def test_event_rule_conditions_with_logical_operation_and_negate(self):
|
||||
"""
|
||||
Test evaluation of EventRule with logical operation (in) and negate.
|
||||
"""
|
||||
event_rule = EventRule(
|
||||
name='Event Rule 1',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
conditions={
|
||||
"attr": "status.value",
|
||||
"value": ["planned", "staging"],
|
||||
"op": "in",
|
||||
"negate": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a Site to evaluate - Status = active
|
||||
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||
data = serialize_for_event(site)
|
||||
|
||||
# Evaluate the conditions (status NOT in ['planned, 'staging'])
|
||||
self.assertTrue(event_rule.eval_conditions(data))
|
||||
|
||||
def test_event_rule_conditions_with_incorrect_key_must_return_false(self):
|
||||
"""
|
||||
Test Event Rule with incorrect condition (key "foo" is wrong). Must return false.
|
||||
"""
|
||||
|
||||
ct = ContentType.objects.get(app_label='extras', model='webhook')
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
|
||||
form = EventRuleForm({
|
||||
"name": "Event Rule 1",
|
||||
"type_create": True,
|
||||
"type_update": True,
|
||||
"action_object_type": ct.pk,
|
||||
"action_type": "webhook",
|
||||
"action_choice": webhook.pk,
|
||||
"content_types": [site_ct.pk],
|
||||
"conditions": {
|
||||
"foo": "status.value",
|
||||
"value": "active"
|
||||
}
|
||||
})
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
|
@ -1140,6 +1140,29 @@ class CustomFieldAPITest(APITestCase):
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
def test_uniqueness_validation(self):
|
||||
# Create a unique custom field
|
||||
cf_text = CustomField.objects.get(name='text_field')
|
||||
cf_text.validation_unique = True
|
||||
cf_text.save()
|
||||
|
||||
# Set a value on site 1
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
site1.custom_field_data['text_field'] = 'ABC123'
|
||||
site1.save()
|
||||
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
data = {'custom_fields': {'text_field': 'ABC123'}}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
data = {'custom_fields': {'text_field': 'DEF456'}}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CustomFieldImportTest(TestCase):
|
||||
user_permissions = (
|
||||
|
@ -9,14 +9,15 @@ from django.urls import reverse
|
||||
from requests import Session
|
||||
from rest_framework import status
|
||||
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
|
||||
from extras.context_managers import event_tracking
|
||||
from extras.choices import EventRuleActionChoices
|
||||
from extras.events import enqueue_object, flush_events, serialize_for_event
|
||||
from extras.models import EventRule, Tag, Webhook
|
||||
from extras.webhooks import generate_signature, send_webhook
|
||||
from netbox.context_managers import event_tracking
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
|
||||
|
@ -6,15 +6,14 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.models import Provider
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ObjectType
|
||||
from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from dcim.filtersets import SiteFilterSet
|
||||
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
||||
from dcim.models import Location
|
||||
from extras.choices import *
|
||||
from extras.filtersets import *
|
||||
from extras.models import *
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
@ -1280,102 +1279,6 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ChangeLoggedFilterSetTestCase(TestCase):
|
||||
"""
|
||||
Evaluate base ChangeLoggedFilterSet filters using the Site model.
|
||||
|
@ -1,6 +1,3 @@
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
@ -567,43 +564,6 @@ class ConfigTemplateTestCase(
|
||||
}
|
||||
|
||||
|
||||
# TODO: Convert to StandardTestCases.Views
|
||||
class ObjectChangeTestCase(TestCase):
|
||||
user_permissions = (
|
||||
'extras.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('extras: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 JournalEntryTestCase(
|
||||
# ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.CreateObjectViewTestCase,
|
||||
|
@ -106,10 +106,6 @@ urlpatterns = [
|
||||
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
||||
|
||||
# Change logging
|
||||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
||||
|
||||
# User dashboard
|
||||
path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'),
|
||||
path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
|
||||
|
@ -20,7 +20,6 @@ from extras.dashboard.utils import get_widget_class
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.data import shallow_compare_dict
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
@ -685,75 +684,6 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
class ObjectChangeListView(generic.ObjectListView):
|
||||
queryset = ObjectChange.objects.valid_models()
|
||||
filterset = filtersets.ObjectChangeFilterSet
|
||||
filterset_form = forms.ObjectChangeFilterForm
|
||||
table = tables.ObjectChangeTable
|
||||
template_name = 'extras/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
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Image attachments
|
||||
#
|
||||
@ -1208,6 +1138,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
'time': log.get('time'),
|
||||
'status': log.get('status'),
|
||||
'message': log.get('message'),
|
||||
'object': log.get('obj'),
|
||||
'url': log.get('url'),
|
||||
}
|
||||
data.append(result)
|
||||
|
||||
|
@ -12,7 +12,7 @@ from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.query import count_related
|
||||
from utilities.tables import get_table_ordering
|
||||
from utilities.views import ViewTab, register_model_view
|
||||
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VMInterface
|
||||
from . import filtersets, forms, tables
|
||||
@ -34,15 +34,10 @@ class VRFListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(VRF)
|
||||
class VRFView(generic.ObjectView):
|
||||
class VRFView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VRF.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
|
||||
(IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
|
||||
)
|
||||
|
||||
import_targets_table = tables.RouteTargetTable(
|
||||
instance.import_targets.all(),
|
||||
orderable=False
|
||||
@ -53,7 +48,7 @@ class VRFView(generic.ObjectView):
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
|
||||
'import_targets_table': import_targets_table,
|
||||
'export_targets_table': export_targets_table,
|
||||
}
|
||||
@ -147,16 +142,12 @@ class RIRListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(RIR)
|
||||
class RIRView(generic.ObjectView):
|
||||
class RIRView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = RIR.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@ -273,17 +264,19 @@ class ASNListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(ASN)
|
||||
class ASNView(generic.ObjectView):
|
||||
class ASNView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = ASN.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
return {
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
extra=(
|
||||
(Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||
(Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@ -427,18 +420,12 @@ class RoleListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(Role)
|
||||
class RoleView(generic.ObjectView):
|
||||
class RoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Role.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
(IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@ -926,16 +913,12 @@ class VLANGroupListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(VLANGroup)
|
||||
class VLANGroupView(generic.ObjectView):
|
||||
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
|
||||
)
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from netbox.context import current_request, events_queue
|
||||
from .events import flush_events
|
||||
from extras.events import flush_events
|
||||
|
||||
|
||||
@contextmanager
|
@ -7,9 +7,11 @@ from django_filters.exceptions import FieldLookupError
|
||||
from django_filters.utils import get_model_field, resolve_field
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import CustomFieldFilterLogicChoices, ObjectChangeActionChoices
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectChange
|
||||
from extras.choices import CustomFieldFilterLogicChoices
|
||||
from extras.filters import TagFilter
|
||||
from extras.models import CustomField, ObjectChange, SavedFilter
|
||||
from extras.models import CustomField, SavedFilter
|
||||
from utilities.constants import (
|
||||
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
||||
FILTER_NUMERIC_BASED_LOOKUP_MAP
|
||||
|
@ -1,17 +1,10 @@
|
||||
from typing import Annotated, List
|
||||
|
||||
import strawberry
|
||||
from strawberry import auto
|
||||
import strawberry_django
|
||||
|
||||
from core.models import ObjectType as ObjectType_
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from extras.graphql.mixins import (
|
||||
ChangelogMixin,
|
||||
CustomFieldsMixin,
|
||||
JournalEntriesMixin,
|
||||
TagsMixin,
|
||||
)
|
||||
|
||||
from core.graphql.mixins import ChangelogMixin
|
||||
from core.models import ObjectType as ObjectType_
|
||||
from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin
|
||||
|
||||
__all__ = (
|
||||
'BaseObjectType',
|
||||
|
@ -10,8 +10,8 @@ from django.db import connection, ProgrammingError
|
||||
from django.db.utils import InternalError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
from extras.context_managers import event_tracking
|
||||
from netbox.config import clear_config, get_config
|
||||
from netbox.context_managers import event_tracking
|
||||
from netbox.views import handler_500
|
||||
from utilities.api import is_api_request
|
||||
from utilities.error_handlers import handle_rest_api_exception
|
||||
|
@ -9,9 +9,10 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||
from extras.utils import is_taggable
|
||||
from netbox.config import get_config
|
||||
from netbox.registry import registry
|
||||
@ -90,7 +91,8 @@ class ChangeLoggingMixin(models.Model):
|
||||
Return a new ObjectChange representing a change made to this object. This will typically be called automatically
|
||||
by ChangeLoggingMiddleware.
|
||||
"""
|
||||
from extras.models import ObjectChange
|
||||
# TODO: Fix circular import
|
||||
from core.models import ObjectChange
|
||||
|
||||
exclude = []
|
||||
if get_config().CHANGELOG_SKIP_EMPTY_CHANGES:
|
||||
@ -248,7 +250,7 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
for cf in visible_custom_fields:
|
||||
value = self.custom_field_data.get(cf.name)
|
||||
if value in (None, '', []) and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET:
|
||||
if value in CUSTOMFIELD_EMPTY_VALUES and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET:
|
||||
continue
|
||||
value = cf.deserialize(value)
|
||||
groups[cf.group_name][cf] = value
|
||||
@ -284,6 +286,15 @@ class CustomFieldsMixin(models.Model):
|
||||
name=field_name, error=e.message
|
||||
))
|
||||
|
||||
# Validate uniqueness if enforced
|
||||
if custom_fields[field_name].validation_unique and value not in CUSTOMFIELD_EMPTY_VALUES:
|
||||
if self._meta.model.objects.filter(**{
|
||||
f'custom_field_data__{field_name}': value
|
||||
}).exists():
|
||||
raise ValidationError(_("Custom field '{name}' must have a unique value.").format(
|
||||
name=field_name
|
||||
))
|
||||
|
||||
# Check for missing required values
|
||||
for cf in custom_fields.values():
|
||||
if cf.required and cf.name not in self.custom_field_data:
|
||||
|
@ -356,7 +356,7 @@ OPERATIONS_MENU = Menu(
|
||||
label=_('Logging'),
|
||||
items=(
|
||||
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
|
||||
get_model_item('extras', 'objectchange', _('Change Log'), actions=[]),
|
||||
get_model_item('core', 'objectchange', _('Change Log'), actions=[]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -553,7 +553,7 @@ if SENTRY_ENABLED:
|
||||
|
||||
# Calculate a unique deployment ID from the secret key
|
||||
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
|
||||
CENSUS_URL = 'https://census.netbox.dev/api/v1/'
|
||||
CENSUS_URL = 'https://census.netbox.oss.netboxlabs.com/api/v1/'
|
||||
CENSUS_PARAMS = {
|
||||
'version': RELEASE.full_version,
|
||||
'python_version': sys.version.split()[0],
|
||||
|
@ -1,3 +1,4 @@
|
||||
import zoneinfo
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column):
|
||||
|
||||
def render(self, value):
|
||||
if value:
|
||||
current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||
value = value.astimezone(current_tz)
|
||||
return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
|
||||
|
||||
def value(self, value):
|
||||
@ -430,7 +433,7 @@ class LinkedCountColumn(tables.Column):
|
||||
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
|
||||
for k, v in self.url_params.items()
|
||||
])
|
||||
return mark_safe(f'<a href="{url}">{value}</a>')
|
||||
return mark_safe(f'<a href="{url}">{escape(value)}</a>')
|
||||
return value
|
||||
|
||||
def value(self, value):
|
||||
|
@ -6,10 +6,11 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
|
||||
from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from extras import forms, tables
|
||||
from extras.models import *
|
||||
from core.models import Job, ObjectChange
|
||||
from core.tables import JobTable, ObjectChangeTable
|
||||
from extras.forms import JournalEntryForm
|
||||
from extras.models import JournalEntry
|
||||
from extras.tables import JournalEntryTable
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.views import GetReturnURLMixin, ViewTab
|
||||
from .base import BaseMultiObjectView
|
||||
@ -56,7 +57,7 @@ class ObjectChangeLogView(View):
|
||||
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
|
||||
Q(related_object_type=content_type, related_object_id=obj.pk)
|
||||
)
|
||||
objectchanges_table = tables.ObjectChangeTable(
|
||||
objectchanges_table = ObjectChangeTable(
|
||||
data=objectchanges,
|
||||
orderable=False,
|
||||
user=request.user
|
||||
@ -108,13 +109,13 @@ class ObjectJournalView(View):
|
||||
assigned_object_type=content_type,
|
||||
assigned_object_id=obj.pk
|
||||
)
|
||||
journalentry_table = tables.JournalEntryTable(journalentries, user=request.user)
|
||||
journalentry_table = JournalEntryTable(journalentries, user=request.user)
|
||||
journalentry_table.configure(request)
|
||||
journalentry_table.columns.hide('assigned_object_type')
|
||||
journalentry_table.columns.hide('assigned_object')
|
||||
|
||||
if request.user.has_perm('extras.add_journalentry'):
|
||||
form = forms.JournalEntryForm(
|
||||
form = JournalEntryForm(
|
||||
initial={
|
||||
'assigned_object_type': ContentType.objects.get_for_model(obj),
|
||||
'assigned_object_id': obj.pk
|
||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user