Compare commits

...

101 Commits

Author SHA1 Message Date
Jeremy Stretch
b99704082b Merge pull request #569 from digitalocean/develop
Release v1.6.1
2016-09-21 10:14:40 -04:00
Jeremy Stretch
75d8852bf7 Release v1.6.1 2016-09-21 09:55:57 -04:00
Jeremy Stretch
0444ac7db9 Introduced NullableModelMultipleChoiceField to allow null filtering without causing introspection issues during database migrations 2016-09-20 15:48:58 -04:00
Jeremy Stretch
b2684aeefc status filter fields should not be required 2016-09-20 11:29:30 -04:00
Jeremy Stretch
6ccc6244dd Corrected PrefixFilterForm 2016-09-20 11:25:16 -04:00
Jeremy Stretch
e618bf40ec Reimplemented FilterChoiceField 2016-09-20 11:08:25 -04:00
Jeremy Stretch
e3f0a12313 PEP8 fix 2016-09-19 16:21:42 -04:00
Jeremy Stretch
687e68db69 Fixes #564: Display custom fields for all applicable objects 2016-09-19 16:13:02 -04:00
Jeremy Stretch
b10e29aaac Closes #561: Make custom fields accessible from within export templates 2016-09-19 16:11:37 -04:00
Jeremy Stretch
d0c92b4f8a Removed obsolete dependency 2016-09-19 10:32:38 -04:00
Jeremy Stretch
513408f16a Fixes #562: Fixed bulk interface creation 2016-09-19 10:31:40 -04:00
Jeremy Stretch
64326e7c9d Closes #552: Added a None filter option for custom select fields 2016-09-16 13:42:07 -04:00
Jeremy Stretch
ce9d853883 Closes #415: Added an expand/collapse toggle button to the prefix list 2016-09-16 11:50:02 -04:00
Jeremy Stretch
814a0e7344 Tweak to #493 2016-09-16 10:31:42 -04:00
Jeremy Stretch
2c7c0ce29d Merge pull request #493 from stianvi/csv_reader_unicode
Fixed csv reader to handle special characters
2016-09-16 10:30:27 -04:00
Jeremy Stretch
2015d08407 Merge pull request #555 from rfdrake/develop
Fix for Docker ldap
2016-09-16 09:59:43 -04:00
Jeremy Stretch
9dea5656ad Added 'none' options to filters for optional fields 2016-09-15 17:12:53 -04:00
Jeremy Stretch
daadf7a49b Fixes #557: Add 'global' choice to VRF filter for prefixes and IP addresses 2016-09-15 16:03:53 -04:00
Jeremy Stretch
2567412121 Fixes #531: Order prefixes by VRF assignment 2016-09-15 12:09:54 -04:00
Jeremy Stretch
5e4fce248c Fixes #558: Update slug field when name is populated without a key press 2016-09-15 11:36:45 -04:00
Jeremy Stretch
824d2d8205 Implemented FilterChoiceField and get_filter_choices() to reduce filter form boilerplate 2016-09-14 16:27:26 -04:00
Robert Drake
9718895ff9 add django-auth-ldap to Dockerfile 2016-09-14 13:32:54 -04:00
Robert Drake
9eec975800 change ldap.py to ldap_config.py 2016-09-14 13:32:54 -04:00
Jeremy Stretch
5601be87f7 Merge pull request #551 from digitalocean/docker-build-perms
Fix permissions on docker-build.sh
2016-09-13 13:15:57 -04:00
Zach Moody
440610836a fixes permissions on docker-build.sh 2016-09-13 11:27:04 -05:00
Jeremy Stretch
4fa536b940 Post-release version bump 2016-09-13 12:16:42 -04:00
Jeremy Stretch
aeec678ce9 Merge pull request #550 from digitalocean/develop
Release v1.6.0
2016-09-13 12:16:00 -04:00
Jeremy Stretch
9591fb9330 PEP8 fix 2016-09-13 12:10:21 -04:00
Jeremy Stretch
cbfdd5dbd1 Relase v1.6.0 2016-09-13 12:08:36 -04:00
Jeremy Stretch
cd2fa6ed78 Simplified FibreChannel interface form factors 2016-09-13 11:21:15 -04:00
Jeremy Stretch
bef9a0c77f Added address family filters to aggregate and prefix lists 2016-09-13 10:35:55 -04:00
Jeremy Stretch
6af91b581b #167: Added more interface form factors 2016-09-12 21:34:47 -04:00
Jeremy Stretch
afe805bd28 Corrected filtering of boolean custom fields 2016-09-12 15:59:43 -04:00
Jeremy Stretch
6af7403738 Fixes #507: Corrected rendering of nav menu on screens narrower than 1200px 2016-09-12 15:02:40 -04:00
Jeremy Stretch
98fe83944f Deleted obsolete javascript 2016-09-12 13:50:55 -04:00
Jeremy Stretch
37e0388a5a Fixes #522: Remove obsolete check for staff status when bulk deleting objects 2016-09-12 11:21:13 -04:00
Jeremy Stretch
99510a990a Fixes #544: Strip CRLF-style line terminators from rendered export templates 2016-09-12 10:42:59 -04:00
Jeremy Stretch
ca6c62facd Custom fields documentation tweaks 2016-09-12 10:30:54 -04:00
Jeremy Stretch
6e5a099834 Fixes #540: Add links for VLAN roles under VLAN nav menu 2016-09-12 09:44:24 -04:00
Jeremy Stretch
e0b17b1496 Fixes #515: Clarify when 'face' field is required 2016-09-12 09:36:23 -04:00
Jeremy Stretch
8341800a85 Merge pull request #504 from digitalocean/custom-fields
Custom fields
2016-08-24 09:45:50 -04:00
Jeremy Stretch
25f1fcc6cb Updated docs to add URL custom field type 2016-08-24 09:42:22 -04:00
Jeremy Stretch
d74d85a042 Added URL custom field type; added is_filterable toggle; fixed bulk editing 2016-08-23 16:45:26 -04:00
Jeremy Stretch
fcd4c9f7de Corrected reporting of bulk edits to custom fields 2016-08-23 12:47:44 -04:00
Jeremy Stretch
25c46894b4 Corrected CustomField ordering 2016-08-23 12:25:23 -04:00
Jeremy Stretch
4a2e80aeee Added a weight to CustomField for ordering fields within a form 2016-08-23 12:11:27 -04:00
Jeremy Stretch
74a5960992 Added custom field support to filter forms 2016-08-23 12:05:28 -04:00
Jeremy Stretch
28b9dda55d Implemented dynamic filters for custom fields 2016-08-23 11:18:00 -04:00
Jeremy Stretch
3b36a35b9a Fixed API tests 2016-08-22 17:15:20 -04:00
Jeremy Stretch
b9dcf9ca12 Refreshed migrations 2016-08-22 15:52:26 -04:00
Jeremy Stretch
faed3c1314 Merged develop 2016-08-22 15:49:41 -04:00
Jeremy Stretch
f0a85b1dd3 Optimized API performance 2016-08-22 15:16:49 -04:00
Jeremy Stretch
76f0463290 Extended API to include custom fields 2016-08-22 13:20:30 -04:00
Jeremy Stretch
b14afaa687 Updated bulk edit forms to support custom fields 2016-08-22 13:11:57 -04:00
Jeremy Stretch
14a908bf66 Merge pull request #496 from rfdrake/develop
fix for Dockerfile
2016-08-22 09:11:05 -04:00
Robert Drake
8f34b6b0b9 fix for Dockerfile
It was hard to test with the old syntax.  It was cloning the "master"
branch, so trying to test a development change was difficult.
I believe I've fixed it so that the "master" branch and "develop"
branch can use the same Dockerfile options.  You override which branch
it pulls by setting a build-args variable, either via docker-compose or
in the docker build options.
2016-08-21 23:48:27 -04:00
Jeremy Stretch
eab18a81c9 Adjusted display of created and last_updated times for primary objects 2016-08-19 12:19:02 -04:00
Jeremy Stretch
7a558d8332 Fixes #490: Corrected display of circuit commit rate 2016-08-19 10:41:37 -04:00
Jeremy Stretch
fa79014585 Merge pull request #489 from rfdrake/develop
Changes to Dockerfile to make the build faster
2016-08-19 10:33:19 -04:00
Jeremy Stretch
8cf2ae7851 Fixes #495: Include tenant in prefix and IP CSV export 2016-08-19 10:31:16 -04:00
Stian Vikan
0708942ab8 Fixed csv reader to handle special characters 2016-08-19 12:09:40 +02:00
Robert Drake
4b4602b703 Changes to Dockerfile to make the build faster
To download a new version with docker, I've been running

	git pull
	docker-compose build --no-cache

This is slow, but no-cache is needed so that "git clone" pulls the
latest copy.

Most of the slowness comes from pulling down apt files each time a
rebuild needs to be done.  If we move that into a docker image then only
the local changes need to be rebuilt.

Further refinements could be done.  If the python dependencies that are
brought in from requirements.txt could be moved to an image then nothing
would change between updates as long as dependant versions hadn't
changed.  This would probably be more trouble than it's worth, unless
you're recreating netbox containers 10-20 times a day.
2016-08-18 23:49:41 -04:00
Jeremy Stretch
ab90a06c54 Fixes #486: Prompt for secret key only if updating a secret's value 2016-08-18 16:43:41 -04:00
Jeremy Stretch
6dbf2043b9 Merge pull request #488 from digitalocean/revert-478-develop
Revert "Changes to Dockerfile to make the build faster"
2016-08-18 15:32:39 -04:00
Jeremy Stretch
041a166217 Revert "Changes to Dockerfile to make the build faster" 2016-08-18 15:31:31 -04:00
Jeremy Stretch
63ac4e2c42 Fixes #476: Corrected rack import instructions 2016-08-18 15:23:28 -04:00
Jeremy Stretch
77d721360f Merge pull request #478 from rfdrake/develop
Changes to Dockerfile to make the build faster
2016-08-18 15:18:30 -04:00
Jeremy Stretch
1d6299622b Documentation and cleanup 2016-08-18 14:23:28 -04:00
Jeremy Stretch
6f44f4245e Fixed default value for boolean fields 2016-08-18 11:44:40 -04:00
Jeremy Stretch
de8fd550cb Fixes #484: Allow bulk deletion of >1K objects 2016-08-18 10:12:43 -04:00
Jeremy Stretch
9bdb50c33e Optimized bulk editing of custom fields 2016-08-17 15:52:27 -04:00
Robert Drake
6ed33af063 Changes to Dockerfile to make the build faster
To download a new version with docker, I've been running

	git pull
	docker-compose build --no-cache

This is slow, but no-cache is needed so that "git clone" pulls the
latest copy.

Most of the slowness comes from pulling down apt files each time a
rebuild needs to be done.  If we move that into a docker image then only
the local changes need to be rebuilt.

