Merge branch 'feature' into 8984-script-log

This commit is contained in:
Jeremy Stretch 2024-06-25 09:12:09 -04:00
commit ae28d32135
173 changed files with 5611 additions and 170039 deletions

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous. 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 ### Bug Bounties

View File

@ -8,7 +8,9 @@ django-cors-headers
# Runtime UI tool for debugging Django # Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst # 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 # Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@ -108,7 +110,7 @@ Pillow
# PostgreSQL database adapter for Python # PostgreSQL database adapter for Python
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst # https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg[binary,pool] psycopg[c,pool]
# YAML rendering library # YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES # https://github.com/yaml/pyyaml/blob/master/CHANGES

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -138,11 +138,11 @@ These two methods will load data in YAML or JSON format, respectively, from file
The Script object provides a set of convenient functions for recording messages at different severity levels: The Script object provides a set of convenient functions for recording messages at different severity levels:
* `log_debug(message, object=None)` * `log_debug(message=None, obj=None)`
* `log_success(message, object=None)` * `log_success(message=None, obj=None)`
* `log_info(message, object=None)` * `log_info(message=None, obj=None)`
* `log_warning(message, object=None)` * `log_warning(message=None, obj=None)`
* `log_failure(message, object=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. 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.) 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 !!! info
This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0. This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0.

View File

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

View File

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

View File

@ -51,3 +51,8 @@ The amount of running memory provisioned, in megabytes.
### Disk ### Disk
The amount of disk storage provisioned, in gigabytes. The amount of disk storage provisioned, in gigabytes.
### Serial Number
Optional serial number assigned to this VM.

View File

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

View File

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

View File

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

View File

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

View File

@ -19,8 +19,10 @@ from django.views.generic import View
from social_core.backends.utils import load_backends from social_core.backends.utils import load_backends
from account.models import UserToken from account.models import UserToken
from extras.models import Bookmark, ObjectChange from core.models import ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable 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.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config from netbox.config import get_config
from netbox.views import generic 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 # Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.) # create_userconfig() on user creation.)
if not hasattr(request.user, 'config'): if not hasattr(request.user, 'config'):
config = get_config() request.user.config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save() 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: else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}") 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") logger.info(f"User {username} has logged out")
messages.info(request, "You have 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 = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key') response.delete_cookie('session_key')
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response return response

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from extras.choices import * from core.choices import *
from extras.models import ObjectChange from core.models import ObjectChange
from netbox.api.exceptions import SerializerNotFound from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import BaseModelSerializer from netbox.api.serializers import BaseModelSerializer
@ -15,7 +15,7 @@ __all__ = (
class ObjectChangeSerializer(BaseModelSerializer): class ObjectChangeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') url = serializers.HyperlinkedIdentityField(view_name='core-api:objectchange-detail')
user = UserSerializer( user = UserSerializer(
nested=True, nested=True,
read_only=True read_only=True

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -5,6 +7,7 @@ import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from utilities.filters import ContentTypeFilter
from .choices import * from .choices import *
from .models import * from .models import *
@ -13,6 +16,7 @@ __all__ = (
'DataFileFilterSet', 'DataFileFilterSet',
'DataSourceFilterSet', 'DataSourceFilterSet',
'JobFilterSet', '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): class ConfigRevisionFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -7,8 +7,10 @@ from core.models import *
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin from netbox.forms.mixins import SavedFiltersMixin
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker from utilities.forms.widgets import DateTimePicker
@ -17,6 +19,7 @@ __all__ = (
'DataFileFilterForm', 'DataFileFilterForm',
'DataSourceFilterForm', 'DataSourceFilterForm',
'JobFilterForm', '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): class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -3,11 +3,12 @@ from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status 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.choices import SiteStatusChoices
from dcim.models import Site from dcim.models import Site
from extras.choices import * 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 import APITestCase
from utilities.testing.utils import create_tags, post_data from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase from utilities.testing.views import ModelViewTestCase

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import logging import urllib.parse
import uuid import uuid
from datetime import datetime 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.job import Job as RQ_Job, JobStatus
from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry 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 utilities.testing import TestCase, ViewTestCases, create_tags
from ..models import *
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@ -99,6 +102,43 @@ class DataFileTestCase(
DataFile.objects.bulk_create(data_files) 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): class BackgroundTaskTestCase(TestCase):
user_permissions = () user_permissions = ()

View File

@ -25,6 +25,10 @@ urlpatterns = (
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'), path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'), 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 # Background Tasks
path('background-queues/', views.BackgroundQueueListView.as_view(), name='background_queue_list'), 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'), path('background-queues/<int:queue_index>/<str:status>/', views.BackgroundTaskListView.as_view(), name='background_task_list'),

View File

@ -29,10 +29,11 @@ from netbox.config import get_config, PARAMS
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.base import BaseObjectView from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.query import count_related 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 . import filtersets, forms, tables
from .models import * from .models import *
@ -51,16 +52,12 @@ class DataSourceListView(generic.ObjectListView):
@register_model_view(DataSource) @register_model_view(DataSource)
class DataSourceView(generic.ObjectView): class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DataSource.objects.all() queryset = DataSource.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
)
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }
@ -176,6 +173,75 @@ class JobBulkDeleteView(generic.BulkDeleteView):
table = tables.JobTable 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 # Config Revisions
# #

View File

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

View File

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

View File

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

View File

@ -393,6 +393,8 @@ class CableTraceSVG:
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()] labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
if cable.ssid: if cable.ssid:
description.append(f"{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] 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] far = [term for term in far_terminations if term.object == cable.interface_b]
if not (near and far): if not (near and far):

View File

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

View File

@ -8,6 +8,7 @@ from dcim.models import *
from extras.models import CustomField from extras.models import CustomField
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.data import drange from utilities.data import drange
from virtualization.models import Cluster, ClusterType
class LocationTestCase(TestCase): class LocationTestCase(TestCase):
@ -533,6 +534,36 @@ class DeviceTestCase(TestCase):
device2.full_clean() device2.full_clean()
device2.save() 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): class CableTestCase(TestCase):

View File

@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView 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 ipam.tables import InterfaceVLANTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic 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.permissions import get_permission_for_model
from utilities.query import count_related from utilities.query import count_related
from utilities.query_functions import CollateAsChar 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.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable from virtualization.tables import VirtualMachineTable
@ -226,19 +228,21 @@ class RegionListView(generic.ObjectListView):
@register_model_view(Region) @register_model_view(Region)
class RegionView(generic.ObjectView): class RegionView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Region.objects.all() queryset = Region.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
regions = instance.get_descendants(include_self=True) 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 { 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) @register_model_view(SiteGroup)
class SiteGroupView(generic.ObjectView): class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = SiteGroup.objects.all() queryset = SiteGroup.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True) 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 { 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) @register_model_view(Site)
class SiteView(generic.ObjectView): class SiteView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group') queryset = Site.objects.prefetch_related('tenant__group')
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
# DCIM
(Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
# Virtualization
(VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'),
# IPAM
(Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
), 'site'),
(VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
# Circuits
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
)
return { return {
'related_models': related_models, 'related_models': self.get_related_models(
request,
instance,
[CableTermination, CircuitTermination],
(
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
), 'site'),
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(),
'site_id'),
),
),
} }
@ -466,18 +466,13 @@ class LocationListView(generic.ObjectListView):
@register_model_view(Location) @register_model_view(Location)
class LocationView(generic.ObjectView): class LocationView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Location.objects.all() queryset = Location.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True) 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 { 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) @register_model_view(RackRole)
class RackRoleView(generic.ObjectView): class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackRole.objects.all() queryset = RackRole.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return { 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) @register_model_view(Rack)
class RackView(generic.ObjectView): class RackView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
def get_extra_context(self, request, instance): 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) peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
if instance.location: if instance.location:
@ -679,7 +665,7 @@ class RackView(generic.ObjectView):
]) ])
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance, [CableTermination]),
'next_rack': next_rack, 'next_rack': next_rack,
'prev_rack': prev_rack, 'prev_rack': prev_rack,
'svg_extra': svg_extra, 'svg_extra': svg_extra,
@ -838,19 +824,12 @@ class ManufacturerListView(generic.ObjectListView):
@register_model_view(Manufacturer) @register_model_view(Manufacturer)
class ManufacturerView(generic.ObjectView): class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Manufacturer.objects.all() queryset = Manufacturer.objects.all()
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(DeviceType)
class DeviceTypeView(generic.ObjectView): class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceType.objects.all() queryset = DeviceType.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'),
)
return { 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) @register_model_view(ModuleType)
class ModuleTypeView(generic.ObjectView): class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'),
)
return { 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) @register_model_view(DeviceRole)
class DeviceRoleView(generic.ObjectView): class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(Platform)
class PlatformView(generic.ObjectView): class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Platform.objects.all() queryset = Platform.objects.all()
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(Module)
class ModuleView(generic.ObjectView): class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Module.objects.all() queryset = Module.objects.all()
def get_extra_context(self, request, instance): 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 { 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(): if membership_form.is_valid():
membership_form.save() membership_form.save()
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>' messages.success(request, mark_safe(
messages.success(request, mark_safe(msg)) f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.get_full_path()) return redirect(request.get_full_path())
@ -3552,16 +3512,12 @@ class PowerPanelListView(generic.ObjectListView):
@register_model_view(PowerPanel) @register_model_view(PowerPanel)
class PowerPanelView(generic.ObjectView): class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
queryset = PowerPanel.objects.all() queryset = PowerPanel.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'),
)
return { 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) @register_model_view(VirtualDeviceContext)
class VirtualDeviceContextView(generic.ObjectView): class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualDeviceContext.objects.all() queryset = VirtualDeviceContext.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
)
return { return {
'related_models': related_models, 'related_models': self.get_related_models(
request,
instance,
extra=(
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
),
),
} }

View File

@ -6,6 +6,7 @@ from rest_framework.serializers import ValidationError
from core.models import ObjectType from core.models import ObjectType
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.models import CustomField from extras.models import CustomField
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -75,7 +76,7 @@ class CustomFieldsDataField(Field):
# Serialize object and multi-object values # Serialize object and multi-object values
for cf in self._get_custom_fields(): 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_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT CustomFieldTypeChoices.TYPE_MULTIOBJECT
): ):

View File

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

View File

@ -65,7 +65,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
'id', 'url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'name', 'label', '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', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', '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') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

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

View File

@ -271,20 +271,6 @@ class ScriptViewSet(ModelViewSet):
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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 # Object types
# #

View File

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

View File

@ -135,23 +135,23 @@ class ConditionSet:
def __init__(self, ruleset): def __init__(self, ruleset):
if type(ruleset) is not dict: if type(ruleset) is not dict:
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset))) 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 if len(ruleset) == 1:
logic = list(ruleset.keys())[0] self.logic = (list(ruleset.keys())[0]).lower()
if type(logic) is not str or logic.lower() not in (AND, OR): if self.logic not in (AND, OR):
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format( raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation."))
logic=logic, op_and=AND, op_or=OR
))
self.logic = logic.lower()
# Compile the set of Conditions # Compile the set of Conditions
self.conditions = [ self.conditions = [
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
for rule in ruleset[self.logic] 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): def eval(self, data):
""" """

View File

@ -7,6 +7,8 @@ EVENT_DELETE = 'delete'
EVENT_JOB_START = 'job_start' EVENT_JOB_START = 'job_start'
EVENT_JOB_END = 'job_end' EVENT_JOB_END = 'job_end'
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
# Webhooks # Webhooks
HTTP_CONTENT_TYPE_JSON = 'application/json' HTTP_CONTENT_TYPE_JSON = 'application/json'
@ -130,7 +132,7 @@ DEFAULT_DASHBOARD = [
'title': 'Change Log', 'title': 'Change Log',
'color': 'blue', 'color': 'blue',
'config': { 'config': {
'model': 'extras.objectchange', 'model': 'core.objectchange',
'page_size': 25, 'page_size': 25,
} }
}, },

View File

@ -381,11 +381,17 @@ class BookmarksWidget(DashboardWidget):
if request.user.is_anonymous: if request.user.is_anonymous:
bookmarks = list() bookmarks = list()
else: 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'): if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types) models = get_models_from_content_types(object_types)
conent_types = ObjectType.objects.get_for_models(*models).values() content_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=conent_types) bookmarks = bookmarks.filter(object_type__in=content_types)
if max_items := self.config.get('max_items'): if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items] bookmarks = bookmarks[:max_items]

View File

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

View File

@ -26,7 +26,6 @@ __all__ = (
'ImageAttachmentFilterSet', 'ImageAttachmentFilterSet',
'JournalEntryFilterSet', 'JournalEntryFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'ObjectTypeFilterSet', 'ObjectTypeFilterSet',
'SavedFilterFilterSet', 'SavedFilterFilterSet',
'ScriptFilterSet', 'ScriptFilterSet',
@ -155,7 +154,7 @@ class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
fields = ( fields = (
'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible',
'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum', 'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum',
'validation_regex', 'validation_regex', 'validation_unique',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -645,43 +644,6 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
return queryset.exclude(local_context_data__isnull=value) 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 # ContentTypes
# #

View File

@ -6,6 +6,7 @@ from extras.models import *
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import BulkEditForm, add_blank_choice from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect
__all__ = ( __all__ = (
@ -64,8 +65,32 @@ class CustomFieldBulkEditForm(BulkEditForm):
required=False, required=False,
widget=BulkEditNullBooleanSelect() 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() 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') nullable_fields = ('group_name', 'description', 'choice_set')

View File

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

View File

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

View File

@ -64,7 +64,9 @@ class CustomFieldForm(forms.ModelForm):
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior') 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior')
), ),
FieldSet('default', 'choice_set', name=_('Values')), 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: class Meta:

View File

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

View File

@ -2,12 +2,8 @@ from typing import TYPE_CHECKING, Annotated, List
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.contrib.contenttypes.models import ContentType
from extras.models import ObjectChange
__all__ = ( __all__ = (
'ChangelogMixin',
'ConfigContextMixin', 'ConfigContextMixin',
'ContactsMixin', 'ContactsMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
@ -17,23 +13,10 @@ __all__ = (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from .types import ImageAttachmentType, JournalEntryType, ObjectChangeType, TagType from .types import ImageAttachmentType, JournalEntryType, TagType
from tenancy.graphql.types import ContactAssignmentType 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 @strawberry.type
class ConfigContextMixin: class ConfigContextMixin:

View File

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

View File

@ -9,8 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
from django.utils import timezone from django.utils import timezone
from packaging import version from packaging import version
from core.models import Job from core.models import Job, ObjectChange
from extras.models import ObjectChange
from netbox.config import Config from netbox.config import Config

View File

@ -10,9 +10,9 @@ from django.db import transaction
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import Job from core.models import Job
from extras.context_managers import event_tracking
from extras.scripts import get_module_and_script from extras.scripts import get_module_and_script
from extras.signals import clear_events from extras.signals import clear_events
from netbox.context_managers import event_tracking
from utilities.exceptions import AbortTransaction from utilities.exceptions import AbortTransaction
from utilities.request import NetBoxFakeRequest from utilities.request import NetBoxFakeRequest

View 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
),
]

View 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),
),
]

View File

@ -1,4 +1,3 @@
from .change_logging import *
from .configs import * from .configs import *
from .customfields import * from .customfields import *
from .dashboard import * from .dashboard import *

View File

@ -10,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError from django.core.validators import RegexValidator, ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ 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.' '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( choice_set = models.ForeignKey(
to='CustomFieldChoiceSet', to='CustomFieldChoiceSet',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -216,7 +222,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
clone_fields = ( clone_fields = (
'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight', 'object_types', 'type', 'related_object_type', 'group_name', 'description', 'required', 'search_weight',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', '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: class Meta:
@ -333,6 +339,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'validation_regex': _("Regular expression validation is supported only for text and URL fields") '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 # Choice set must be set on selection fields, and *only* on selection fields
if self.type in ( if self.type in (
CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_SELECT,
@ -520,7 +532,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
RegexValidator( RegexValidator(
regex=self.validation_regex, regex=self.validation_regex,
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format( 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 # Validate date & time
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
if type(value) is not 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: try:
datetime.fromisoformat(value) datetime.fromisoformat(value)
except ValueError: except ValueError:

View File

@ -8,7 +8,6 @@ from django.db import models
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
@ -23,9 +22,9 @@ from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
) )
from utilities.html import clean_html from utilities.html import clean_html
from utilities.jinja2 import render_jinja2
from utilities.querydict import dict_to_querydict from utilities.querydict import dict_to_querydict
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.jinja2 import render_jinja2
__all__ = ( __all__ = (
'Bookmark', 'Bookmark',

View File

@ -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.contrib.postgres.aggregates import JSONBAgg
from django.db.models import OuterRef, Subquery, Q from django.db.models import OuterRef, Subquery, Q
from django.db.utils import ProgrammingError
from extras.models.tags import TaggedItem from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg from utilities.query_functions import EmptyGroupByJSONBAgg
@ -148,20 +145,3 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
) )
return base_query 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)

View File

@ -21,11 +21,11 @@ from extras.models import ScriptModule, Script as ScriptModel
from extras.signals import clear_events from extras.signals import clear_events
from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from netbox.context_managers import event_tracking
from utilities.exceptions import AbortScript, AbortTransaction from utilities.exceptions import AbortScript, AbortTransaction
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import DatePicker, DateTimePicker from utilities.forms.widgets import DatePicker, DateTimePicker
from .context_managers import event_tracking
from .forms import ScriptForm from .forms import ScriptForm
from .utils import is_report from .utils import is_report
@ -480,19 +480,21 @@ class BaseScript:
# A test method is currently active, so log the message using legacy Report logging # A test method is currently active, so log the message using legacy Report logging
if self._current_test: if self._current_test:
# TODO: Use a dataclass for test method logs
self.tests[self._current_test]['log'].append((
timezone.now().isoformat(),
level,
str(obj) if obj else None,
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
str(message),
))
# Increment the event counter for this level # Increment the event counter for this level
if level in self.tests[self._current_test]: if level in self.tests[self._current_test]:
self.tests[self._current_test][level] += 1 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(),
level,
str(obj) if obj else None,
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
str(message),
))
elif message: elif message:
# Record to the script's log # Record to the script's log
@ -500,6 +502,8 @@ class BaseScript:
'time': timezone.now().isoformat(), 'time': timezone.now().isoformat(),
'status': level, 'status': level,
'message': str(message), '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 # Record to the system log
@ -507,19 +511,19 @@ class BaseScript:
message = f"{obj}: {message}" message = f"{obj}: {message}"
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], 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) 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) 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) 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) 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._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
self.failed = True self.failed = True

View File

@ -9,7 +9,8 @@ from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates 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 core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules 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.models.features import ChangeLoggingMixin
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices
from .events import enqueue_object, get_snapshots, serialize_for_event 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 from .validators import CustomValidator

View File

@ -1,12 +1,12 @@
import json import json
import django_tables2 as tables import django_tables2 as tables
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.models import * from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns from netbox.tables import BaseTable, NetBoxTable, columns
from .template_code import *
__all__ = ( __all__ = (
'BookmarkTable', 'BookmarkTable',
@ -19,7 +19,6 @@ __all__ = (
'ExportTemplateTable', 'ExportTemplateTable',
'ImageAttachmentTable', 'ImageAttachmentTable',
'JournalEntryTable', 'JournalEntryTable',
'ObjectChangeTable',
'SavedFilterTable', 'SavedFilterTable',
'ReportResultsTable', 'ReportResultsTable',
'ScriptResultsTable', 'ScriptResultsTable',
@ -72,13 +71,26 @@ class CustomFieldTable(NetBoxTable):
is_cloneable = columns.BooleanColumn( is_cloneable = columns.BooleanColumn(
verbose_name=_('Is Cloneable'), 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): class Meta(NetBoxTable.Meta):
model = CustomField model = CustomField
fields = ( fields = (
'pk', 'id', 'name', 'object_types', 'label', 'type', 'related_object_type', 'group_name', 'required', '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', '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') 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): class JournalEntryTable(NetBoxTable):
created = columns.DateTimeColumn( created = columns.DateTimeColumn(
verbose_name=_('Created'), verbose_name=_('Created'),
@ -545,6 +514,9 @@ class ScriptResultsTable(BaseTable):
template_code="""{% load log_levels %}{% log_level record.status %}""", template_code="""{% load log_levels %}{% log_level record.status %}""",
verbose_name=_('Level') verbose_name=_('Level')
) )
object = tables.Column(
verbose_name=_('Object')
)
message = columns.MarkdownColumn( message = columns.MarkdownColumn(
verbose_name=_('Message') verbose_name=_('Message')
) )
@ -552,8 +524,17 @@ class ScriptResultsTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
empty_text = _(EMPTY_TABLE_TEXT) empty_text = _(EMPTY_TABLE_TEXT)
fields = ( 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): class ReportResultsTable(BaseTable):
@ -585,3 +566,9 @@ class ReportResultsTable(BaseTable):
fields = ( fields = (
'index', 'method', 'time', 'status', 'object', 'url', 'message', '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)

View File

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

View File

@ -1,4 +1,5 @@
from django import template from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from core.models import ObjectType from core.models import ObjectType
@ -59,8 +60,7 @@ def custom_links(context, obj):
# Add non-grouped links # Add non-grouped links
else: else:
try: try:
rendered = cl.render(link_context) if rendered := cl.render(link_context):
if rendered:
template_code += LINK_BUTTON.format( template_code += LINK_BUTTON.format(
rendered['link'], rendered['link_target'], cl.button_class, rendered['text'] rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
) )
@ -75,8 +75,7 @@ def custom_links(context, obj):
for cl in links: for cl in links:
try: try:
rendered = cl.render(link_context) if rendered := cl.render(link_context):
if rendered:
links_rendered.append( links_rendered.append(
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text']) GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
) )
@ -88,7 +87,7 @@ def custom_links(context, obj):
if links_rendered: if links_rendered:
template_code += GROUP_BUTTON.format( 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) return mark_safe(template_code)

View File

@ -1,6 +1,12 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.conditions import Condition, ConditionSet 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): class ConditionTestCase(TestCase):
@ -217,3 +223,93 @@ class ConditionSetTest(TestCase):
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) 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': 2, 'c': 9}))
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) 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())

View File

@ -1140,6 +1140,29 @@ class CustomFieldAPITest(APITestCase):
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) 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): class CustomFieldImportTest(TestCase):
user_permissions = ( user_permissions = (

View File

@ -9,14 +9,15 @@ from django.urls import reverse
from requests import Session from requests import Session
from rest_framework import status from rest_framework import status
from core.choices import ObjectChangeActionChoices
from core.models import ObjectType from core.models import ObjectType
from dcim.choices import SiteStatusChoices from dcim.choices import SiteStatusChoices
from dcim.models import Site from dcim.models import Site
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices from extras.choices import EventRuleActionChoices
from extras.context_managers import event_tracking
from extras.events import enqueue_object, flush_events, serialize_for_event from extras.events import enqueue_object, flush_events, serialize_for_event
from extras.models import EventRule, Tag, Webhook from extras.models import EventRule, Tag, Webhook
from extras.webhooks import generate_signature, send_webhook from extras.webhooks import generate_signature, send_webhook
from netbox.context_managers import event_tracking
from utilities.testing import APITestCase from utilities.testing import APITestCase

View File

@ -6,15 +6,14 @@ from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from circuits.models import Provider from circuits.models import Provider
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices
from core.models import ObjectType from core.models import ObjectChange, ObjectType
from dcim.filtersets import SiteFilterSet from dcim.filtersets import SiteFilterSet
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
from dcim.models import Location from dcim.models import Location
from extras.choices import * from extras.choices import *
from extras.filtersets import * from extras.filtersets import *
from extras.models import * from extras.models import *
from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
from virtualization.models import Cluster, ClusterGroup, ClusterType 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): class ChangeLoggedFilterSetTestCase(TestCase):
""" """
Evaluate base ChangeLoggedFilterSet filters using the Site model. Evaluate base ChangeLoggedFilterSet filters using the Site model.

View File

@ -1,6 +1,3 @@
import urllib.parse
import uuid
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
@ -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( class JournalEntryTestCase(
# ViewTestCases.GetObjectViewTestCase, # ViewTestCases.GetObjectViewTestCase,
ViewTestCases.CreateObjectViewTestCase, ViewTestCases.CreateObjectViewTestCase,

View File

@ -106,10 +106,6 @@ urlpatterns = [
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))), path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
# Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
# User dashboard # User dashboard
path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'), path('dashboard/reset/', views.DashboardResetView.as_view(), name='dashboard_reset'),
path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'), path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),

View File

@ -20,7 +20,6 @@ from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@ -685,75 +684,6 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
queryset = ConfigTemplate.objects.all() 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 # Image attachments
# #
@ -1208,6 +1138,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
'time': log.get('time'), 'time': log.get('time'),
'status': log.get('status'), 'status': log.get('status'),
'message': log.get('message'), 'message': log.get('message'),
'object': log.get('obj'),
'url': log.get('url'),
} }
data.append(result) data.append(result)

View File

@ -12,7 +12,7 @@ from netbox.views import generic
from tenancy.views import ObjectContactsView from tenancy.views import ObjectContactsView
from utilities.query import count_related from utilities.query import count_related
from utilities.tables import get_table_ordering 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.filtersets import VMInterfaceFilterSet
from virtualization.models import VMInterface from virtualization.models import VMInterface
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -34,15 +34,10 @@ class VRFListView(generic.ObjectListView):
@register_model_view(VRF) @register_model_view(VRF)
class VRFView(generic.ObjectView): class VRFView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VRF.objects.all() queryset = VRF.objects.all()
def get_extra_context(self, request, instance): 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( import_targets_table = tables.RouteTargetTable(
instance.import_targets.all(), instance.import_targets.all(),
orderable=False orderable=False
@ -53,7 +48,7 @@ class VRFView(generic.ObjectView):
) )
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
'import_targets_table': import_targets_table, 'import_targets_table': import_targets_table,
'export_targets_table': export_targets_table, 'export_targets_table': export_targets_table,
} }
@ -147,16 +142,12 @@ class RIRListView(generic.ObjectListView):
@register_model_view(RIR) @register_model_view(RIR)
class RIRView(generic.ObjectView): class RIRView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RIR.objects.all() queryset = RIR.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'),
)
return { 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) @register_model_view(ASN)
class ASNView(generic.ObjectView): class ASNView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ASN.objects.all() queryset = ASN.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(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 { return {
'related_models': related_models, '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'),
),
),
} }
@ -427,18 +420,12 @@ class RoleListView(generic.ObjectListView):
@register_model_view(Role) @register_model_view(Role)
class RoleView(generic.ObjectView): class RoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Role.objects.all() queryset = Role.objects.all()
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(VLANGroup)
class VLANGroupView(generic.ObjectView): class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
)
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }

View File

@ -1,7 +1,7 @@
from contextlib import contextmanager from contextlib import contextmanager
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from .events import flush_events from extras.events import flush_events
@contextmanager @contextmanager

View File

@ -7,9 +7,11 @@ from django_filters.exceptions import FieldLookupError
from django_filters.utils import get_model_field, resolve_field from django_filters.utils import get_model_field, resolve_field
from django.utils.translation import gettext as _ 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.filters import TagFilter
from extras.models import CustomField, ObjectChange, SavedFilter from extras.models import CustomField, SavedFilter
from utilities.constants import ( from utilities.constants import (
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
FILTER_NUMERIC_BASED_LOOKUP_MAP FILTER_NUMERIC_BASED_LOOKUP_MAP

View File

@ -1,17 +1,10 @@
from typing import Annotated, List
import strawberry import strawberry
from strawberry import auto
import strawberry_django import strawberry_django
from core.models import ObjectType as ObjectType_
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from extras.graphql.mixins import (
ChangelogMixin, from core.graphql.mixins import ChangelogMixin
CustomFieldsMixin, from core.models import ObjectType as ObjectType_
JournalEntriesMixin, from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin
TagsMixin,
)
__all__ = ( __all__ = (
'BaseObjectType', 'BaseObjectType',

View File

@ -10,8 +10,8 @@ from django.db import connection, ProgrammingError
from django.db.utils import InternalError from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from extras.context_managers import event_tracking
from netbox.config import clear_config, get_config from netbox.config import clear_config, get_config
from netbox.context_managers import event_tracking
from netbox.views import handler_500 from netbox.views import handler_500
from utilities.api import is_api_request from utilities.api import is_api_request
from utilities.error_handlers import handle_rest_api_exception from utilities.error_handlers import handle_rest_api_exception

View File

@ -9,9 +9,10 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from core.choices import JobStatusChoices from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.models import ObjectType from core.models import ObjectType
from extras.choices import * from extras.choices import *
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.utils import is_taggable from extras.utils import is_taggable
from netbox.config import get_config from netbox.config import get_config
from netbox.registry import registry 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 Return a new ObjectChange representing a change made to this object. This will typically be called automatically
by ChangeLoggingMiddleware. by ChangeLoggingMiddleware.
""" """
from extras.models import ObjectChange # TODO: Fix circular import
from core.models import ObjectChange
exclude = [] exclude = []
if get_config().CHANGELOG_SKIP_EMPTY_CHANGES: if get_config().CHANGELOG_SKIP_EMPTY_CHANGES:
@ -248,7 +250,7 @@ class CustomFieldsMixin(models.Model):
for cf in visible_custom_fields: for cf in visible_custom_fields:
value = self.custom_field_data.get(cf.name) value = self.custom_field_data.get(cf.name)
if value in (None, '', []) and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET: if value in CUSTOMFIELD_EMPTY_VALUES and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET:
continue continue
value = cf.deserialize(value) value = cf.deserialize(value)
groups[cf.group_name][cf] = value groups[cf.group_name][cf] = value
@ -284,6 +286,15 @@ class CustomFieldsMixin(models.Model):
name=field_name, error=e.message 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 # Check for missing required values
for cf in custom_fields.values(): for cf in custom_fields.values():
if cf.required and cf.name not in self.custom_field_data: if cf.required and cf.name not in self.custom_field_data:

View File

@ -356,7 +356,7 @@ OPERATIONS_MENU = Menu(
label=_('Logging'), label=_('Logging'),
items=( items=(
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']), 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=[]),
), ),
), ),
), ),

View File

@ -553,7 +553,7 @@ if SENTRY_ENABLED:
# Calculate a unique deployment ID from the secret key # Calculate a unique deployment ID from the secret key
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] 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 = { CENSUS_PARAMS = {
'version': RELEASE.full_version, 'version': RELEASE.full_version,
'python_version': sys.version.split()[0], 'python_version': sys.version.split()[0],

View File

@ -1,3 +1,4 @@
import zoneinfo
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
from urllib.parse import quote from urllib.parse import quote
@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column):
def render(self, value): def render(self, value):
if 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)}" return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
def value(self, value): def value(self, value):
@ -430,7 +433,7 @@ class LinkedCountColumn(tables.Column):
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}' f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
for k, v in self.url_params.items() 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 return value
def value(self, value): def value(self, value):

View File

@ -6,10 +6,11 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
from core.models import Job from core.models import Job, ObjectChange
from core.tables import JobTable from core.tables import JobTable, ObjectChangeTable
from extras import forms, tables from extras.forms import JournalEntryForm
from extras.models import * from extras.models import JournalEntry
from extras.tables import JournalEntryTable
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.views import GetReturnURLMixin, ViewTab from utilities.views import GetReturnURLMixin, ViewTab
from .base import BaseMultiObjectView from .base import BaseMultiObjectView
@ -56,7 +57,7 @@ class ObjectChangeLogView(View):
Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(changed_object_type=content_type, changed_object_id=obj.pk) |
Q(related_object_type=content_type, related_object_id=obj.pk) Q(related_object_type=content_type, related_object_id=obj.pk)
) )
objectchanges_table = tables.ObjectChangeTable( objectchanges_table = ObjectChangeTable(
data=objectchanges, data=objectchanges,
orderable=False, orderable=False,
user=request.user user=request.user
@ -108,13 +109,13 @@ class ObjectJournalView(View):
assigned_object_type=content_type, assigned_object_type=content_type,
assigned_object_id=obj.pk 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.configure(request)
journalentry_table.columns.hide('assigned_object_type') journalentry_table.columns.hide('assigned_object_type')
journalentry_table.columns.hide('assigned_object') journalentry_table.columns.hide('assigned_object')
if request.user.has_perm('extras.add_journalentry'): if request.user.has_perm('extras.add_journalentry'):
form = forms.JournalEntryForm( form = JournalEntryForm(
initial={ initial={
'assigned_object_type': ContentType.objects.get_for_model(obj), 'assigned_object_type': ContentType.objects.get_for_model(obj),
'assigned_object_id': obj.pk 'assigned_object_id': obj.pk

Binary file not shown.

Binary file not shown.

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