mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-18 19:32:24 -06:00
Merge branch 'develop-2.9' into 554-object-permissions
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
from django import forms
|
||||
from taggit.forms import TagField
|
||||
|
||||
from dcim.models import Region, Site
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
|
||||
TagField,
|
||||
)
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
|
||||
@@ -276,6 +276,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_NEMA_L620P = 'nema-l6-20p'
|
||||
TYPE_NEMA_L630P = 'nema-l6-30p'
|
||||
TYPE_NEMA_L650P = 'nema-l6-50p'
|
||||
TYPE_NEMA_L1420P = 'nema-l14-20p'
|
||||
TYPE_NEMA_L1430P = 'nema-l14-30p'
|
||||
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
||||
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
||||
# California style
|
||||
TYPE_CS6361C = 'cs6361c'
|
||||
TYPE_CS6365C = 'cs6365c'
|
||||
@@ -337,6 +341,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_NEMA_L620P, 'NEMA L6-20P'),
|
||||
(TYPE_NEMA_L630P, 'NEMA L6-30P'),
|
||||
(TYPE_NEMA_L650P, 'NEMA L6-50P'),
|
||||
(TYPE_NEMA_L1420P, 'NEMA L14-20P'),
|
||||
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
|
||||
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
||||
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
||||
)),
|
||||
('California Style', (
|
||||
(TYPE_CS6361C, 'CS6361C'),
|
||||
@@ -405,6 +413,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_NEMA_L620R = 'nema-l6-20r'
|
||||
TYPE_NEMA_L630R = 'nema-l6-30r'
|
||||
TYPE_NEMA_L650R = 'nema-l6-50r'
|
||||
TYPE_NEMA_L1420R = 'nema-l14-20r'
|
||||
TYPE_NEMA_L1430R = 'nema-l14-30r'
|
||||
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
||||
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
||||
# California style
|
||||
TYPE_CS6360C = 'CS6360C'
|
||||
TYPE_CS6364C = 'CS6364C'
|
||||
@@ -467,6 +479,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_NEMA_L620R, 'NEMA L6-20R'),
|
||||
(TYPE_NEMA_L630R, 'NEMA L6-30R'),
|
||||
(TYPE_NEMA_L650R, 'NEMA L6-50R'),
|
||||
(TYPE_NEMA_L1420R, 'NEMA L14-20R'),
|
||||
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
|
||||
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
||||
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
||||
)),
|
||||
('California Style', (
|
||||
(TYPE_CS6360C, 'CS6360C'),
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.contrib.auth.models import User
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.constants import COLOR_CHOICES
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.filters import (
|
||||
BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
|
||||
NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter,
|
||||
@@ -1084,7 +1084,7 @@ class CableFilterSet(BaseFilterSet):
|
||||
choices=CableStatusChoices
|
||||
)
|
||||
color = django_filters.MultipleChoiceFilter(
|
||||
choices=COLOR_CHOICES
|
||||
choices=ColorChoices
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
|
||||
@@ -9,13 +9,12 @@ from django.utils.safestring import mark_safe
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
from netaddr import EUI
|
||||
from netaddr.core import AddrFormatError
|
||||
from taggit.forms import TagField
|
||||
from timezone_field import TimeZoneFormField
|
||||
|
||||
from circuits.models import Circuit, Provider
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
|
||||
LocalConfigContextFilterForm,
|
||||
LocalConfigContextFilterForm, TagField,
|
||||
)
|
||||
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
|
||||
from ipam.models import IPAddress, VLAN
|
||||
@@ -933,6 +932,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'comments',
|
||||
]
|
||||
|
||||
|
||||
@@ -1957,7 +1957,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||
help_text='Parent device'
|
||||
)
|
||||
device_bay = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
queryset=DeviceBay.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Device bay in which this device is installed'
|
||||
)
|
||||
@@ -1977,6 +1977,20 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
|
||||
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Set parent_bay reverse relationship
|
||||
device_bay = self.cleaned_data.get('device_bay')
|
||||
if device_bay:
|
||||
self.instance.parent_bay = device_bay
|
||||
|
||||
# Inherit site and rack from parent device
|
||||
parent = self.cleaned_data.get('parent')
|
||||
if parent:
|
||||
self.instance.site = parent.site
|
||||
self.instance.rack = parent.rack
|
||||
|
||||
|
||||
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
@@ -3659,6 +3673,11 @@ class CableForm(BootstrapMixin, forms.ModelForm):
|
||||
'type': StaticSelect2,
|
||||
'length_unit': StaticSelect2,
|
||||
}
|
||||
error_messages = {
|
||||
'length': {
|
||||
'max_value': 'Maximum length is 32767 (any unit)'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CableCSVForm(CSVModelForm):
|
||||
|
||||
24
netbox/dcim/migrations/0106_role_default_color.py
Normal file
24
netbox/dcim/migrations/0106_role_default_color.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-26 13:33
|
||||
|
||||
from django.db import migrations
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0105_interface_name_collation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devicerole',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rackrole',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ from dcim.fields import ASNField
|
||||
from dcim.elevations import RackElevationSVG
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object, to_meters
|
||||
@@ -379,7 +380,9 @@ class RackRole(ChangeLoggedModel):
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
color = ColorField()
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
@@ -1190,7 +1193,9 @@ class DeviceRole(ChangeLoggedModel):
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
color = ColorField()
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
vm_role = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='VM Role',
|
||||
@@ -2182,23 +2187,29 @@ class Cable(ChangeLoggedModel):
|
||||
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||
self.termination_a_type, self.termination_b_type
|
||||
))
|
||||
raise ValidationError(
|
||||
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
|
||||
)
|
||||
|
||||
# A RearPort with multiple positions must be connected to a component with an equal number of positions
|
||||
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
|
||||
if self.termination_a.positions != self.termination_b.positions:
|
||||
raise ValidationError(
|
||||
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
|
||||
self.termination_a, self.termination_a.positions,
|
||||
self.termination_b, self.termination_b.positions
|
||||
# A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
|
||||
for term_a, term_b in [
|
||||
(self.termination_a, self.termination_b),
|
||||
(self.termination_b, self.termination_a)
|
||||
]:
|
||||
if isinstance(term_a, RearPort) and term_a.positions > 1:
|
||||
if not isinstance(term_b, RearPort):
|
||||
raise ValidationError(
|
||||
"Rear ports with multiple positions may only be connected to other rear ports"
|
||||
)
|
||||
elif term_a.positions != term_b.positions:
|
||||
raise ValidationError(
|
||||
f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
|
||||
f"Both terminations must have the same number of positions."
|
||||
)
|
||||
)
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
|
||||
@@ -1195,7 +1195,7 @@ class InventoryItemTable(BaseTable):
|
||||
args=[Accessor('device.pk')]
|
||||
)
|
||||
manufacturer = tables.Column(
|
||||
accessor=Accessor('manufacturer.name')
|
||||
accessor=Accessor('manufacturer')
|
||||
)
|
||||
discovered = BooleanColumn()
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ manufacturer: Generic
|
||||
model: TEST-1000
|
||||
slug: test-1000
|
||||
u_height: 2
|
||||
comments: test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
type: de-9
|
||||
@@ -456,6 +457,7 @@ device-bays:
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
dt = DeviceType.objects.get(model='TEST-1000')
|
||||
self.assertEqual(dt.comments, 'test comment')
|
||||
|
||||
# Verify all of the components were created
|
||||
self.assertEqual(dt.consoleport_templates.count(), 3)
|
||||
|
||||
@@ -986,7 +986,7 @@ class DeviceListView(ObjectListView):
|
||||
|
||||
class DeviceView(ObjectView):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
|
||||
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
|
||||
)
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from mptt.forms import TreeNodeMultipleChoiceField
|
||||
from taggit.forms import TagField
|
||||
from taggit.forms import TagField as TagField_
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -142,6 +142,15 @@ class CustomFieldFilterForm(forms.Form):
|
||||
# Tags
|
||||
#
|
||||
|
||||
class TagField(TagField_):
|
||||
|
||||
def widget_attrs(self, widget):
|
||||
# Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags
|
||||
return {
|
||||
'class': 'tagfield'
|
||||
}
|
||||
|
||||
|
||||
class TagForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
@@ -423,11 +432,11 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
|
||||
def __init__(self, vars, *args, commit_default=True, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Dynamically populate fields for variables
|
||||
for name, var in vars.items():
|
||||
self.fields[name] = var.as_field()
|
||||
self.base_fields[name] = var.as_field()
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Toggle default commit behavior based on Meta option
|
||||
if not commit_default:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Generated by Django 1.11 on 2017-04-04 19:58
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import extras.models
|
||||
import extras.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')),
|
||||
('image', models.ImageField(height_field=b'image_height', upload_to=extras.utils.image_upload, width_field=b'image_width')),
|
||||
('image_height', models.PositiveSmallIntegerField()),
|
||||
('image_width', models.PositiveSmallIntegerField()),
|
||||
('name', models.CharField(blank=True, max_length=50)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-05-24 15:34
|
||||
from django.db import migrations, models
|
||||
import extras.models
|
||||
import extras.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -74,7 +74,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='imageattachment',
|
||||
name='image',
|
||||
field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'),
|
||||
field=models.ImageField(height_field='image_height', upload_to=extras.utils.image_upload, width_field='image_width'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='topologymap',
|
||||
|
||||
20
netbox/extras/migrations/0042_customfield_manager.py
Normal file
20
netbox/extras/migrations/0042_customfield_manager.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-07 21:06
|
||||
|
||||
from django.db import migrations
|
||||
import extras.models.customfields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0041_tag_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='customfield',
|
||||
managers=[
|
||||
('objects', extras.models.customfields.CustomFieldManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
25
netbox/extras/models/__init__.py
Normal file
25
netbox/extras/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
|
||||
from .models import (
|
||||
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult,
|
||||
Script, Webhook,
|
||||
)
|
||||
from .tags import Tag, TaggedItem
|
||||
|
||||
__all__ = (
|
||||
'ConfigContext',
|
||||
'ConfigContextModel',
|
||||
'CustomField',
|
||||
'CustomFieldChoice',
|
||||
'CustomFieldModel',
|
||||
'CustomFieldValue',
|
||||
'CustomLink',
|
||||
'ExportTemplate',
|
||||
'Graph',
|
||||
'ImageAttachment',
|
||||
'ObjectChange',
|
||||
'ReportResult',
|
||||
'Script',
|
||||
'Tag',
|
||||
'TaggedItem',
|
||||
'Webhook',
|
||||
)
|
||||
308
netbox/extras/models/customfields.py
Normal file
308
netbox/extras/models/customfields.py
Normal file
@@ -0,0 +1,308 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
|
||||
from extras.choices import *
|
||||
from extras.utils import FeatureQuery
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldModel(models.Model):
|
||||
_cf = None
|
||||
|
||||
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:
|
||||
self.cache_custom_fields()
|
||||
return self._cf
|
||||
|
||||
def get_custom_fields(self):
|
||||
"""
|
||||
Return a dictionary of custom fields for a single object in the form {<field>: value}.
|
||||
"""
|
||||
fields = CustomField.objects.get_for_model(self)
|
||||
|
||||
# If the object exists, populate its custom fields with values
|
||||
if hasattr(self, 'pk'):
|
||||
values = self.custom_field_values.all()
|
||||
values_dict = {cfv.field_id: cfv.value for cfv in values}
|
||||
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
|
||||
else:
|
||||
return OrderedDict([(field, None) for field in fields])
|
||||
|
||||
|
||||
class CustomFieldManager(models.Manager):
|
||||
use_in_migrations = True
|
||||
|
||||
def get_for_model(self, model):
|
||||
"""
|
||||
Return all CustomFields assigned to the given model.
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
|
||||
return self.get_queryset().filter(obj_type=content_type)
|
||||
|
||||
|
||||
class CustomField(models.Model):
|
||||
obj_type = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='custom_fields',
|
||||
verbose_name='Object(s)',
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
help_text='The object(s) to which this field applies.'
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldTypeChoices,
|
||||
default=CustomFieldTypeChoices.TYPE_TEXT
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text='Name of the field as displayed to users (if not provided, '
|
||||
'the field\'s name will be used)'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
help_text='If true, this field is required when creating new objects '
|
||||
'or editing an existing object.'
|
||||
)
|
||||
filter_logic = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldFilterLogicChoices,
|
||||
default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
|
||||
help_text='Loose matches any instance of a given string; exact '
|
||||
'matches the entire field.'
|
||||
)
|
||||
default = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text='Default value for the field. Use "true" or "false" for booleans.'
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100,
|
||||
help_text='Fields with higher weights appear lower in a form.'
|
||||
)
|
||||
|
||||
objects = CustomFieldManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.label or self.name.replace('_', ' ').capitalize()
|
||||
|
||||
def serialize_value(self, value):
|
||||
"""
|
||||
Serialize the given value to a string suitable for storage as a CustomFieldValue
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
return str(int(bool(value)))
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
# Could be date/datetime object or string
|
||||
try:
|
||||
return value.strftime('%Y-%m-%d')
|
||||
except AttributeError:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
# Could be ModelChoiceField or TypedChoiceField
|
||||
return str(value.id) if hasattr(value, 'id') else str(value)
|
||||
return value
|
||||
|
||||
def deserialize_value(self, serialized_value):
|
||||
"""
|
||||
Convert a string into the object it represents depending on the type of field
|
||||
"""
|
||||
if serialized_value == '':
|
||||
return None
|
||||
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
return int(serialized_value)
|
||||
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
return bool(int(serialized_value))
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
# Read date as YYYY-MM-DD
|
||||
return date(*[int(n) for n in serialized_value.split('-')])
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
return self.choices.get(pk=int(serialized_value))
|
||||
return serialized_value
|
||||
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
"""
|
||||
initial = self.default if set_initial else None
|
||||
required = self.required if enforce_required else False
|
||||
|
||||
# Integer
|
||||
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
field = forms.IntegerField(required=required, initial=initial)
|
||||
|
||||
# Boolean
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
choices = (
|
||||
(None, '---------'),
|
||||
(1, 'True'),
|
||||
(0, 'False'),
|
||||
)
|
||||
if initial is not None and initial.lower() in ['true', 'yes', '1']:
|
||||
initial = 1
|
||||
elif initial is not None and initial.lower() in ['false', 'no', '0']:
|
||||
initial = 0
|
||||
else:
|
||||
initial = None
|
||||
field = forms.NullBooleanField(
|
||||
required=required, initial=initial, widget=StaticSelect2(choices=choices)
|
||||
)
|
||||
|
||||
# Date
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
|
||||
|
||||
# Select
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
|
||||
|
||||
if not required:
|
||||
choices = add_blank_choice(choices)
|
||||
|
||||
# Set the initial value to the PK of the default choice, if any
|
||||
if set_initial:
|
||||
default_choice = self.choices.filter(value=self.default).first()
|
||||
if default_choice:
|
||||
initial = default_choice.pk
|
||||
|
||||
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
||||
field = field_class(
|
||||
choices=choices, required=required, initial=initial, widget=StaticSelect2()
|
||||
)
|
||||
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
field = LaxURLField(required=required, initial=initial)
|
||||
|
||||
# Text
|
||||
else:
|
||||
field = forms.CharField(max_length=255, required=required, initial=initial)
|
||||
|
||||
field.model = self
|
||||
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
|
||||
if self.description:
|
||||
field.help_text = self.description
|
||||
|
||||
return field
|
||||
|
||||
|
||||
class CustomFieldValue(models.Model):
|
||||
field = models.ForeignKey(
|
||||
to='extras.CustomField',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='values'
|
||||
)
|
||||
obj_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
obj_id = models.PositiveIntegerField()
|
||||
obj = GenericForeignKey(
|
||||
ct_field='obj_type',
|
||||
fk_field='obj_id'
|
||||
)
|
||||
serialized_value = models.CharField(
|
||||
max_length=255
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
|
||||
unique_together = ('field', 'obj_type', 'obj_id')
|
||||
|
||||
def __str__(self):
|
||||
return '{} {}'.format(self.obj, self.field)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self.field.deserialize_value(self.serialized_value)
|
||||
|
||||
@value.setter
|
||||
def value(self, value):
|
||||
self.serialized_value = self.field.serialize_value(value)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Delete this object if it no longer has a value to store
|
||||
if self.pk and self.value is None:
|
||||
self.delete()
|
||||
else:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomFieldChoice(models.Model):
|
||||
field = models.ForeignKey(
|
||||
to='extras.CustomField',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='choices',
|
||||
limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
|
||||
)
|
||||
value = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100,
|
||||
help_text='Higher weights appear lower in the list'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['field', 'weight', 'value']
|
||||
unique_together = ['field', 'value']
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def clean(self):
|
||||
if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
|
||||
raise ValidationError("Custom field choices can only be assigned to selection fields.")
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
|
||||
pk = self.pk
|
||||
super().delete(using, keep_parents)
|
||||
CustomFieldValue.objects.filter(
|
||||
field__type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
serialized_value=str(pk)
|
||||
).delete()
|
||||
@@ -1,8 +1,6 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -12,37 +10,13 @@ from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.template import Template, Context
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from utilities.fields import ColorField
|
||||
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
|
||||
from utilities.utils import deepmerge, render_jinja2
|
||||
from .choices import *
|
||||
from .constants import *
|
||||
from .querysets import ConfigContextQuerySet
|
||||
from .utils import FeatureQuery
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ConfigContext',
|
||||
'ConfigContextModel',
|
||||
'CustomField',
|
||||
'CustomFieldChoice',
|
||||
'CustomFieldModel',
|
||||
'CustomFieldValue',
|
||||
'CustomLink',
|
||||
'ExportTemplate',
|
||||
'Graph',
|
||||
'ImageAttachment',
|
||||
'ObjectChange',
|
||||
'ReportResult',
|
||||
'Script',
|
||||
'Tag',
|
||||
'TaggedItem',
|
||||
'Webhook',
|
||||
)
|
||||
from extras.choices import *
|
||||
from extras.constants import *
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from extras.utils import FeatureQuery, image_upload
|
||||
|
||||
|
||||
#
|
||||
@@ -174,291 +148,6 @@ class Webhook(models.Model):
|
||||
return json.dumps(context, cls=JSONEncoder)
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldModel(models.Model):
|
||||
_cf = None
|
||||
|
||||
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:
|
||||
self.cache_custom_fields()
|
||||
return self._cf
|
||||
|
||||
def get_custom_fields(self):
|
||||
"""
|
||||
Return a dictionary of custom fields for a single object in the form {<field>: value}.
|
||||
"""
|
||||
|
||||
# Find all custom fields applicable to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
fields = CustomField.objects.filter(obj_type=content_type)
|
||||
|
||||
# If the object exists, populate its custom fields with values
|
||||
if hasattr(self, 'pk'):
|
||||
values = self.custom_field_values.all()
|
||||
values_dict = {cfv.field_id: cfv.value for cfv in values}
|
||||
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
|
||||
else:
|
||||
return OrderedDict([(field, None) for field in fields])
|
||||
|
||||
|
||||
class CustomField(models.Model):
|
||||
obj_type = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='custom_fields',
|
||||
verbose_name='Object(s)',
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
help_text='The object(s) to which this field applies.'
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldTypeChoices,
|
||||
default=CustomFieldTypeChoices.TYPE_TEXT
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text='Name of the field as displayed to users (if not provided, '
|
||||
'the field\'s name will be used)'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
help_text='If true, this field is required when creating new objects '
|
||||
'or editing an existing object.'
|
||||
)
|
||||
filter_logic = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldFilterLogicChoices,
|
||||
default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
|
||||
help_text='Loose matches any instance of a given string; exact '
|
||||
'matches the entire field.'
|
||||
)
|
||||
default = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text='Default value for the field. Use "true" or "false" for booleans.'
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100,
|
||||
help_text='Fields with higher weights appear lower in a form.'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.label or self.name.replace('_', ' ').capitalize()
|
||||
|
||||
def serialize_value(self, value):
|
||||
"""
|
||||
Serialize the given value to a string suitable for storage as a CustomFieldValue
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
return str(int(bool(value)))
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
# Could be date/datetime object or string
|
||||
try:
|
||||
return value.strftime('%Y-%m-%d')
|
||||
except AttributeError:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
# Could be ModelChoiceField or TypedChoiceField
|
||||
return str(value.id) if hasattr(value, 'id') else str(value)
|
||||
return value
|
||||
|
||||
def deserialize_value(self, serialized_value):
|
||||
"""
|
||||
Convert a string into the object it represents depending on the type of field
|
||||
"""
|
||||
if serialized_value == '':
|
||||
return None
|
||||
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
return int(serialized_value)
|
||||
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
return bool(int(serialized_value))
|
||||
if self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
# Read date as YYYY-MM-DD
|
||||
return date(*[int(n) for n in serialized_value.split('-')])
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
return self.choices.get(pk=int(serialized_value))
|
||||
return serialized_value
|
||||
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
"""
|
||||
initial = self.default if set_initial else None
|
||||
required = self.required if enforce_required else False
|
||||
|
||||
# Integer
|
||||
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
field = forms.IntegerField(required=required, initial=initial)
|
||||
|
||||
# Boolean
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||
choices = (
|
||||
(None, '---------'),
|
||||
(1, 'True'),
|
||||
(0, 'False'),
|
||||
)
|
||||
if initial is not None and initial.lower() in ['true', 'yes', '1']:
|
||||
initial = 1
|
||||
elif initial is not None and initial.lower() in ['false', 'no', '0']:
|
||||
initial = 0
|
||||
else:
|
||||
initial = None
|
||||
field = forms.NullBooleanField(
|
||||
required=required, initial=initial, widget=StaticSelect2(choices=choices)
|
||||
)
|
||||
|
||||
# Date
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
|
||||
|
||||
# Select
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
|
||||
|
||||
if not required:
|
||||
choices = add_blank_choice(choices)
|
||||
|
||||
# Set the initial value to the PK of the default choice, if any
|
||||
if set_initial:
|
||||
default_choice = self.choices.filter(value=self.default).first()
|
||||
if default_choice:
|
||||
initial = default_choice.pk
|
||||
|
||||
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
||||
field = field_class(
|
||||
choices=choices, required=required, initial=initial, widget=StaticSelect2()
|
||||
)
|
||||
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
field = LaxURLField(required=required, initial=initial)
|
||||
|
||||
# Text
|
||||
else:
|
||||
field = forms.CharField(max_length=255, required=required, initial=initial)
|
||||
|
||||
field.model = self
|
||||
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
|
||||
if self.description:
|
||||
field.help_text = self.description
|
||||
|
||||
return field
|
||||
|
||||
|
||||
class CustomFieldValue(models.Model):
|
||||
field = models.ForeignKey(
|
||||
to='extras.CustomField',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='values'
|
||||
)
|
||||
obj_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
obj_id = models.PositiveIntegerField()
|
||||
obj = GenericForeignKey(
|
||||
ct_field='obj_type',
|
||||
fk_field='obj_id'
|
||||
)
|
||||
serialized_value = models.CharField(
|
||||
max_length=255
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
|
||||
unique_together = ('field', 'obj_type', 'obj_id')
|
||||
|
||||
def __str__(self):
|
||||
return '{} {}'.format(self.obj, self.field)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self.field.deserialize_value(self.serialized_value)
|
||||
|
||||
@value.setter
|
||||
def value(self, value):
|
||||
self.serialized_value = self.field.serialize_value(value)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Delete this object if it no longer has a value to store
|
||||
if self.pk and self.value is None:
|
||||
self.delete()
|
||||
else:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomFieldChoice(models.Model):
|
||||
field = models.ForeignKey(
|
||||
to='extras.CustomField',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='choices',
|
||||
limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
|
||||
)
|
||||
value = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100,
|
||||
help_text='Higher weights appear lower in the list'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['field', 'weight', 'value']
|
||||
unique_together = ['field', 'value']
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def clean(self):
|
||||
if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
|
||||
raise ValidationError("Custom field choices can only be assigned to selection fields.")
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
|
||||
pk = self.pk
|
||||
super().delete(using, keep_parents)
|
||||
CustomFieldValue.objects.filter(
|
||||
field__type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
serialized_value=str(pk)
|
||||
).delete()
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
#
|
||||
@@ -663,20 +352,6 @@ class ExportTemplate(models.Model):
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
def image_upload(instance, filename):
|
||||
|
||||
path = 'image-attachments/'
|
||||
|
||||
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
|
||||
extension = filename.rsplit('.')[-1].lower()
|
||||
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
|
||||
filename = '.'.join([instance.name, extension])
|
||||
elif instance.name:
|
||||
filename = instance.name
|
||||
|
||||
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
|
||||
|
||||
|
||||
class ImageAttachment(models.Model):
|
||||
"""
|
||||
An uploaded image which is associated with an object.
|
||||
@@ -1038,44 +713,3 @@ class ObjectChange(models.Model):
|
||||
self.object_repr,
|
||||
self.object_data,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
# TODO: figure out a way around this circular import for ObjectChange
|
||||
from utilities.models import ChangeLoggedModel # noqa: E402
|
||||
|
||||
|
||||
class Tag(TagBase, ChangeLoggedModel):
|
||||
color = ColorField(
|
||||
default='9e9e9e'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:tag', args=[self.slug])
|
||||
|
||||
def slugify(self, tag, i=None):
|
||||
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
|
||||
slug = slugify(tag, allow_unicode=True)
|
||||
if i is not None:
|
||||
slug += "_%d" % i
|
||||
return slug
|
||||
|
||||
|
||||
class TaggedItem(GenericTaggedItemBase):
|
||||
tag = models.ForeignKey(
|
||||
to=Tag,
|
||||
related_name="%(app_label)s_%(class)s_items",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
index_together = (
|
||||
("content_type", "object_id")
|
||||
)
|
||||
45
netbox/extras/models/tags.py
Normal file
45
netbox/extras/models/tags.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
class Tag(TagBase, ChangeLoggedModel):
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:tag', args=[self.slug])
|
||||
|
||||
def slugify(self, tag, i=None):
|
||||
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
|
||||
slug = slugify(tag, allow_unicode=True)
|
||||
if i is not None:
|
||||
slug += "_%d" % i
|
||||
return slug
|
||||
|
||||
|
||||
class TaggedItem(GenericTaggedItemBase):
|
||||
tag = models.ForeignKey(
|
||||
to=Tag,
|
||||
related_name="%(app_label)s_%(class)s_items",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
index_together = (
|
||||
("content_type", "object_id")
|
||||
)
|
||||
@@ -92,7 +92,7 @@ class Report(object):
|
||||
self.active_test = None
|
||||
self.failed = False
|
||||
|
||||
self.logger = logging.getLogger(f"netbox.reports.{self.module}.{self.name}")
|
||||
self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
|
||||
|
||||
# Compile test methods and initialize results skeleton
|
||||
test_methods = []
|
||||
@@ -120,7 +120,7 @@ class Report(object):
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return '.'.join([self.module, self.name])
|
||||
return '.'.join([self.__module__, self.__class__.__name__])
|
||||
|
||||
def _log(self, obj, message, level=LOG_DEFAULT):
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,8 @@ def _get_registered_content(obj, method, template_context):
|
||||
'object': obj,
|
||||
'request': template_context['request'],
|
||||
'settings': template_context['settings'],
|
||||
'csrf_token': template_context['csrf_token'],
|
||||
'perms': template_context['perms'],
|
||||
}
|
||||
|
||||
model_name = obj._meta.label_lower
|
||||
|
||||
@@ -99,6 +99,19 @@ class CustomFieldTest(TestCase):
|
||||
cf.delete()
|
||||
|
||||
|
||||
class CustomFieldManagerTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
|
||||
custom_field.save()
|
||||
custom_field.obj_type.set([content_type])
|
||||
|
||||
def test_get_for_model(self):
|
||||
self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
|
||||
self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
|
||||
|
||||
|
||||
class CustomFieldAPITest(APITestCase):
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -22,6 +22,22 @@ def is_taggable(obj):
|
||||
return False
|
||||
|
||||
|
||||
def image_upload(instance, filename):
|
||||
"""
|
||||
Return a path for uploading image attchments.
|
||||
"""
|
||||
path = 'image-attachments/'
|
||||
|
||||
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
|
||||
extension = filename.rsplit('.')[-1].lower()
|
||||
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
|
||||
filename = '.'.join([instance.name, extension])
|
||||
elif instance.name:
|
||||
filename = instance.name
|
||||
|
||||
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
|
||||
|
||||
|
||||
@deconstructible
|
||||
class FeatureQuery:
|
||||
"""
|
||||
|
||||
@@ -118,9 +118,12 @@ class ConfigContextView(ObjectView):
|
||||
# Determine user's preferred output format
|
||||
if request.GET.get('format') in ['json', 'yaml']:
|
||||
format = request.GET.get('format')
|
||||
request.user.config.set('extras.configcontext.format', format, commit=True)
|
||||
else:
|
||||
if request.user.is_authenticated:
|
||||
request.user.config.set('extras.configcontext.format', format, commit=True)
|
||||
elif request.user.is_authenticated:
|
||||
format = request.user.config.get('extras.configcontext.format', 'json')
|
||||
else:
|
||||
format = 'json'
|
||||
|
||||
return render(request, 'extras/configcontext.html', {
|
||||
'configcontext': configcontext,
|
||||
@@ -166,9 +169,12 @@ class ObjectConfigContextView(ObjectView):
|
||||
# Determine user's preferred output format
|
||||
if request.GET.get('format') in ['json', 'yaml']:
|
||||
format = request.GET.get('format')
|
||||
request.user.config.set('extras.configcontext.format', format, commit=True)
|
||||
else:
|
||||
if request.user.is_authenticated:
|
||||
request.user.config.set('extras.configcontext.format', format, commit=True)
|
||||
elif request.user.is_authenticated:
|
||||
format = request.user.config.get('extras.configcontext.format', 'json')
|
||||
else:
|
||||
format = 'json'
|
||||
|
||||
return render(request, 'extras/object_configcontext.html', {
|
||||
model_name: obj,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from django import forms
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from taggit.forms import TagField
|
||||
|
||||
from dcim.models import Device, Interface, Rack, Region, Site
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
|
||||
TagField,
|
||||
)
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
@@ -618,7 +618,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
if self.instance and self.instance.interface:
|
||||
self.fields['interface'].queryset = Interface.objects.filter(
|
||||
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
|
||||
)
|
||||
).prefetch_related(
|
||||
'device__primary_ip4',
|
||||
'device__primary_ip6',
|
||||
'virtual_machine__primary_ip4',
|
||||
'virtual_machine__primary_ip6',
|
||||
) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
|
||||
else:
|
||||
self.fields['interface'].choices = []
|
||||
|
||||
@@ -775,18 +780,6 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set interface
|
||||
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
|
||||
self.instance.interface = Interface.objects.get(
|
||||
device=self.cleaned_data['device'],
|
||||
name=self.cleaned_data['interface_name']
|
||||
)
|
||||
elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
|
||||
self.instance.interface = Interface.objects.get(
|
||||
virtual_machine=self.cleaned_data['virtual_machine'],
|
||||
name=self.cleaned_data['interface_name']
|
||||
)
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
# Set as primary for device/VM
|
||||
|
||||
@@ -640,7 +640,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
'dns_name', 'description',
|
||||
]
|
||||
clone_fields = [
|
||||
'vrf', 'tenant', 'status', 'role', 'description',
|
||||
'vrf', 'tenant', 'status', 'role', 'description', 'interface',
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
|
||||
@@ -378,6 +378,8 @@ class PrefixTable(BaseTable):
|
||||
verbose_name='Pool'
|
||||
)
|
||||
|
||||
add_prefetch = False
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description')
|
||||
@@ -665,6 +667,9 @@ class ServiceTable(BaseTable):
|
||||
viewname='ipam:service',
|
||||
args=[Accessor('pk')]
|
||||
)
|
||||
parent = tables.LinkColumn(
|
||||
order_by=('device', 'virtual_machine')
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:service_list'
|
||||
)
|
||||
|
||||
@@ -108,6 +108,8 @@ EMAIL = {
|
||||
'PORT': 25,
|
||||
'USERNAME': '',
|
||||
'PASSWORD': '',
|
||||
'USE_SSL': False,
|
||||
'USE_TLS': False,
|
||||
'TIMEOUT': 10, # seconds
|
||||
'FROM_EMAIL': '',
|
||||
}
|
||||
@@ -130,6 +132,10 @@ EXEMPT_VIEW_PERMISSIONS = [
|
||||
# 'https': 'http://10.10.1.10:1080',
|
||||
# }
|
||||
|
||||
# IP addresses recognized as internal to the system. The debugging toolbar will be available only to clients accessing
|
||||
# NetBox from an internal IP.
|
||||
INTERNAL_IPS = ('127.0.0.1', '::1')
|
||||
|
||||
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
|
||||
# https://docs.djangoproject.com/en/stable/topics/logging/
|
||||
LOGGING = {}
|
||||
|
||||
@@ -78,6 +78,7 @@ EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
||||
@@ -246,12 +247,16 @@ if SESSION_FILE_PATH is not None:
|
||||
#
|
||||
|
||||
EMAIL_HOST = EMAIL.get('SERVER')
|
||||
EMAIL_PORT = EMAIL.get('PORT', 25)
|
||||
EMAIL_HOST_USER = EMAIL.get('USERNAME')
|
||||
EMAIL_HOST_PASSWORD = EMAIL.get('PASSWORD')
|
||||
EMAIL_PORT = EMAIL.get('PORT', 25)
|
||||
EMAIL_SSL_CERTFILE = EMAIL.get('SSL_CERTFILE')
|
||||
EMAIL_SSL_KEYFILE = EMAIL.get('SSL_KEYFILE')
|
||||
EMAIL_SUBJECT_PREFIX = '[NetBox] '
|
||||
EMAIL_USE_SSL = EMAIL.get('USE_SSL', False)
|
||||
EMAIL_USE_TLS = EMAIL.get('USE_TLS', False)
|
||||
EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10)
|
||||
SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
|
||||
EMAIL_SUBJECT_PREFIX = '[NetBox] '
|
||||
|
||||
|
||||
#
|
||||
@@ -611,15 +616,6 @@ RQ_QUEUES = {
|
||||
'check_releases': RQ_PARAMS,
|
||||
}
|
||||
|
||||
#
|
||||
# Django debug toolbar
|
||||
#
|
||||
|
||||
INTERNAL_IPS = (
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# NetBox internal settings
|
||||
|
||||
@@ -292,9 +292,9 @@ $(document).ready(function() {
|
||||
});
|
||||
|
||||
// API backed tags
|
||||
var tags = $('#id_tags');
|
||||
var tags = $('#id_tags.tagfield');
|
||||
if (tags.length > 0 && tags.val().length > 0){
|
||||
tags = $('#id_tags').val().split(/,\s*/);
|
||||
tags = $('#id_tags.tagfield').val().split(/,\s*/);
|
||||
} else {
|
||||
tags = [];
|
||||
}
|
||||
@@ -306,8 +306,8 @@ $(document).ready(function() {
|
||||
}
|
||||
});
|
||||
// Replace the django issued text input with a select element
|
||||
$('#id_tags').replaceWith('<select name="tags" id="id_tags" class="form-control"></select>');
|
||||
$('#id_tags').select2({
|
||||
$('#id_tags.tagfield').replaceWith('<select name="tags" id="id_tags" class="form-control tagfield"></select>');
|
||||
$('#id_tags.tagfield').select2({
|
||||
tags: true,
|
||||
data: tag_objs,
|
||||
multiple: true,
|
||||
@@ -354,14 +354,14 @@ $(document).ready(function() {
|
||||
}
|
||||
}
|
||||
});
|
||||
$('#id_tags').closest('form').submit(function(event){
|
||||
$('#id_tags.tagfield').closest('form').submit(function(event){
|
||||
// django-taggit can only accept a single comma seperated string value
|
||||
var value = $('#id_tags').val();
|
||||
var value = $('#id_tags.tagfield').val();
|
||||
if (value.length > 0){
|
||||
var final_tags = value.join(', ');
|
||||
$('#id_tags').val(null).trigger('change');
|
||||
$('#id_tags.tagfield').val(null).trigger('change');
|
||||
var option = new Option(final_tags, final_tags, true, true);
|
||||
$('#id_tags').append(option).trigger('change');
|
||||
$('#id_tags.tagfield').append(option).trigger('change');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from Crypto.Cipher import PKCS1_OAEP
|
||||
from Crypto.PublicKey import RSA
|
||||
from django import forms
|
||||
from taggit.forms import TagField
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
|
||||
TagField,
|
||||
)
|
||||
from utilities.forms import (
|
||||
APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
|
||||
|
||||
@@ -10,9 +10,23 @@
|
||||
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
|
||||
<div class="col-md-5">
|
||||
{{ form.length }}
|
||||
{% if form.length.errors %}
|
||||
<ul>
|
||||
{% for error in form.length.errors %}
|
||||
<li class="text-danger">{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.length_unit }}
|
||||
{% if form.length_unit.errors %}
|
||||
<ul>
|
||||
{% for error in form.length_unit.errors %}
|
||||
<li class="text-danger">{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<code>python3 manage.py migrate</code> from the command line.
|
||||
</p>
|
||||
<p>
|
||||
<i class="fa fa-warning"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 9.4 or higher is in use. You
|
||||
<i class="fa fa-warning"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 9.6 or higher is in use. You
|
||||
can check this by connecting to the database using NetBox's credentials and issuing a query for
|
||||
<code>SELECT VERSION()</code>.
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="pull-right noprint">
|
||||
{% block buttons %}{% endblock %}
|
||||
{% if table_config_form %}
|
||||
{% if request.user.is_authenticated and table_config_form %}
|
||||
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#tableconfig" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
|
||||
{% endif %}
|
||||
{% if permissions.add and 'add' in action_buttons %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from django import forms
|
||||
from taggit.forms import TagField
|
||||
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
|
||||
TagField,
|
||||
)
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
|
||||
|
||||
@@ -80,6 +80,70 @@ def unpack_grouped_choices(choices):
|
||||
return unpacked_choices
|
||||
|
||||
|
||||
#
|
||||
# Generic color choices
|
||||
#
|
||||
|
||||
class ColorChoices(ChoiceSet):
|
||||
COLOR_DARK_RED = 'aa1409'
|
||||
COLOR_RED = 'f44336'
|
||||
COLOR_PINK = 'e91e63'
|
||||
COLOR_ROSE = 'ffe4e1'
|
||||
COLOR_FUCHSIA = 'ff66ff'
|
||||
COLOR_PURPLE = '9c27b0'
|
||||
COLOR_DARK_PURPLE = '673ab7'
|
||||
COLOR_INDIGO = '3f51b5'
|
||||
COLOR_BLUE = '2196f3'
|
||||
COLOR_LIGHT_BLUE = '03a9f4'
|
||||
COLOR_CYAN = '00bcd4'
|
||||
COLOR_TEAL = '009688'
|
||||
COLOR_AQUA = '00ffff'
|
||||
COLOR_DARK_GREEN = '2f6a31'
|
||||
COLOR_GREEN = '4caf50'
|
||||
COLOR_LIGHT_GREEN = '8bc34a'
|
||||
COLOR_LIME = 'cddc39'
|
||||
COLOR_YELLOW = 'ffeb3b'
|
||||
COLOR_AMBER = 'ffc107'
|
||||
COLOR_ORANGE = 'ff9800'
|
||||
COLOR_DARK_ORANGE = 'ff5722'
|
||||
COLOR_BROWN = '795548'
|
||||
COLOR_LIGHT_GREY = 'c0c0c0'
|
||||
COLOR_GREY = '9e9e9e'
|
||||
COLOR_DARK_GREY = '607d8b'
|
||||
COLOR_BLACK = '111111'
|
||||
COLOR_WHITE = 'ffffff'
|
||||
|
||||
CHOICES = (
|
||||
(COLOR_DARK_RED, 'Dark red'),
|
||||
(COLOR_RED, 'Red'),
|
||||
(COLOR_PINK, 'Pink'),
|
||||
(COLOR_ROSE, 'Rose'),
|
||||
(COLOR_FUCHSIA, 'Fuchsia'),
|
||||
(COLOR_PURPLE, 'Purple'),
|
||||
(COLOR_DARK_PURPLE, 'Dark purple'),
|
||||
(COLOR_INDIGO, 'Indigo'),
|
||||
(COLOR_BLUE, 'Blue'),
|
||||
(COLOR_LIGHT_BLUE, 'Light blue'),
|
||||
(COLOR_CYAN, 'Cyan'),
|
||||
(COLOR_TEAL, 'Teal'),
|
||||
(COLOR_AQUA, 'Aqua'),
|
||||
(COLOR_DARK_GREEN, 'Dark green'),
|
||||
(COLOR_GREEN, 'Green'),
|
||||
(COLOR_LIGHT_GREEN, 'Light green'),
|
||||
(COLOR_LIME, 'Lime'),
|
||||
(COLOR_YELLOW, 'Yellow'),
|
||||
(COLOR_AMBER, 'Amber'),
|
||||
(COLOR_ORANGE, 'Orange'),
|
||||
(COLOR_DARK_ORANGE, 'Dark orange'),
|
||||
(COLOR_BROWN, 'Brown'),
|
||||
(COLOR_LIGHT_GREY, 'Light grey'),
|
||||
(COLOR_GREY, 'Grey'),
|
||||
(COLOR_DARK_GREY, 'Dark grey'),
|
||||
(COLOR_BLACK, 'Black'),
|
||||
(COLOR_WHITE, 'White'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Button color choices
|
||||
#
|
||||
|
||||
@@ -1,34 +1,3 @@
|
||||
COLOR_CHOICES = (
|
||||
('aa1409', 'Dark red'),
|
||||
('f44336', 'Red'),
|
||||
('e91e63', 'Pink'),
|
||||
('ffe4e1', 'Rose'),
|
||||
('ff66ff', 'Fuschia'),
|
||||
('9c27b0', 'Purple'),
|
||||
('673ab7', 'Dark purple'),
|
||||
('3f51b5', 'Indigo'),
|
||||
('2196f3', 'Blue'),
|
||||
('03a9f4', 'Light blue'),
|
||||
('00bcd4', 'Cyan'),
|
||||
('009688', 'Teal'),
|
||||
('00ffff', 'Aqua'),
|
||||
('2f6a31', 'Dark green'),
|
||||
('4caf50', 'Green'),
|
||||
('8bc34a', 'Light green'),
|
||||
('cddc39', 'Lime'),
|
||||
('ffeb3b', 'Yellow'),
|
||||
('ffc107', 'Amber'),
|
||||
('ff9800', 'Orange'),
|
||||
('ff5722', 'Dark orange'),
|
||||
('795548', 'Brown'),
|
||||
('c0c0c0', 'Light grey'),
|
||||
('9e9e9e', 'Grey'),
|
||||
('607d8b', 'Dark grey'),
|
||||
('111111', 'Black'),
|
||||
('ffffff', 'White'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Filter lookup expressions
|
||||
#
|
||||
|
||||
@@ -14,8 +14,7 @@ from django.forms import BoundField
|
||||
from django.forms.models import fields_for_model
|
||||
from django.urls import reverse
|
||||
|
||||
from .choices import unpack_grouped_choices
|
||||
from .constants import *
|
||||
from .choices import ColorChoices, unpack_grouped_choices
|
||||
from .validators import EnhancedURLValidator
|
||||
|
||||
NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
|
||||
@@ -163,7 +162,7 @@ class ColorSelect(forms.Select):
|
||||
option_template_name = 'widgets/colorselect_option.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['choices'] = add_blank_choice(COLOR_CHOICES)
|
||||
kwargs['choices'] = add_blank_choice(ColorChoices)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'netbox-select2-color-picker'
|
||||
|
||||
@@ -607,15 +606,18 @@ class DynamicModelChoiceMixin:
|
||||
filter = django_filters.ModelChoiceFilter
|
||||
widget = APISelect
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def _get_initial_value(self, initial_data, field_name):
|
||||
return initial_data.get(field_name)
|
||||
|
||||
def get_bound_field(self, form, field_name):
|
||||
bound_field = BoundField(form, self, field_name)
|
||||
|
||||
# Override initial() to allow passing multiple values
|
||||
bound_field.initial = self._get_initial_value(form.initial, field_name)
|
||||
|
||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||
# will be populated on-demand via the APISelect widget.
|
||||
data = self.prepare_value(bound_field.data or bound_field.initial)
|
||||
data = bound_field.value()
|
||||
if data:
|
||||
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
|
||||
self.queryset = filter.filter(self.queryset, data)
|
||||
@@ -648,6 +650,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
|
||||
filter = django_filters.ModelMultipleChoiceFilter
|
||||
widget = APISelectMultiple
|
||||
|
||||
def _get_initial_value(self, initial_data, field_name):
|
||||
# If a QueryDict has been passed as initial form data, get *all* listed values
|
||||
if hasattr(initial_data, 'getlist'):
|
||||
return initial_data.getlist(field_name)
|
||||
return initial_data.get(field_name)
|
||||
|
||||
|
||||
class LaxURLField(forms.URLField):
|
||||
"""
|
||||
|
||||
@@ -50,9 +50,12 @@ def get_paginate_count(request):
|
||||
if 'per_page' in request.GET:
|
||||
try:
|
||||
per_page = int(request.GET.get('per_page'))
|
||||
request.user.config.set('pagination.per_page', per_page, commit=True)
|
||||
if request.user.is_authenticated:
|
||||
request.user.config.set('pagination.per_page', per_page, commit=True)
|
||||
return per_page
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
|
||||
if request.user.is_authenticated:
|
||||
return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
|
||||
return settings.PAGINATE_COUNT
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import django_tables2 as tables
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models import ForeignKey
|
||||
from django.db.models.fields.related import RelatedField
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.data import TableQuerysetData
|
||||
@@ -9,7 +8,13 @@ from django_tables2.data import TableQuerysetData
|
||||
class BaseTable(tables.Table):
|
||||
"""
|
||||
Default table for object lists
|
||||
|
||||
:param add_prefetch: By default, modify the queryset passed to the table upon initialization to automatically
|
||||
prefetch related data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to
|
||||
accommodate PrefixQuerySet.annotate_depth()).
|
||||
"""
|
||||
add_prefetch = True
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-hover table-headings',
|
||||
@@ -50,7 +55,7 @@ class BaseTable(tables.Table):
|
||||
self.sequence.append('actions')
|
||||
|
||||
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
|
||||
if isinstance(self.data, TableQuerysetData):
|
||||
if self.add_prefetch and isinstance(self.data, TableQuerysetData):
|
||||
model = getattr(self.Meta, 'model')
|
||||
prefetch_fields = []
|
||||
for column in self.columns:
|
||||
|
||||
@@ -188,6 +188,13 @@ class ViewTestCases:
|
||||
# Try GET to non-permitted object
|
||||
self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_list_objects_anonymous(self):
|
||||
# Make the request as an unauthenticated user
|
||||
self.client.logout()
|
||||
response = self.client.get(self.model.objects.first().get_absolute_url())
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
class CreateObjectViewTestCase(ModelViewTestCase):
|
||||
"""
|
||||
Create a single new instance.
|
||||
@@ -460,6 +467,13 @@ class ViewTestCases:
|
||||
|
||||
# TODO: Verify that only the permitted object is returned
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_list_objects_anonymous(self):
|
||||
# Make the request as an unauthenticated user
|
||||
self.client.logout()
|
||||
response = self.client.get(self._get_url('list'))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
class BulkCreateObjectsViewTestCase(ModelViewTestCase):
|
||||
"""
|
||||
Create multiple instances using a single form. Expects the creation of three new instances by default.
|
||||
|
||||
@@ -3,6 +3,7 @@ import sys
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
|
||||
@@ -14,6 +15,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template import loader
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.html import escape
|
||||
from django.utils.http import is_safe_url
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -246,7 +248,10 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
permissions[action] = request.user.has_perm(perm_name)
|
||||
|
||||
# Construct the table based on the user's permissions
|
||||
columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
|
||||
if request.user.is_authenticated:
|
||||
columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
|
||||
else:
|
||||
columns = None
|
||||
table = self.table(self.queryset, columns=columns)
|
||||
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
||||
table.columns.show('pk')
|
||||
@@ -270,6 +275,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
@method_decorator(login_required)
|
||||
def post(self, request):
|
||||
|
||||
# Update the user's table configuration
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from taggit.forms import TagField
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
|
||||
@@ -8,6 +7,7 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT
|
||||
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
|
||||
TagField,
|
||||
)
|
||||
from ipam.models import IPAddress, VLAN
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
|
||||
Reference in New Issue
Block a user