Further refinements could be done.  If the python dependencies that are
brought in from requirements.txt could be moved to an image then nothing
would change between updates as long as dependant versions hadn't
changed.  This would probably be more trouble than it's worth, unless
you're recreating netbox containers 10-20 times a day.
2016-08-17 15:19:56 -04:00
Jeremy Stretch
a33e89fed7 Converted to a single column for value storage 2016-08-17 14:49:42 -04:00
Jeremy Stretch
b0a325f173 More performance improvements 2016-08-17 13:40:22 -04:00
Jeremy Stretch
b7a90dd09a Added icon for boolean fields 2016-08-17 13:40:06 -04:00
Jeremy Stretch
8d99ad3099 Corrected issue with duplicate queries 2016-08-17 12:41:12 -04:00
Jeremy Stretch
c49177e59c Merge branch 'develop' into custom-fields 2016-08-17 12:05:13 -04:00
Jeremy Stretch
96d6be3608 Merge branch 'develop' of github.com:digitalocean/netbox into develop 2016-08-17 12:04:26 -04:00
Jeremy Stretch
aa84d04c8b Removed redundant PK list from bulk edit template 2016-08-17 12:04:16 -04:00
Jeremy Stretch
c8b85202d1 Fixed test case name 2016-08-16 17:49:52 -04:00
Jeremy Stretch
af459cd19b Added some simple tests for custom fields 2016-08-16 17:48:35 -04:00
Zach Moody
c056d86b24 Merge pull request #471 from joonas/travis/automated-docker-builds
travis: Automate docker builds
2016-08-16 15:54:24 -05:00
Jeremy Stretch
7d879bb0dc Added bulk editing capability for custom fields 2016-08-16 14:57:04 -04:00
Jeremy Stretch
a9a55350df Merging v1.5.2 2016-08-16 10:19:55 -04:00
Jeremy Stretch
f019253c8e Post-release version bump 2016-08-16 09:34:26 -04:00
Jeremy Stretch
58e3d5ae09 Merge pull request #474 from digitalocean/develop
Release v1.5.2
2016-08-16 09:33:57 -04:00
Jeremy Stretch
2eb8b4fe71 Release v1.5.2 2016-08-16 09:32:53 -04:00
Jeremy Stretch
989ec721d3 Fixes #472: Hide the connection button for interfaces which have a circuit terminated to them 2016-08-16 09:29:20 -04:00
Joonas Bergius
4fc0fd9a9a Produce a docker image when there's a new tag 2016-08-15 22:00:34 -04:00
Joonas Bergius
5afb98ffa7 Added scripts/docker-build.sh for building docker images in CI 2016-08-15 21:59:01 -04:00
Jeremy Stretch
d045429b51 Fixes #469: Added missing import buttons to list views 2016-08-15 17:57:17 -04:00
Jeremy Stretch
c60c4ad0df Added templates for IPAM objects; cleaned up admin 2016-08-15 16:58:25 -04:00
Jeremy Stretch
78c3b25f0a Fixes #467: Include prefixes and IPs which inherit tenancy from their VRF in tenant stats 2016-08-15 16:11:17 -04:00
Jeremy Stretch
13136d0ccb Fixes #468: Added validation to prevent a connected interface from having its form factor set to 'virtual' 2016-08-15 15:52:06 -04:00
Jeremy Stretch
8faa16c831 Fixes #460: For real this time 2016-08-15 15:39:48 -04:00
Jeremy Stretch
6cdb62b67e Minimal implemtnation of custom fields 2016-08-15 15:24:23 -04:00
Jeremy Stretch
4f774f8ba6 Fixes #460: Corrected ordering of IP addresses with differing prefix lengths 2016-08-13 01:02:03 -04:00
Jeremy Stretch
550a05487d Initial work on custom fields 2016-08-12 17:20:01 -04:00
Jeremy Stretch
bf1b8ab9b8 Enable custom export templates for Tenants 2016-08-12 13:29:24 -04:00
Jeremy Stretch
b74f338aa1 Fixes #463: Prevent prepopulation of livesearch field with '---------' 2016-08-12 11:24:29 -04:00
Jeremy Stretch
35aa8acd09 Post-release version bump 2016-08-11 13:54:07 -04:00
93 changed files with 1804 additions and 902 deletions

View File

@@ -1,3 +1,11 @@
sudo: required
services:
- docker
env:
- DOCKER_TAG=$TRAVIS_TAG
language: python
python:
- "2.7"
@@ -6,3 +14,7 @@ install:
- pip install pep8
script:
- ./scripts/cibuild.sh
after_success:
- if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
./scripts/docker-build.sh;
fi

View File

@@ -1,24 +1,14 @@
FROM ubuntu:14.04
FROM python:2.7-wheezy
RUN apt-get update && apt-get install -y \
python2.7 \
python-dev \
git \
python-pip \
libxml2-dev \
libxslt1-dev \
libffi-dev \
graphviz \
libpq-dev \
build-essential \
gunicorn \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /opt/netbox \
&& cd /opt/netbox \
&& git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \
&& pip install -r requirements.txt \
&& apt-get purge -y --auto-remove git build-essential
WORKDIR /opt/netbox
ARG BRANCH=master
ARG URL=https://github.com/digitalocean/netbox.git
RUN git clone --depth 1 $URL -b $BRANCH . && \
apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \
pip install gunicorn==17.5 && \
pip install django-auth-ldap && \
pip install -r requirements.txt
ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py

View File

