mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-18 19:32:24 -06:00
Merge remote-tracking branch 'upstream/develop' into 3619-new-400G-osfp-interface-type
This commit is contained in:
@@ -2588,6 +2588,16 @@ class DeviceBay(ComponentModel):
|
||||
if self.device == self.installed_device:
|
||||
raise ValidationError("Cannot install a device into itself.")
|
||||
|
||||
# Check that the installed device is not already installed elsewhere
|
||||
if self.installed_device:
|
||||
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
|
||||
if current_bay:
|
||||
raise ValidationError({
|
||||
'installed_device': "Cannot install the specified device; device is already installed in {}".format(
|
||||
current_bay
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Inventory items
|
||||
@@ -3112,6 +3122,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
return (
|
||||
self.power_panel.site.name,
|
||||
self.power_panel.name,
|
||||
self.rack.group.name if self.rack and self.rack.group else None,
|
||||
self.rack.name if self.rack else None,
|
||||
self.name,
|
||||
self.get_status_display(),
|
||||
|
||||
@@ -404,8 +404,12 @@ class RackView(PermissionRequiredMixin, View):
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
|
||||
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
|
||||
if rack.group:
|
||||
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
|
||||
else:
|
||||
peer_racks = Rack.objects.filter(site=rack.site, group__isnull=True)
|
||||
next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first()
|
||||
prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first()
|
||||
|
||||
reservations = RackReservation.objects.filter(rack=rack)
|
||||
power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
|
||||
|
||||
@@ -97,13 +97,13 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
def _populate_custom_fields(instance, fields):
|
||||
custom_fields = {f.name: None for f in fields}
|
||||
for cfv in instance.custom_field_values.all():
|
||||
if cfv.field.type == CF_TYPE_SELECT:
|
||||
custom_fields[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data
|
||||
instance.custom_fields = {}
|
||||
for field in fields:
|
||||
value = instance.cf.get(field.name)
|
||||
if field.type == CF_TYPE_SELECT and value is not None:
|
||||
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
|
||||
else:
|
||||
custom_fields[cfv.field.name] = cfv.value
|
||||
instance.custom_fields = custom_fields
|
||||
instance.custom_fields[field.name] = value
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import random
|
||||
import threading
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import curry
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from utilities.querysets import DummyQuerySet
|
||||
from .constants import *
|
||||
from .models import ObjectChange
|
||||
from .signals import purge_changelog
|
||||
@@ -19,33 +20,34 @@ _thread_locals = threading.local()
|
||||
|
||||
def handle_changed_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated
|
||||
Fires when an object is created or updated.
|
||||
"""
|
||||
# Queue the object and a new ObjectChange for processing once the request completes
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
|
||||
objectchange = instance.to_objectchange(action)
|
||||
_thread_locals.changed_objects.append(
|
||||
(instance, objectchange)
|
||||
)
|
||||
# Queue the object for processing once the request completes
|
||||
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
|
||||
_thread_locals.changed_objects.append(
|
||||
(instance, action)
|
||||
)
|
||||
|
||||
|
||||
def _handle_deleted_object(request, sender, instance, **kwargs):
|
||||
def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
# Record an Object Change
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
# Cache custom fields prior to copying the instance
|
||||
if hasattr(instance, 'cache_custom_fields'):
|
||||
instance.cache_custom_fields()
|
||||
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
|
||||
# Create a copy of the object being deleted
|
||||
copy = deepcopy(instance)
|
||||
|
||||
# Increment metric counters
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
# Preserve tags
|
||||
if hasattr(instance, 'tags'):
|
||||
copy.tags = DummyQuerySet(instance.tags.all())
|
||||
|
||||
# Queue the copy of the object for processing once the request completes
|
||||
_thread_locals.changed_objects.append(
|
||||
(copy, OBJECTCHANGE_ACTION_DELETE)
|
||||
)
|
||||
|
||||
|
||||
def purge_objectchange_cache(sender, **kwargs):
|
||||
@@ -81,12 +83,9 @@ class ObjectChangeMiddleware(object):
|
||||
# the same request.
|
||||
request.id = uuid.uuid4()
|
||||
|
||||
# Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
|
||||
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='cache_changed_object')
|
||||
post_delete.connect(handle_deleted_object, dispatch_uid='cache_deleted_object')
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
|
||||
# Provide a hook for purging the change cache
|
||||
purge_changelog.connect(purge_objectchange_cache)
|
||||
@@ -98,22 +97,31 @@ class ObjectChangeMiddleware(object):
|
||||
if not _thread_locals.changed_objects:
|
||||
return response
|
||||
|
||||
# Create records for any cached objects that were created/updated.
|
||||
for obj, objectchange in _thread_locals.changed_objects:
|
||||
# Create records for any cached objects that were changed.
|
||||
for instance, action in _thread_locals.changed_objects:
|
||||
|
||||
# Record the change
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
# Refresh cached custom field values
|
||||
if action in [OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_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
|
||||
enqueue_webhooks(obj, request.user, request.id, objectchange.action)
|
||||
enqueue_webhooks(instance, request.user, request.id, action)
|
||||
|
||||
# Increment metric counters
|
||||
if objectchange.action == OBJECTCHANGE_ACTION_CREATE:
|
||||
model_inserts.labels(obj._meta.model_name).inc()
|
||||
elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
|
||||
model_updates.labels(obj._meta.model_name).inc()
|
||||
if action == OBJECTCHANGE_ACTION_CREATE:
|
||||
model_inserts.labels(instance._meta.model_name).inc()
|
||||
elif action == OBJECTCHANGE_ACTION_UPDATE:
|
||||
model_updates.labels(instance._meta.model_name).inc()
|
||||
elif action == OBJECTCHANGE_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.
|
||||
|
||||
@@ -138,16 +138,21 @@ class CustomFieldModel(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def cache_custom_fields(self):
|
||||
"""
|
||||
Cache all custom field values for this instance
|
||||
"""
|
||||
self._cf = {
|
||||
field.name: value for field, value in self.get_custom_fields().items()
|
||||
}
|
||||
|
||||
@property
|
||||
def cf(self):
|
||||
"""
|
||||
Name-based CustomFieldValue accessor for use in templates
|
||||
"""
|
||||
if self._cf is None:
|
||||
# Cache all custom field values for this instance
|
||||
self._cf = {
|
||||
field.name: value for field, value in self.get_custom_fields().items()
|
||||
}
|
||||
self.cache_custom_fields()
|
||||
return self._cf
|
||||
|
||||
def get_custom_fields(self):
|
||||
|
||||
@@ -24,6 +24,7 @@ from .signals import purge_changelog
|
||||
__all__ = [
|
||||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'ChoiceVar',
|
||||
'FileVar',
|
||||
'IntegerVar',
|
||||
'IPNetworkVar',
|
||||
@@ -133,6 +134,27 @@ class BooleanVar(ScriptVariable):
|
||||
self.field_attrs['required'] = False
|
||||
|
||||
|
||||
class ChoiceVar(ScriptVariable):
|
||||
"""
|
||||
Select one of several predefined static choices, passed as a list of two-tuples. Example:
|
||||
|
||||
color = ChoiceVar(
|
||||
choices=(
|
||||
('#ff0000', 'Red'),
|
||||
('#00ff00', 'Green'),
|
||||
('#0000ff', 'Blue')
|
||||
)
|
||||
)
|
||||
"""
|
||||
form_field = forms.ChoiceField
|
||||
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set field choices
|
||||
self.field_attrs['choices'] = choices
|
||||
|
||||
|
||||
class ObjectVar(ScriptVariable):
|
||||
"""
|
||||
NetBox object representation. The provided QuerySet will determine the choices available.
|
||||
|
||||
@@ -1,33 +1,57 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE, OBJECTCHANGE_ACTION_DELETE
|
||||
from extras.models import ObjectChange
|
||||
from extras.constants import *
|
||||
from extras.models import CustomField, CustomFieldValue, ObjectChange
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
|
||||
class ChangeLogTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
# Create a custom field on the Site model
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
cf = CustomField(
|
||||
type=CF_TYPE_TEXT,
|
||||
name='my_field',
|
||||
required=False
|
||||
)
|
||||
cf.save()
|
||||
cf.obj_type.set([ct])
|
||||
|
||||
def test_create_object(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC'
|
||||
},
|
||||
'tags': [
|
||||
'bar', 'foo'
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ObjectChange.objects.count(), 1)
|
||||
|
||||
oc = ObjectChange.objects.first()
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
)
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_CREATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
|
||||
|
||||
def test_update_object(self):
|
||||
|
||||
@@ -37,26 +61,43 @@ class ChangeLogTest(APITestCase):
|
||||
data = {
|
||||
'name': 'Test Site X',
|
||||
'slug': 'test-site-x',
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF'
|
||||
},
|
||||
'tags': [
|
||||
'abc', 'xyz'
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ObjectChange.objects.count(), 1)
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(site.name, data['name'])
|
||||
|
||||
oc = ObjectChange.objects.first()
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
oc = ObjectChange.objects.get(
|
||||
changed_object_type=ContentType.objects.get_for_model(Site),
|
||||
changed_object_id=site.pk
|
||||
)
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_UPDATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
|
||||
|
||||
def test_delete_object(self):
|
||||
|
||||
site = Site(name='Test Site 1', slug='test-site-1')
|
||||
site = Site(
|
||||
name='Test Site 1',
|
||||
slug='test-site-1'
|
||||
)
|
||||
site.save()
|
||||
site.tags.add('foo', 'bar')
|
||||
CustomFieldValue.objects.create(
|
||||
field=CustomField.objects.get(name='my_field'),
|
||||
obj=site,
|
||||
value='ABC'
|
||||
)
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
@@ -70,3 +111,5 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, None)
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])
|
||||
|
||||
@@ -99,6 +99,31 @@ class ScriptVariablesTest(TestCase):
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], False)
|
||||
|
||||
def test_choicevar(self):
|
||||
|
||||
CHOICES = (
|
||||
('ff0000', 'Red'),
|
||||
('00ff00', 'Green'),
|
||||
('0000ff', 'Blue')
|
||||
)
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = ChoiceVar(
|
||||
choices=CHOICES
|
||||
)
|
||||
|
||||
# Validate valid choice
|
||||
data = {'var1': CHOICES[0][0]}
|
||||
form = TestScript().as_form(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], CHOICES[0][0])
|
||||
|
||||
# Validate invalid choices
|
||||
data = {'var1': 'taupe'}
|
||||
form = TestScript().as_form(data)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
def test_objectvar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
@@ -17,12 +17,13 @@ DATABASE = {
|
||||
'PASSWORD': '', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
'CONN_MAX_AGE': 300, # Max database connection age
|
||||
}
|
||||
|
||||
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
|
||||
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
|
||||
# symbols. NetBox will not run without this defined. For more information, see
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY
|
||||
SECRET_KEY = ''
|
||||
|
||||
# Redis database settings. The Redis database is used for caching and background processing such as webhooks
|
||||
@@ -106,7 +107,7 @@ EXEMPT_VIEW_PERMISSIONS = [
|
||||
]
|
||||
|
||||
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
|
||||
# https://docs.djangoproject.com/en/1.11/topics/logging/
|
||||
# https://docs.djangoproject.com/en/stable/topics/logging/
|
||||
LOGGING = {}
|
||||
|
||||
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
|
||||
@@ -171,7 +172,7 @@ TIME_ZONE = 'UTC'
|
||||
WEBHOOKS_ENABLED = False
|
||||
|
||||
# Date/time formatting. See the following link for supported formats:
|
||||
# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
|
||||
# https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date
|
||||
DATE_FORMAT = 'N j, Y'
|
||||
SHORT_DATE_FORMAT = 'Y-m-d'
|
||||
TIME_FORMAT = 'g:i a'
|
||||
|
||||
@@ -364,6 +364,7 @@ CACHEOPS = {
|
||||
'auth.user': {'ops': 'get', 'timeout': 60 * 15},
|
||||
'auth.*': {'ops': ('fetch', 'get')},
|
||||
'auth.permission': {'ops': 'all'},
|
||||
'circuits.*': {'ops': 'all'},
|
||||
'dcim.*': {'ops': 'all'},
|
||||
'ipam.*': {'ops': 'all'},
|
||||
'extras.*': {'ops': 'all'},
|
||||
|
||||
@@ -52,10 +52,10 @@
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$.ajax({
|
||||
url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_lldp_neighbors",
|
||||
url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_lldp_neighbors_detail",
|
||||
dataType: 'json',
|
||||
success: function(json) {
|
||||
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
|
||||
$.each(json['get_lldp_neighbors_detail'], function(iface, neighbors) {
|
||||
var neighbor = neighbors[0];
|
||||
var row = $('#' + iface.split(".")[0].replace(/([\/:])/g, "\\$1"));
|
||||
|
||||
@@ -69,8 +69,8 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// Clean up hostnames/interfaces learned via LLDP
|
||||
var neighbor_host = neighbor['hostname'] || ""; // sanitize hostname if it's null to avoid breaking the split func
|
||||
var neighbor_port = neighbor['port'] || ""; // sanitize port if it's null to avoid breaking the split func
|
||||
var neighbor_host = neighbor['remote_system_name'] || ""; // sanitize hostname if it's null to avoid breaking the split func
|
||||
var neighbor_port = neighbor['remote_port'] || ""; // sanitize port if it's null to avoid breaking the split func
|
||||
var lldp_device = neighbor_host.split(".")[0]; // Strip off any trailing domain name
|
||||
var lldp_interface = neighbor_port.split(".")[0]; // Strip off any trailing subinterface ID
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='console-server-port' %}?return_url={{ device.get_absolute_url }}">Console Server Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='console-port' %}?return_url={{ device.get_absolute_url }}">Console Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
|
||||
|
||||
9
netbox/utilities/querysets.py
Normal file
9
netbox/utilities/querysets.py
Normal file
@@ -0,0 +1,9 @@
|
||||
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 all(self):
|
||||
return self._cache
|
||||
@@ -99,7 +99,7 @@ def serialize_object(obj, extra=None):
|
||||
# Include any custom fields
|
||||
if hasattr(obj, 'get_custom_fields'):
|
||||
data['custom_fields'] = {
|
||||
field.name: str(value) for field, value in obj.get_custom_fields().items()
|
||||
field: str(value) for field, value in obj.cf.items()
|
||||
}
|
||||
|
||||
# Include any tags
|
||||
|
||||
Reference in New Issue
Block a user