mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-21 09:28:08 +01:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b99704082b | ||
|
|
75d8852bf7 | ||
|
|
0444ac7db9 | ||
|
|
b2684aeefc | ||
|
|
6ccc6244dd | ||
|
|
e618bf40ec | ||
|
|
e3f0a12313 | ||
|
|
687e68db69 | ||
|
|
b10e29aaac | ||
|
|
d0c92b4f8a | ||
|
|
513408f16a | ||
|
|
64326e7c9d | ||
|
|
ce9d853883 | ||
|
|
814a0e7344 | ||
|
|
2c7c0ce29d | ||
|
|
2015d08407 | ||
|
|
9dea5656ad | ||
|
|
daadf7a49b | ||
|
|
2567412121 | ||
|
|
5e4fce248c | ||
|
|
824d2d8205 | ||
|
|
9718895ff9 | ||
|
|
9eec975800 | ||
|
|
5601be87f7 | ||
|
|
440610836a | ||
|
|
4fa536b940 | ||
|
|
aeec678ce9 | ||
|
|
9591fb9330 | ||
|
|
cbfdd5dbd1 | ||
|
|
cd2fa6ed78 | ||
|
|
bef9a0c77f | ||
|
|
6af91b581b | ||
|
|
afe805bd28 | ||
|
|
6af7403738 | ||
|
|
98fe83944f | ||
|
|
37e0388a5a | ||
|
|
99510a990a | ||
|
|
ca6c62facd | ||
|
|
6e5a099834 | ||
|
|
e0b17b1496 | ||
|
|
8341800a85 | ||
|
|
25f1fcc6cb | ||
|
|
d74d85a042 | ||
|
|
fcd4c9f7de | ||
|
|
25c46894b4 | ||
|
|
4a2e80aeee | ||
|
|
74a5960992 | ||
|
|
28b9dda55d | ||
|
|
3b36a35b9a | ||
|
|
b9dcf9ca12 | ||
|
|
faed3c1314 | ||
|
|
f0a85b1dd3 | ||
|
|
76f0463290 | ||
|
|
b14afaa687 | ||
|
|
14a908bf66 | ||
|
|
8f34b6b0b9 | ||
|
|
eab18a81c9 | ||
|
|
7a558d8332 | ||
|
|
fa79014585 | ||
|
|
8cf2ae7851 | ||
|
|
0708942ab8 | ||
|
|
4b4602b703 | ||
|
|
ab90a06c54 | ||
|
|
6dbf2043b9 | ||
|
|
041a166217 | ||
|
|
63ac4e2c42 | ||
|
|
77d721360f | ||
|
|
1d6299622b | ||
|
|
6f44f4245e | ||
|
|
de8fd550cb | ||
|
|
9bdb50c33e | ||
|
|
6ed33af063 | ||
|
|
a33e89fed7 | ||
|
|
b0a325f173 | ||
|
|
b7a90dd09a | ||
|
|
8d99ad3099 | ||
|
|
c49177e59c | ||
|
|
96d6be3608 | ||
|
|
aa84d04c8b | ||
|
|
c8b85202d1 | ||
|
|
af459cd19b | ||
|
|
c056d86b24 | ||
|
|
7d879bb0dc | ||
|
|
a9a55350df | ||
|
|
f019253c8e | ||
|
|
58e3d5ae09 | ||
|
|
2eb8b4fe71 | ||
|
|
989ec721d3 | ||
|
|
4fc0fd9a9a | ||
|
|
5afb98ffa7 | ||
|
|
d045429b51 | ||
|
|
c60c4ad0df | ||
|
|
78c3b25f0a | ||
|
|
13136d0ccb | ||
|
|
8faa16c831 | ||
|
|
6cdb62b67e | ||
|
|
4f774f8ba6 | ||
|
|
550a05487d | ||
|
|
bf1b8ab9b8 | ||
|
|
b74f338aa1 | ||
|
|
35aa8acd09 | ||
|
|
6a48b310d2 | ||
|
|
0b4d3446bf | ||
|
|
2b8e06faa2 | ||
|
|
c86a1123f0 | ||
|
|
e2ad1d4be0 | ||
|
|
219f084805 | ||
|
|
6f12297dcf | ||
|
|
098ff961e3 | ||
|
|
74528c6036 | ||
|
|
3324f397d9 | ||
|
|
2509405465 | ||
|
|
0f779dd682 | ||
|
|
6bf8a7707c | ||
|
|
8ff752a58b | ||
|
|
7938c9211f | ||
|
|
1f4d5b84b3 | ||
|
|
3a018888c8 | ||
|
|
a89fb734a0 | ||
|
|
b2f03dfb16 | ||
|
|
cbde6e8321 | ||
|
|
ed03449164 | ||
|
|
47a89999b8 | ||
|
|
a7dd2695a2 | ||
|
|
8035538b74 | ||
|
|
474b19d927 | ||
|
|
31ebbb3324 | ||
|
|
bddd29c99f | ||
|
|
e7116b81a4 | ||
|
|
d463161619 | ||
|
|
5116db3344 | ||
|
|
b131fbd774 | ||
|
|
324a5e10d7 | ||
|
|
69debfdefa |
12
.travis.yml
12
.travis.yml
@@ -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
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# NetBox
|
||||