@@ -1,4 +1,31 @@
This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value.
This section entails features of NetBox which are not crucial to its primary functions, but provide additional value.
# Custom Fields
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address` and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data.
Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types:
* Free-form text (up to 255 characters)
* Integer
* Boolean (true/false)
* Date
* URL
* Selection
Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form.
Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.)
When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically.
## Using Custom Fields
When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object.
When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field.
# Export Templates
@@ -8,6 +35,8 @@ Each export template is associated with a certain type of object. For instance,
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
## Example

View File

@@ -2,6 +2,7 @@ from rest_framework import serializers
from circuits.models import Provider, CircuitType, Circuit
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from extras.api.serializers import CustomFieldSerializer
from tenancy.api.serializers import TenantNestedSerializer
@@ -9,11 +10,12 @@ from tenancy.api.serializers import TenantNestedSerializer
# Providers
#
class ProviderSerializer(serializers.ModelSerializer):
class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Provider
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
'custom_fields']
class ProviderNestedSerializer(ProviderSerializer):
@@ -43,7 +45,7 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
# Circuits
#
class CircuitSerializer(serializers.ModelSerializer):
class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer()
tenant = TenantNestedSerializer()
@@ -53,7 +55,7 @@ class CircuitSerializer(serializers.ModelSerializer):
class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
'upstream_speed', 'commit_rate', 'xconnect_id', 'comments']
'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
class CircuitNestedSerializer(CircuitSerializer):

View File

@@ -3,22 +3,23 @@ from rest_framework import generics
from circuits.models import Provider, CircuitType, Circuit
from circuits.filters import CircuitFilter
from extras.api.views import CustomFieldModelAPIView
from . import serializers
class ProviderListView(generics.ListAPIView):
class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List all providers
"""
queryset = Provider.objects.all()
queryset = Provider.objects.prefetch_related('custom_field_values__field')
serializer_class = serializers.ProviderSerializer
class ProviderDetailView(generics.RetrieveAPIView):
class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single provider
"""
queryset = Provider.objects.all()
queryset = Provider.objects.prefetch_related('custom_field_values__field')
serializer_class = serializers.ProviderSerializer
@@ -38,18 +39,20 @@ class CircuitTypeDetailView(generics.RetrieveAPIView):
serializer_class = serializers.CircuitTypeSerializer
class CircuitListView(generics.ListAPIView):
class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List circuits (filterable)
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.CircuitSerializer
filter_class = CircuitFilter
class CircuitDetailView(generics.RetrieveAPIView):
class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single circuit
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.CircuitSerializer

View File

@@ -3,11 +3,14 @@ import django_filters
from django.db.models import Q
from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import Provider, Circuit, CircuitType
class ProviderFilter(django_filters.FilterSet):
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -36,7 +39,7 @@ class ProviderFilter(django_filters.FilterSet):
)
class CircuitFilter(django_filters.FilterSet):
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -63,12 +66,12 @@ class CircuitFilter(django_filters.FilterSet):
to_field_name='slug',
label='Circuit type (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',

View File

@@ -2,10 +2,12 @@ from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
SlugField,
)
from .models import Circuit, CircuitType, Provider
@@ -15,7 +17,7 @@ from .models import Circuit, CircuitType, Provider
# Providers
#
class ProviderForm(forms.ModelForm, BootstrapMixin):
class ProviderForm(BootstrapMixin, CustomFieldForm):
slug = SlugField()
comments = CommentField()
@@ -46,7 +48,7 @@ class ProviderImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ProviderFromCSVForm)
class ProviderBulkEditForm(forms.Form, BootstrapMixin):
class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
asn = forms.IntegerField(required=False, label='ASN')
account = forms.CharField(max_length=30, required=False, label='Account number')
@@ -56,14 +58,9 @@ class ProviderBulkEditForm(forms.Form, BootstrapMixin):
comments = CommentField()
def provider_site_choices():
site_choices = Site.objects.all()
return [(s.slug, s.name) for s in site_choices]
class ProviderFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
#
@@ -82,7 +79,7 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
# Circuits
#
class CircuitForm(forms.ModelForm, BootstrapMixin):
class CircuitForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
@@ -177,7 +174,7 @@ class CircuitImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(forms.Form, BootstrapMixin):
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
@@ -187,31 +184,12 @@ class CircuitBulkEditForm(forms.Form, BootstrapMixin):
comments = CommentField()
def circuit_type_choices():
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
def circuit_provider_choices():
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
def circuit_tenant_choices():
tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
def circuit_site_choices():
site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
class CircuitFilterForm(forms.Form, BootstrapMixin):
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug')

View File

@@ -1,13 +1,15 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse
from django.db import models
from dcim.fields import ASNField
from dcim.models import Site, Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
class Provider(CreatedUpdatedModel):
class Provider(CreatedUpdatedModel, CustomFieldModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider.
@@ -20,6 +22,7 @@ class Provider(CreatedUpdatedModel):
noc_contact = models.TextField(blank=True, verbose_name='NOC contact')
admin_contact = models.TextField(blank=True, verbose_name='Admin contact')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta:
ordering = ['name']
@@ -58,7 +61,7 @@ class CircuitType(models.Model):
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
class Circuit(CreatedUpdatedModel):
class Circuit(CreatedUpdatedModel, CustomFieldModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device
@@ -78,6 +81,7 @@ class Circuit(CreatedUpdatedModel):
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta:
ordering = ['provider', 'cid']

View File

@@ -66,15 +66,6 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'circuits/provider_bulk_edit.html'
default_redirect_url = 'circuits:provider_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
@@ -159,19 +150,6 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'circuits/circuit_bulk_edit.html'
default_redirect_url = 'circuits:circuit_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['tenant'] == 0:
fields_to_update['tenant'] = None
elif form.cleaned_data['tenant']:
fields_to_update['tenant'] = form.cleaned_data['tenant']
for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'

View File

@@ -6,6 +6,7 @@ from dcim.models import (
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
)
from extras.api.serializers import CustomFieldSerializer
from tenancy.api.serializers import TenantNestedSerializer
@@ -13,13 +14,13 @@ from tenancy.api.serializers import TenantNestedSerializer
# Sites
#
class SiteSerializer(serializers.ModelSerializer):
class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
tenant = TenantNestedSerializer()
class Meta:
model = Site
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
class SiteNestedSerializer(SiteSerializer):
@@ -68,7 +69,7 @@ class RackRoleNestedSerializer(RackRoleSerializer):
#
class RackSerializer(serializers.ModelSerializer):
class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer()
group = RackGroupNestedSerializer()
tenant = TenantNestedSerializer()
@@ -77,7 +78,7 @@ class RackSerializer(serializers.ModelSerializer):
class Meta:
model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'comments']
'u_height', 'comments', 'custom_fields']
class RackNestedSerializer(RackSerializer):
@@ -92,7 +93,7 @@ class RackDetailSerializer(RackSerializer):
class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
'u_height', 'comments', 'front_units', 'rear_units']
'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units']
def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT)
@@ -237,7 +238,7 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
fields = ['id', 'family', 'address']
class DeviceSerializer(serializers.ModelSerializer):
class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
device_type = DeviceTypeNestedSerializer()
device_role = DeviceRoleNestedSerializer()
tenant = TenantNestedSerializer()
@@ -252,7 +253,7 @@ class DeviceSerializer(serializers.ModelSerializer):
model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'comments']
'primary_ip6', 'comments', 'custom_fields']
def get_parent_device(self, obj):
try:

View File

@@ -5,6 +5,7 @@ from rest_framework.settings import api_settings
from rest_framework.views import APIView
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
@@ -13,29 +14,30 @@ from dcim.models import (
InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
)
from dcim import filters
from .exceptions import MissingFilterException
from . import serializers
from extras.api.views import CustomFieldModelAPIView
from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
from utilities.api import ServiceUnavailable
from .exceptions import MissingFilterException
from . import serializers
#
# Sites
#
class SiteListView(generics.ListAPIView):
class SiteListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List all sites
"""
queryset = Site.objects.select_related('tenant')
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
serializer_class = serializers.SiteSerializer
class SiteDetailView(generics.RetrieveAPIView):
class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single site
"""
queryset = Site.objects.select_related('tenant')
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
serializer_class = serializers.SiteSerializer
@@ -84,20 +86,22 @@ class RackRoleDetailView(generics.RetrieveAPIView):
# Racks
#
class RackListView(generics.ListAPIView):
class RackListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List racks (filterable)
"""
queryset = Rack.objects.select_related('site', 'group', 'tenant')
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.RackSerializer
filter_class = filters.RackFilter
class RackDetailView(generics.RetrieveAPIView):
class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single rack
"""
queryset = Rack.objects.select_related('site', 'group', 'tenant')
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.RackDetailSerializer
@@ -209,24 +213,25 @@ class PlatformDetailView(generics.RetrieveAPIView):
# Devices
#
class DeviceListView(generics.ListAPIView):
class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List devices (filterable)
"""
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside',
'primary_ip6__nat_outside')
'primary_ip6__nat_outside',
'custom_field_values__field')
serializer_class = serializers.DeviceSerializer
filter_class = filters.DeviceFilter
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
class DeviceDetailView(generics.RetrieveAPIView):
class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single device
"""
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
'rack__site', 'parent_bay')
'rack__site', 'parent_bay').prefetch_related('custom_field_values__field')
serializer_class = serializers.DeviceSerializer
@@ -426,6 +431,13 @@ class RelatedConnectionsView(APIView):
Retrieve all connections related to a given console/power/interface connection
"""
def __init__(self):
super(RelatedConnectionsView, self).__init__()
# Custom fields
self.content_type = ContentType.objects.get_for_model(Device)
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
def get(self, request):
peer_device = request.GET.get('peer-device')
@@ -450,7 +462,7 @@ class RelatedConnectionsView(APIView):
# Initialize response skeleton
response = {
'device': serializers.DeviceSerializer(device).data,
'device': serializers.DeviceSerializer(device, context={'view': self}).data,
'console-ports': [],
'power-ports': [],
'interfaces': [],

View File

@@ -2,24 +2,26 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
)
from tenancy.models import Tenant
class SiteFilter(django_filters.FilterSet):
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -58,7 +60,7 @@ class RackGroupFilter(django_filters.FilterSet):
fields = ['site_id', 'site']
class RackFilter(django_filters.FilterSet):
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -74,34 +76,34 @@ class RackFilter(django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=RackRole.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=RackRole.objects.all(),
to_field_name='slug',
@@ -139,7 +141,7 @@ class DeviceTypeFilter(django_filters.FilterSet):
'is_network_device']
class DeviceFilter(django_filters.FilterSet):
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -176,12 +178,12 @@ class DeviceFilter(django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -209,12 +211,12 @@ class DeviceFilter(django_filters.FilterSet):
to_field_name='slug',
label='Device model (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
platform_id = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = django_filters.ModelMultipleChoiceFilter(
platform = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',

View File

@@ -3,12 +3,13 @@ import re
from django import forms
from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
)
from .models import (
@@ -78,7 +79,7 @@ def bulkedit_rackrole_choices():
# Sites
#
class SiteForm(forms.ModelForm, BootstrapMixin):
class SiteForm(BootstrapMixin, CustomFieldForm):
slug = SlugField()
comments = CommentField()
@@ -111,19 +112,15 @@ class SiteImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=SiteFromCSVForm)
class SiteBulkEditForm(forms.Form, BootstrapMixin):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
def site_tenant_choices():
tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
class SiteFilterForm(forms.Form, BootstrapMixin):
tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Site
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
null_option=(0, 'None'))
#
@@ -138,14 +135,8 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
def rackgroup_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
class RackGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
#
@@ -164,7 +155,7 @@ class RackRoleForm(forms.ModelForm, BootstrapMixin):
# Racks
#
class RackForm(forms.ModelForm, BootstrapMixin):
class RackForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
))
@@ -240,7 +231,7 @@ class RackImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=RackFromCSVForm)
class RackBulkEditForm(forms.Form, BootstrapMixin):
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
@@ -252,35 +243,15 @@ class RackBulkEditForm(forms.Form, BootstrapMixin):
comments = CommentField()
def rack_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('racks'))
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
def rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
def rack_tenant_choices():
tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
def rack_role_choices():
role_choices = RackRole.objects.annotate(rack_count=Count('racks'))
return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices]
class RackFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=rack_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Rack
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
.annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
null_option=(0, 'None'))
#
@@ -314,14 +285,9 @@ class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
u_height = forms.IntegerField(min_value=1, required=False)
def devicetype_manufacturer_choices():
manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
to_field_name='slug')
#
@@ -404,7 +370,7 @@ class PlatformForm(forms.ModelForm, BootstrapMixin):
# Devices
#
class DeviceForm(forms.ModelForm, BootstrapMixin):
class DeviceForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
@@ -613,7 +579,7 @@ class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
class DeviceBulkEditForm(forms.Form, BootstrapMixin):
class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
@@ -624,48 +590,18 @@ class DeviceBulkEditForm(forms.Form, BootstrapMixin):
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
def device_site_choices():
site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices]
def device_rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices]
def device_role_choices():
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
def device_tenant_choices():
tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
def device_type_choices():
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
def device_platform_choices():
platform_choices = Platform.objects.annotate(device_count=Count('devices'))
return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
class DeviceFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
widget=forms.SelectMultiple(attrs={'size': 8}))
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
label='Rack Group')
role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
null_option=(0, 'None'))
device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
.annotate(filter_count=Count('instances')), label='Type')
platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
to_field_name='slug', null_option=(0, 'None'))
status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-09-13 15:20
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0018_device_add_asset_tag'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
]

View File

@@ -1,12 +1,15 @@
from collections import OrderedDict
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import MultipleObjectsReturned, ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
from extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant
from utilities.fields import NullableCharField
@@ -74,23 +77,39 @@ ROLE_COLOR_CHOICES = [
[COLOR_GRAY3, 'Dark Gray'],
]
# Virtual
IFACE_FF_VIRTUAL = 0
IFACE_FF_100M_COPPER = 800
IFACE_FF_1GE_COPPER = 1000
IFACE_FF_GBIC = 1050
IFACE_FF_SFP = 1100
IFACE_FF_10GE_COPPER = 1150
IFACE_FF_SFP_PLUS = 1200
IFACE_FF_XFP = 1300
IFACE_FF_QSFP_PLUS = 1400
IFACE_FF_CFP = 1500
IFACE_FF_QSFP28 = 1600
# Ethernet
IFACE_FF_100ME_FIXED = 800
IFACE_FF_1GE_FIXED = 1000
IFACE_FF_1GE_GBIC = 1050
IFACE_FF_1GE_SFP = 1100
IFACE_FF_10GE_FIXED = 1150
IFACE_FF_10GE_SFP_PLUS = 1200
IFACE_FF_10GE_XFP = 1300
IFACE_FF_10GE_XENPAK = 1310
IFACE_FF_10GE_X2 = 1320
IFACE_FF_25GE_SFP28 = 1350
IFACE_FF_40GE_QSFP_PLUS = 1400
IFACE_FF_100GE_CFP = 1500
IFACE_FF_100GE_QSFP28 = 1600
# Fibrechannel
IFACE_FF_1GFC_SFP = 3010
IFACE_FF_2GFC_SFP = 3020
IFACE_FF_4GFC_SFP = 3040
IFACE_FF_8GFC_SFP_PLUS = 3080
IFACE_FF_16GFC_SFP_PLUS = 3160
# Serial
IFACE_FF_T1 = 4000
IFACE_FF_E1 = 4010
IFACE_FF_T3 = 4040
IFACE_FF_E3 = 4050
# Stacking
IFACE_FF_STACKWISE = 5000
IFACE_FF_STACKWISE_PLUS = 5050
# Other
IFACE_FF_OTHER = 32767
IFACE_FF_CHOICES = [
[
'Virtual interfaces',
@@ -99,23 +118,36 @@ IFACE_FF_CHOICES = [
]
],
[
'Ethernet',
'Ethernet (fixed)',
[
[IFACE_FF_100M_COPPER, '100BASE-TX (10/100M)'],
[IFACE_FF_1GE_COPPER, '1000BASE-T (1GE)'],
[IFACE_FF_10GE_COPPER, '10GBASE-T (10GE)'],
[IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
[IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
[IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
]
],
[
'Modular',
'Ethernet (modular)',
[
[IFACE_FF_GBIC, 'GBIC (1GE)'],
[IFACE_FF_SFP, 'SFP (1GE)'],
[IFACE_FF_XFP, 'XFP (10GE)'],
[IFACE_FF_SFP_PLUS, 'SFP+ (10GE)'],
[IFACE_FF_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_CFP, 'CFP (100GE)'],
[IFACE_FF_QSFP28, 'QSFP28 (100GE)'],
[IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
[IFACE_FF_1GE_SFP, 'SFP (1GE)'],
[IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
[IFACE_FF_10GE_XFP, 'XFP (10GE)'],
[IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
[IFACE_FF_10GE_X2, 'X2 (10GE)'],
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
]
],
[
'FibreChannel',
[
[IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
[IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
[IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
[IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
[IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
]
],
[
@@ -134,6 +166,12 @@ IFACE_FF_CHOICES = [
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
]
],
[
'Other',
[
[IFACE_FF_OTHER, 'Other'],
]
],
]
STATUS_ACTIVE = True
@@ -213,7 +251,7 @@ class SiteManager(NaturalOrderByManager):
return self.natural_order_by('name')
class Site(CreatedUpdatedModel):
class Site(CreatedUpdatedModel, CustomFieldModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
@@ -226,6 +264,7 @@ class Site(CreatedUpdatedModel):
physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = SiteManager()
@@ -320,7 +359,7 @@ class RackManager(NaturalOrderByManager):
return self.natural_order_by('site__name', 'name')
class Rack(CreatedUpdatedModel):
class Rack(CreatedUpdatedModel, CustomFieldModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a RackGroup.
@@ -337,6 +376,7 @@ class Rack(CreatedUpdatedModel):
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)])
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = RackManager()
@@ -642,7 +682,7 @@ class InterfaceTemplate(models.Model):
"""
device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
objects = InterfaceTemplateManager()
@@ -719,7 +759,7 @@ class DeviceManager(NaturalOrderByManager):
return self.natural_order_by('name')
class Device(CreatedUpdatedModel):
class Device(CreatedUpdatedModel, CustomFieldModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@@ -750,6 +790,7 @@ class Device(CreatedUpdatedModel):
primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv6')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = DeviceManager()
@@ -1017,7 +1058,7 @@ class Interface(models.Model):
"""
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management',
help_text="This interface is used only for out-of-band management")
@@ -1032,6 +1073,13 @@ class Interface(models.Model):
def __unicode__(self):
return self.name
def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or "
"circuit. Disconnect the interface or choose a physical form "
"factor."})
@property
def is_physical(self):
return self.form_factor != IFACE_FF_VIRTUAL

View File

@@ -21,6 +21,7 @@ class SiteTest(APITestCase):
'physical_address',
'shipping_address',
'comments',
'custom_fields',
'count_prefixes',
'count_vlans',
'count_racks',
@@ -46,7 +47,8 @@ class SiteTest(APITestCase):
'type',
'width',
'u_height',
'comments'
'comments',
'custom_fields',
]
graph_fields = [
@@ -125,7 +127,8 @@ class RackTest(APITestCase):
'type',
'width',
'u_height',
'comments'
'comments',
'custom_fields',
]
detail_fields = [
@@ -141,6 +144,7 @@ class RackTest(APITestCase):
'width',
'u_height',
'comments',
'custom_fields',
'front_units',
'rear_units'
]
@@ -337,6 +341,7 @@ class DeviceTest(APITestCase):
'primary_ip4',
'primary_ip6',
'comments',
'custom_fields',
]
nested_fields = ['id', 'name', 'display_name']

View File

@@ -122,16 +122,6 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/site_bulk_edit.html'
default_redirect_url = 'dcim:site_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['tenant'] == 0:
fields_to_update['tenant'] = None
elif form.cleaned_data['tenant']:
fields_to_update['tenant'] = form.cleaned_data['tenant']
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
#
# Rack groups
@@ -248,20 +238,6 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/rack_bulk_edit.html'
default_redirect_url = 'dcim:rack_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['group', 'tenant', 'role']:
if form.cleaned_data[field] == 0:
fields_to_update[field] = None
elif form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
for field in ['site', 'type', 'width', 'u_height', 'comments']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rack'
@@ -372,15 +348,6 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/devicetype_bulk_edit.html'
default_redirect_url = 'dcim:devicetype_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['manufacturer', 'u_height']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicetype'
@@ -682,23 +649,6 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/device_bulk_edit.html'
default_redirect_url = 'dcim:device_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['tenant', 'platform']:
if form.cleaned_data[field] == 0:
fields_to_update[field] = None
elif form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
if form.cleaned_data['status']:
status = form.cleaned_data['status']
fields_to_update['status'] = True if status == 'True' else False
for field in ['tenant', 'device_type', 'device_role', 'serial']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_device'
@@ -1449,7 +1399,7 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
template_name = 'dcim/interface_add_multi.html'
default_redirect_url = 'dcim:device_list'
def update_objects(self, pk_list, form):
def update_objects(self, pk_list, form, fields):
selected_devices = Device.objects.filter(pk__in=pk_list)
interfaces = []

View File

@@ -1,6 +1,36 @@
from django import forms
from django.contrib import admin
from .models import Graph, ExportTemplate, TopologyMap, UserAction
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
class CustomFieldForm(forms.ModelForm):
class Meta:
model = CustomField
exclude = []
def __init__(self, *args, **kwargs):
super(CustomFieldForm, self).__init__(*args, **kwargs)
# Organize the available ContentTypes
queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model')
self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
class CustomFieldChoiceAdmin(admin.TabularInline):
model = CustomFieldChoice
extra = 5
@admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
form = CustomFieldForm
def models(self, obj):
return ', '.join([ct.name for ct in obj.obj_type.all()])
@admin.register(Graph)

View File

@@ -1,6 +1,44 @@
from rest_framework import serializers
from extras.models import Graph
from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph
class CustomFieldSerializer(serializers.Serializer):
"""
Extends a ModelSerializer to render any CustomFields and their values associated with an object.
"""
custom_fields = serializers.SerializerMethodField()
def get_custom_fields(self, obj):
# Gather all CustomFields applicable to this object
fields = {cf.name: None for cf in self.context['view'].custom_fields}
# Attach any defined CustomFieldValues to their respective CustomFields
for cfv in obj.custom_field_values.all():
# Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view
# context.
if cfv.field.type == CF_TYPE_SELECT and hasattr(self, 'custom_field_choices'):
cfc = {
'id': int(cfv.serialized_value),
'value': self.context['view'].custom_field_choices[int(cfv.serialized_value)]
}
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data
# Fall back to hitting the database in case we're in a view that doesn't inherit CustomFieldModelAPIView.
elif cfv.field.type == CF_TYPE_SELECT:
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data
else:
fields[cfv.field.name] = cfv.value
return fields
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
class Meta:
model = CustomFieldChoice
fields = ['id', 'value']
class GraphSerializer(serializers.ModelSerializer):

View File

@@ -1,9 +1,8 @@
import graphviz
from rest_framework import generics
from rest_framework.views import APIView
import tempfile
from wsgiref.util import FileWrapper
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
@@ -15,6 +14,24 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_P
from .serializers import GraphSerializer
class CustomFieldModelAPIView(object):
"""
Include the applicable set of CustomField in the view context.
"""
def __init__(self):
super(CustomFieldModelAPIView, self).__init__()
self.content_type = ContentType.objects.get_for_model(self.queryset.model)
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
# Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
custom_field_choices = {}
for field in self.custom_fields:
for cfc in field.choices.all():
custom_field_choices[cfc.id] = cfc.value
self.custom_field_choices = custom_field_choices
class GraphListView(generics.ListAPIView):
"""
Returns a list of relevant graphs

46
netbox/extras/filters.py Normal file
View File

@@ -0,0 +1,46 @@
import django_filters
from django.contrib.contenttypes.models import ContentType
from .models import CF_TYPE_SELECT, CustomField
class CustomFieldFilter(django_filters.Filter):
"""
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
"""
def __init__(self, cf_type, *args, **kwargs):
self.cf_type = cf_type
super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value):
# Skip filter on empty value
if not value.strip():
return queryset
# Treat 0 as None for Select fields
try:
if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
return queryset.exclude(
custom_field_values__field__name=self.name,
)
except ValueError:
pass
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value,
)
class CustomFieldFilterSet(django_filters.FilterSet):
"""
Dynamically add a Filter for each CustomField applicable to the parent model.
"""
def __init__(self, *args, **kwargs):
super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)

158
netbox/extras/forms.py Normal file
View File

@@ -0,0 +1,158 @@
from collections import OrderedDict
from django import forms
from django.contrib.contenttypes.models import ContentType
from .models import (
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
)
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
"""
Retrieve all CustomFields applicable to the given ContentType
"""
field_dict = OrderedDict()
kwargs = {'obj_type': content_type}
if filterable_only:
kwargs['is_filterable'] = True
custom_fields = CustomField.objects.filter(**kwargs)
for cf in custom_fields:
field_name = 'cf_{}'.format(str(cf.name))
# Integer
if cf.type == CF_TYPE_INTEGER:
field = forms.IntegerField(required=cf.required, initial=cf.default)
# Boolean
elif cf.type == CF_TYPE_BOOLEAN:
choices = (
(None, '---------'),
(1, 'True'),
(0, 'False'),
)
if cf.default.lower() in ['true', 'yes', '1']:
initial = True
elif cf.default.lower() in ['false', 'no', '0']:
initial = False
else:
initial = None
field = forms.NullBooleanField(required=cf.required, initial=initial,
widget=forms.Select(choices=choices))
# Date
elif cf.type == CF_TYPE_DATE:
field = forms.DateField(required=cf.required, initial=cf.default)
# Select
elif cf.type == CF_TYPE_SELECT:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required:
choices = [(0, 'None')] + choices
if bulk_edit or filterable_only:
choices = [(None, '---------')] + choices
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
# URL
elif cf.type == CF_TYPE_URL:
field = forms.URLField(required=cf.required, initial=cf.default)
# Text
else:
field = forms.CharField(max_length=255, required=cf.required, initial=cf.default)
field.model = cf
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
field.help_text = cf.description
field_dict[field_name] = field
return field_dict
class CustomFieldForm(forms.ModelForm):
custom_fields = []
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
super(CustomFieldForm, self).__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
custom_fields = []
for name, field in get_custom_fields_for_model(self.obj_type).items():
self.fields[name] = field
custom_fields.append(name)
self.custom_fields = custom_fields
# If editing an existing object, initialize values for all custom fields
if self.instance.pk:
existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\
.select_related('field')
for cfv in existing_values:
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.value
def _save_custom_fields(self):
for field_name in self.custom_fields:
try:
cfv = CustomFieldValue.objects.select_related('field').get(field=self.fields[field_name].model,
obj_type=self.obj_type,
obj_id=self.instance.pk)
except CustomFieldValue.DoesNotExist:
# Skip this field if none exists already and its value is empty
if self.cleaned_data[field_name] in [None, u'']:
continue
cfv = CustomFieldValue(
field=self.fields[field_name].model,
obj_type=self.obj_type,
obj_id=self.instance.pk
)
cfv.value = self.cleaned_data[field_name]
cfv.save()
def save(self, commit=True):
obj = super(CustomFieldForm, self).save(commit)
# Handle custom fields the same way we do M2M fields
if commit:
self._save_custom_fields()
else:
self.save_custom_fields = self._save_custom_fields
return obj
class CustomFieldBulkEditForm(forms.Form):
custom_fields = []
def __init__(self, model, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(model)
super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
custom_fields = []
for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items():
field.required = False
self.fields[name] = field
custom_fields.append(name)
self.custom_fields = custom_fields
class CustomFieldFilterForm(forms.Form):
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self.model)
super(CustomFieldFilterForm, self).__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
for name, field in custom_fields:
field.required = False
self.fields[name] = field

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-08-23 20:33
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CustomField',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.PositiveSmallIntegerField(choices=[(100, b'Text'), (200, b'Integer'), (300, b'Boolean (true/false)'), (400, b'Date'), (500, b'URL'), (600, b'Selection')], default=100)),
('name', models.CharField(max_length=50, unique=True)),
('label', models.CharField(blank=True, help_text=b"Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50)),
('description', models.CharField(blank=True, max_length=100)),
('required', models.BooleanField(default=False, help_text=b'Determines whether this field is required when creating new objects or editing an existing object.')),
('is_filterable', models.BooleanField(default=True, help_text=b'This field can be used to filter objects.')),
('default', models.CharField(blank=True, help_text=b'Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100)),
('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Fields with higher weights appear lower in a form')),
('obj_type', models.ManyToManyField(help_text=b'The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name=b'Object(s)')),
],
options={
'ordering': ['weight', 'name'],
},
),
migrations.CreateModel(
name='CustomFieldChoice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(max_length=100)),
('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Higher weights appear lower in the list')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField')),
],
options={
'ordering': ['field', 'weight', 'value'],
},
),
migrations.CreateModel(
name='CustomFieldValue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('obj_id', models.PositiveIntegerField()),
('serialized_value', models.CharField(max_length=255)),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='extras.CustomField')),
('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
],
options={
'ordering': ['obj_type', 'obj_id'],
},
),
migrations.AlterUniqueTogether(
name='customfieldvalue',
unique_together=set([('field', 'obj_type', 'obj_id')]),
),
migrations.AlterUniqueTogether(
name='customfieldchoice',
unique_together=set([('field', 'value')]),
),
]

View File

@@ -1,12 +1,37 @@
from collections import OrderedDict
from datetime import date
from django.contrib.auth.models import User
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 django.http import HttpResponse
from django.template import Template, Context
from django.utils.safestring import mark_safe
from dcim.models import Site
CUSTOMFIELD_MODELS = (
'site', 'rack', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
'provider', 'circuit', # Circuits
'tenant', # Tenants
)
CF_TYPE_TEXT = 100
CF_TYPE_INTEGER = 200
CF_TYPE_BOOLEAN = 300
CF_TYPE_DATE = 400
CF_TYPE_URL = 500
CF_TYPE_SELECT = 600
CUSTOMFIELD_TYPE_CHOICES = (
(CF_TYPE_TEXT, 'Text'),
(CF_TYPE_INTEGER, 'Integer'),
(CF_TYPE_BOOLEAN, 'Boolean (true/false)'),
(CF_TYPE_DATE, 'Date'),
(CF_TYPE_URL, 'URL'),
(CF_TYPE_SELECT, 'Selection'),
)
GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200
@@ -18,9 +43,10 @@ GRAPH_TYPE_CHOICES = (
)
EXPORTTEMPLATE_MODELS = [
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection',
'aggregate', 'prefix', 'ipaddress', 'vlan',
'provider', 'circuit'
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
'provider', 'circuit', # Circuits
'tenant', # Tenants
]
ACTION_CREATE = 1
@@ -39,6 +65,140 @@ ACTION_CHOICES = (
)
class CustomFieldModel(object):
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if not hasattr(self, 'custom_fields'):
return dict()
return {field.name: value for field, value in self.custom_fields.items()}
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 = CustomFieldValue.objects.filter(obj_type=content_type, obj_id=self.pk).select_related('field')
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(ContentType, related_name='custom_fields', verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
help_text="The object(s) to which this field applies.")
type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_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=100, blank=True)
required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
"new objects or editing an existing object.")
is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
"\"false\" for booleans. N/A for selection "
"fields.")
weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
"form")
class Meta:
ordering = ['weight', 'name']
def __unicode__(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 == CF_TYPE_BOOLEAN:
return str(int(bool(value)))
if self.type == CF_TYPE_DATE:
return value.strftime('%Y-%m-%d')
if self.type == CF_TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value)
return str(value)
def deserialize_value(self, serialized_value):
"""
Convert a string into the object it represents depending on the type of field
"""
if serialized_value is '':
return None
if self.type == CF_TYPE_INTEGER:
return int(serialized_value)
if self.type == CF_TYPE_BOOLEAN:
return bool(int(serialized_value))
if self.type == CF_TYPE_DATE:
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CF_TYPE_SELECT:
# return CustomFieldChoice.objects.get(pk=int(serialized_value))
return self.choices.get(pk=int(serialized_value))
return serialized_value
class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values')
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey('obj_type', 'obj_id')
serialized_value = models.CharField(max_length=255)
class Meta:
ordering = ['obj_type', 'obj_id']
unique_together = ['field', 'obj_type', 'obj_id']
def __unicode__(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(CustomFieldValue, self).save(*args, **kwargs)
class CustomFieldChoice(models.Model):
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
on_delete=models.CASCADE)
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 __unicode__(self):
return self.value
def clean(self):
if self.field.type != CF_TYPE_SELECT:
raise ValidationError("Custom field choices can only be assigned to selection fields.")
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000)
@@ -85,10 +245,10 @@ class ExportTemplate(models.Model):
"""
template = Template(self.template_code)
mime_type = 'text/plain' if not self.mime_type else self.mime_type
response = HttpResponse(
template.render(Context(context_dict)),
content_type=mime_type
)
output = template.render(Context(context_dict))
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
response = HttpResponse(output, content_type=mime_type)
if self.file_extension:
filename += '.{}'.format(self.file_extension)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
@@ -98,7 +258,7 @@ class ExportTemplate(models.Model):
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
site = models.ForeignKey(Site, related_name='topology_maps', blank=True, null=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions,"
"one per line. Each line will result in a new tier of the drawing. "
"Separate multiple regexes on a line using commas. Devices will be "

View File

View File

@@ -0,0 +1,97 @@
from datetime import date
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Site
from extras.models import (
CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
CF_TYPE_SELECT, CF_TYPE_URL,
)
class CustomFieldTestCase(TestCase):
def setUp(self):
Site.objects.bulk_create([
Site(name='Site A', slug='site-a'),
Site(name='Site B', slug='site-b'),
Site(name='Site C', slug='site-c'),
])
def test_simple_fields(self):
DATA = (
{'field_type': CF_TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''},
{'field_type': CF_TYPE_INTEGER, 'field_value': 0, 'empty_value': None},
{'field_type': CF_TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
{'field_type': CF_TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
{'field_type': CF_TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
{'field_type': CF_TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None},
{'field_type': CF_TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
)
obj_type = ContentType.objects.get_for_model(Site)
for data in DATA:
# Create a custom field
cf = CustomField(type=data['field_type'], name='my_field', required=False)
cf.save()
cf.obj_type = [obj_type]
cf.save()
# Assign a value to the first Site
site = Site.objects.first()
cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
cfv.value = data['field_value']
cfv.save()
# Retrieve the stored value
cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
self.assertEqual(cfv.value, data['field_value'])
# Delete the stored value
cfv.value = data['empty_value']
cfv.save()
self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
# Delete the custom field
cf.delete()
def test_select_field(self):
obj_type = ContentType.objects.get_for_model(Site)
# Create a custom field
cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False)
cf.save()
cf.obj_type = [obj_type]
cf.save()
# Create some choices for the field
CustomFieldChoice.objects.bulk_create([
CustomFieldChoice(field=cf, value='Option A'),
CustomFieldChoice(field=cf, value='Option B'),
CustomFieldChoice(field=cf, value='Option C'),
])
# Assign a value to the first Site
site = Site.objects.first()
cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
cfv.value = cf.choices.first()
cfv.save()
# Retrieve the stored value
cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
self.assertEqual(str(cfv.value), 'Option A')
# Delete the stored value
cfv.value = None
cfv.save()
self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
# Delete the custom field
cf.delete()

View File

@@ -1,6 +1,7 @@
from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from extras.api.serializers import CustomFieldSerializer
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from tenancy.api.serializers import TenantNestedSerializer
@@ -9,12 +10,12 @@ from tenancy.api.serializers import TenantNestedSerializer
# VRFs
#
class VRFSerializer(serializers.ModelSerializer):
class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer):
tenant = TenantNestedSerializer()
class Meta:
model = VRF
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
class VRFNestedSerializer(VRFSerializer):
@@ -70,12 +71,12 @@ class RIRNestedSerializer(RIRSerializer):
# Aggregates
#
class AggregateSerializer(serializers.ModelSerializer):
class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer):
rir = RIRNestedSerializer()
class Meta:
model = Aggregate
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description']
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
class AggregateNestedSerializer(AggregateSerializer):
@@ -106,7 +107,7 @@ class VLANGroupNestedSerializer(VLANGroupSerializer):
# VLANs
#
class VLANSerializer(serializers.ModelSerializer):
class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer()
group = VLANGroupNestedSerializer()
tenant = TenantNestedSerializer()
@@ -114,7 +115,8 @@ class VLANSerializer(serializers.ModelSerializer):
class Meta:
model = VLAN
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name']
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
'custom_fields']
class VLANNestedSerializer(VLANSerializer):
@@ -127,7 +129,7 @@ class VLANNestedSerializer(VLANSerializer):
# Prefixes
#
class PrefixSerializer(serializers.ModelSerializer):
class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer()
vrf = VRFTenantSerializer()
tenant = TenantNestedSerializer()
@@ -136,7 +138,8 @@ class PrefixSerializer(serializers.ModelSerializer):
class Meta:
model = Prefix
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description']
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description',
'custom_fields']
class PrefixNestedSerializer(PrefixSerializer):
@@ -149,14 +152,15 @@ class PrefixNestedSerializer(PrefixSerializer):
# IP addresses
#
class IPAddressSerializer(serializers.ModelSerializer):
class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
vrf = VRFTenantSerializer()
tenant = TenantNestedSerializer()
interface = InterfaceNestedSerializer()
class Meta:
model = IPAddress
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside']
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside',
'custom_fields']
class IPAddressNestedSerializer(IPAddressSerializer):

View File

@@ -3,6 +3,7 @@ from rest_framework import generics
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from ipam import filters
from extras.api.views import CustomFieldModelAPIView
from . import serializers
@@ -10,20 +11,20 @@ from . import serializers
# VRFs
#
class VRFListView(generics.ListAPIView):
class VRFListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List all VRFs
"""
queryset = VRF.objects.select_related('tenant')
queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field')
serializer_class = serializers.VRFSerializer
filter_class = filters.VRFFilter
class VRFDetailView(generics.RetrieveAPIView):
class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single VRF
"""
queryset = VRF.objects.select_related('tenant')
queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field')
serializer_class = serializers.VRFSerializer
@@ -71,20 +72,20 @@ class RIRDetailView(generics.RetrieveAPIView):
# Aggregates
#
class AggregateListView(generics.ListAPIView):
class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List aggregates (filterable)
"""
queryset = Aggregate.objects.select_related('rir')
queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field')
serializer_class = serializers.AggregateSerializer
filter_class = filters.AggregateFilter
class AggregateDetailView(generics.RetrieveAPIView):
class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single aggregate
"""
queryset = Aggregate.objects.select_related('rir')
queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field')
serializer_class = serializers.AggregateSerializer
@@ -92,20 +93,22 @@ class AggregateDetailView(generics.RetrieveAPIView):
# Prefixes
#
class PrefixListView(generics.ListAPIView):
class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List prefixes (filterable)
"""
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.PrefixSerializer
filter_class = filters.PrefixFilter
class PrefixDetailView(generics.RetrieveAPIView):
class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single prefix
"""
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.PrefixSerializer
@@ -113,22 +116,22 @@ class PrefixDetailView(generics.RetrieveAPIView):
# IP addresses
#
class IPAddressListView(generics.ListAPIView):
class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List IP addresses (filterable)
"""
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
.prefetch_related('nat_outside', 'custom_field_values__field')
serializer_class = serializers.IPAddressSerializer
filter_class = filters.IPAddressFilter
class IPAddressDetailView(generics.RetrieveAPIView):
class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single IP address
"""
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
.prefetch_related('nat_outside', 'custom_field_values__field')
serializer_class = serializers.IPAddressSerializer
@@ -157,18 +160,20 @@ class VLANGroupDetailView(generics.RetrieveAPIView):
# VLANs
#
class VLANListView(generics.ListAPIView):
class VLANListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List VLANs (filterable)
"""
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.VLANSerializer
filter_class = filters.VLANFilter
class VLANDetailView(generics.RetrieveAPIView):
class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single VLAN
"""
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.VLANSerializer

View File

@@ -5,12 +5,14 @@ from netaddr.core import AddrFormatError
from django.db.models import Q
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
class VRFFilter(django_filters.FilterSet):
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -20,12 +22,12 @@ class VRFFilter(django_filters.FilterSet):
lookup_type='icontains',
label='Name',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
@@ -44,7 +46,7 @@ class VRFFilter(django_filters.FilterSet):
fields = ['name', 'rd']
class AggregateFilter(django_filters.FilterSet):
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -75,7 +77,7 @@ class AggregateFilter(django_filters.FilterSet):
return queryset.filter(qs_filter)
class PrefixFilter(django_filters.FilterSet):
class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -84,29 +86,34 @@ class PrefixFilter(django_filters.FilterSet):
action='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='_vrf',
label='VRF',
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = django_filters.MethodFilter(
action='_tenant_id',
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.MethodFilter(
action='_tenant',
label='Tenant',
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
@@ -121,12 +128,12 @@ class PrefixFilter(django_filters.FilterSet):
name='vlan__vid',
label='VLAN number (1-4095)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
@@ -135,7 +142,7 @@ class PrefixFilter(django_filters.FilterSet):
class Meta:
model = Prefix
fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -156,17 +163,6 @@ class PrefixFilter(django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
def _tenant(self, queryset, value):
if str(value) == '':
return queryset
@@ -186,7 +182,7 @@ class PrefixFilter(django_filters.FilterSet):
)
class IPAddressFilter(django_filters.FilterSet):
class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -195,22 +191,27 @@ class IPAddressFilter(django_filters.FilterSet):
action='search_by_parent',
label='Parent prefix',
)
vrf = django_filters.MethodFilter(
action='_vrf',
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
label='VRF',
)
# Duplicate of `vrf` for backward-compatibility
vrf_id = django_filters.MethodFilter(
action='_vrf',
label='VRF',
vrf = NullableModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
tenant_id = django_filters.MethodFilter(
action='_tenant_id',
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.MethodFilter(
action='_tenant',
label='Tenant',
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
@@ -231,7 +232,7 @@ class IPAddressFilter(django_filters.FilterSet):
class Meta:
model = IPAddress
fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
fields = ['q', 'family', 'device_id', 'device', 'interface_id']
def search(self, queryset, value):
qs_filter = Q(description__icontains=value)
@@ -252,35 +253,6 @@ class IPAddressFilter(django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
def _tenant(self, queryset, value):
if str(value) == '':
return queryset
return queryset.filter(
Q(tenant__slug=value) |
Q(tenant__isnull=True, vrf__tenant__slug=value)
)
def _tenant_id(self, queryset, value):
try:
value = int(value)
except ValueError:
return queryset.none()
return queryset.filter(
Q(tenant__pk=value) |
Q(tenant__isnull=True, vrf__tenant__pk=value)
)
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -300,7 +272,7 @@ class VLANGroupFilter(django_filters.FilterSet):
fields = ['site_id', 'site']
class VLANFilter(django_filters.FilterSet):
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
@@ -316,12 +288,12 @@ class VLANFilter(django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
to_field_name='slug',
@@ -336,23 +308,23 @@ class VLANFilter(django_filters.FilterSet):
name='vid',
label='VLAN number (1-4095)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
tenant = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
role_id = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
role = NullableModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',

View File

@@ -1,12 +1,13 @@
from netaddr import IPNetwork
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant
from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
from utilities.forms import (
APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
)
from .models import (
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
@@ -15,6 +16,11 @@ from .models import (
FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
IP_FAMILY_CHOICES = [
('', 'All'),
(4, 'IPv4'),
(6, 'IPv6'),
]
def bulkedit_vrf_choices():
@@ -33,7 +39,7 @@ def bulkedit_vrf_choices():
# VRFs
#
class VRFForm(forms.ModelForm, BootstrapMixin):
class VRFForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = VRF
@@ -59,20 +65,16 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VRFFromCSVForm)
class VRFBulkEditForm(forms.Form, BootstrapMixin):
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
description = forms.CharField(max_length=100, required=False)
def vrf_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
class VRFFilterForm(forms.Form, BootstrapMixin):
tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
null_option=(0, None))
#
@@ -91,7 +93,7 @@ class RIRForm(forms.ModelForm, BootstrapMixin):
# Aggregates
#
class AggregateForm(forms.ModelForm, BootstrapMixin):
class AggregateForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Aggregate
@@ -116,21 +118,18 @@ class AggregateImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=AggregateFromCSVForm)
class AggregateBulkEditForm(forms.Form, BootstrapMixin):
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
date_added = forms.DateField(required=False)
description = forms.CharField(max_length=100, required=False)
def aggregate_rir_choices():
rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
class AggregateFilterForm(forms.Form, BootstrapMixin):
rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
widget=forms.SelectMultiple(attrs={'size': 8}))
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
label='RIR')
#
@@ -149,7 +148,7 @@ class RoleForm(forms.ModelForm, BootstrapMixin):
# Prefixes
#
class PrefixForm(forms.ModelForm, BootstrapMixin):
class PrefixForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
@@ -251,7 +250,7 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=PrefixFromCSVForm)
class PrefixBulkEditForm(forms.Form, BootstrapMixin):
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
@@ -261,21 +260,6 @@ class PrefixBulkEditForm(forms.Form, BootstrapMixin):
description = forms.CharField(max_length=100, required=False)
def prefix_vrf_choices():
vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes'))
return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices]
def tenant_choices():
tenant_choices = Tenant.objects.all()
return [(t.slug, t.name) for t in tenant_choices]
def prefix_site_choices():
site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
def prefix_status_choices():
status_counts = {}
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -283,25 +267,21 @@ def prefix_status_choices():
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
def prefix_role_choices():
role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
class PrefixFilterForm(forms.Form, BootstrapMixin):
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Prefix
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Network',
}))
vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
widget=forms.SelectMultiple(attrs={'size': 6}))
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
widget=forms.SelectMultiple(attrs={'size': 6}))
status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
widget=forms.SelectMultiple(attrs={'size': 6}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
@@ -309,7 +289,7 @@ class PrefixFilterForm(forms.Form, BootstrapMixin):
# IP addresses
#
class IPAddressForm(forms.ModelForm, BootstrapMixin):
class IPAddressForm(BootstrapMixin, CustomFieldForm):
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
@@ -425,31 +405,23 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
class IPAddressBulkEditForm(forms.Form, BootstrapMixin):
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
description = forms.CharField(max_length=100, required=False)
def ipaddress_family_choices():
return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')]
def ipaddress_vrf_choices():
vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices]
class IPAddressFilterForm(forms.Form, BootstrapMixin):
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family')
vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
widget=forms.SelectMultiple(attrs={'size': 6}))
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
widget=forms.SelectMultiple(attrs={'size': 6}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug', null_option=(0, 'None'))
#
@@ -464,21 +436,15 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin):
fields = ['site', 'name', 'slug']
def vlangroup_site_choices():
site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
#
# VLANs
#
class VLANForm(forms.ModelForm, BootstrapMixin):
class VLANForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
))
@@ -539,7 +505,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VLANFromCSVForm)
class VLANBulkEditForm(forms.Form, BootstrapMixin):
class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
@@ -549,21 +515,6 @@ class VLANBulkEditForm(forms.Form, BootstrapMixin):
description = forms.CharField(max_length=100, required=False)
def vlan_site_choices():
site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
def vlan_group_choices():
group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
def vlan_tenant_choices():
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
def vlan_status_choices():
status_counts = {}
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -571,18 +522,13 @@ def vlan_status_choices():
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
def vlan_role_choices():
role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
class VLANFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-09-15 16:08
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0007_prefix_ipaddress_add_tenant'),
]
operations = [
migrations.AlterModelOptions(
name='prefix',
options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
),
]

View File

@@ -1,14 +1,18 @@
from netaddr import IPNetwork, cidr_merge
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.expressions import RawSQL
from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from .fields import IPNetworkField, IPAddressField
@@ -39,7 +43,7 @@ STATUS_CHOICE_CLASSES = {
}
class VRF(CreatedUpdatedModel):
class VRF(CreatedUpdatedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
@@ -51,6 +55,7 @@ class VRF(CreatedUpdatedModel):
enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
help_text="Prevent duplicate prefixes/IP addresses within this VRF")
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta:
ordering = ['name']
@@ -93,7 +98,7 @@ class RIR(models.Model):
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
class Aggregate(CreatedUpdatedModel):
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@@ -103,6 +108,7 @@ class Aggregate(CreatedUpdatedModel):
rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR')
date_added = models.DateField(blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta:
ordering = ['family', 'prefix']
@@ -187,7 +193,7 @@ class Role(models.Model):
return self.vlans.count()
class PrefixQuerySet(models.QuerySet):
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
@@ -222,7 +228,7 @@ class PrefixQuerySet(models.QuerySet):
return filter(lambda p: p.depth <= limit, queryset)
class Prefix(CreatedUpdatedModel):
class Prefix(CreatedUpdatedModel, CustomFieldModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@@ -239,11 +245,12 @@ class Prefix(CreatedUpdatedModel):
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1)
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = PrefixQuerySet.as_manager()
class Meta:
ordering = ['family', 'prefix']
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):
@@ -274,6 +281,7 @@ class Prefix(CreatedUpdatedModel):
return ','.join([
str(self.prefix),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.site.name if self.site else '',
self.get_status_display(),
self.role.name if self.role else '',
@@ -295,7 +303,21 @@ class Prefix(CreatedUpdatedModel):
return STATUS_CHOICE_CLASSES[self.status]
class IPAddress(CreatedUpdatedModel):
class IPAddressManager(models.Manager):
def get_queryset(self):
"""
By default, PostgreSQL will order INETs with shorter (larger) prefix lengths ahead of those with longer
(smaller) masks. This makes no sense when ordering IPs, which should be ordered solely by family and host
address. We can use HOST() to extract just the host portion of the address (ignoring its mask), but we must
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
IP address as a /32 or /128.
"""
qs = super(IPAddressManager, self).get_queryset()
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
class IPAddress(CreatedUpdatedModel, CustomFieldModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
@@ -316,6 +338,9 @@ class IPAddress(CreatedUpdatedModel):
nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
null=True, verbose_name='NAT IP (inside)')
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = IPAddressManager()
class Meta:
ordering = ['family', 'address']
@@ -361,6 +386,7 @@ class IPAddress(CreatedUpdatedModel):
return ','.join([
str(self.address),
self.vrf.rd if self.vrf else '',
self.tenant.name if self.tenant else '',
self.device.identifier if self.device else '',
self.interface.name if self.interface else '',
'True' if is_primary else '',
@@ -398,7 +424,7 @@ class VLANGroup(models.Model):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
class VLAN(CreatedUpdatedModel):
class VLAN(CreatedUpdatedModel, CustomFieldModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
@@ -418,6 +444,7 @@ class VLAN(CreatedUpdatedModel):
status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta:
ordering = ['site', 'group', 'vid']

View File

@@ -111,6 +111,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vrf'
model = VRF
form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
cancel_url = 'ipam:vrf_list'
@@ -135,19 +136,6 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/vrf_bulk_edit.html'
default_redirect_url = 'ipam:vrf_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['tenant'] == 0:
fields_to_update['tenant'] = None
elif form.cleaned_data['tenant']:
fields_to_update['tenant'] = form.cleaned_data['tenant']
for field in ['description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf'
@@ -235,6 +223,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_aggregate'
model = Aggregate
form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
cancel_url = 'ipam:aggregate_list'
@@ -259,15 +248,6 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/aggregate_bulk_edit.html'
default_redirect_url = 'ipam:aggregate_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['rir', 'date_added', 'description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
@@ -373,6 +353,7 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_prefix'
model = Prefix
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
cancel_url = 'ipam:prefix_list'
@@ -398,20 +379,6 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/prefix_bulk_edit.html'
default_redirect_url = 'ipam:prefix_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['vrf', 'tenant']:
if form.cleaned_data[field] == 0:
fields_to_update[field] = None
elif form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
for field in ['site', 'status', 'role', 'description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
@@ -524,20 +491,6 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/ipaddress_bulk_edit.html'
default_redirect_url = 'ipam:ipaddress_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['vrf', 'tenant']:
if form.cleaned_data[field] == 0:
fields_to_update[field] = None
elif form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
for field in ['description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
@@ -601,6 +554,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlan'
model = VLAN
form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
cancel_url = 'ipam:vlan_list'
@@ -625,19 +579,6 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'ipam/vlan_bulk_edit.html'
default_redirect_url = 'ipam:vlan_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['tenant'] == 0:
fields_to_update['tenant'] = None
elif form.cleaned_data['tenant']:
fields_to_update['tenant'] = form.cleaned_data['tenant']
for field in ['site', 'group', 'status', 'role', 'description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'

View File

@@ -12,7 +12,7 @@ except ImportError:
"the documentation.")
VERSION = '1.5.1'
VERSION = '1.6.1'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -71,7 +71,7 @@ if LDAP_CONFIGURED:
logger.setLevel(logging.DEBUG)
except ImportError:
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
"You can remove netbox/ldap.py to disable LDAP.")
"You can remove netbox/ldap_config.py to disable LDAP.")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -164,6 +164,9 @@ STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"),
)
# Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
DATA_UPLOAD_MAX_NUMBER_FIELDS = None
# Messages
MESSAGE_TAGS = {
messages.ERROR: 'danger',

View File

@@ -34,7 +34,8 @@ body {
footer p {
margin: 20px 0;
}
@media (max-width: 1120px) {
@media (max-width: 1200px) {
.navbar-header {
float: none;
}
@@ -54,6 +55,7 @@ footer p {
}
.navbar-collapse.collapse {
display: none!important;
max-height: none;
}
.navbar-nav {
float: none!important;
@@ -84,13 +86,11 @@ th.pk, td.pk {
width: 30px;
}
/* Paginator */
nav ul.pagination {
margin-top: 0;
}
/* Racks */
div.rack_header {
margin-left: 36px;

View File

@@ -25,68 +25,13 @@ $(document).ready(function() {
});
if (slug_field) {
var slug_source = $('#id_' + slug_field.attr('slug-source'));
slug_source.keyup(function() {
slug_source.on('keyup change', function() {
if (slug_field && !slug_field.attr('_changed')) {
slug_field.val(slugify($(this).val(), 50));
}
})
}
// Helper select fields
$('select.helper-parent').change(function () {
// Resolve child field by ID specified in parent
var child_field = $('#id_' + $(this).attr('child'));
// Wipe out any existing options within the child field
child_field.empty();
child_field.append($("<option></option>").attr("value", "").text(""));
// If the parent has a value set, fetch a list of child options via the API and populate the child field with them
if ($(this).val()) {
// Construct the API request URL
var api_url = $(this).attr('child-source');
var parent_accessor = $(this).attr('parent-accessor');
if (parent_accessor) {
api_url += '?' + parent_accessor + '=' + $(this).val();
} else {
api_url += '?' + $(this).attr('name') + '_id=' + $(this).val();
}
var api_url_extra = $(this).attr('child-filter');
if (api_url_extra) {
api_url += '&' + api_url_extra;
}
var disabled_indicator = $(this).attr('disabled-indicator');
var disabled_exempt = child_field.attr('exempt');
var child_display = $(this).attr('child-display');
if (!child_display) {
child_display = 'name';
}
$.ajax({
url: api_url,
dataType: 'json',
success: function (response, status) {
console.log(response);
$.each(response, function (index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[child_display]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != disabled_exempt) {
option.attr("disabled", "disabled")
}
child_field.append(option);
});
}
});
}
// Trigger change event in case the child field is the parent of another field
child_field.change();
});
// API select widget
$('select[filter-for]').change(function () {

View File

@@ -8,9 +8,15 @@ $(document).ready(function() {
}
// Update livesearch text when real field changes
search_field.val(real_field.children('option:selected').text());
real_field.change(function() {
if (real_field.val()) {
search_field.val(real_field.children('option:selected').text());
}
real_field.change(function() {
if (real_field.val()) {
search_field.val(real_field.children('option:selected').text());
} else {
search_field.val('');
}
});
search_field.autocomplete({

View File

@@ -25,17 +25,20 @@ $(document).ready(function() {
});
// Adding/editing a secret
$('form.requires-private-key').submit(function(event) {
private_key_field = $('#id_private_key');
private_key_field.parents('form').submit(function(event) {
console.log("form submitted");
var private_key = sessionStorage.getItem('private_key');
if (private_key) {
$('#id_private_key').val(private_key);
} else {
private_key_field.val(private_key);
} else if ($('form .requires-private-key:first').val()) {
console.log("we need a key!");
$('#privkey_modal').modal('show');
return false;
}
});
// Prompt the user to enter a private RSA key for decryption
// Saving a private RSA key locally
$('#submit_privkey').click(function() {
var private_key = $('#user_privkey').val();
sessionStorage.setItem('private_key', private_key);

View File

@@ -5,7 +5,7 @@ from django import forms
from django.db.models import Count
from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, SlugField
from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
from .models import Secret, SecretRole, UserKey
@@ -47,8 +47,9 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin):
#
class SecretForm(forms.ModelForm, BootstrapMixin):
private_key = forms.CharField(widget=forms.HiddenInput())
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext')
private_key = forms.CharField(required=False, widget=forms.HiddenInput())
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
widget=forms.TextInput(attrs={'class': 'requires-private-key'}))
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
class Meta:
@@ -56,7 +57,8 @@ class SecretForm(forms.ModelForm, BootstrapMixin):
fields = ['role', 'name', 'plaintext', 'plaintext2']
def clean(self):
validate_rsa_key(self.cleaned_data['private_key'])
if self.cleaned_data['plaintext']:
validate_rsa_key(self.cleaned_data['private_key'])
def clean_plaintext2(self):
plaintext = self.cleaned_data['plaintext']
@@ -84,7 +86,7 @@ class SecretFromCSVForm(forms.ModelForm):
class SecretImportForm(BulkImportForm, BootstrapMixin):
private_key = forms.CharField(widget=forms.HiddenInput())
csv = CSVDataField(csv_form=SecretFromCSVForm)
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
class SecretBulkEditForm(forms.Form, BootstrapMixin):
@@ -93,13 +95,8 @@ class SecretBulkEditForm(forms.Form, BootstrapMixin):
name = forms.CharField(max_length=100, required=False)
def secret_role_choices():
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
class SecretFilterForm(forms.Form, BootstrapMixin):
role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
#

View File

@@ -205,15 +205,6 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'secrets/secret_bulk_edit.html'
default_redirect_url = 'secrets:secret_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['role', 'name']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secret'

View File

@@ -167,7 +167,7 @@
{% if perms.ipam.add_rir or perms.ipam.add_role %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefix/VLAN Roles</a></li>
<li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefix Roles</a></li>
{% if perms.ipam.add_role %}
<li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
{% endif %}
@@ -186,6 +186,11 @@
{% if perms.ipam.add_vlangroup %}
<li><a href="{% url 'ipam:vlangroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLAN Roles</a></li>
{% if perms.ipam.add_role %}
<li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">

View File

@@ -95,23 +95,19 @@
<tr>
<td>Commit Rate</td>
<td>
{% if circuit.commit_speed %}
{{ circuit.commit_speed_human }}
{% if circuit.commit_rate %}
{{ circuit.commit_rate_human }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ circuit.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ circuit.last_updated }}</td>
</tr>
</table>
</div>
{% with circuit.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=circuit %}
</div>
<div class="col-md-6">
<div class="panel panel-default">

View File

@@ -23,6 +23,14 @@
{% render_field form.commit_rate %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Termination</strong></div>
<div class="panel-body">

View File

@@ -10,6 +10,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add a circuit
</a>
<a href="{% url 'circuits:circuit_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import circuits
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='circuits' %}
</div>

View File

@@ -103,16 +103,11 @@
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ provider.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ provider.last_updated }}</td>
</tr>
</table>
</div>
{% with provider.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
@@ -125,6 +120,7 @@
{% endif %}
</div>
</div>
{% include 'inc/created_updated.html' with obj=provider %}
</div>
<div class="col-md-6">
<div class="panel panel-default">

View File

@@ -19,6 +19,14 @@
{% render_field form.admin_contact %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

View File

@@ -9,6 +9,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add a provider
</a>
<a href="{% url 'circuits:provider_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import providers
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='providers' %}
</div>

View File

@@ -79,14 +79,6 @@
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ device.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ device.last_updated }}</td>
</tr>
</table>
</div>
<div class="panel panel-default">
@@ -152,6 +144,9 @@
</tr>
</table>
</div>
{% with device.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% if request.user.is_authenticated %}
<div class="panel panel-default">
<div class="panel-heading">
@@ -306,6 +301,7 @@
<div class="panel-body text-muted">None found</div>
{% endif %}
</div>
{% include 'inc/created_updated.html' with obj=device %}
</div>
<div class="col-md-6">
{% if device_bays or device.device_type.is_parent_device %}

View File

@@ -63,6 +63,14 @@
{% endif %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

View File

@@ -83,7 +83,7 @@
</tr>
<tr>
<td>Face</td>
<td>Rack face; front or rear (optional)</td>
<td>Rack face; front or rear (required if position is set)</td>
<td>Rear</td>
</tr>
</tbody>

View File

@@ -56,6 +56,10 @@
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
</a>
{% elif iface.circuit and perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=iface.circuit.pk %}" class="btn btn-danger btn-xs" title="Edit circuit">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>

View File

@@ -130,16 +130,11 @@
<a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ rack.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ rack.last_updated }}</td>
</tr>
</table>
</div>
{% with rack.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Non-Racked Devices</strong>
@@ -187,6 +182,7 @@
{% endif %}
</div>
</div>
{% include 'inc/created_updated.html' with obj=rack %}
</div>
<div class="row col-md-6">
<div class="col-md-6 col-sm-6 col-xs-12">

View File

@@ -16,6 +16,14 @@
{% render_field form.u_height %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

View File

@@ -53,6 +53,11 @@
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Compute</td>
</tr>
<tr>
<td>Type</td>
<td>Rack type (optional)</td>
@@ -71,7 +76,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,4-post cabinet,19,42</pre>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42</pre>
</div>
</div>
{% endblock %}

View File

@@ -109,16 +109,11 @@
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ site.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ site.last_updated }}</td>
</tr>
</table>
</div>
{% with site.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
@@ -131,6 +126,7 @@
{% endif %}
</div>
</div>
{% include 'inc/created_updated.html' with obj=site %}
</div>
<div class="col-md-5">
<div class="panel panel-default">

View File

@@ -6,7 +6,7 @@
{% block select_objects_table %}
{% for site in selected_objects %}
<tr>
<td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site.slug }}</a></td>
<td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site }}</a></td>
<td>{{ site.tenant }}</td>
</tr>
{% endfor %}

View File

@@ -14,6 +14,14 @@
{% render_field form.shipping_address %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

View File

@@ -0,0 +1,3 @@
<p>
<small class="text-muted">Created {{ obj.created }} &middot; Updated <span title="{{ obj.last_updated }}">{{ obj.last_updated|timesince }}</span> ago</small>
</p>

View File

@@ -0,0 +1,29 @@
{% if custom_fields %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Custom Fields</strong>
</div>
<table class="table table-hover panel-body">
{% for field, value in custom_fields.items %}
<tr>
<td>{{ field }}</td>
<td>
{% if value == True %}
<i class="glyphicon glyphicon-ok text-success" title="True"></i>
{% elif value == False %}
<i class="glyphicon glyphicon-remove text-danger" title="False"></i>
{% elif field.type == 500 and value %}
{{ value|urlizetrunc:75 }}
{% elif value %}
{{ value }}
{% elif field.required %}
<span class="text-warning">Not defined</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}

View File

@@ -1,27 +1,32 @@
{% load form_helpers %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="fa fa-filter" aria-hidden="true"></span>
<strong>Filter</strong>
</div>
<div class="panel-body">
<form action="." method="get" class="form">
{% for field in filter_form %}
<div class="form-group">
{% if field|widget_type == 'checkboxinput' %}
<label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label>
{% else %}
{{ field.label_tag }}
{{ field }}
{% endif %}
{% if filter_form %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="fa fa-filter" aria-hidden="true"></span>
<strong>Filter</strong>
</div>
<div class="panel-body">
<form action="." method="get" class="form">
{% for field in filter_form %}
<div class="form-group">
{% if field|widget_type == 'checkboxinput' %}
<label for="{{ field.id_for_label }}">{{ field }} {{ field.label }}</label>
{% else %}
{{ field.label_tag }}
{{ field }}
{% endif %}
</div>
{% endfor %}
<div class="text-right">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span> Apply
</button>
<a href="." class="btn btn-default">
<span class="fa fa-remove" aria-hidden="true"></span> Clear
</a>
</div>
{% endfor %}
<div class="text-right">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span> Apply filters
</button>
</div>
</form>
</form>
</div>
</div>
</div>
{% endif %}

View File

@@ -77,16 +77,14 @@
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ aggregate.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ aggregate.last_updated }}</td>
</tr>
</table>
</div>
{% include 'inc/created_updated.html' with obj=aggregate %}
</div>
<div class="col-md-6">
{% with aggregate.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
</div>
</div>
<div class="row">

View File

@@ -0,0 +1,22 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Aggregate</strong></div>
<div class="panel-body">
{% render_field form.prefix %}
{% render_field form.rir %}
{% render_field form.date_added %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -11,6 +11,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add an aggregate
</a>
<a href="{% url 'ipam:aggregate_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import aggregates
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='aggregates' %}
</div>

View File

@@ -119,16 +119,12 @@
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ ipaddress.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ ipaddress.last_updated }}</td>
</tr>
</table>
</div>
{% with ipaddress.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=ipaddress %}
</div>
<div class="col-md-6">
{% with heading='Parent Prefixes' %}

View File

@@ -51,6 +51,14 @@
{% render_field form.nat_inside %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}
{% block javascript %}

View File

@@ -99,16 +99,13 @@
<td>IP Addresses</td>
<td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }}</a></td>
</tr>
<tr>
<td>Created</td>
<td>{{ prefix.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ prefix.last_updated }}</td>
</tr>
</table>
</div>
{% with prefix.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=prefix %}
<br />
</div>
<div class="col-md-7">
{% if duplicate_prefix_table.rows %}

View File

@@ -0,0 +1,26 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Prefix</strong></div>
<div class="panel-body">
{% render_field form.prefix %}
{% render_field form.vrf %}
{% render_field form.tenant %}
{% render_field form.site %}
{% render_field form.vlan %}
{% render_field form.status %}
{% render_field form.role %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -6,6 +6,15 @@
{% block content %}
<div class="pull-right">
<a href="{% url 'ipam:prefix_list' %}{% querystring_toggle request expand='on' %}" class="btn btn-default">
{% if 'expand' in request.GET %}
<span class="fa fa-chevron-right" aria-hidden="true"></span>
Collapse all
{% else %}
<span class="fa fa-chevron-down" aria-hidden="true"></span>
Expand all
{% endif %}
</a>
{% if perms.ipam.add_prefix %}
<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>

View File

@@ -107,17 +107,13 @@
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ vlan.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ vlan.last_updated }}</td>
</tr>
</table>
</div>
{% with vlan.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=vlan %}
</div>
<div class="col-md-6">
<div class="panel panel-default">

View File

@@ -0,0 +1,26 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>VLAN</strong></div>
<div class="panel-body">
{% render_field form.site %}
{% render_field form.group %}
{% render_field form.vid %}
{% render_field form.name %}
{% render_field form.tenant %}
{% render_field form.status %}
{% render_field form.role %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -79,17 +79,13 @@
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ vrf.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ vrf.last_updated }}</td>
</tr>
</table>
</div>
{% with vrf.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/created_updated.html' with obj=vrf %}
</div>
<div class="col-md-6">
<div class="panel panel-default">

View File

@@ -0,0 +1,23 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>VRF</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.rd %}
{% render_field form.tenant %}
{% render_field form.enforce_unique %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -56,16 +56,9 @@
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ secret.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ secret.last_updated }}</td>
</tr>
</table>
</div>
{% include 'inc/created_updated.html' with obj=secret %}
</div>
<div class="col-md-6">
{% if secret|decryptable_by:request.user %}

View File

@@ -5,7 +5,7 @@
{% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal requires-private-key">
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{{ form.private_key }}
<div class="row">

View File

@@ -17,7 +17,7 @@
</div>
</div>
{% endif %}
<form action="." method="post" class="form requires-private-key">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">

View File

@@ -63,16 +63,11 @@
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ tenant.created }}</td>
</tr>
<tr>
<td>Last Updated</td>
<td>{{ tenant.last_updated }}</td>
</tr>
</table>
</div>
{% with tenant.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
@@ -85,6 +80,7 @@
{% endif %}
</div>
</div>
{% include 'inc/created_updated.html' with obj=tenant %}
</div>
<div class="col-md-5">
<div class="panel panel-default">

View File

@@ -12,6 +12,14 @@
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

View File

@@ -10,6 +10,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add a tenant
</a>
<a href="{% url 'tenancy:tenant_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import tenants
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='tenants' %}
</div>

View File

@@ -31,6 +31,7 @@
Edit user key
</a>
</div>
{% include 'inc/created_updated.html' with obj=userkey %}
{% else %}
<p>You don't have a user key on file.</p>
<p>

View File

@@ -8,9 +8,6 @@
{% if request.POST.redirect_url %}
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
{% endif %}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
<div class="row">
<div class="col-md-7">
<div class="panel panel-default">

View File

@@ -0,0 +1,7 @@
{% load form_helpers %}
{% for field in form %}
{% if field.name in form.custom_fields %}
{% render_field field %}
{% endif %}
{% endfor %}

View File

@@ -1,5 +1,6 @@
from rest_framework import serializers
from extras.api.serializers import CustomFieldSerializer
from tenancy.models import Tenant, TenantGroup
@@ -24,12 +25,12 @@ class TenantGroupNestedSerializer(TenantGroupSerializer):
# Tenants
#
class TenantSerializer(serializers.ModelSerializer):
class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer):
group = TenantGroupNestedSerializer()
class Meta:
model = Tenant
fields = ['id', 'name', 'slug', 'group', 'comments']
fields = ['id', 'name', 'slug', 'group', 'comments', 'custom_fields']
class TenantNestedSerializer(TenantSerializer):

View File

@@ -3,6 +3,7 @@ from rest_framework import generics
from tenancy.models import Tenant, TenantGroup
from tenancy.filters import TenantFilter
from extras.api.views import CustomFieldModelAPIView
from . import serializers
@@ -22,18 +23,18 @@ class TenantGroupDetailView(generics.RetrieveAPIView):
serializer_class = serializers.TenantGroupSerializer
class TenantListView(generics.ListAPIView):
class TenantListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List tenants (filterable)
"""
queryset = Tenant.objects.select_related('group')
queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field')
serializer_class = serializers.TenantSerializer
filter_class = TenantFilter
class TenantDetailView(generics.RetrieveAPIView):
class TenantDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single tenant
"""
queryset = Tenant.objects.select_related('group')
queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field')
serializer_class = serializers.TenantSerializer

View File

@@ -2,20 +2,22 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import Tenant, TenantGroup
class TenantFilter(django_filters.FilterSet):
class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
group_id = django_filters.ModelMultipleChoiceFilter(
group_id = NullableModelMultipleChoiceFilter(
name='group',
queryset=TenantGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
group = NullableModelMultipleChoiceFilter(
name='group',
queryset=TenantGroup.objects.all(),
to_field_name='slug',

View File

@@ -1,9 +1,8 @@
from django import forms
from django.db.models import Count
from utilities.forms import (
BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField,
)
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField
from .models import Tenant, TenantGroup
@@ -48,7 +47,7 @@ class TenantGroupForm(forms.ModelForm, BootstrapMixin):
# Tenants
#
class TenantForm(forms.ModelForm, BootstrapMixin):
class TenantForm(BootstrapMixin, CustomFieldForm):
slug = SlugField()
comments = CommentField()
@@ -70,16 +69,12 @@ class TenantImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=TenantFromCSVForm)
class TenantBulkEditForm(forms.Form, BootstrapMixin):
class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput)
group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group')
def tenant_group_choices():
group_choices = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices]
class TenantFilterForm(forms.Form, BootstrapMixin):
group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Tenant
group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')),
to_field_name='slug', null_option=(0, 'None'))

View File

@@ -1,6 +1,8 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse
from django.db import models
from extras.models import CustomFieldModel, CustomFieldValue
from utilities.models import CreatedUpdatedModel
@@ -21,7 +23,7 @@ class TenantGroup(models.Model):
return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
class Tenant(CreatedUpdatedModel):
class Tenant(CreatedUpdatedModel, CustomFieldModel):
"""
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
department.
@@ -31,6 +33,7 @@ class Tenant(CreatedUpdatedModel):
group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL)
description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)")
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
class Meta:
ordering = ['group', 'name']

View File

@@ -1,5 +1,5 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render
from circuits.models import Circuit
@@ -59,8 +59,14 @@ def tenant(request, slug):
'rack_count': Rack.objects.filter(tenant=tenant).count(),
'device_count': Device.objects.filter(tenant=tenant).count(),
'vrf_count': VRF.objects.filter(tenant=tenant).count(),
'prefix_count': Prefix.objects.filter(tenant=tenant).count(),
'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(),
'prefix_count': Prefix.objects.filter(
Q(tenant=tenant) |
Q(tenant__isnull=True, vrf__tenant=tenant)
).count(),
'ipaddress_count': IPAddress.objects.filter(
Q(tenant=tenant) |
Q(tenant__isnull=True, vrf__tenant=tenant)
).count(),
'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
}
@@ -101,16 +107,6 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
template_name = 'tenancy/tenant_bulk_edit.html'
default_redirect_url = 'tenancy:tenant_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['group'] == 0:
fields_to_update['group'] = None
elif form.cleaned_data['group']:
fields_to_update['group'] = form.cleaned_data['group']
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'tenancy.delete_tenant'

View File

@@ -0,0 +1,93 @@
import django_filters
import itertools
from django import forms
from django.db.models import Q
from django.utils.encoding import force_text
class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is
used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null
choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar
to defining a MultipleChoiceField with:
choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()]
However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating
database migrations.
"""
iterator = forms.models.ModelChoiceIterator
def __init__(self, null_value=0, null_label='None', *args, **kwargs):
self.null_value = null_value
self.null_label = null_label
super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs)
def _get_choices(self):
if hasattr(self, '_choices'):
return self._choices
# Prepend the null choice to the queryset iterator
return itertools.chain(
[(self.null_value, self.null_label)],
self.iterator(self),
)
choices = property(_get_choices, forms.ChoiceField._set_choices)
def clean(self, value):
# Strip all instances of the null value before cleaning
if value is not None:
stripped_value = [x for x in value if x != force_text(self.null_value)]
else:
stripped_value = value
super(NullableModelMultipleChoiceField, self).clean(stripped_value)
return value
class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
"""
This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default
queryset filter argument is:
.filter(fieldname=value)
When filtering by the value representing "is null" ('0' by default) the argument is modified to:
.filter(fieldname__isnull=True)
"""
field_class = NullableModelMultipleChoiceField
def __init__(self, *args, **kwargs):
self.null_value = kwargs.get('null_value', 0)
super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs)
def filter(self, qs, value):
value = value or () # Make sure we have an iterable
if self.is_noop(qs, value):
return qs
# Even though not a noop, no point filtering if empty
if not value:
return qs
q = Q()
for v in set(value):
# Filtering by "is null"
if v == force_text(self.null_value):
arg = {'{}__isnull'.format(self.name): True}
# Filtering by a related field (e.g. slug)
elif self.field.to_field_name is not None:
arg = {'{}__{}'.format(self.name, self.field.to_field_name): v}
# Filtering by primary key (default)
else:
arg = {self.name: v}
if self.conjoined:
qs = self.get_method(qs)(**arg)
else:
q |= Q(**arg)
if self.distinct:
return self.get_method(qs)(q).distinct()
return self.get_method(qs)(q)

View File

@@ -1,4 +1,5 @@
import csv
import itertools
import re
from django import forms
@@ -130,11 +131,11 @@ class CSVDataField(forms.CharField):
'"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff']
"""
csv_form = None
widget = forms.Textarea
def __init__(self, csv_form, *args, **kwargs):
self.csv_form = csv_form
self.columns = self.csv_form().fields.keys()
self.widget = forms.Textarea
super(CSVDataField, self).__init__(*args, **kwargs)
self.strip = False
if not self.label:
@@ -142,10 +143,14 @@ class CSVDataField(forms.CharField):
if not self.help_text:
self.help_text = 'Enter one line per record in CSV format.'
def utf_8_encoder(self, unicode_csv_data):
for line in unicode_csv_data:
yield line.encode('utf-8')
def to_python(self, value):
# Return a list of dictionaries, each representing an individual record
records = []
reader = csv.reader(value.splitlines())
reader = csv.reader(self.utf_8_encoder(value.splitlines()))
for i, row in enumerate(reader, start=1):
if row:
if len(row) < len(self.columns):
@@ -222,6 +227,32 @@ class SlugField(forms.SlugField):
self.widget.attrs['slug-source'] = slug_source
class FilterChoiceField(forms.ModelMultipleChoiceField):
iterator = forms.models.ModelChoiceIterator
def __init__(self, null_option=None, *args, **kwargs):
self.null_option = null_option
if 'required' not in kwargs:
kwargs['required'] = False
if 'widget' not in kwargs:
kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
super(FilterChoiceField, self).__init__(*args, **kwargs)
def label_from_instance(self, obj):
if hasattr(obj, 'filter_count'):
return u'{} ({})'.format(obj, obj.filter_count)
return force_text(obj)
def _get_choices(self):
if hasattr(self, '_choices'):
return self._choices
if self.null_option is not None:
return itertools.chain([self.null_option], self.iterator(self))
return self.iterator(self)
choices = property(_get_choices, forms.ChoiceField._set_choices)
#
# Forms
#

32
netbox/utilities/sql.py Normal file
View File

@@ -0,0 +1,32 @@
from django.db import connections, models
from django.db.models.sql.compiler import SQLCompiler
class NullsFirstSQLCompiler(SQLCompiler):
def get_order_by(self):
result = super(NullsFirstSQLCompiler, self).get_order_by()
if result:
return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result]
return result
class NullsFirstQuery(models.sql.query.Query):
def get_compiler(self, using=None, connection=None):
if using is None and connection is None:
raise ValueError("Need either using or connection")
if using:
connection = connections[using]
return NullsFirstSQLCompiler(self, connection, using)
class NullsFirstQuerySet(models.QuerySet):
"""
Override PostgreSQL's default behavior of ordering NULLs last. This is needed e.g. to order Prefixes in the global
table before those assigned to a VRF.
"""
def __init__(self, model=None, query=None, using=None, hints=None):
super(NullsFirstQuerySet, self).__init__(model, query, using, hints)
self.query = query or NullsFirstQuery(self.model)

View File

@@ -14,6 +14,16 @@ def render_field(field):
}
@register.inclusion_tag('utilities/render_custom_fields.html')
def render_custom_fields(form):
"""
Render all custom fields in a form
"""
return {
'form': form,
}
@register.inclusion_tag('utilities/render_form.html')
def render_form(form):
"""

View File

@@ -1,27 +1,40 @@
from collections import OrderedDict
from django_tables2 import RequestConfig
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse
from django.db import transaction, IntegrityError
from django.db.models import ProtectedError
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError
from django.utils.decorators import method_decorator
from django.utils.http import is_safe_url
from django.views.generic import View
from extras.models import ExportTemplate, UserAction
from extras.forms import CustomFieldForm
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm
from .paginator import EnhancedPaginator
class annotate_custom_fields:
def __init__(self, queryset, custom_fields):
self.queryset = queryset
self.custom_fields = custom_fields
def __iter__(self):
for obj in self.queryset:
values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
yield obj
class ObjectListView(View):
queryset = None
filter = None
@@ -39,19 +52,26 @@ class ObjectListView(View):
if self.filter:
self.queryset = self.filter(request.GET, self.queryset).qs
# If this type of object has one or more custom fields, prefetch any relevant custom field values
custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\
.prefetch_related('choices')
if custom_fields:
self.queryset = self.queryset.prefetch_related('custom_field_values')
# Check for export template rendering
if request.GET.get('export'):
et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export'))
queryset = annotate_custom_fields(self.queryset, custom_fields) if custom_fields else self.queryset
try:
response = et.to_response(context_dict={'queryset': self.queryset.all()},
filename='netbox_{}'.format(self.queryset.model._meta.verbose_name_plural))
response = et.to_response(context_dict={'queryset': queryset},
filename='netbox_{}'.format(model._meta.verbose_name_plural))
return response
except TemplateSyntaxError:
messages.error(request, "There was an error rendering the selected export template ({})."
.format(et.name))
# Fall back to built-in CSV export
elif 'export' in request.GET and hasattr(model, 'to_csv'):
output = '\n'.join([obj.to_csv() for obj in self.queryset.all()])
output = '\n'.join([obj.to_csv() for obj in self.queryset])
response = HttpResponse(
output,
content_type='text/csv'
@@ -135,6 +155,8 @@ class ObjectEditView(View):
obj = form.save(commit=False)
obj_created = not obj.pk
obj.save()
if isinstance(form, CustomFieldForm):
form.save_custom_fields()
msg = u'Created ' if obj_created else u'Modified '
msg += self.model._meta.verbose_name
@@ -274,14 +296,29 @@ class BulkEditView(View):
redirect_url = reverse(self.default_redirect_url)
if request.POST.get('_all'):
pk_list = [x for x in request.POST.get('pk_all').split(',') if x]
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else:
pk_list = request.POST.getlist('pk')
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_apply' in request.POST:
form = self.form(request.POST)
if hasattr(self.form, 'custom_fields'):
form = self.form(self.cls, request.POST)
else:
form = self.form(request.POST)
if form.is_valid():
updated_count = self.update_objects(pk_list, form)
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
# Update objects
updated_count = self.update_objects(pk_list, form, standard_fields)
# Update custom fields for objects
if custom_fields:
objs_updated = self.update_custom_fields(pk_list, form, custom_fields)
if objs_updated and not updated_count:
updated_count = objs_updated
if updated_count:
msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
messages.success(self.request, msg)
@@ -289,7 +326,10 @@ class BulkEditView(View):
return redirect(redirect_url)
else:
form = self.form(initial={'pk': pk_list})
if hasattr(self.form, 'custom_fields'):
form = self.form(self.cls, initial={'pk': pk_list})
else:
form = self.form(initial={'pk': pk_list})
selected_objects = self.cls.objects.filter(pk__in=pk_list)
if not selected_objects:
@@ -302,11 +342,55 @@ class BulkEditView(View):
'cancel_url': redirect_url,
})
def update_objects(self, obj_list, form):
"""
This method provides the update logic (must be overridden by subclasses).
"""
raise NotImplementedError()
def update_objects(self, pk_list, form, fields):
fields_to_update = {}
for name in fields:
# Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
fields_to_update[name] = None
elif form.cleaned_data[name]:
fields_to_update[name] = form.cleaned_data[name]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
def update_custom_fields(self, pk_list, form, fields):
obj_type = ContentType.objects.get_for_model(self.cls)
objs_updated = False
for name in fields:
if form.cleaned_data[name] not in [None, u'']:
field = form.fields[name].model
# Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
serialized_value = field.serialize_value(None)
else:
serialized_value = field.serialize_value(form.cleaned_data[name])
# Gather any pre-existing CustomFieldValues for the objects being edited.
existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list)
# Determine which objects have an existing CFV to update and which need a new CFV created.
update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()]
create_list = list(set(pk_list) - set(update_list))
# Creating/updating CFVs
if serialized_value:
existing_cfvs.update(serialized_value=serialized_value)
CustomFieldValue.objects.bulk_create([
CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value)
for pk in create_list
])
# Deleting CFVs
else:
existing_cfvs.delete()
objs_updated = True
return len(pk_list) if objs_updated else 0
class BulkDeleteView(View):
@@ -316,10 +400,6 @@ class BulkDeleteView(View):
template_name = 'utilities/confirm_bulk_delete.html'
default_redirect_url = None
@method_decorator(staff_member_required)
def dispatch(self, *args, **kwargs):
return super(BulkDeleteView, self).dispatch(*args, **kwargs)
def post(self, request, *args, **kwargs):
# Attempt to derive parent object if a parent class has been given

21
scripts/docker-build.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
if [ $? -ne 0 ]; then
echo "docker login failed."
exit 1
fi
docker build -t "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG" .
if [ $? -ne 0 ]; then
echo "docker build failed."
exit 1
fi
docker push "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG"
if [ $? -ne 0 ]; then
echo "docker push failed."
exit 1
fi
exit 0