mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 19:52:52 -06:00
Merge pull request #5010 from netbox-community/4990-custom-script-changelog
Fixes #4990: Object change logging during custom script execution
This commit is contained in:
commit
046272ff37
@ -176,13 +176,12 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
|
||||||
custom_fields = validated_data.pop('custom_fields', None)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
|
||||||
instance = super().create(validated_data)
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
# Save custom fields
|
# Save custom fields
|
||||||
|
custom_fields = validated_data.get('custom_fields')
|
||||||
if custom_fields is not None:
|
if custom_fields is not None:
|
||||||
self._save_custom_fields(instance, custom_fields)
|
self._save_custom_fields(instance, custom_fields)
|
||||||
instance.custom_fields = custom_fields
|
instance.custom_fields = custom_fields
|
||||||
@ -191,10 +190,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
|||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
|
||||||
custom_fields = validated_data.pop('custom_fields', None)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
|
||||||
|
custom_fields = validated_data.get('custom_fields')
|
||||||
|
instance._cf = custom_fields
|
||||||
|
|
||||||
instance = super().update(instance, validated_data)
|
instance = super().update(instance, validated_data)
|
||||||
|
|
||||||
# Save custom fields
|
# Save custom fields
|
||||||
|
@ -108,6 +108,10 @@ class TaggedObjectSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
tags = validated_data.pop('tags', [])
|
tags = validated_data.pop('tags', [])
|
||||||
|
|
||||||
|
# Cache tags on instance for change logging
|
||||||
|
instance._tags = tags
|
||||||
|
|
||||||
instance = super().update(instance, validated_data)
|
instance = super().update(instance, validated_data)
|
||||||
|
|
||||||
return self._save_tags(instance, tags)
|
return self._save_tags(instance, tags)
|
||||||
|
32
netbox/extras/context_managers.py
Normal file
32
netbox/extras/context_managers.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from django.db.models.signals import m2m_changed, pre_delete, post_save
|
||||||
|
|
||||||
|
from extras.signals import _handle_changed_object, _handle_deleted_object
|
||||||
|
from utilities.utils import curry
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def change_logging(request):
|
||||||
|
"""
|
||||||
|
Enable change logging by connecting the appropriate signals to their receivers before code is run, and
|
||||||
|
disconnecting them afterward.
|
||||||
|
|
||||||
|
:param request: WSGIRequest object with a unique `id` set
|
||||||
|
"""
|
||||||
|
# Curry signals receivers to pass the current request
|
||||||
|
handle_changed_object = curry(_handle_changed_object, request)
|
||||||
|
handle_deleted_object = curry(_handle_deleted_object, request)
|
||||||
|
|
||||||
|
# Connect our receivers to the post_save and post_delete signals.
|
||||||
|
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||||
|
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||||
|
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Disconnect change logging signals. This is necessary to avoid recording any errant
|
||||||
|
# changes during test cleanup.
|
||||||
|
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||||
|
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||||
|
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
@ -29,6 +29,9 @@ class CustomFieldModelForm(forms.ModelForm):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.instance._cf is None:
|
||||||
|
self.instance._cf = {}
|
||||||
|
|
||||||
self._append_customfield_fields()
|
self._append_customfield_fields()
|
||||||
|
|
||||||
def _append_customfield_fields(self):
|
def _append_customfield_fields(self):
|
||||||
@ -48,9 +51,12 @@ class CustomFieldModelForm(forms.ModelForm):
|
|||||||
field_name = 'cf_{}'.format(cf.name)
|
field_name = 'cf_{}'.format(cf.name)
|
||||||
if self.instance.pk:
|
if self.instance.pk:
|
||||||
self.fields[field_name] = cf.to_form_field(set_initial=False)
|
self.fields[field_name] = cf.to_form_field(set_initial=False)
|
||||||
self.fields[field_name].initial = self.custom_field_values.get(cf.name)
|
value = self.custom_field_values.get(cf.name)
|
||||||
|
self.fields[field_name].initial = value
|
||||||
|
self.instance._cf[cf.name] = value
|
||||||
else:
|
else:
|
||||||
self.fields[field_name] = cf.to_form_field()
|
self.fields[field_name] = cf.to_form_field()
|
||||||
|
self.instance._cf[cf.name] = self.fields[field_name].initial
|
||||||
|
|
||||||
# Annotate the field in the list of CustomField form fields
|
# Annotate the field in the list of CustomField form fields
|
||||||
self.custom_fields.append(field_name)
|
self.custom_fields.append(field_name)
|
||||||
@ -77,13 +83,18 @@ class CustomFieldModelForm(forms.ModelForm):
|
|||||||
cfv.save()
|
cfv.save()
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
|
|
||||||
|
# Cache custom field values on object prior to save to ensure change logging
|
||||||
|
for cf_name in self.custom_fields:
|
||||||
|
self.instance._cf[cf_name[3:]] = self.cleaned_data.get(cf_name)
|
||||||
|
|
||||||
obj = super().save(commit)
|
obj = super().save(commit)
|
||||||
|
|
||||||
# Handle custom fields the same way we do M2M fields
|
# Handle custom fields the same way we do M2M fields
|
||||||
if commit:
|
if commit:
|
||||||
self._save_custom_fields()
|
self._save_custom_fields()
|
||||||
else:
|
else:
|
||||||
self.save_custom_fields = self._save_custom_fields
|
obj.save_custom_fields = self._save_custom_fields
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
@ -1,64 +1,6 @@
|
|||||||
import random
|
|
||||||
import threading
|
|
||||||
import uuid
|
import uuid
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.conf import settings
|
from .context_managers import change_logging
|
||||||
from django.contrib import messages
|
|
||||||
from django.db.models.signals import pre_delete, post_save
|
|
||||||
from django.utils import timezone
|
|
||||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
|
||||||
from redis.exceptions import RedisError
|
|
||||||
|
|
||||||
from extras.utils import is_taggable
|
|
||||||
from utilities.api import is_api_request
|
|
||||||
from utilities.querysets import DummyQuerySet
|
|
||||||
from .choices import ObjectChangeActionChoices
|
|
||||||
from .models import ObjectChange
|
|
||||||
from .signals import purge_changelog
|
|
||||||
from .webhooks import enqueue_webhooks
|
|
||||||
|
|
||||||
_thread_locals = threading.local()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_changed_object(sender, instance, **kwargs):
|
|
||||||
"""
|
|
||||||
Fires when an object is created or updated.
|
|
||||||
"""
|
|
||||||
# Queue the object for processing once the request completes
|
|
||||||
action = ObjectChangeActionChoices.ACTION_CREATE if kwargs['created'] else ObjectChangeActionChoices.ACTION_UPDATE
|
|
||||||
_thread_locals.changed_objects.append(
|
|
||||||
(instance, action)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_deleted_object(sender, instance, **kwargs):
|
|
||||||
"""
|
|
||||||
Fires when an object is deleted.
|
|
||||||
"""
|
|
||||||
# Cache custom fields prior to copying the instance
|
|
||||||
if hasattr(instance, 'cache_custom_fields'):
|
|
||||||
instance.cache_custom_fields()
|
|
||||||
|
|
||||||
# Create a copy of the object being deleted
|
|
||||||
copy = deepcopy(instance)
|
|
||||||
|
|
||||||
# Preserve tags
|
|
||||||
if is_taggable(instance):
|
|
||||||
copy.tags = DummyQuerySet(instance.tags.all())
|
|
||||||
|
|
||||||
# Queue the copy of the object for processing once the request completes
|
|
||||||
_thread_locals.changed_objects.append(
|
|
||||||
(copy, ObjectChangeActionChoices.ACTION_DELETE)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def purge_objectchange_cache(sender, **kwargs):
|
|
||||||
"""
|
|
||||||
Delete any queued object changes waiting to be written.
|
|
||||||
"""
|
|
||||||
_thread_locals.changed_objects = []
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectChangeMiddleware(object):
|
class ObjectChangeMiddleware(object):
|
||||||
@ -79,74 +21,12 @@ class ObjectChangeMiddleware(object):
|
|||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
|
|
||||||
# Initialize an empty list to cache objects being saved.
|
|
||||||
_thread_locals.changed_objects = []
|
|
||||||
|
|
||||||
# Assign a random unique ID to the request. This will be used to associate multiple object changes made during
|
# Assign a random unique ID to the request. This will be used to associate multiple object changes made during
|
||||||
# the same request.
|
# the same request.
|
||||||
request.id = uuid.uuid4()
|
request.id = uuid.uuid4()
|
||||||
|
|
||||||
# Connect our receivers to the post_save and post_delete signals.
|
# Process the request with change logging enabled
|
||||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
with change_logging(request):
|
||||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
response = self.get_response(request)
|
||||||
|
|
||||||
# Provide a hook for purging the change cache
|
|
||||||
purge_changelog.connect(purge_objectchange_cache)
|
|
||||||
|
|
||||||
# Process the request
|
|
||||||
response = self.get_response(request)
|
|
||||||
|
|
||||||
# If the change cache is empty, there's nothing more we need to do.
|
|
||||||
if not _thread_locals.changed_objects:
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Disconnect our receivers from the post_save and post_delete signals.
|
|
||||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
|
||||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
|
||||||
|
|
||||||
# Create records for any cached objects that were changed.
|
|
||||||
redis_failed = False
|
|
||||||
for instance, action in _thread_locals.changed_objects:
|
|
||||||
|
|
||||||
# Refresh cached custom field values
|
|
||||||
if action in [ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]:
|
|
||||||
if hasattr(instance, 'cache_custom_fields'):
|
|
||||||
instance.cache_custom_fields()
|
|
||||||
|
|
||||||
# Record an ObjectChange if applicable
|
|
||||||
if hasattr(instance, 'to_objectchange'):
|
|
||||||
objectchange = instance.to_objectchange(action)
|
|
||||||
objectchange.user = request.user
|
|
||||||
objectchange.request_id = request.id
|
|
||||||
objectchange.save()
|
|
||||||
|
|
||||||
# Enqueue webhooks
|
|
||||||
try:
|
|
||||||
enqueue_webhooks(instance, request.user, request.id, action)
|
|
||||||
except RedisError as e:
|
|
||||||
if not redis_failed and not is_api_request(request):
|
|
||||||
messages.error(
|
|
||||||
request,
|
|
||||||
"There was an error processing webhooks for this request. Check that the Redis service is "
|
|
||||||
"running and reachable. The full error details were: {}".format(e)
|
|
||||||
)
|
|
||||||
redis_failed = True
|
|
||||||
|
|
||||||
# Increment metric counters
|
|
||||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
|
||||||
model_inserts.labels(instance._meta.model_name).inc()
|
|
||||||
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
|
|
||||||
model_updates.labels(instance._meta.model_name).inc()
|
|
||||||
elif action == ObjectChangeActionChoices.ACTION_DELETE:
|
|
||||||
model_deletes.labels(instance._meta.model_name).inc()
|
|
||||||
|
|
||||||
# Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
|
|
||||||
# one or more changes being logged.
|
|
||||||
if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
|
|
||||||
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
|
||||||
purged_count, _ = ObjectChange.objects.filter(
|
|
||||||
time__lt=cutoff
|
|
||||||
).delete()
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import logging
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
@ -18,11 +17,14 @@ from extras.utils import FeatureQuery
|
|||||||
#
|
#
|
||||||
|
|
||||||
class CustomFieldModel(models.Model):
|
class CustomFieldModel(models.Model):
|
||||||
_cf = None
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
def __init__(self, *args, custom_fields=None, **kwargs):
|
||||||
|
self._cf = custom_fields
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def cache_custom_fields(self):
|
def cache_custom_fields(self):
|
||||||
"""
|
"""
|
||||||
Cache all custom field values for this instance
|
Cache all custom field values for this instance
|
||||||
|
@ -22,8 +22,8 @@ from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
|||||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
|
from .context_managers import change_logging
|
||||||
from .forms import ScriptForm
|
from .forms import ScriptForm
|
||||||
from .signals import purge_changelog
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'BaseScript',
|
'BaseScript',
|
||||||
@ -436,41 +436,44 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
|||||||
if 'commit' in inspect.signature(script.run).parameters:
|
if 'commit' in inspect.signature(script.run).parameters:
|
||||||
kwargs['commit'] = commit
|
kwargs['commit'] = commit
|
||||||
else:
|
else:
|
||||||
warnings.warn(f"The run() method of script {script} should support a 'commit' argument. This will be required "
|
warnings.warn(
|
||||||
f"beginning with NetBox v2.10.")
|
f"The run() method of script {script} should support a 'commit' argument. This will be required beginning "
|
||||||
|
f"with NetBox v2.10."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
with change_logging(request):
|
||||||
with transaction.atomic():
|
|
||||||
script.output = script.run(**kwargs)
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
script.output = script.run(**kwargs)
|
||||||
|
|
||||||
|
if not commit:
|
||||||
|
raise AbortTransaction()
|
||||||
|
|
||||||
|
except AbortTransaction:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
stacktrace = traceback.format_exc()
|
||||||
|
script.log_failure(
|
||||||
|
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
|
||||||
|
)
|
||||||
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
|
commit = False
|
||||||
|
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if job_result.status != JobResultStatusChoices.STATUS_ERRORED:
|
||||||
|
job_result.data = ScriptOutputSerializer(script).data
|
||||||
|
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
||||||
|
|
||||||
if not commit:
|
if not commit:
|
||||||
raise AbortTransaction()
|
# Delete all pending changelog entries
|
||||||
|
script.log_info(
|
||||||
|
"Database changes have been reverted automatically."
|
||||||
|
)
|
||||||
|
|
||||||
except AbortTransaction:
|
logger.info(f"Script completed in {job_result.duration}")
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
stacktrace = traceback.format_exc()
|
|
||||||
script.log_failure(
|
|
||||||
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
|
|
||||||
)
|
|
||||||
logger.error(f"Exception raised during script execution: {e}")
|
|
||||||
commit = False
|
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if job_result.status != JobResultStatusChoices.STATUS_ERRORED:
|
|
||||||
job_result.data = ScriptOutputSerializer(script).data
|
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
|
||||||
|
|
||||||
if not commit:
|
|
||||||
# Delete all pending changelog entries
|
|
||||||
purge_changelog.send(Script)
|
|
||||||
script.log_info(
|
|
||||||
"Database changes have been reverted automatically."
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Script completed in {job_result.duration}")
|
|
||||||
|
|
||||||
# Delete any previous terminal state results
|
# Delete any previous terminal state results
|
||||||
JobResult.objects.filter(
|
JobResult.objects.filter(
|
||||||
|
@ -1,7 +1,75 @@
|
|||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from cacheops.signals import cache_invalidated, cache_read
|
from cacheops.signals import cache_invalidated, cache_read
|
||||||
from django.dispatch import Signal
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||||
from prometheus_client import Counter
|
from prometheus_client import Counter
|
||||||
|
|
||||||
|
from .choices import ObjectChangeActionChoices
|
||||||
|
from .models import ObjectChange
|
||||||
|
from .webhooks import enqueue_webhooks
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Change logging/webhooks
|
||||||
|
#
|
||||||
|
|
||||||
|
def _handle_changed_object(request, sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Fires when an object is created or updated.
|
||||||
|
"""
|
||||||
|
# Queue the object for processing once the request completes
|
||||||
|
if kwargs.get('created'):
|
||||||
|
action = ObjectChangeActionChoices.ACTION_CREATE
|
||||||
|
elif 'created' in kwargs:
|
||||||
|
action = ObjectChangeActionChoices.ACTION_UPDATE
|
||||||
|
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
|
||||||
|
# m2m_changed with objects added or removed
|
||||||
|
action = ObjectChangeActionChoices.ACTION_UPDATE
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Record an ObjectChange if applicable
|
||||||
|
if hasattr(instance, 'to_objectchange'):
|
||||||
|
objectchange = instance.to_objectchange(action)
|
||||||
|
objectchange.user = request.user
|
||||||
|
objectchange.request_id = request.id
|
||||||
|
objectchange.save()
|
||||||
|
|
||||||
|
# Enqueue webhooks
|
||||||
|
enqueue_webhooks(instance, request.user, request.id, action)
|
||||||
|
|
||||||
|
# Increment metric counters
|
||||||
|
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||||
|
model_inserts.labels(instance._meta.model_name).inc()
|
||||||
|
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
|
||||||
|
model_updates.labels(instance._meta.model_name).inc()
|
||||||
|
|
||||||
|
# Housekeeping: 0.1% chance of clearing out expired ObjectChanges
|
||||||
|
if settings.CHANGELOG_RETENTION and random.randint(1, 1000) == 1:
|
||||||
|
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
||||||
|
ObjectChange.objects.filter(time__lt=cutoff).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_deleted_object(request, sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Fires when an object is deleted.
|
||||||
|
"""
|
||||||
|
# Record an ObjectChange if applicable
|
||||||
|
if hasattr(instance, 'to_objectchange'):
|
||||||
|
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
|
objectchange.user = request.user
|
||||||
|
objectchange.request_id = request.id
|
||||||
|
objectchange.save()
|
||||||
|
|
||||||
|
# Enqueue webhooks
|
||||||
|
enqueue_webhooks(instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
|
|
||||||
|
# Increment metric counters
|
||||||
|
model_deletes.labels(instance._meta.model_name).inc()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Caching
|
# Caching
|
||||||
@ -25,10 +93,3 @@ def cache_invalidated_collector(sender, obj_dict, **kwargs):
|
|||||||
|
|
||||||
cache_read.connect(cache_read_collector)
|
cache_read.connect(cache_read_collector)
|
||||||
cache_invalidated.connect(cache_invalidated_collector)
|
cache_invalidated.connect(cache_invalidated_collector)
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Change logging
|
|
||||||
#
|
|
||||||
|
|
||||||
purge_changelog = Signal()
|
|
||||||
|
@ -2,13 +2,125 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
|
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, CustomFieldValue, ObjectChange
|
from extras.models import CustomField, CustomFieldValue, ObjectChange, Tag
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
|
from utilities.testing.utils import post_data
|
||||||
|
from utilities.testing.views import ModelViewTestCase
|
||||||
|
|
||||||
|
|
||||||
class ChangeLogTest(APITestCase):
|
class ChangeLogViewTest(ModelViewTestCase):
|
||||||
|
model = Site
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
# Create a custom field on the Site model
|
||||||
|
ct = ContentType.objects.get_for_model(Site)
|
||||||
|
cf = CustomField(
|
||||||
|
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||||
|
name='my_field',
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cf.save()
|
||||||
|
cf.obj_type.set([ct])
|
||||||
|
|
||||||
|
def test_create_object(self):
|
||||||
|
tags = self.create_tags('Tag 1', 'Tag 2')
|
||||||
|
form_data = {
|
||||||
|
'name': 'Test Site 1',
|
||||||
|
'slug': 'test-site-1',
|
||||||
|
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||||
|
'cf_my_field': 'ABC',
|
||||||
|
'tags': [tag.pk for tag in tags],
|
||||||
|
}
|
||||||
|
|
||||||
|
request = {
|
||||||
|
'path': self._get_url('add'),
|
||||||
|
'data': post_data(form_data),
|
||||||
|
}
|
||||||
|
self.add_permissions('dcim.add_site')
|
||||||
|
response = self.client.post(**request)
|
||||||
|
self.assertHttpStatus(response, 302)
|
||||||
|
|
||||||
|
site = Site.objects.get(name='Test Site 1')
|
||||||
|
# First OC is the creation; second is the tags update
|
||||||
|
oc_list = ObjectChange.objects.filter(
|
||||||
|
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||||
|
changed_object_id=site.pk
|
||||||
|
).order_by('pk')
|
||||||
|
self.assertEqual(oc_list[0].changed_object, site)
|
||||||
|
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||||
|
self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||||
|
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||||
|
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
|
def test_update_object(self):
|
||||||
|
site = Site(name='Test Site 1', slug='test-site-1')
|
||||||
|
site.save()
|
||||||
|
tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3')
|
||||||
|
site.tags.set('Tag 1', 'Tag 2')
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'name': 'Test Site X',
|
||||||
|
'slug': 'test-site-x',
|
||||||
|
'status': SiteStatusChoices.STATUS_PLANNED,
|
||||||
|
'cf_my_field': 'DEF',
|
||||||
|
'tags': [tags[2].pk],
|
||||||
|
}
|
||||||
|
|
||||||
|
request = {
|
||||||
|
'path': self._get_url('edit', instance=site),
|
||||||
|
'data': post_data(form_data),
|
||||||
|
}
|
||||||
|
self.add_permissions('dcim.change_site')
|
||||||
|
response = self.client.post(**request)
|
||||||
|
self.assertHttpStatus(response, 302)
|
||||||
|
|
||||||
|
site.refresh_from_db()
|
||||||
|
# Get only the most recent OC
|
||||||
|
oc = ObjectChange.objects.filter(
|
||||||
|
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||||
|
changed_object_id=site.pk
|
||||||
|
).first()
|
||||||
|
self.assertEqual(oc.changed_object, site)
|
||||||
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||||
|
self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
|
||||||
|
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
|
||||||
|
|
||||||
|
def test_delete_object(self):
|
||||||
|
site = Site(
|
||||||
|
name='Test Site 1',
|
||||||
|
slug='test-site-1'
|
||||||
|
)
|
||||||
|
site.save()
|
||||||
|
self.create_tags('Tag 1', 'Tag 2')
|
||||||
|
site.tags.set('Tag 1', 'Tag 2')
|
||||||
|
CustomFieldValue.objects.create(
|
||||||
|
field=CustomField.objects.get(name='my_field'),
|
||||||
|
obj=site,
|
||||||
|
value='ABC'
|
||||||
|
)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
'path': self._get_url('delete', instance=site),
|
||||||
|
'data': post_data({'confirm': True}),
|
||||||
|
}
|
||||||
|
self.add_permissions('dcim.delete_site')
|
||||||
|
response = self.client.post(**request)
|
||||||
|
self.assertHttpStatus(response, 302)
|
||||||
|
|
||||||
|
oc = ObjectChange.objects.first()
|
||||||
|
self.assertEqual(oc.changed_object, None)
|
||||||
|
self.assertEqual(oc.object_repr, site.name)
|
||||||
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
|
self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
|
||||||
|
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeLogAPITest(APITestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@ -23,6 +135,14 @@ class ChangeLogTest(APITestCase):
|
|||||||
cf.save()
|
cf.save()
|
||||||
cf.obj_type.set([ct])
|
cf.obj_type.set([ct])
|
||||||
|
|
||||||
|
# Create some tags
|
||||||
|
tags = (
|
||||||
|
Tag(name='Tag 1', slug='tag-1'),
|
||||||
|
Tag(name='Tag 2', slug='tag-2'),
|
||||||
|
Tag(name='Tag 3', slug='tag-3'),
|
||||||
|
)
|
||||||
|
Tag.objects.bulk_create(tags)
|
||||||
|
|
||||||
def test_create_object(self):
|
def test_create_object(self):
|
||||||
data = {
|
data = {
|
||||||
'name': 'Test Site 1',
|
'name': 'Test Site 1',
|
||||||
@ -30,6 +150,10 @@ class ChangeLogTest(APITestCase):
|
|||||||
'custom_fields': {
|
'custom_fields': {
|
||||||
'my_field': 'ABC'
|
'my_field': 'ABC'
|
||||||
},
|
},
|
||||||
|
'tags': [
|
||||||
|
{'name': 'Tag 1'},
|
||||||
|
{'name': 'Tag 2'},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||||
url = reverse('dcim-api:site-list')
|
url = reverse('dcim-api:site-list')
|
||||||
@ -39,13 +163,16 @@ class ChangeLogTest(APITestCase):
|
|||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
site = Site.objects.get(pk=response.data['id'])
|
site = Site.objects.get(pk=response.data['id'])
|
||||||
oc = ObjectChange.objects.get(
|
# First OC is the creation; second is the tags update
|
||||||
|
oc_list = ObjectChange.objects.filter(
|
||||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||||
changed_object_id=site.pk
|
changed_object_id=site.pk
|
||||||
)
|
).order_by('pk')
|
||||||
self.assertEqual(oc.changed_object, site)
|
self.assertEqual(oc_list[0].changed_object, site)
|
||||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
self.assertEqual(oc_list[0].object_data['custom_fields'], data['custom_fields'])
|
||||||
|
self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||||
|
self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
def test_update_object(self):
|
def test_update_object(self):
|
||||||
site = Site(name='Test Site 1', slug='test-site-1')
|
site = Site(name='Test Site 1', slug='test-site-1')
|
||||||
@ -57,6 +184,9 @@ class ChangeLogTest(APITestCase):
|
|||||||
'custom_fields': {
|
'custom_fields': {
|
||||||
'my_field': 'DEF'
|
'my_field': 'DEF'
|
||||||
},
|
},
|
||||||
|
'tags': [
|
||||||
|
{'name': 'Tag 3'}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||||
self.add_permissions('dcim.change_site')
|
self.add_permissions('dcim.change_site')
|
||||||
@ -66,13 +196,15 @@ class ChangeLogTest(APITestCase):
|
|||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
|
||||||
site = Site.objects.get(pk=response.data['id'])
|
site = Site.objects.get(pk=response.data['id'])
|
||||||
oc = ObjectChange.objects.get(
|
# Get only the most recent OC
|
||||||
|
oc = ObjectChange.objects.filter(
|
||||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||||
changed_object_id=site.pk
|
changed_object_id=site.pk
|
||||||
)
|
).first()
|
||||||
self.assertEqual(oc.changed_object, site)
|
self.assertEqual(oc.changed_object, site)
|
||||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||||
|
self.assertEqual(oc.object_data['tags'], ['Tag 3'])
|
||||||
|
|
||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
site = Site(
|
site = Site(
|
||||||
@ -80,6 +212,7 @@ class ChangeLogTest(APITestCase):
|
|||||||
slug='test-site-1'
|
slug='test-site-1'
|
||||||
)
|
)
|
||||||
site.save()
|
site.save()
|
||||||
|
site.tags.set(*Tag.objects.all()[:2])
|
||||||
CustomFieldValue.objects.create(
|
CustomFieldValue.objects.create(
|
||||||
field=CustomField.objects.get(name='my_field'),
|
field=CustomField.objects.get(name='my_field'),
|
||||||
obj=site,
|
obj=site,
|
||||||
@ -98,3 +231,4 @@ class ChangeLogTest(APITestCase):
|
|||||||
self.assertEqual(oc.object_repr, site.name)
|
self.assertEqual(oc.object_repr, site.name)
|
||||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
||||||
|
self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
@ -3,7 +3,6 @@ import collections
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
from taggit.managers import _TaggableManager
|
from taggit.managers import _TaggableManager
|
||||||
from utilities.querysets import DummyQuerySet
|
|
||||||
|
|
||||||
from extras.constants import EXTRAS_FEATURES
|
from extras.constants import EXTRAS_FEATURES
|
||||||
from extras.registry import registry
|
from extras.registry import registry
|
||||||
@ -16,9 +15,6 @@ def is_taggable(obj):
|
|||||||
if hasattr(obj, 'tags'):
|
if hasattr(obj, 'tags'):
|
||||||
if issubclass(obj.tags.__class__, _TaggableManager):
|
if issubclass(obj.tags.__class__, _TaggableManager):
|
||||||
return True
|
return True
|
||||||
# TaggableManager has been replaced with a DummyQuerySet prior to object deletion
|
|
||||||
if isinstance(obj.tags, DummyQuerySet):
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@ -142,9 +142,9 @@ class APISelect(SelectWithDisabled):
|
|||||||
|
|
||||||
values = json.loads(self.attrs.get(key, '[]'))
|
values = json.loads(self.attrs.get(key, '[]'))
|
||||||
if type(value) is list:
|
if type(value) is list:
|
||||||
values.extend(value)
|
values.extend([str(v) for v in value])
|
||||||
else:
|
else:
|
||||||
values.append(value)
|
values.append(str(value))
|
||||||
|
|
||||||
self.attrs[key] = json.dumps(values)
|
self.attrs[key] = json.dumps(values)
|
||||||
|
|
||||||
|
@ -3,20 +3,6 @@ from django.db.models import Q, QuerySet
|
|||||||
from utilities.permissions import permission_is_exempt
|
from utilities.permissions import permission_is_exempt
|
||||||
|
|
||||||
|
|
||||||
class DummyQuerySet:
|
|
||||||
"""
|
|
||||||
A fake QuerySet that can be used to cache relationships to objects that have been deleted.
|
|
||||||
"""
|
|
||||||
def __init__(self, queryset):
|
|
||||||
self._cache = [obj for obj in queryset.all()]
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return iter(self._cache)
|
|
||||||
|
|
||||||
def all(self):
|
|
||||||
return self._cache
|
|
||||||
|
|
||||||
|
|
||||||
class RestrictedQuerySet(QuerySet):
|
class RestrictedQuerySet(QuerySet):
|
||||||
|
|
||||||
def restrict(self, user, action='view'):
|
def restrict(self, user, action='view'):
|
||||||
|
@ -115,7 +115,7 @@ class TestCase(_TestCase):
|
|||||||
"""
|
"""
|
||||||
err_message = "Expected HTTP status {}; received {}: {}"
|
err_message = "Expected HTTP status {}; received {}: {}"
|
||||||
self.assertEqual(response.status_code, expected_status, err_message.format(
|
self.assertEqual(response.status_code, expected_status, err_message.format(
|
||||||
expected_status, response.status_code, getattr(response, 'data', 'No data')
|
expected_status, response.status_code, getattr(response, 'data', response.content)
|
||||||
))
|
))
|
||||||
|
|
||||||
def assertInstanceEqual(self, instance, data, api=False):
|
def assertInstanceEqual(self, instance, data, api=False):
|
||||||
|
@ -97,9 +97,10 @@ def serialize_object(obj, extra=None, exclude=None):
|
|||||||
field: str(value) for field, value in obj.cf.items()
|
field: str(value) for field, value in obj.cf.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Include any tags
|
# Include any tags. Check for tags cached on the instance; fall back to using the manager.
|
||||||
if is_taggable(obj):
|
if is_taggable(obj):
|
||||||
data['tags'] = [tag.name for tag in obj.tags.all()]
|
tags = getattr(obj, '_tags', obj.tags.all())
|
||||||
|
data['tags'] = [tag.name for tag in tags]
|
||||||
|
|
||||||
# Append any extra data
|
# Append any extra data
|
||||||
if extra is not None:
|
if extra is not None:
|
||||||
@ -276,6 +277,13 @@ def flatten_dict(d, prefix='', separator='.'):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
# Taken from django.utils.functional (<3.0)
|
||||||
|
def curry(_curried_func, *args, **kwargs):
|
||||||
|
def _curried(*moreargs, **morekwargs):
|
||||||
|
return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs})
|
||||||
|
return _curried
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Fake request object
|
# Fake request object
|
||||||
#
|
#
|
||||||
@ -305,5 +313,6 @@ def copy_safe_request(request):
|
|||||||
'GET': request.GET,
|
'GET': request.GET,
|
||||||
'FILES': request.FILES,
|
'FILES': request.FILES,
|
||||||
'user': request.user,
|
'user': request.user,
|
||||||
'path': request.path
|
'path': request.path,
|
||||||
|
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
||||||
})
|
})
|
||||||
|
@ -949,6 +949,12 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
|||||||
elif form.cleaned_data[name] not in (None, ''):
|
elif form.cleaned_data[name] not in (None, ''):
|
||||||
setattr(obj, name, form.cleaned_data[name])
|
setattr(obj, name, form.cleaned_data[name])
|
||||||
|
|
||||||
|
# Cache custom fields on instance prior to save()
|
||||||
|
if custom_fields:
|
||||||
|
obj._cf = {
|
||||||
|
name: form.cleaned_data[name] for name in custom_fields
|
||||||
|
}
|
||||||
|
|
||||||
obj.full_clean()
|
obj.full_clean()
|
||||||
obj.save()
|
obj.save()
|
||||||
updated_objects.append(obj)
|
updated_objects.append(obj)
|
||||||
|
Loading…
Reference in New Issue
Block a user