|
||||
|
||||
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
|
||||
|
||||
|
||||
@@ -10,15 +10,21 @@ Sites can be assigned an optional facility ID to identify the actual facility ho
|
||||
|
||||
# Racks
|
||||
|
||||
Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units *(U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
|
||||
Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units* (U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
|
||||
|
||||
Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, M204.313) whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number.
|
||||
|
||||
The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches.
|
||||
|
||||
### Rack Groups
|
||||
|
||||
Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site is a campus, each group might be a building. If each site is a building, each rack group might be a floor or room.
|
||||
|
||||
Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not currently supported.
|
||||
Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported.
|
||||
|
||||
### Rack Roles
|
||||
|
||||
Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices.
|
||||
|
||||
---
|
||||
|
||||
@@ -74,7 +80,7 @@ The assignment of platforms to devices is an entirely optional feature, and may
|
||||
|
||||
### Modules
|
||||
|
||||
A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand.
|
||||
A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer.
|
||||
|
||||
### Components
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,9 +10,10 @@ NetBox requires following system dependencies:
|
||||
* libffi-dev
|
||||
* graphviz
|
||||
* libpq-dev
|
||||
* libssl-dev
|
||||
|
||||
```
|
||||
# sudo apt-get install -y python2.7 python-dev git python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev
|
||||
# sudo apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
|
||||
```
|
||||
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
|
||||
BIN
docs/netbox_logo.png
Normal file
BIN
docs/netbox_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -21,8 +21,8 @@ class CircuitTypeAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Circuit)
|
||||
class CircuitAdmin(admin.ModelAdmin):
|
||||
list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
|
||||
'xconnect_id']
|
||||
list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human',
|
||||
'upstream_speed_human', 'commit_rate_human', 'xconnect_id']
|
||||
list_filter = ['provider', 'type', 'tenant']
|
||||
exclude = ['interface']
|
||||
|
||||
|
||||
@@ -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',
|
||||
'commit_rate', 'xconnect_id', 'comments']
|
||||
'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
class CircuitNestedSerializer(CircuitSerializer):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}}',
|
||||
@@ -102,7 +99,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
|
||||
'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
|
||||
'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
|
||||
]
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
@@ -169,15 +166,15 @@ class CircuitFromCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate',
|
||||
'xconnect_id', 'pp_info']
|
||||
fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
|
||||
'commit_rate', 'xconnect_id', 'pp_info']
|
||||
|
||||
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-08 20:24
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0004_circuit_add_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuit',
|
||||
name='upstream_speed',
|
||||
field=models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed', null=True, verbose_name=b'Upstream speed (Kbps)'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -72,10 +75,13 @@ class Circuit(CreatedUpdatedModel):
|
||||
interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
|
||||
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
|
||||
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
|
||||
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
|
||||
help_text='Upstream speed, if different from port speed')
|
||||
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
||||
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']
|
||||
@@ -96,6 +102,7 @@ class Circuit(CreatedUpdatedModel):
|
||||
self.site.name,
|
||||
self.install_date.isoformat() if self.install_date else '',
|
||||
str(self.port_speed),
|
||||
str(self.upstream_speed),
|
||||
str(self.commit_rate) if self.commit_rate else '',
|
||||
self.xconnect_id,
|
||||
self.pp_info,
|
||||
@@ -116,12 +123,18 @@ class Circuit(CreatedUpdatedModel):
|
||||
else:
|
||||
return '{} Kbps'.format(speed)
|
||||
|
||||
@property
|
||||
def port_speed_human(self):
|
||||
return self._humanize_speed(self.port_speed)
|
||||
port_speed_human.admin_order_field = 'port_speed'
|
||||
|
||||
def upstream_speed_human(self):
|
||||
if not self.upstream_speed:
|
||||
return ''
|
||||
return self._humanize_speed(self.upstream_speed)
|
||||
upstream_speed_human.admin_order_field = 'upstream_speed'
|
||||
|
||||
@property
|
||||
def commit_rate_human(self):
|
||||
if not self.commit_rate:
|
||||
return ''
|
||||
return self._humanize_speed(self.commit_rate)
|
||||
commit_rate_human.admin_order_field = 'commit_rate'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db.models import Count
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,9 +24,17 @@ class RackGroupAdmin(admin.ModelAdmin):
|
||||
}
|
||||
|
||||
|
||||
@admin.register(RackRole)
|
||||
class RackRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'slug', 'color']
|
||||
prepopulated_fields = {
|
||||
'slug': ['name'],
|
||||
}
|
||||
|
||||
|
||||
@admin.register(Rack)
|
||||
class RackAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'facility_id', 'site', 'u_height']
|
||||
list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height']
|
||||
|
||||
|
||||
#
|
||||
@@ -175,7 +183,8 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
DeviceBayAdmin,
|
||||
ModuleAdmin,
|
||||
]
|
||||
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
|
||||
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
|
||||
'serial']
|
||||
list_filter = ['device_role']
|
||||
|
||||
def get_queryset(self, request):
|
||||
|
||||
@@ -3,9 +3,10 @@ from rest_framework import serializers
|
||||
from ipam.models import IPAddress
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
|
||||
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
|
||||
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):
|
||||
@@ -46,19 +47,38 @@ class RackGroupNestedSerializer(RackGroupSerializer):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackRoleNestedSerializer(RackRoleSerializer):
|
||||
|
||||
class Meta(RackRoleSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
|
||||
class RackSerializer(serializers.ModelSerializer):
|
||||
class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
group = RackGroupNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
role = RackRoleNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments']
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
class RackNestedSerializer(RackSerializer):
|
||||
@@ -72,8 +92,8 @@ class RackDetailSerializer(RackSerializer):
|
||||
rear_units = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(RackSerializer.Meta):
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments',
|
||||
'front_units', 'rear_units']
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units']
|
||||
|
||||
def get_front_units(self, obj):
|
||||
units = obj.get_rack_units(face=RACK_FACE_FRONT)
|
||||
@@ -218,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()
|
||||
@@ -231,8 +251,9 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'rack',
|
||||
'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
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', 'custom_fields']
|
||||
|
||||
def get_parent_device(self, obj):
|
||||
try:
|
||||
@@ -384,6 +405,25 @@ class DeviceBayDetailSerializer(DeviceBaySerializer):
|
||||
fields = ['id', 'device', 'name', 'installed_device']
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
manufacturer = ManufacturerNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
|
||||
|
||||
|
||||
class ModuleNestedSerializer(ModuleSerializer):
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ['id', 'device', 'parent', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
|
||||
@@ -18,6 +18,10 @@ urlpatterns = [
|
||||
url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
|
||||
url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
|
||||
|
||||
# Rack roles
|
||||
url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'),
|
||||
url(r'^rack-roles/(?P<pk>\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'),
|
||||
|
||||
# Racks
|
||||
url(r'^racks/$', RackListView.as_view(), name='rack_list'),
|
||||
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
|
||||
@@ -50,6 +54,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
|
||||
url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
|
||||
url(r'^devices/(?P<pk>\d+)/modules/$', ModuleListView.as_view(), name='device_modules'),
|
||||
|
||||
# Console ports
|
||||
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
|
||||
|
||||
@@ -5,37 +5,39 @@ 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
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
|
||||
InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
|
||||
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
|
||||
|
||||
|
||||
@@ -60,24 +62,46 @@ class RackGroupDetailView(generics.RetrieveAPIView):
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all rack roles
|
||||
"""
|
||||
queryset = RackRole.objects.all()
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
|
||||
|
||||
class RackRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack role
|
||||
"""
|
||||
queryset = RackRole.objects.all()
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
|
||||
|
||||
#
|
||||
# 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
|
||||
|
||||
|
||||
@@ -189,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
|
||||
|
||||
|
||||
@@ -349,18 +374,23 @@ class DeviceBayListView(generics.ListAPIView):
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
queryset = DeviceBay.objects.filter(device=device).select_related('installed_device')
|
||||
return DeviceBay.objects.filter(device=device).select_related('installed_device')
|
||||
|
||||
# Filter by type (physical or virtual)
|
||||
iface_type = self.request.query_params.get('type')
|
||||
if iface_type == 'physical':
|
||||
queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
|
||||
elif iface_type == 'virtual':
|
||||
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
|
||||
elif iface_type is not None:
|
||||
queryset = queryset.empty()
|
||||
|
||||
return queryset
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleListView(generics.ListAPIView):
|
||||
"""
|
||||
List device modules (by device)
|
||||
"""
|
||||
serializer_class = serializers.ModuleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return Module.objects.filter(device=device).select_related('device', 'manufacturer')
|
||||
|
||||
|
||||
#
|
||||
@@ -401,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')
|
||||
@@ -425,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': [],
|
||||
|
||||
@@ -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, Site,
|
||||
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,28 +76,39 @@ 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 = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=RackRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=RackRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
@@ -128,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',
|
||||
@@ -165,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',
|
||||
@@ -198,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',
|
||||
@@ -228,15 +241,16 @@ class DeviceFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['q', 'name', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id', 'manufacturer_id',
|
||||
'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server', 'is_pdu',
|
||||
'is_network_device']
|
||||
fields = ['q', 'name', 'serial', 'asset_tag', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id',
|
||||
'manufacturer_id', 'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server',
|
||||
'is_pdu', 'is_network_device']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(serial__icontains=value) |
|
||||
Q(modules__serial__icontains=value) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(modules__serial__icontains=value.strip()) |
|
||||
Q(asset_tag=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
|
||||
|
||||
@@ -3,19 +3,21 @@ 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, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
|
||||
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
|
||||
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
|
||||
Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole,
|
||||
Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
|
||||
)
|
||||
|
||||
|
||||
@@ -49,11 +51,35 @@ def bulkedit_platform_choices():
|
||||
return choices
|
||||
|
||||
|
||||
def bulkedit_rackgroup_choices():
|
||||
"""
|
||||
Include an option to remove the currently assigned group from a rack.
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(r.pk, r) for r in RackGroup.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
def bulkedit_rackrole_choices():
|
||||
"""
|
||||
Include an option to remove the currently assigned role from a rack.
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(r.pk, r.name) for r in RackRole.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteForm(forms.ModelForm, BootstrapMixin):
|
||||
class SiteForm(BootstrapMixin, CustomFieldForm):
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
|
||||
@@ -86,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'))
|
||||
|
||||
|
||||
#
|
||||
@@ -113,21 +135,27 @@ 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')
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['name', 'slug', 'color']
|
||||
|
||||
|
||||
#
|
||||
# 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}}',
|
||||
))
|
||||
@@ -135,7 +163,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments']
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments']
|
||||
help_texts = {
|
||||
'site': "The site at which the rack exists",
|
||||
'name': "Organizational rack name",
|
||||
@@ -165,10 +193,13 @@ class RackFromCSVForm(forms.ModelForm):
|
||||
group_name = forms.CharField(required=False)
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Role not found.'})
|
||||
type = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height']
|
||||
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -182,42 +213,45 @@ class RackFromCSVForm(forms.ModelForm):
|
||||
except RackGroup.DoesNotExist:
|
||||
self.add_error('group_name', "Invalid rack group ({})".format(group))
|
||||
|
||||
def clean_type(self):
|
||||
rack_type = self.cleaned_data['type']
|
||||
if not rack_type:
|
||||
return None
|
||||
try:
|
||||
choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES}
|
||||
return choices[rack_type.lower()]
|
||||
except KeyError:
|
||||
raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format(
|
||||
rack_type,
|
||||
', '.join({v: k for k, v in RACK_TYPE_CHOICES}),
|
||||
))
|
||||
|
||||
|
||||
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)
|
||||
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
|
||||
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role')
|
||||
type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
|
||||
width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
|
||||
u_height = forms.IntegerField(required=False, label='Height (U)')
|
||||
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]
|
||||
|
||||
|
||||
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}))
|
||||
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'))
|
||||
|
||||
|
||||
#
|
||||
@@ -251,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')
|
||||
|
||||
|
||||
#
|
||||
@@ -341,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}}',
|
||||
@@ -362,8 +391,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position',
|
||||
'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
'serial': "Chassis serial number",
|
||||
@@ -402,7 +431,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
|
||||
# Limit rack choices
|
||||
if self.is_bound:
|
||||
if self.is_bound and self.data.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
@@ -443,6 +472,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
self.fields['rack'].disabled = True
|
||||
self.initial['site'] = self.instance.parent_bay.device.rack.site_id
|
||||
self.initial['rack'] = self.instance.parent_bay.device.rack_id
|
||||
|
||||
|
||||
class BaseDeviceFromCSVForm(forms.ModelForm):
|
||||
@@ -481,8 +512,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
|
||||
face = forms.CharField(required=False)
|
||||
|
||||
class Meta(BaseDeviceFromCSVForm.Meta):
|
||||
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'site',
|
||||
'rack_name', 'position', 'face']
|
||||
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack_name', 'position', 'face']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -517,8 +548,8 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
|
||||
device_bay_name = forms.CharField(required=False)
|
||||
|
||||
class Meta(BaseDeviceFromCSVForm.Meta):
|
||||
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
|
||||
'device_bay_name']
|
||||
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
|
||||
'parent', 'device_bay_name']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -548,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')
|
||||
@@ -559,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))
|
||||
|
||||
|
||||
@@ -1254,4 +1255,4 @@ class ModuleForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['name', 'part_id', 'serial']
|
||||
fields = ['name', 'manufacturer', 'part_id', 'serial']
|
||||
|
||||
25
netbox/dcim/migrations/0014_rack_add_type_width.py
Normal file
25
netbox/dcim/migrations/0014_rack_add_type_width.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-08 21:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0013_add_interface_form_factors'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='type',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[(100, b'2-post frame'), (200, b'4-post frame'), (300, b'4-post cabinet'), (1000, b'Wall-mounted frame'), (1100, b'Wall-mounted cabinet')], null=True, verbose_name=b'Type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='width',
|
||||
field=models.PositiveSmallIntegerField(choices=[(19, b'19 inches'), (23, b'23 inches')], default=19, help_text=b'Rail-to-rail width', verbose_name=b'Width'),
|
||||
),
|
||||
]
|
||||
21
netbox/dcim/migrations/0015_rack_add_u_height_validator.py
Normal file
21
netbox/dcim/migrations/0015_rack_add_u_height_validator.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-09 21:18
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0014_rack_add_type_width'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='u_height',
|
||||
field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Height (U)'),
|
||||
),
|
||||
]
|
||||
21
netbox/dcim/migrations/0016_module_add_manufacturer.py
Normal file
21
netbox/dcim/migrations/0016_module_add_manufacturer.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-10 13:45
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0015_rack_add_u_height_validator'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='manufacturer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='modules', to='dcim.Manufacturer'),
|
||||
),
|
||||
]
|
||||
33
netbox/dcim/migrations/0017_rack_add_role.py
Normal file
33
netbox/dcim/migrations/0017_rack_add_role.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-10 14:58
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0016_module_add_manufacturer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RackRole',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('color', models.CharField(choices=[[b'teal', b'Teal'], [b'green', b'Green'], [b'blue', b'Blue'], [b'purple', b'Purple'], [b'yellow', b'Yellow'], [b'orange', b'Orange'], [b'red', b'Red'], [b'light_gray', b'Light Gray'], [b'medium_gray', b'Medium Gray'], [b'dark_gray', b'Dark Gray']], max_length=30)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='role',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.RackRole'),
|
||||
),
|
||||
]
|
||||
21
netbox/dcim/migrations/0018_device_add_asset_tag.py
Normal file
21
netbox/dcim/migrations/0018_device_add_asset_tag.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-08-11 15:42
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0017_rack_add_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='asset_tag',
|
||||
field=utilities.fields.NullableCharField(blank=True, help_text=b'A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name=b'Asset tag'),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0019_new_iface_form_factors.py
Normal file
25
netbox/dcim/migrations/0019_new_iface_form_factors.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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 MinValueValidator
|
||||
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
|
||||
@@ -16,6 +19,26 @@ from utilities.models import CreatedUpdatedModel
|
||||
from .fields import ASNField, MACAddressField
|
||||
|
||||
|
||||
RACK_TYPE_2POST = 100
|
||||
RACK_TYPE_4POST = 200
|
||||
RACK_TYPE_CABINET = 300
|
||||
RACK_TYPE_WALLFRAME = 1000
|
||||
RACK_TYPE_WALLCABINET = 1100
|
||||
RACK_TYPE_CHOICES = (
|
||||
(RACK_TYPE_2POST, '2-post frame'),
|
||||
(RACK_TYPE_4POST, '4-post frame'),
|
||||
(RACK_TYPE_CABINET, '4-post cabinet'),
|
||||
(RACK_TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||
(RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
||||
)
|
||||
|
||||
RACK_WIDTH_19IN = 19
|
||||
RACK_WIDTH_23IN = 23
|
||||
RACK_WIDTH_CHOICES = (
|
||||
(RACK_WIDTH_19IN, '19 inches'),
|
||||
(RACK_WIDTH_23IN, '23 inches'),
|
||||
)
|
||||
|
||||
RACK_FACE_FRONT = 0
|
||||
RACK_FACE_REAR = 1
|
||||
RACK_FACE_CHOICES = [
|
||||
@@ -41,7 +64,7 @@ COLOR_RED = 'red'
|
||||
COLOR_GRAY1 = 'light_gray'
|
||||
COLOR_GRAY2 = 'medium_gray'
|
||||
COLOR_GRAY3 = 'dark_gray'
|
||||
DEVICE_ROLE_COLOR_CHOICES = [
|
||||
ROLE_COLOR_CHOICES = [
|
||||
[COLOR_TEAL, 'Teal'],
|
||||
[COLOR_GREEN, 'Green'],
|
||||
[COLOR_BLUE, 'Blue'],
|
||||
@@ -54,23 +77,39 @@ DEVICE_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',
|
||||
@@ -79,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)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -114,6 +166,12 @@ IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Other',
|
||||
[
|
||||
[IFACE_FF_OTHER, 'Other'],
|
||||
]
|
||||
],
|
||||
]
|
||||
|
||||
STATUS_ACTIVE = True
|
||||
@@ -183,13 +241,17 @@ def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
|
||||
}).order_by(*ordering)
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
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).
|
||||
@@ -202,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()
|
||||
|
||||
@@ -244,6 +307,10 @@ class Site(CreatedUpdatedModel):
|
||||
return self.circuits.count()
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackGroup(models.Model):
|
||||
"""
|
||||
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
|
||||
@@ -268,13 +335,31 @@ class RackGroup(models.Model):
|
||||
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
||||
|
||||
|
||||
class RackRole(models.Model):
|
||||
"""
|
||||
Racks can be organized by functional role, similar to Devices.
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
|
||||
|
||||
|
||||
class RackManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
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.
|
||||
@@ -284,8 +369,14 @@ class Rack(CreatedUpdatedModel):
|
||||
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
|
||||
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
|
||||
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
|
||||
role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT)
|
||||
type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type')
|
||||
width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width',
|
||||
help_text='Rail-to-rail width')
|
||||
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()
|
||||
|
||||
@@ -320,6 +411,9 @@ class Rack(CreatedUpdatedModel):
|
||||
self.name,
|
||||
self.facility_id or '',
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.role.name if self.role else '',
|
||||
self.get_type_display() if self.type else '',
|
||||
str(self.width),
|
||||
str(self.u_height),
|
||||
])
|
||||
|
||||
@@ -588,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()
|
||||
@@ -627,7 +721,7 @@ class DeviceRole(models.Model):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
color = models.CharField(max_length=30, choices=DEVICE_ROLE_COLOR_CHOICES)
|
||||
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -665,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.
|
||||
@@ -683,6 +777,8 @@ class Device(CreatedUpdatedModel):
|
||||
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
|
||||
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
|
||||
asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
|
||||
help_text='A unique tag used to identify this device')
|
||||
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
|
||||
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
|
||||
verbose_name='Position (U)',
|
||||
@@ -694,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()
|
||||
|
||||
@@ -778,6 +875,7 @@ class Device(CreatedUpdatedModel):
|
||||
self.device_type.model,
|
||||
self.platform.name if self.platform else '',
|
||||
self.serial,
|
||||
self.asset_tag if self.asset_tag else '',
|
||||
self.rack.site.name,
|
||||
self.rack.name,
|
||||
str(self.position) if self.position else '',
|
||||
@@ -960,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")
|
||||
@@ -975,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
|
||||
@@ -1073,6 +1178,8 @@ class Module(models.Model):
|
||||
device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=50, verbose_name='Name')
|
||||
manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True,
|
||||
on_delete=models.PROTECT)
|
||||
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
|
||||
serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
|
||||
discovered = models.BooleanField(default=False, verbose_name='Discovered')
|
||||
|
||||
@@ -10,6 +10,10 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
COLOR_LABEL = """
|
||||
<label class="label {{ record.color }}">{{ record }}</label>
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
<a href="{% url 'dcim:device' pk=record.pk %}">
|
||||
{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}
|
||||
@@ -22,6 +26,20 @@ RACKGROUP_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACKROLE_ACTIONS = """
|
||||
{% if perms.dcim.change_rackrole %}
|
||||
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACK_ROLE = """
|
||||
{% if record.role %}
|
||||
<label class="label {{ record.role.color }}">{{ value }}</label>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_ACTIONS = """
|
||||
{% if perms.dcim.change_devicerole %}
|
||||
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
@@ -40,6 +58,10 @@ PLATFORM_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_ROLE = """
|
||||
<label class="label {{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
STATUS_ICON = """
|
||||
{% if record.status %}
|
||||
<span class="glyphicon glyphicon-ok-sign text-success" title="Active" aria-hidden="true"></span>
|
||||
@@ -94,6 +116,24 @@ class RackGroupTable(BaseTable):
|
||||
fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
@@ -105,6 +145,7 @@ class RackTable(BaseTable):
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
facility_id = tables.Column(verbose_name='Facility ID')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
|
||||
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
|
||||
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
|
||||
u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
|
||||
@@ -112,7 +153,7 @@ class RackTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'u_height', 'devices', 'u_consumed',
|
||||
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed',
|
||||
'utilization')
|
||||
|
||||
|
||||
@@ -233,14 +274,14 @@ class DeviceRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
color = tables.Column(verbose_name='Color')
|
||||
actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceRole
|
||||
fields = ('pk', 'name', 'device_count', 'slug', 'color', 'actions')
|
||||
fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -270,7 +311,7 @@ class DeviceTable(BaseTable):
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||
device_role = tables.Column(verbose_name='Role')
|
||||
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
|
||||
device_type = tables.Column(verbose_name='Type')
|
||||
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
|
||||
template_code="{{ record.primary_ip.address.ip }}")
|
||||
|
||||
@@ -21,6 +21,7 @@ class SiteTest(APITestCase):
|
||||
'physical_address',
|
||||
'shipping_address',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'count_prefixes',
|
||||
'count_vlans',
|
||||
'count_racks',
|
||||
@@ -42,8 +43,12 @@ class SiteTest(APITestCase):
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'comments'
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
graph_fields = [
|
||||
@@ -118,8 +123,12 @@ class RackTest(APITestCase):
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'comments'
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
detail_fields = [
|
||||
@@ -130,8 +139,12 @@ class RackTest(APITestCase):
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'front_units',
|
||||
'rear_units'
|
||||
]
|
||||
@@ -318,6 +331,7 @@ class DeviceTest(APITestCase):
|
||||
'tenant',
|
||||
'platform',
|
||||
'serial',
|
||||
'asset_tag',
|
||||
'rack',
|
||||
'position',
|
||||
'face',
|
||||
@@ -327,6 +341,7 @@ class DeviceTest(APITestCase):
|
||||
'primary_ip4',
|
||||
'primary_ip6',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
nested_fields = ['id', 'name', 'display_name']
|
||||
@@ -361,6 +376,7 @@ class DeviceTest(APITestCase):
|
||||
def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'):
|
||||
|
||||
flat_fields = [
|
||||
'asset_tag',
|
||||
'comments',
|
||||
'device_role_id',
|
||||
'device_role_name',
|
||||
|
||||
@@ -13,59 +13,67 @@ class DeviceTestCase(TestCase):
|
||||
|
||||
def test_racked_device(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': RACK_FACE_FRONT,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'position': 41,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'juniper'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 41,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
||||
self.assertTrue(test.save())
|
||||
|
||||
def test_racked_device_occupied(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': RACK_FACE_FRONT,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'position': 1,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'juniper'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 1,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(test.is_valid())
|
||||
|
||||
def test_non_racked_device(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': None,
|
||||
'platform': None,
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'position': None,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'servertech'),
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': None,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
|
||||
def test_non_racked_device_with_face(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': RACK_FACE_REAR,
|
||||
'platform': None,
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'position': None,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'servertech'),
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': RACK_FACE_REAR,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
|
||||
@@ -26,6 +26,12 @@ urlpatterns = [
|
||||
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
|
||||
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
|
||||
|
||||
# Rack roles
|
||||
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
|
||||
url(r'^rack-roles/add/$', views.RackRoleEditView.as_view(), name='rackrole_add'),
|
||||
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
|
||||
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
|
||||
|
||||
# Racks
|
||||
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
|
||||
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.http import urlencode
|
||||
@@ -26,7 +27,7 @@ from .models import (
|
||||
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
||||
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
|
||||
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
Site,
|
||||
RackRole, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -121,23 +122,13 @@ 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
|
||||
#
|
||||
|
||||
class RackGroupListView(ObjectListView):
|
||||
queryset = RackGroup.objects.annotate(rack_count=Count('racks'))
|
||||
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
|
||||
filter = filters.RackGroupFilter
|
||||
filter_form = forms.RackGroupFilterForm
|
||||
table = tables.RackGroupTable
|
||||
@@ -149,6 +140,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_rackgroup'
|
||||
model = RackGroup
|
||||
form_class = forms.RackGroupForm
|
||||
success_url = 'dcim:rackgroup_list'
|
||||
cancel_url = 'dcim:rackgroup_list'
|
||||
|
||||
|
||||
@@ -158,13 +150,39 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
default_redirect_url = 'dcim:rackgroup_list'
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleListView(ObjectListView):
|
||||
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
|
||||
table = tables.RackRoleTable
|
||||
edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole']
|
||||
template_name = 'dcim/rackrole_list.html'
|
||||
|
||||
|
||||
class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_rackrole'
|
||||
model = RackRole
|
||||
form_class = forms.RackRoleForm
|
||||
success_url = 'dcim:rackrole_list'
|
||||
cancel_url = 'dcim:rackrole_list'
|
||||
|
||||
|
||||
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_rackrole'
|
||||
cls = RackRole
|
||||
default_redirect_url = 'dcim:rackrole_list'
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackListView(ObjectListView):
|
||||
queryset = Rack.objects.select_related('site').prefetch_related('devices__device_type')\
|
||||
.annotate(device_count=Count('devices', distinct=True), u_consumed=Sum('devices__device_type__u_height'))
|
||||
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\
|
||||
.annotate(device_count=Count('devices', distinct=True),
|
||||
u_consumed=Coalesce(Sum('devices__device_type__u_height'), 0))
|
||||
filter = filters.RackFilter
|
||||
filter_form = forms.RackFilterForm
|
||||
table = tables.RackTable
|
||||
@@ -220,19 +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 = {}
|
||||
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', 'tenant', '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'
|
||||
@@ -343,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'
|
||||
@@ -533,8 +529,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class DeviceListView(ObjectListView):
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip4',
|
||||
'primary_ip6')
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site',
|
||||
'primary_ip4', 'primary_ip6')
|
||||
filter = filters.DeviceFilter
|
||||
filter_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
@@ -653,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'
|
||||
@@ -680,7 +659,8 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
def device_inventory(request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
modules = Module.objects.filter(device=device, parent=None).prefetch_related('submodules')
|
||||
modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\
|
||||
.prefetch_related('submodules')
|
||||
|
||||
return render(request, 'dcim/device_inventory.html', {
|
||||
'device': device,
|
||||
@@ -1419,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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
46
netbox/extras/filters.py
Normal 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
158
netbox/extras/forms.py
Normal 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
|
||||
68
netbox/extras/migrations/0002_custom_fields.py
Normal file
68
netbox/extras/migrations/0002_custom_fields.py
Normal 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')]),
|
||||
),
|
||||
]
|
||||
@@ -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 "
|
||||
|
||||
0
netbox/extras/tests/__init__.py
Normal file
0
netbox/extras/tests/__init__.py
Normal file
97
netbox/extras/tests/test_customfields.py
Normal file
97
netbox/extras/tests/test_customfields.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
@@ -182,18 +181,14 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
|
||||
self.fields['vlan'].choices = []
|
||||
|
||||
def clean_prefix(self):
|
||||
data = self.cleaned_data['prefix']
|
||||
try:
|
||||
prefix = IPNetwork(data)
|
||||
except:
|
||||
raise
|
||||
prefix = self.cleaned_data['prefix']
|
||||
if prefix.version == 4 and prefix.prefixlen == 32:
|
||||
raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
|
||||
"addresses instead.")
|
||||
elif prefix.version == 6 and prefix.prefixlen == 128:
|
||||
raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
|
||||
"addresses instead.")
|
||||
return data
|
||||
return prefix
|
||||
|
||||
|
||||
class PrefixFromCSVForm(forms.ModelForm):
|
||||
@@ -255,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')
|
||||
@@ -265,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'):
|
||||
@@ -287,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')
|
||||
|
||||
|
||||
@@ -313,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',
|
||||
@@ -429,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'))
|
||||
|
||||
|
||||
#
|
||||
@@ -468,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}}',
|
||||
))
|
||||
@@ -543,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)
|
||||
@@ -553,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'):
|
||||
@@ -575,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'))
|
||||
|
||||
19
netbox/ipam/migrations/0008_prefix_change_order.py
Normal file
19
netbox/ipam/migrations/0008_prefix_change_order.py
Normal 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'},
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
@@ -254,12 +261,13 @@ class Prefix(CreatedUpdatedModel):
|
||||
|
||||
def clean(self):
|
||||
# Disallow host masks
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
|
||||
raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
|
||||
"instead.")
|
||||
elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
|
||||
raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
|
||||
"instead.")
|
||||
if self.prefix:
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
|
||||
raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
|
||||
"instead.")
|
||||
elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
|
||||
raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
|
||||
"instead.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.prefix:
|
||||
@@ -273,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 '',
|
||||
@@ -294,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
|
||||
@@ -315,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']
|
||||
@@ -360,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 '',
|
||||
@@ -397,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,
|
||||
@@ -417,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']
|
||||
|
||||
@@ -43,12 +43,22 @@ IPADDRESS_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
||||
{% elif perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
|
||||
{% else %}
|
||||
{{ record.0 }}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VRF_LINK = """
|
||||
{% if record.vrf %}
|
||||
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
|
||||
{% elif prefix.vrf %}
|
||||
{{ prefix.vrf }}
|
||||
{% else %}
|
||||
Global
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
{% if record.pk %}
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
@@ -149,7 +159,7 @@ class PrefixTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
|
||||
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
|
||||
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
role = tables.Column(verbose_name='Role')
|
||||
@@ -183,7 +193,7 @@ class PrefixBriefTable(BaseTable):
|
||||
class IPAddressTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
|
||||
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
|
||||
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
|
||||
verbose_name='Device')
|
||||
|
||||
@@ -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'
|
||||
@@ -305,7 +285,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class PrefixListView(ObjectListView):
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'role')
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role')
|
||||
filter = filters.PrefixFilter
|
||||
filter_form = forms.PrefixFilterForm
|
||||
table = tables.PrefixTable
|
||||
@@ -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'
|
||||
@@ -445,7 +412,7 @@ def prefix_ipaddresses(request, pk):
|
||||
#
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'interface__device')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
|
||||
filter = filters.IPAddressFilter
|
||||
filter_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressTable
|
||||
@@ -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'
|
||||
@@ -550,7 +503,7 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class VLANGroupListView(ObjectListView):
|
||||
queryset = VLANGroup.objects.annotate(vlan_count=Count('vlans'))
|
||||
queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
|
||||
filter = filters.VLANGroupFilter
|
||||
filter_form = forms.VLANGroupFilterForm
|
||||
table = tables.VLANGroupTable
|
||||
@@ -562,6 +515,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_vlangroup'
|
||||
model = VLANGroup
|
||||
form_class = forms.VLANGroupForm
|
||||
success_url = 'ipam:vlangroup_list'
|
||||
cancel_url = 'ipam:vlangroup_list'
|
||||
|
||||
|
||||
@@ -576,7 +530,7 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class VLANListView(ObjectListView):
|
||||
queryset = VLAN.objects.select_related('site', 'role')
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||
filter = filters.VLANFilter
|
||||
filter_form = forms.VLANFilterForm
|
||||
table = tables.VLANTable
|
||||
@@ -600,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'
|
||||
|
||||
|
||||
@@ -624,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'
|
||||
|
||||
@@ -12,7 +12,7 @@ except ImportError:
|
||||
"the documentation.")
|
||||
|
||||
|
||||
VERSION = '1.4.2'
|
||||
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',
|
||||
@@ -172,7 +175,6 @@ MESSAGE_TAGS = {
|
||||
# Authentication URLs
|
||||
LOGIN_URL = '/login/'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
LOGOUT_URL = '/logout/'
|
||||
|
||||
# Secrets
|
||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
|
||||
@@ -21,6 +21,9 @@ body {
|
||||
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
.navbar-brand {
|
||||
padding: 12px 15px 8px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 60px; /* .push must be the same height as .footer */
|
||||
}
|
||||
@@ -31,7 +34,8 @@ body {
|
||||
footer p {
|
||||
margin: 20px 0;
|
||||
}
|
||||
@media (max-width: 1120px) {
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.navbar-header {
|
||||
float: none;
|
||||
}
|
||||
@@ -51,6 +55,7 @@ footer p {
|
||||
}
|
||||
.navbar-collapse.collapse {
|
||||
display: none!important;
|
||||
max-height: none;
|
||||
}
|
||||
.navbar-nav {
|
||||
float: none!important;
|
||||
@@ -81,13 +86,11 @@ th.pk, td.pk {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
|
||||
/* Paginator */
|
||||
nav ul.pagination {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Racks */
|
||||
div.rack_header {
|
||||
margin-left: 36px;
|
||||
@@ -291,27 +294,39 @@ ul.rack_near_face li.empty:hover a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Rack elevation colors (from http://flatuicolors.com) */
|
||||
.teal { background-color: #1abc9c; border-bottom: 1px solid #16a085; }
|
||||
.teal:hover { background-color: #16a085; }
|
||||
.green { background-color: #2ecc71; border-bottom: 1px solid #27ae60; }
|
||||
.green:hover { background-color: #27ae60; }
|
||||
.blue { background-color: #3498db; border-bottom: 1px solid #2980b9; }
|
||||
.blue:hover { background-color: #2980b9; }
|
||||
.purple { background-color: #9b59b6; border-bottom: 1px solid #8e44ad; }
|
||||
.purple:hover { background-color: #8e44ad; }
|
||||
.yellow { background-color: #f1c40f; border-bottom: 1px solid #f39c12; }
|
||||
.yellow:hover { background-color: #f39c12; }
|
||||
.orange { background-color: #e67e22; border-bottom: 1px solid #d35400; }
|
||||
.orange:hover { background-color: #d35400; }
|
||||
.red { background-color: #e74c3c; border-bottom: 1px solid #c0392b; }
|
||||
.red:hover { background-color: #c0392b; }
|
||||
.light_gray { background-color: #dce2e3; border-bottom: 1px solid #bdc3c7; }
|
||||
.light_gray:hover { background-color: #bdc3c7; }
|
||||
.medium_gray { background-color: #95a5a6; border-bottom: 1px solid #7f8c8d; }
|
||||
.medium_gray:hover { background-color: #7f8c8d; }
|
||||
.dark_gray { background-color: #34495e; border-bottom: 1px solid #2c3e50; }
|
||||
.dark_gray:hover { background-color: #2c3e50; }
|
||||
/* Colors (from http://flatuicolors.com) */
|
||||
.teal { background-color: #1abc9c; }
|
||||
.green { background-color: #2ecc71; }
|
||||
.blue { background-color: #3498db; }
|
||||
.purple { background-color: #9b59b6; }
|
||||
.yellow { background-color: #f1c40f; }
|
||||
.orange { background-color: #e67e22; }
|
||||
.red { background-color: #e74c3c; }
|
||||
.light_gray { background-color: #dce2e3; }
|
||||
.medium_gray { background-color: #95a5a6; }
|
||||
.dark_gray { background-color: #34495e; }
|
||||
|
||||
/* Rack elevation coloring */
|
||||
ul.rack .teal { border-bottom: 1px solid #16a085; }
|
||||
ul.rack .teal:hover { background-color: #16a085; }
|
||||
ul.rack .green { border-bottom: 1px solid #27ae60; }
|
||||
ul.rack .green:hover { background-color: #27ae60; }
|
||||
ul.rack .blue { border-bottom: 1px solid #2980b9; }
|
||||
ul.rack .blue:hover { background-color: #2980b9; }
|
||||
ul.rack .purple { border-bottom: 1px solid #8e44ad; }
|
||||
ul.rack .purple:hover { background-color: #8e44ad; }
|
||||
ul.rack .yellow { border-bottom: 1px solid #f39c12; }
|
||||
ul.rack .yellow:hover { background-color: #f39c12; }
|
||||
ul.rack .orange { border-bottom: 1px solid #d35400; }
|
||||
ul.rack .orange:hover { background-color: #d35400; }
|
||||
ul.rack .red { border-bottom: 1px solid #c0392b; }
|
||||
ul.rack .red:hover { background-color: #c0392b; }
|
||||
ul.rack .light_gray { border-bottom: 1px solid #bdc3c7; }
|
||||
ul.rack .light_gray:hover { background-color: #bdc3c7; }
|
||||
ul.rack .medium_gray { border-bottom: 1px solid #7f8c8d; }
|
||||
ul.rack .medium_gray:hover { background-color: #7f8c8d; }
|
||||
ul.rack .dark_gray { border-bottom: 1px solid #2c3e50; }
|
||||
ul.rack .dark_gray:hover { background-color: #2c3e50; }
|
||||
|
||||
/* Misc */
|
||||
.banner-bottom {
|
||||
|
||||
BIN
netbox/project-static/img/netbox.ico
Normal file
BIN
netbox/project-static/img/netbox.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
netbox/project-static/img/netbox_logo.png
Normal file
BIN
netbox/project-static/img/netbox_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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 () {
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="{% static 'font-awesome-4.6.3/css/font-awesome.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-default navbar-fixed-top">
|
||||
@@ -19,7 +20,9 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/">NetBox</a>
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="{% static 'img/netbox_logo.png' %}" />
|
||||
</a>
|
||||
</div>
|
||||
<div id="navbar" class="navbar-collapse collapse">
|
||||
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
|
||||
@@ -58,6 +61,11 @@
|
||||
{% if perms.dcim.add_rackgroup %}
|
||||
<li><a href="{% url 'dcim:rackgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'dcim:rackrole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Roles</a></li>
|
||||
{% if perms.dcim.add_rackrole %}
|
||||
<li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/device' or request.path|startswith:'/dcim/manufacturers/' or request.path|startswith:'/dcim/platforms/' %} active{% endif %}">
|
||||
@@ -159,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 %}
|
||||
@@ -178,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 %}">
|
||||
|
||||
@@ -82,35 +82,32 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Port Speed</td>
|
||||
<td>Speed</td>
|
||||
<td>
|
||||
{% if circuit.port_speed %}
|
||||
{{ circuit.port_speed_human }}
|
||||
{% if circuit.upstream_speed %}
|
||||
<i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }}
|
||||
<i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{{ circuit.port_speed_human }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<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">
|
||||
|
||||
@@ -19,9 +19,18 @@
|
||||
<div class="panel-heading"><strong>Bandwidth</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.port_speed %}
|
||||
{% render_field form.upstream_speed %}
|
||||
{% 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">
|
||||
|
||||
@@ -60,8 +60,13 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Port Speed</td>
|
||||
<td>Physical speed in Kbps/td>
|
||||
<td>10000</td>
|
||||
<td>Physical speed in Kbps</td>
|
||||
<td>100000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Upstream Speed</td>
|
||||
<td>Upstream speed in Kbps (optional)</td>
|
||||
<td>20000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Commit rate</td>
|
||||
@@ -81,7 +86,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
|
||||
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Serial</td>
|
||||
<td>Serial Number</td>
|
||||
<td>
|
||||
{% if device.serial %}
|
||||
<span>{{ device.serial }}</span>
|
||||
@@ -70,12 +70,14 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>{{ device.created }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Updated</td>
|
||||
<td>{{ device.last_updated }}</td>
|
||||
<td>Asset Tag</td>
|
||||
<td>
|
||||
{% if device.asset_tag %}
|
||||
<span>{{ device.asset_tag }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -87,7 +89,7 @@
|
||||
<tr>
|
||||
<td>Role</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:device_list' %}?role={{ device.device_role.slug }}">{{ device.device_role }}</a>
|
||||
<a href="{{ device.device_role.get_absolute_url }}">{{ device.device_role }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -142,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">
|
||||
@@ -296,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 %}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
{% render_field form.manufacturer %}
|
||||
{% render_field form.device_type %}
|
||||
{% render_field form.serial %}
|
||||
{% render_field form.asset_tag %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
@@ -62,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">
|
||||
|
||||
@@ -57,10 +57,15 @@
|
||||
<td>Juniper Junos</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Serial</td>
|
||||
<td>Serial number (optional)</td>
|
||||
<td>Serial number</td>
|
||||
<td>Physical serial number (optional)</td>
|
||||
<td>CAB00577291</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Asset tag</td>
|
||||
<td>Unique alphanumeric tag (optional)</td>
|
||||
<td>ABC123456</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>Site name</td>
|
||||
@@ -78,13 +83,13 @@
|
||||
</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>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
|
||||
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Ashburn-VA,R101,21,Rear</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
<td>Functional role of device</td>
|
||||
<td>Blade Server</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tenant</td>
|
||||
<td>Name of tenant (optional)</td>
|
||||
<td>Pied Piper</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Device manufacturer</td>
|
||||
<td>Hardware manufacturer</td>
|
||||
@@ -52,10 +57,15 @@
|
||||
<td>Linux</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Serial</td>
|
||||
<td>Serial number (optional)</td>
|
||||
<td>Serial number</td>
|
||||
<td>Physical serial number (optional)</td>
|
||||
<td>CAB00577291</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Asset tag</td>
|
||||
<td>Unique alphanumeric tag (optional)</td>
|
||||
<td>ABC123456</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Parent device</td>
|
||||
<td>Parent device</td>
|
||||
@@ -69,7 +79,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<pre>Blade12,Blade Server,Dell,BS2000T,Linux,CAB00577291,Server101,Slot4</pre>
|
||||
<pre>Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Server101,Slot4</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,7 +17,23 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Serial Number</td>
|
||||
<td>{{ device.serial }}</td>
|
||||
<td>
|
||||
{% if device.serial %}
|
||||
<span>{{ device.serial }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Asset Tag</td>
|
||||
<td>
|
||||
{% if device.asset_tag %}
|
||||
<span>{{ device.asset_tag }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -32,6 +48,7 @@
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th></th>
|
||||
<th>Manufacturer</th>
|
||||
<th>Part Number</th>
|
||||
<th>Serial Number</th>
|
||||
<th></th>
|
||||
@@ -42,6 +59,7 @@
|
||||
<tr>
|
||||
<td>{{ m.name }}</td>
|
||||
<td>{% if not m.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m.manufacturer|default:'' }}</td>
|
||||
<td>{{ m.part_id }}</td>
|
||||
<td>{{ m.serial }}</td>
|
||||
<td class="text-right">
|
||||
@@ -57,6 +75,7 @@
|
||||
<tr>
|
||||
<td style="padding-left: 20px">{{ m2.name }}</td>
|
||||
<td>{% if not m2.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m2.manufacturer|default:'' }}</td>
|
||||
<td>{{ m2.part_id }}</td>
|
||||
<td>{{ m2.serial }}</td>
|
||||
<td class="text-right">
|
||||
@@ -72,6 +91,7 @@
|
||||
<tr>
|
||||
<td style="padding-left: 40px">{{ m3.name }}</td>
|
||||
<td>{% if not m3.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m3.manufacturer|default:'' }}</td>
|
||||
<td>{{ m3.part_id }}</td>
|
||||
<td>{{ m3.serial }}</td>
|
||||
<td class="text-right">
|
||||
@@ -87,6 +107,7 @@
|
||||
<tr>
|
||||
<td style="padding-left: 60px">{{ m4.name }}</td>
|
||||
<td>{% if not m4.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m4.manufacturer|default:'' }}</td>
|
||||
<td>{{ m4.part_id }}</td>
|
||||
<td>{{ m4.serial }}</td>
|
||||
<td class="text-right">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -96,6 +96,30 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Role</td>
|
||||
<td>
|
||||
{% if rack.role %}
|
||||
<a href="{{ rack.role.get_absolute_url }}">{{ rack.role }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>
|
||||
{% if rack.type %}
|
||||
{{ rack.get_type_display }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Width</td>
|
||||
<td>{{ rack.get_width_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Height</td>
|
||||
<td>{{ rack.u_height }}U</td>
|
||||
@@ -106,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>
|
||||
@@ -163,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">
|
||||
|
||||
@@ -4,13 +4,24 @@
|
||||
{% block title %}Rack Bulk Edit{% endblock %}
|
||||
|
||||
{% block select_objects_table %}
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Site</th>
|
||||
<th>Group</th>
|
||||
<th>Tenant</th>
|
||||
<th>Type</th>
|
||||
<th>Width</th>
|
||||
<th>Height</th>
|
||||
</tr>
|
||||
{% for rack in selected_objects %}
|
||||
<tr>
|
||||
<td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
|
||||
<td>{{ rack.facility_id }}</td>
|
||||
<td>{{ rack.site }}</td>
|
||||
<td>{{ rack.group }}</td>
|
||||
<td>{{ rack.tenant }}</td>
|
||||
<td>{{ rack.u_height }}</td>
|
||||
<td>{{ rack.get_type_display }}</td>
|
||||
<td>{{ rack.get_width_display }}</td>
|
||||
<td>{{ rack.u_height }}U</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,9 +10,20 @@
|
||||
{% render_field form.name %}
|
||||
{% render_field form.facility_id %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.role %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.width %}
|
||||
{% 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">
|
||||
|
||||
@@ -53,6 +53,21 @@
|
||||
<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>
|
||||
<td>4-post cabinet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Width</td>
|
||||
<td>Rail-to-rail width (19 or 23 inches)</td>
|
||||
<td>19</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Height</td>
|
||||
<td>Height in rack units</td>
|
||||
@@ -61,7 +76,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,42</pre>
|
||||
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
21
netbox/templates/dcim/rackrole_list.html
Normal file
21
netbox/templates/dcim/rackrole_list.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block title %}Rack Role{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.add_rackrole %}
|
||||
<a href="{% url 'dcim:rackrole_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a rack role
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>Rack Roles</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row home-search" style="padding: 15px 0px 20px">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<form action="{% url 'dcim:device_list' %}" method="get">
|
||||
<div class="input-group input-group-lg">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" placeholder="Search devices" class="form-control" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@@ -17,9 +17,9 @@
|
||||
</form>
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<form action="{% url 'ipam:prefix_list' %}" method="get">
|
||||
<div class="input-group input-group-lg">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" placeholder="Search prefixes" class="form-control" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
@@ -31,9 +31,23 @@
|
||||
</form>
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<form action="{% url 'ipam:ipaddress_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" placeholder="Search IPs" class="form-control" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
IPs
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
<p></p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<form action="{% url 'circuits:circuit_list' %}" method="get">
|
||||
<div class="input-group input-group-lg">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" placeholder="Search circuits" class="form-control" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
|
||||
3
netbox/templates/inc/created_updated.html
Normal file
3
netbox/templates/inc/created_updated.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<p>
|
||||
<small class="text-muted">Created {{ obj.created }} · Updated <span title="{{ obj.last_updated }}">{{ obj.last_updated|timesince }}</span> ago</small>
|
||||
</p>
|
||||
29
netbox/templates/inc/custom_fields_panel.html
Normal file
29
netbox/templates/inc/custom_fields_panel.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
22
netbox/templates/ipam/aggregate_edit.html
Normal file
22
netbox/templates/ipam/aggregate_edit.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
26
netbox/templates/ipam/prefix_edit.html
Normal file
26
netbox/templates/ipam/prefix_edit.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
26
netbox/templates/ipam/vlan_edit.html
Normal file
26
netbox/templates/ipam/vlan_edit.html
Normal 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 %}
|
||||
@@ -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">
|
||||
|
||||
23
netbox/templates/ipam/vrf_edit.html
Normal file
23
netbox/templates/ipam/vrf_edit.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user