mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 11:42:52 -06:00
Merge branch 'develop-2.9' into 554-object-permissions
This commit is contained in:
commit
03da9348e5
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -30,8 +30,7 @@ about: Report a reproducible bug in the current release of NetBox
|
||||
library such as pynetbox.
|
||||
-->
|
||||
### Steps to Reproduce
|
||||
1. Disable any installed plugins by commenting out the `PLUGINS` setting in
|
||||
`configuration.py`.
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
|
@ -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
|
||||
```
|
||||
|
||||
#### 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).
|
@ -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.constants import CONNECTION_STATUS_PLANNED
|
||||
from dcim.models import ConsolePort, Device, PowerPort
|
||||
from extras.reports import Report
|
||||
|
||||
@ -51,7 +50,7 @@ class DeviceConnectionsReport(Report):
|
||||
console_port.device,
|
||||
"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(
|
||||
console_port.device,
|
||||
"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):
|
||||
if power_port.connected_endpoint is not None:
|
||||
connected_ports += 1
|
||||
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
|
||||
if not power_port.connection_status:
|
||||
self.log_warning(
|
||||
device,
|
||||
"Power connection for {} marked as planned".format(power_port.name)
|
||||
|
@ -2,18 +2,7 @@
|
||||
|
||||
The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API.
|
||||
|
||||
## 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.
|
||||
{!docs/models/users/token.md!}
|
||||
|
||||
## Authenticating to the API
|
||||
|
||||
|
@ -86,7 +86,12 @@ CORS_ORIGIN_WHITELIST = [
|
||||
|
||||
Default: False
|
||||
|
||||
This setting enables debugging. This should be done only during development or troubleshooting. Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
|
||||
This setting enables debugging. This should be done only during development or troubleshooting. Note that only clients
|
||||
which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user
|
||||
interface.
|
||||
|
||||
!!! warning
|
||||
Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
|
||||
|
||||
---
|
||||
|
||||
@ -108,16 +113,20 @@ The file path to NetBox's documentation. This is used when presenting context-se
|
||||
|
||||
## 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)
|
||||
* PORT - TCP port to use for the connection (default: 25)
|
||||
* USERNAME - Username with which to authenticate
|
||||
* PASSSWORD - Password with which to authenticate
|
||||
* TIMEOUT - Amount of time to wait for a connection (seconds)
|
||||
* FROM_EMAIL - Sender address for emails sent by NetBox
|
||||
* `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`)
|
||||
* `USERNAME` - Username with which to authenticate
|
||||
* `PASSSWORD` - Password with which to authenticate
|
||||
* `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`.
|
||||
* `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
|
||||
@ -180,6 +189,16 @@ HTTP_PROXIES = {
|
||||
|
||||
---
|
||||
|
||||
## INTERNAL_IPS
|
||||
|
||||
Default: `('127.0.0.1', '::1',)`
|
||||
|
||||
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
|
||||
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
|
||||
addresses (and [`DEBUG`](#debug) is true).
|
||||
|
||||
---
|
||||
|
||||
## LOGGING
|
||||
|
||||
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
|
||||
@ -381,7 +400,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
||||
|
||||
## REMOTE_AUTH_AUTO_CREATE_USER
|
||||
|
||||
Default: `True`
|
||||
Default: `False`
|
||||
|
||||
If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
|
@ -49,7 +49,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
| HTTP service | nginx or Apache |
|
||||
| WSGI service | gunicorn or uWSGI |
|
||||
| Application | Django/Python |
|
||||
| Database | PostgreSQL 9.4+ |
|
||||
| Database | PostgreSQL 9.6+ |
|
||||
| Task queuing | Redis/django-rq |
|
||||
| Live device access | NAPALM |
|
||||
|
||||
|
@ -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).
|
||||
|
||||
!!! 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.
|
||||
|
||||
@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
|
||||
|
||||
```no-highlight
|
||||
# sudo -u postgres psql
|
||||
psql (9.4.5)
|
||||
psql (10.10)
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
|
@ -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.
|
||||
|
||||
```
|
||||
# adduser --system --group netbox
|
||||
# groupadd --system netbox
|
||||
# adduser --system --gid netbox netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
|
12
docs/models/users/token.md
Normal file
12
docs/models/users/token.md
Normal 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.
|
@ -1,5 +1,58 @@
|
||||
# NetBox v2.8
|
||||
|
||||
## v2.8.5 (2020-05-26)
|
||||
|
||||
**Note:** The minimum required version of PostgreSQL is now 9.6.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4650](https://github.com/netbox-community/netbox/issues/4650) - Expose `INTERNAL_IPS` configuration parameter
|
||||
* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates
|
||||
* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates
|
||||
* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
|
||||
* [#4672](https://github.com/netbox-community/netbox/issues/4672) - Set default color for rack and devices roles
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
|
||||
* [#4525](https://github.com/netbox-community/netbox/issues/4525) - Allow passing initial data to custom script MultiObjectVar
|
||||
* [#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
|
||||
* [#4676](https://github.com/netbox-community/netbox/issues/4676) - Set default value of `REMOTE_AUTH_AUTO_CREATE_USER` as `False` in docs
|
||||
* [#4684](https://github.com/netbox-community/netbox/issues/4684) - Respect `comments` field when importing device type in YAML/JSON format
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
### Enhancements
|
||||
|
@ -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
|
||||
))
|
||||
|
||||
# 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
|
||||
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
|
||||
)
|
||||
|
||||
# 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')
|
||||
if request.user.is_authenticated:
|
||||
request.user.config.set('extras.configcontext.format', format, commit=True)
|
||||
else:
|
||||
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')
|
||||
if request.user.is_authenticated:
|
||||
request.user.config.set('extras.configcontext.format', format, commit=True)
|
||||
else:
|
||||
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'))
|
||||
if request.user.is_authenticated:
|
||||
request.user.config.set('pagination.per_page', per_page, commit=True)
|
||||
return per_page
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
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
|
||||
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
|
||||
|
@ -6,7 +6,7 @@ django-filter==2.2.0
|
||||
django-mptt==0.11.0
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.0.0
|
||||
django-rq==2.3.1
|
||||
django-rq==2.3.2
|
||||
django-tables2==2.3.1
|
||||
django-taggit==1.2.0
|
||||
django-taggit-serializer==0.1.7
|
||||
|
Loading…
Reference in New Issue
Block a user