1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into develop-2.9

This commit is contained in:
Jeremy Stretch
2020-05-15 11:29:07 -04:00
43 changed files with 658 additions and 462 deletions

View File

@ -32,3 +32,7 @@ This can be setup by first creating a shared directory and then adding this line
``` ```
environment=prometheus_multiproc_dir=/tmp/prometheus_metrics environment=prometheus_multiproc_dir=/tmp/prometheus_metrics
``` ```
#### Accuracy
If having accurate long-term metrics in a multiprocess environment is important to you then it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).

View File

@ -33,7 +33,6 @@ Within each report class, we'll create a number of test methods to execute our r
``` ```
from dcim.choices import DeviceStatusChoices from dcim.choices import DeviceStatusChoices
from dcim.constants import CONNECTION_STATUS_PLANNED
from dcim.models import ConsolePort, Device, PowerPort from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report from extras.reports import Report
@ -51,7 +50,7 @@ class DeviceConnectionsReport(Report):
console_port.device, console_port.device,
"No console connection defined for {}".format(console_port.name) "No console connection defined for {}".format(console_port.name)
) )
elif console_port.connection_status == CONNECTION_STATUS_PLANNED: elif not console_port.connection_status:
self.log_warning( self.log_warning(
console_port.device, console_port.device,
"Console connection for {} marked as planned".format(console_port.name) "Console connection for {} marked as planned".format(console_port.name)
@ -67,7 +66,7 @@ class DeviceConnectionsReport(Report):
for power_port in PowerPort.objects.filter(device=device): for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None: if power_port.connected_endpoint is not None:
connected_ports += 1 connected_ports += 1
if power_port.connection_status == CONNECTION_STATUS_PLANNED: if not power_port.connection_status:
self.log_warning( self.log_warning(
device, device,
"Power connection for {} marked as planned".format(power_port.name) "Power connection for {} marked as planned".format(power_port.name)

View File

@ -2,18 +2,7 @@
The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API. The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API.
## Tokens {!docs/models/users/token.md!}
A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
!!! note
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
## Authenticating to the API ## Authenticating to the API

View File

@ -108,16 +108,20 @@ The file path to NetBox's documentation. This is used when presenting context-se
## EMAIL ## EMAIL
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting: In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter:
* SERVER - Host name or IP address of the email server (use `localhost` if running locally) * `SERVER` - Host name or IP address of the email server (use `localhost` if running locally)
* PORT - TCP port to use for the connection (default: 25) * `PORT` - TCP port to use for the connection (default: `25`)
* USERNAME - Username with which to authenticate * `USERNAME` - Username with which to authenticate
* PASSSWORD - Password with which to authenticate * `PASSSWORD` - Password with which to authenticate
* TIMEOUT - Amount of time to wait for a connection (seconds) * `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`.
* FROM_EMAIL - Sender address for emails sent by NetBox * `USE_TLS` - Use TLS when connecting to the server (default: `False`). Mutually exclusive with `USE_SSL`.
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)
* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional)
* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`)
* `FROM_EMAIL` - Sender address for emails sent by NetBox (default: `root@localhost`)
Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail): Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
``` ```
# python ./manage.py nbshell # python ./manage.py nbshell

View File

@ -49,7 +49,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache | | HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI | | WSGI service | gunicorn or uWSGI |
| Application | Django/Python | | Application | Django/Python |
| Database | PostgreSQL 9.4+ | | Database | PostgreSQL 9.6+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |
| Live device access | NAPALM | | Live device access | NAPALM |

View File

@ -3,7 +3,7 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning !!! warning
NetBox requires PostgreSQL 9.4 or higher. Please note that MySQL and other relational databases are **not** supported. NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported.
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
```no-highlight ```no-highlight
# sudo -u postgres psql # sudo -u postgres psql
psql (9.4.5) psql (10.10)
Type "help" for help. Type "help" for help.
postgres=# CREATE DATABASE netbox; postgres=# CREATE DATABASE netbox;

View File

@ -78,7 +78,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
CentOS users may need to create the `netbox` group first. CentOS users may need to create the `netbox` group first.
``` ```
# adduser --system --group netbox # groupadd --system netbox
# adduser --system --gid netbox netbox
# chown --recursive netbox /opt/netbox/netbox/media/ # chown --recursive netbox /opt/netbox/netbox/media/
``` ```

View File

@ -0,0 +1,12 @@
## Tokens
A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
!!! note
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.

View File

@ -1,5 +1,47 @@
# NetBox v2.8 # NetBox v2.8
v2.8.5 (FUTURE)
**Note:** The minimum required version of PostgreSQL is now 9.6.
### Bug Fixes
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses
---
v2.8.4 (2020-05-13)
### Enhancements
* [#4632](https://github.com/netbox-community/netbox/issues/4632) - Extend email configuration parameters to support SSL/TLS
### Bug Fixes
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
* [#4613](https://github.com/netbox-community/netbox/issues/4613) - Fix tag assignment on config contexts (regression from #4527)
* [#4617](https://github.com/netbox-community/netbox/issues/4617) - Restore IP prefix depth notation in list view
* [#4629](https://github.com/netbox-community/netbox/issues/4629) - Replicate assigned interface when cloning IP addresses
* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
* [#4634](https://github.com/netbox-community/netbox/issues/4634) - Inventory Item List view exception caused by incorrect accessor definition
---
## v2.8.3 (2020-05-06)
### Bug Fixes
* [#4593](https://github.com/netbox-community/netbox/issues/4593) - Fix AttributeError exception when viewing object lists as a non-authenticated user
---
## v2.8.2 (2020-05-06) ## v2.8.2 (2020-05-06)
### Enhancements ### Enhancements

View File

@ -1,9 +1,9 @@
from django import forms from django import forms
from taggit.forms import TagField
from dcim.models import Region, Site from dcim.models import Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
TagField,
) )
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant

View File

@ -9,13 +9,12 @@ from django.utils.safestring import mark_safe
from mptt.forms import TreeNodeChoiceField from mptt.forms import TreeNodeChoiceField
from netaddr import EUI from netaddr import EUI
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from taggit.forms import TagField
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
from circuits.models import Circuit, Provider from circuits.models import Circuit, Provider
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
LocalConfigContextFilterForm, LocalConfigContextFilterForm, TagField,
) )
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
@ -1957,7 +1956,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
help_text='Parent device' help_text='Parent device'
) )
device_bay = CSVModelChoiceField( device_bay = CSVModelChoiceField(
queryset=Device.objects.all(), queryset=DeviceBay.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Device bay in which this device is installed' help_text='Device bay in which this device is installed'
) )
@ -1977,6 +1976,20 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')} params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params) 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): class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
@ -3659,6 +3672,11 @@ class CableForm(BootstrapMixin, forms.ModelForm):
'type': StaticSelect2, 'type': StaticSelect2,
'length_unit': StaticSelect2, 'length_unit': StaticSelect2,
} }
error_messages = {
'length': {
'max_value': 'Maximum length is 32767 (any unit)'
}
}
class CableCSVForm(CSVModelForm): class CableCSVForm(CSVModelForm):

View File

@ -2182,23 +2182,29 @@ class Cable(ChangeLoggedModel):
# Check that termination types are compatible # Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format( raise ValidationError(
self.termination_a_type, self.termination_b_type 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 # A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort): for term_a, term_b in [
if self.termination_a.positions != self.termination_b.positions: (self.termination_a, self.termination_b),
raise ValidationError( (self.termination_b, self.termination_a)
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format( ]:
self.termination_a, self.termination_a.positions, if isinstance(term_a, RearPort) and term_a.positions > 1:
self.termination_b, self.termination_b.positions 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 # A termination point cannot be connected to itself
if self.termination_a == self.termination_b: 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 # A front port cannot be connected to its corresponding rear port
if ( if (

View File

@ -1195,7 +1195,7 @@ class InventoryItemTable(BaseTable):
args=[Accessor('device.pk')] args=[Accessor('device.pk')]
) )
manufacturer = tables.Column( manufacturer = tables.Column(
accessor=Accessor('manufacturer.name') accessor=Accessor('manufacturer')
) )
discovered = BooleanColumn() discovered = BooleanColumn()

View File

@ -1105,7 +1105,7 @@ class DeviceView(PermissionRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
device = get_object_or_404(Device.objects.prefetch_related( device = get_object_or_404(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'
), pk=pk) ), pk=pk)
# VirtualChassis members # VirtualChassis members

View File

@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from mptt.forms import TreeNodeMultipleChoiceField 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 dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -142,6 +142,15 @@ class CustomFieldFilterForm(forms.Form):
# Tags # 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): class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField() slug = SlugField()

View File

@ -2,7 +2,7 @@
# Generated by Django 1.11 on 2017-04-04 19:58 # Generated by Django 1.11 on 2017-04-04 19:58
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import extras.models import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()), ('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_height', models.PositiveSmallIntegerField()),
('image_width', models.PositiveSmallIntegerField()), ('image_width', models.PositiveSmallIntegerField()),
('name', models.CharField(blank=True, max_length=50)), ('name', models.CharField(blank=True, max_length=50)),

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34 # Generated by Django 1.11 on 2017-05-24 15:34
from django.db import migrations, models from django.db import migrations, models
import extras.models import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -74,7 +74,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='imageattachment', model_name='imageattachment',
name='image', 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( migrations.AlterField(
model_name='topologymap', model_name='topologymap',

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

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

View 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()

View File

@ -1,8 +1,6 @@
import json import json
from collections import OrderedDict from collections import OrderedDict
from datetime import date
from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -12,37 +10,13 @@ from django.db import models
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Template, Context from django.template import Template, Context
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify
from rest_framework.utils.encoders import JSONEncoder 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 utilities.utils import deepmerge, render_jinja2
from .choices import * from extras.choices import *
from .constants import * from extras.constants import *
from .querysets import ConfigContextQuerySet from extras.querysets import ConfigContextQuerySet
from .utils import FeatureQuery from extras.utils import FeatureQuery, image_upload
__all__ = (
'ConfigContext',
'ConfigContextModel',
'CustomField',
'CustomFieldChoice',
'CustomFieldModel',
'CustomFieldValue',
'CustomLink',
'ExportTemplate',
'Graph',
'ImageAttachment',
'ObjectChange',
'ReportResult',
'Script',
'Tag',
'TaggedItem',
'Webhook',
)
# #
@ -174,291 +148,6 @@ class Webhook(models.Model):
return json.dumps(context, cls=JSONEncoder) 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 # Custom links
# #
@ -663,20 +352,6 @@ class ExportTemplate(models.Model):
# Image attachments # 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): class ImageAttachment(models.Model):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.
@ -1038,44 +713,3 @@ class ObjectChange(models.Model):
self.object_repr, self.object_repr,
self.object_data, 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")
)

View File

@ -0,0 +1,44 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
from utilities.models import ChangeLoggedModel
#
# Tags
#
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")
)

View File

@ -92,7 +92,7 @@ class Report(object):
self.active_test = None self.active_test = None
self.failed = False 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 # Compile test methods and initialize results skeleton
test_methods = [] test_methods = []
@ -120,7 +120,7 @@ class Report(object):
@property @property
def full_name(self): 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): def _log(self, obj, message, level=LOG_DEFAULT):
""" """

View File

@ -99,6 +99,19 @@ class CustomFieldTest(TestCase):
cf.delete() 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): class CustomFieldAPITest(APITestCase):
@classmethod @classmethod

View File

@ -22,6 +22,22 @@ def is_taggable(obj):
return False 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 @deconstructible
class FeatureQuery: class FeatureQuery:
""" """

View File

@ -124,9 +124,12 @@ class ConfigContextView(PermissionRequiredMixin, View):
# Determine user's preferred output format # Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']: if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format') format = request.GET.get('format')
request.user.config.set('extras.configcontext.format', format, commit=True) if request.user.is_authenticated:
else: request.user.config.set('extras.configcontext.format', format, commit=True)
elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json') format = request.user.config.get('extras.configcontext.format', 'json')
else:
format = 'json'
return render(request, 'extras/configcontext.html', { return render(request, 'extras/configcontext.html', {
'configcontext': configcontext, 'configcontext': configcontext,
@ -181,9 +184,12 @@ class ObjectConfigContextView(View):
# Determine user's preferred output format # Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']: if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format') format = request.GET.get('format')
request.user.config.set('extras.configcontext.format', format, commit=True) if request.user.is_authenticated:
else: request.user.config.set('extras.configcontext.format', format, commit=True)
elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json') format = request.user.config.get('extras.configcontext.format', 'json')
else:
format = 'json'
return render(request, 'extras/object_configcontext.html', { return render(request, 'extras/object_configcontext.html', {
model_name: obj, model_name: obj,

View File

@ -1,10 +1,10 @@
from django import forms from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField
from dcim.models import Device, Interface, Rack, Region, Site from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
TagField,
) )
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
@ -618,7 +618,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
if self.instance and self.instance.interface: if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter( self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine 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: else:
self.fields['interface'].choices = [] self.fields['interface'].choices = []
@ -775,18 +780,6 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
def save(self, *args, **kwargs): 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) ipaddress = super().save(*args, **kwargs)
# Set as primary for device/VM # Set as primary for device/VM

View File

@ -640,7 +640,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
'dns_name', 'description', 'dns_name', 'description',
] ]
clone_fields = [ clone_fields = [
'vrf', 'tenant', 'status', 'role', 'description', 'vrf', 'tenant', 'status', 'role', 'description', 'interface',
] ]
STATUS_CLASS_MAP = { STATUS_CLASS_MAP = {

View File

@ -378,6 +378,8 @@ class PrefixTable(BaseTable):
verbose_name='Pool' verbose_name='Pool'
) )
add_prefetch = False
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Prefix model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description') fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description')
@ -665,6 +667,9 @@ class ServiceTable(BaseTable):
viewname='ipam:service', viewname='ipam:service',
args=[Accessor('pk')] args=[Accessor('pk')]
) )
parent = tables.LinkColumn(
order_by=('device', 'virtual_machine')
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:service_list' url_name='ipam:service_list'
) )

View File

@ -108,6 +108,8 @@ EMAIL = {
'PORT': 25, 'PORT': 25,
'USERNAME': '', 'USERNAME': '',
'PASSWORD': '', 'PASSWORD': '',
'USE_SSL': False,
'USE_TLS': False,
'TIMEOUT': 10, # seconds 'TIMEOUT': 10, # seconds
'FROM_EMAIL': '', 'FROM_EMAIL': '',
} }

View File

@ -246,12 +246,16 @@ if SESSION_FILE_PATH is not None:
# #
EMAIL_HOST = EMAIL.get('SERVER') EMAIL_HOST = EMAIL.get('SERVER')
EMAIL_PORT = EMAIL.get('PORT', 25)
EMAIL_HOST_USER = EMAIL.get('USERNAME') EMAIL_HOST_USER = EMAIL.get('USERNAME')
EMAIL_HOST_PASSWORD = EMAIL.get('PASSWORD') 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) EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10)
SERVER_EMAIL = EMAIL.get('FROM_EMAIL') SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
EMAIL_SUBJECT_PREFIX = '[NetBox] '
# #

View File

@ -292,9 +292,9 @@ $(document).ready(function() {
}); });
// API backed tags // API backed tags
var tags = $('#id_tags'); var tags = $('#id_tags.tagfield');
if (tags.length > 0 && tags.val().length > 0){ if (tags.length > 0 && tags.val().length > 0){
tags = $('#id_tags').val().split(/,\s*/); tags = $('#id_tags.tagfield').val().split(/,\s*/);
} else { } else {
tags = []; tags = [];
} }
@ -306,8 +306,8 @@ $(document).ready(function() {
} }
}); });
// Replace the django issued text input with a select element // 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.tagfield').replaceWith('<select name="tags" id="id_tags" class="form-control tagfield"></select>');
$('#id_tags').select2({ $('#id_tags.tagfield').select2({
tags: true, tags: true,
data: tag_objs, data: tag_objs,
multiple: true, 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 // 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){ if (value.length > 0){
var final_tags = value.join(', '); 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); var option = new Option(final_tags, final_tags, true, true);
$('#id_tags').append(option).trigger('change'); $('#id_tags.tagfield').append(option).trigger('change');
} }
}); });

View File

@ -1,11 +1,11 @@
from Crypto.Cipher import PKCS1_OAEP from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from django import forms from django import forms
from taggit.forms import TagField
from dcim.models import Device from dcim.models import Device
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
TagField,
) )
from utilities.forms import ( from utilities.forms import (
APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,

View File

@ -10,9 +10,23 @@
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label> <label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-5"> <div class="col-md-5">
{{ form.length }} {{ form.length }}
{% if form.length.errors %}
<ul>
{% for error in form.length.errors %}
<li class="text-danger">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
{{ form.length_unit }} {{ 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> </div>
</div> </div>

View File

@ -10,7 +10,7 @@
<code>python3 manage.py migrate</code> from the command line. <code>python3 manage.py migrate</code> from the command line.
</p> </p>
<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 can check this by connecting to the database using NetBox's credentials and issuing a query for
<code>SELECT VERSION()</code>. <code>SELECT VERSION()</code>.
</p> </p>

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="pull-right noprint"> <div class="pull-right noprint">
{% block buttons %}{% endblock %} {% 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> <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 %} {% endif %}
{% if permissions.add and 'add' in action_buttons %} {% if permissions.add and 'add' in action_buttons %}

View File

@ -1,8 +1,8 @@
from django import forms from django import forms
from taggit.forms import TagField
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
TagField,
) )
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,

View File

@ -50,9 +50,12 @@ def get_paginate_count(request):
if 'per_page' in request.GET: if 'per_page' in request.GET:
try: try:
per_page = int(request.GET.get('per_page')) 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 return per_page
except ValueError: except ValueError:
pass 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

View File

@ -1,6 +1,5 @@
import django_tables2 as tables import django_tables2 as tables
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db.models import ForeignKey
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_tables2.data import TableQuerysetData from django_tables2.data import TableQuerysetData
@ -9,7 +8,13 @@ from django_tables2.data import TableQuerysetData
class BaseTable(tables.Table): class BaseTable(tables.Table):
""" """
Default table for object lists 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: class Meta:
attrs = { attrs = {
'class': 'table table-hover table-headings', 'class': 'table table-hover table-headings',
@ -50,7 +55,7 @@ class BaseTable(tables.Table):
self.sequence.append('actions') self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched # 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') model = getattr(self.Meta, 'model')
prefetch_fields = [] prefetch_fields = []
for column in self.columns: for column in self.columns:

View File

@ -164,6 +164,13 @@ class ViewTestCases:
response = self.client.get(instance.get_absolute_url()) response = self.client.get(instance.get_absolute_url())
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
@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): class CreateObjectViewTestCase(ModelViewTestCase):
""" """
Create a single new instance. Create a single new instance.
@ -287,6 +294,13 @@ class ViewTestCases:
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv') self.assertEqual(response.get('Content-Type'), 'text/csv')
@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): class BulkCreateObjectsViewTestCase(ModelViewTestCase):
""" """
Create multiple instances using a single form. Expects the creation of three new instances by default. Create multiple instances using a single form. Expects the creation of three new instances by default.

View File

@ -3,6 +3,7 @@ import sys
from copy import deepcopy from copy import deepcopy
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ValidationError from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
@ -13,6 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template import loader from django.template import loader
from django.template.exceptions import TemplateDoesNotExist from django.template.exceptions import TemplateDoesNotExist
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.html import escape from django.utils.html import escape
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -164,7 +166,10 @@ class ObjectListView(View):
permissions[action] = request.user.has_perm(perm_name) permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions # 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) table = self.table(self.queryset, columns=columns)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk') table.columns.show('pk')
@ -188,6 +193,7 @@ class ObjectListView(View):
return render(request, self.template_name, context) return render(request, self.template_name, context)
@method_decorator(login_required)
def post(self, request): def post(self, request):
# Update the user's table configuration # Update the user's table configuration

View File

@ -1,6 +1,5 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from taggit.forms import TagField
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN 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 dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
TagField,
) )
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm

View File

@ -6,7 +6,7 @@ django-filter==2.2.0
django-mptt==0.11.0 django-mptt==0.11.0
django-pglocks==1.0.4 django-pglocks==1.0.4
django-prometheus==2.0.0 django-prometheus==2.0.0
django-rq==2.3.1 django-rq==2.3.2
django-tables2==2.3.1 django-tables2==2.3.1
django-taggit==1.2.0 django-taggit==1.2.0
django-taggit-serializer==0.1.7 django-taggit-serializer==0.1.7