Merge pull request #1903 from digitalocean/develop

Release v2.2.10
This commit is contained in:
Jeremy Stretch 2018-02-21 16:05:45 -05:00 committed by GitHub
commit c4f7e8121a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 933 additions and 648 deletions

View File

@ -10,24 +10,23 @@ We have established a Google Groups Mailing List for issues and general
discussion. This is the best forum for obtaining assistance with NetBox discussion. This is the best forum for obtaining assistance with NetBox
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss). installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
### Freenode IRC ### Slack
For real-time discussion, you can join the #netbox channel on [Freenode](https://freenode.net/). For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/).
You can connect to Freenode at irc.freenode.net using an IRC client, or you can
use their [webchat client](https://webchat.freenode.net/).
## Reporting Bugs ## Reporting Bugs
* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) of * First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases)
NetBox. If you're running an older version, it's possible that the bug has of NetBox. If you're running an older version, it's possible that the bug has
already been fixed. already been fixed.
* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the bug you've found has already * Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
been reported. If you think you may be experiencing a reported issue that to see if the bug you've found has already been reported. If you think you may
hasn't already been resolved, please click "add a reaction" in the top right be experiencing a reported issue that hasn't already been resolved, please
corner of the issue and add a thumbs up (+1). You mightalso want to add a click "add a reaction" in the top right corner of the issue and add a thumbs
comment describing how it's affecting your installation. This will allow us to up (+1). You mightalso want to add a comment describing how it's affecting your
prioritize bugs based on how many users are affected. installation. This will allow us to prioritize bugs based on how many users are
affected.
* If you haven't found an existing issue that describes your suspected bug, * If you haven't found an existing issue that describes your suspected bug,
please inquire about it on the mailing list. **Do not** file an issue until you please inquire about it on the mailing list. **Do not** file an issue until you
@ -44,7 +43,7 @@ include:
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title. * Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
The issue will be reviewed by a moderator after submission and the appropriate The issue will be reviewed by a moderator after submission and the appropriate
labels will be applied. labels will be applied for categorization.
* Keep in mind that we prioritize bugs based on their severity and how much * Keep in mind that we prioritize bugs based on their severity and how much
work is required to resolve them. It may take some time for someone to address work is required to resolve them. It may take some time for someone to address
@ -52,15 +51,15 @@ your issue.
## Feature Requests ## Feature Requests
* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're requesting * First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
is already listed. (Be sure to search closed issues as well, since some to see if the feature you're requesting is already listed. (Be sure to search
feature requests have been rejected.) If the feature you'd like to see has closed issues as well, since some feature requests have been rejected.) If the
already been requested and is open, click "add a reaction" in the top right feature you'd like to see has already been requested and is open, click "add a
corner of the issue and add a thumbs up (+1). This ensures that the issue has reaction" in the top right corner of the issue and add a thumbs up (+1). This
a better chance of receiving attention. Also feel free to add a comment with ensures that the issue has a better chance of receiving attention. Also feel
any additional justification for the feature. (However, note that comments with free to add a comment with any additional justification for the feature.
no substance other than a "+1" will be deleted. Please use GitHub's reactions (However, note that comments with no substance other than a "+1" will be
feature to indicate your support.) deleted. Please use GitHub's reactions feature to indicate your support.)
* Due to an excessive backlog of feature requests, we are not currently * Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which substantially extend NetBox's functionality accepting any proposals which substantially extend NetBox's functionality
@ -88,7 +87,7 @@ following:
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue * Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue
title. The issue will be reviewed by a moderator after submission and the title. The issue will be reviewed by a moderator after submission and the
appropriate labels will be applied. appropriate labels will be applied for categorization.
## Submitting Pull Requests ## Submitting Pull Requests
@ -109,3 +108,10 @@ these checks):
* All tests pass when run with `./manage.py test` * All tests pass when run with `./manage.py test`
* PEP 8 compliance is enforced, with the exception that lines may be * PEP 8 compliance is enforced, with the exception that lines may be
greater than 80 characters in length greater than 80 characters in length
## Commenting
Only comment on an issue if you are sharing a relevant idea or constructive
feedback. **Do not** comment on an issue just to show your support (give the
top post a :+1: instead) or ask for an ETA. These comments will be deleted to
reduce noise in the discussion.

View File

@ -1,12 +1,18 @@
![NetBox](docs/netbox_logo.png "NetBox logo") ![NetBox](docs/netbox_logo.png "NetBox logo")
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. 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.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox). NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**! Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)!
### Build Status ### Build Status
@ -27,7 +33,9 @@ NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended.
# Installation # Installation
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`. Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases)
and run `upgrade.sh`.
## Alternative Installations ## Alternative Installations

View File

@ -5,7 +5,7 @@ Supported HTTP methods:
* `GET`: Retrieve an object or list of objects * `GET`: Retrieve an object or list of objects
* `POST`: Create a new object * `POST`: Create a new object
* `PUT`: Update an existing object, all mandatory fields must be specified * `PUT`: Update an existing object, all mandatory fields must be specified
* `PATCH`: Updates an existing object, only specifiying the field to be changed * `PATCH`: Updates an existing object, only specifying the field to be changed
* `DELETE`: Delete an existing object * `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header: To authenticate a request, attach your token in an `Authorization` header:
@ -144,4 +144,4 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
* Closing connection 0 * Closing connection 0
``` ```
The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty. The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.

View File

@ -87,7 +87,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group # This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
# heirarchy. # hierarchy.
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE, AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
"(objectClass=group)") "(objectClass=group)")
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()

View File

@ -1,4 +1,4 @@
NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command: NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
``` ```
./manage.py nbshell ./manage.py nbshell
@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
982 982
``` ```
Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper." Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
``` ```
>>> Device.objects.filter(tenant__name='Pied Piper') >>> Device.objects.filter(tenant__name='Pied Piper')

View File

@ -43,7 +43,7 @@ class ProviderCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Provider model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments'] fields = Provider.csv_headers
help_texts = { help_texts = {
'name': 'Provider name', 'name': 'Provider name',
'asn': '32-bit autonomous system number', 'asn': '32-bit autonomous system number',
@ -89,7 +89,7 @@ class CircuitTypeCSVForm(forms.ModelForm):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ['name', 'slug'] fields = CircuitType.csv_headers
help_texts = { help_texts = {
'name': 'Name of circuit type', 'name': 'Name of circuit type',
} }

View File

@ -9,7 +9,6 @@ from dcim.fields import ASNField
from extras.models import CustomFieldModel, CustomFieldValue from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .constants import * from .constants import *
@ -29,7 +28,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url'] csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -41,13 +40,16 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
return reverse('circuits:provider', args=[self.slug]) return reverse('circuits:provider', args=[self.slug])
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.slug, self.slug,
self.asn, self.asn,
self.account, self.account,
self.portal_url, self.portal_url,
]) self.noc_contact,
self.admin_contact,
self.comments,
)
@python_2_unicode_compatible @python_2_unicode_compatible
@ -59,6 +61,8 @@ class CircuitType(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
csv_headers = ['name', 'slug']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -68,6 +72,12 @@ class CircuitType(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
)
@python_2_unicode_compatible @python_2_unicode_compatible
class Circuit(CreatedUpdatedModel, CustomFieldModel): class Circuit(CreatedUpdatedModel, CustomFieldModel):
@ -86,7 +96,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description'] csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
class Meta: class Meta:
ordering = ['provider', 'cid'] ordering = ['provider', 'cid']
@ -99,15 +109,16 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
return reverse('circuits:circuit', args=[self.pk]) return reverse('circuits:circuit', args=[self.pk])
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.cid, self.cid,
self.provider.name, self.provider.name,
self.type.name, self.type.name,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.install_date.isoformat() if self.install_date else None, self.install_date,
self.commit_rate, self.commit_rate,
self.description, self.description,
]) self.comments,
)
def _get_termination(self, side): def _get_termination(self, side):
for ct in self.terminations.all(): for ct in self.terminations.all():

View File

@ -72,9 +72,7 @@ class RegionCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Region model = Region
fields = [ fields = Region.csv_headers
'name', 'slug', 'parent',
]
help_texts = { help_texts = {
'name': 'Region name', 'name': 'Region name',
'slug': 'URL-friendly slug', 'slug': 'URL-friendly slug',
@ -136,10 +134,7 @@ class SiteCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = Site.csv_headers
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments',
]
help_texts = { help_texts = {
'name': 'Site name', 'name': 'Site name',
'slug': 'URL-friendly slug', 'slug': 'URL-friendly slug',
@ -196,9 +191,7 @@ class RackGroupCSVForm(forms.ModelForm):
class Meta: class Meta:
model = RackGroup model = RackGroup
fields = [ fields = RackGroup.csv_headers
'site', 'name', 'slug',
]
help_texts = { help_texts = {
'name': 'Name of rack group', 'name': 'Name of rack group',
'slug': 'URL-friendly slug', 'slug': 'URL-friendly slug',
@ -226,7 +219,7 @@ class RackRoleCSVForm(forms.ModelForm):
class Meta: class Meta:
model = RackRole model = RackRole
fields = ['name', 'slug', 'color'] fields = RackRole.csv_headers
help_texts = { help_texts = {
'name': 'Name of rack role', 'name': 'Name of rack role',
'color': 'RGB color in hexadecimal (e.g. 00ff00)' 'color': 'RGB color in hexadecimal (e.g. 00ff00)'
@ -313,10 +306,7 @@ class RackCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = Rack.csv_headers
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
'desc_units',
]
help_texts = { help_texts = {
'name': 'Rack name', 'name': 'Rack name',
'u_height': 'Height in rack units', 'u_height': 'Height in rack units',
@ -444,9 +434,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm):
class ManufacturerCSVForm(forms.ModelForm): class ManufacturerCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ fields = Manufacturer.csv_headers
'name', 'slug'
]
help_texts = { help_texts = {
'name': 'Manufacturer name', 'name': 'Manufacturer name',
'slug': 'URL-friendly slug', 'slug': 'URL-friendly slug',
@ -492,8 +480,7 @@ class DeviceTypeCSVForm(forms.ModelForm):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', fields = DeviceType.csv_headers
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
help_texts = { help_texts = {
'model': 'Model name', 'model': 'Model name',
'slug': 'URL-friendly slug', 'slug': 'URL-friendly slug',
@ -658,7 +645,7 @@ class DeviceRoleCSVForm(forms.ModelForm):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = ['name', 'slug', 'color', 'vm_role'] fields = DeviceRole.csv_headers
help_texts = { help_texts = {
'name': 'Name of device role', 'name': 'Name of device role',
'color': 'RGB color in hexadecimal (e.g. 00ff00)' 'color': 'RGB color in hexadecimal (e.g. 00ff00)'
@ -682,7 +669,7 @@ class PlatformCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ['name', 'slug', 'napalm_driver'] fields = Platform.csv_headers
help_texts = { help_texts = {
'name': 'Platform name', 'name': 'Platform name',
} }
@ -932,7 +919,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
class Meta(BaseDeviceCSVForm.Meta): class Meta(BaseDeviceCSVForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'comments',
] ]
def clean(self): def clean(self):
@ -981,7 +968,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
class Meta(BaseDeviceCSVForm.Meta): class Meta(BaseDeviceCSVForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay_name', 'cluster', 'parent', 'device_bay_name', 'cluster', 'comments',
] ]
def clean(self): def clean(self):
@ -1061,6 +1048,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
) )
status = forms.MultipleChoiceField(choices=device_status_choices, required=False) status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
mac_address = forms.CharField(required=False, label='MAC address') mac_address = forms.CharField(required=False, label='MAC address')
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=forms.Select(choices=[
('', '---------'),
('True', 'Yes'),
('False', 'No'),
])
)
# #
@ -1610,7 +1606,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = Interface model = Interface
fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description'] fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description']
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
} }
@ -1636,7 +1632,11 @@ class InterfaceCreateForm(ComponentForm):
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address') mac_address = MACAddressFormField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField(required=False, label='OOB Management') mgmt_only = forms.BooleanField(
required=False,
label='OOB Management',
help_text='This interface is used only for out-of-band management'
)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1808,7 +1808,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
class Meta: class Meta:
model = InterfaceConnection model = InterfaceConnection
fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] fields = InterfaceConnection.csv_headers
def clean_interface_a(self): def clean_interface_a(self):
@ -1951,7 +1951,7 @@ class InventoryItemCSVForm(forms.ModelForm):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = ['device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] fields = InventoryItem.csv_headers
class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):

View File

@ -22,7 +22,6 @@ from tenancy.models import Tenant
from utilities.fields import ColorField, NullableCharField from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .constants import * from .constants import *
from .fields import ASNField, MACAddressField from .fields import ASNField, MACAddressField
from .querysets import InterfaceQuerySet from .querysets import InterfaceQuerySet
@ -43,9 +42,7 @@ class Region(MPTTModel):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
csv_headers = [ csv_headers = ['name', 'slug', 'parent']
'name', 'slug', 'parent',
]
class MPTTMeta: class MPTTMeta:
order_insertion_by = ['name'] order_insertion_by = ['name']
@ -57,11 +54,11 @@ class Region(MPTTModel):
return "{}?region={}".format(reverse('dcim:site_list'), self.slug) return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.slug, self.slug,
self.parent.name if self.parent else None, self.parent.name if self.parent else None,
]) )
# #
@ -98,7 +95,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
objects = SiteManager() objects = SiteManager()
csv_headers = [ csv_headers = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
'contact_phone', 'contact_email', 'comments',
] ]
class Meta: class Meta:
@ -111,17 +109,20 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
return reverse('dcim:site', args=[self.slug]) return reverse('dcim:site', args=[self.slug])
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.slug, self.slug,
self.region.name if self.region else None, self.region.name if self.region else None,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.facility, self.facility,
self.asn, self.asn,
self.physical_address,
self.shipping_address,
self.contact_name, self.contact_name,
self.contact_phone, self.contact_phone,
self.contact_email, self.contact_email,
]) self.comments,
)
@property @property
def count_prefixes(self): def count_prefixes(self):
@ -164,9 +165,7 @@ class RackGroup(models.Model):
slug = models.SlugField() slug = models.SlugField()
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
csv_headers = [ csv_headers = ['site', 'name', 'slug']
'site', 'name', 'slug',
]
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
@ -182,11 +181,11 @@ class RackGroup(models.Model):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.site, self.site,
self.name, self.name,
self.slug, self.slug,
]) )
@python_2_unicode_compatible @python_2_unicode_compatible
@ -198,6 +197,8 @@ class RackRole(models.Model):
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
color = ColorField() color = ColorField()
csv_headers = ['name', 'slug', 'color']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -207,6 +208,13 @@ class RackRole(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
self.color,
)
class RackManager(NaturalOrderByManager): class RackManager(NaturalOrderByManager):
@ -242,7 +250,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
csv_headers = [ csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
'desc_units', 'desc_units', 'comments',
] ]
class Meta: class Meta:
@ -292,7 +300,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
Device.objects.filter(rack=self).update(site_id=self.site.pk) Device.objects.filter(rack=self).update(site_id=self.site.pk)
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.site.name, self.site.name,
self.group.name if self.group else None, self.group.name if self.group else None,
self.name, self.name,
@ -304,7 +312,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
self.width, self.width,
self.u_height, self.u_height,
self.desc_units, self.desc_units,
]) self.comments,
)
@property @property
def units(self): def units(self):
@ -479,9 +488,7 @@ class Manufacturer(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
csv_headers = [ csv_headers = ['name', 'slug']
'name', 'slug',
]
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -493,10 +500,10 @@ class Manufacturer(models.Model):
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.slug, self.slug,
]) )
@python_2_unicode_compatible @python_2_unicode_compatible
@ -539,7 +546,7 @@ class DeviceType(models.Model, CustomFieldModel):
csv_headers = [ csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
] ]
class Meta: class Meta:
@ -562,7 +569,7 @@ class DeviceType(models.Model, CustomFieldModel):
return reverse('dcim:devicetype', args=[self.pk]) return reverse('dcim:devicetype', args=[self.pk])
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.manufacturer.name, self.manufacturer.name,
self.model, self.model,
self.slug, self.slug,
@ -574,7 +581,8 @@ class DeviceType(models.Model, CustomFieldModel):
self.is_network_device, self.is_network_device,
self.get_subdevice_role_display() if self.subdevice_role else None, self.get_subdevice_role_display() if self.subdevice_role else None,
self.get_interface_ordering_display(), self.get_interface_ordering_display(),
]) self.comments,
)
def clean(self): def clean(self):
@ -754,6 +762,8 @@ class DeviceRole(models.Model):
help_text="Virtual machines may be assigned to this role" help_text="Virtual machines may be assigned to this role"
) )
csv_headers = ['name', 'slug', 'color', 'vm_role']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -763,6 +773,14 @@ class DeviceRole(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?role={}".format(reverse('dcim:device_list'), self.slug) return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
self.color,
self.vm_role,
)
@python_2_unicode_compatible @python_2_unicode_compatible
class Platform(models.Model): class Platform(models.Model):
@ -778,6 +796,8 @@ class Platform(models.Model):
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
verbose_name='Legacy RPC client') verbose_name='Legacy RPC client')
csv_headers = ['name', 'slug', 'napalm_driver']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -787,6 +807,13 @@ class Platform(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?platform={}".format(reverse('dcim:device_list'), self.slug) return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
self.napalm_driver,
)
class DeviceManager(NaturalOrderByManager): class DeviceManager(NaturalOrderByManager):
@ -848,7 +875,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
] ]
class Meta: class Meta:
@ -989,7 +1016,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack) Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name or '', self.name or '',
self.device_role.name, self.device_role.name,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
@ -1004,7 +1031,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.rack.name if self.rack else None, self.rack.name if self.rack else None,
self.position, self.position,
self.get_face_display(), self.get_face_display(),
]) self.comments,
)
@property @property
def display_name(self): def display_name(self):
@ -1076,15 +1104,14 @@ class ConsolePort(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
# Used for connections export
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.cs_port.device.identifier if self.cs_port else None, self.cs_port.device.identifier if self.cs_port else None,
self.cs_port.name if self.cs_port else None, self.cs_port.name if self.cs_port else None,
self.device.identifier, self.device.identifier,
self.name, self.name,
self.get_connection_status_display(), self.get_connection_status_display(),
]) )
# #
@ -1153,15 +1180,14 @@ class PowerPort(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
# Used for connections export
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.power_outlet.device.identifier if self.power_outlet else None, self.power_outlet.device.identifier if self.power_outlet else None,
self.power_outlet.name if self.power_outlet else None, self.power_outlet.name if self.power_outlet else None,
self.device.identifier, self.device.identifier,
self.name, self.name,
self.get_connection_status_display(), self.get_connection_status_display(),
]) )
# #
@ -1382,15 +1408,14 @@ class InterfaceConnection(models.Model):
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
# Used for connections export
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.interface_a.device.identifier, self.interface_a.device.identifier,
self.interface_a.name, self.interface_a.name,
self.interface_b.device.identifier, self.interface_b.device.identifier,
self.interface_b.name, self.interface_b.name,
self.get_connection_status_display(), self.get_connection_status_display(),
]) )
# #
@ -1453,7 +1478,7 @@ class InventoryItem(models.Model):
description = models.CharField(max_length=100, blank=True) description = models.CharField(max_length=100, blank=True)
csv_headers = [ csv_headers = [
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
] ]
class Meta: class Meta:
@ -1464,12 +1489,13 @@ class InventoryItem(models.Model):
return self.name return self.name
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.device.name or '{' + self.device.pk + '}', self.device.name or '{' + self.device.pk + '}',
self.name, self.name,
self.manufacturer.name if self.manufacturer else None, self.manufacturer.name if self.manufacturer else None,
self.part_id, self.part_id,
self.serial, self.serial,
self.asset_tag, self.asset_tag,
self.description self.discovered,
]) self.description,
)

View File

@ -65,6 +65,10 @@ RACK_ROLE = """
{% endif %} {% endif %}
""" """
RACK_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
"""
RACKRESERVATION_ACTIONS = """ RACKRESERVATION_ACTIONS = """
{% if perms.dcim.change_rackreservation %} {% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@ -83,6 +87,22 @@ MANUFACTURER_ACTIONS = """
{% endif %} {% endif %}
""" """
DEVICEROLE_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
"""
DEVICEROLE_VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
"""
PLATFORM_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
"""
PLATFORM_VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
"""
PLATFORM_ACTIONS = """ PLATFORM_ACTIONS = """
{% if perms.dcim.change_platform %} {% if perms.dcim.change_platform %}
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@ -218,12 +238,16 @@ class RackTable(BaseTable):
class RackDetailTable(RackTable): class RackDetailTable(RackTable):
devices = tables.Column(accessor=Accessor('device_count')) device_count = tables.TemplateColumn(
template_code=RACK_DEVICE_COUNT,
verbose_name='Devices'
)
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(RackTable.Meta): class Meta(RackTable.Meta):
fields = ( fields = (
'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization' 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization',
) )
@ -362,12 +386,25 @@ class DeviceBayTemplateTable(BaseTable):
class DeviceRoleTable(BaseTable): class DeviceRoleTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name') name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices') device_count = tables.TemplateColumn(
vm_count = tables.Column(verbose_name='VMs') template_code=DEVICEROLE_DEVICE_COUNT,
accessor=Accessor('devices.count'),
orderable=False,
verbose_name='Devices'
)
vm_count = tables.TemplateColumn(
template_code=DEVICEROLE_VM_COUNT,
accessor=Accessor('virtual_machines.count'),
orderable=False,
verbose_name='VMs'
)
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label') color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
slug = tables.Column(verbose_name='Slug') slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, actions = tables.TemplateColumn(
verbose_name='') template_code=DEVICEROLE_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceRole model = DeviceRole
@ -380,10 +417,18 @@ class DeviceRoleTable(BaseTable):
class PlatformTable(BaseTable): class PlatformTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name') device_count = tables.TemplateColumn(
device_count = tables.Column(verbose_name='Devices') template_code=PLATFORM_DEVICE_COUNT,
vm_count = tables.Column(verbose_name='VMs') accessor=Accessor('devices.count'),
slug = tables.Column(verbose_name='Slug') orderable=False,
verbose_name='Devices'
)
vm_count = tables.TemplateColumn(
template_code=PLATFORM_VM_COUNT,
accessor=Accessor('virtual_machines.count'),
orderable=False,
verbose_name='VMs'
)
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=PLATFORM_ACTIONS, template_code=PLATFORM_ACTIONS,
attrs={'td': {'class': 'text-right'}}, attrs={'td': {'class': 'text-right'}},

View File

@ -276,7 +276,7 @@ class RackListView(ObjectListView):
).prefetch_related( ).prefetch_related(
'devices__device_type' 'devices__device_type'
).annotate( ).annotate(
device_count=Count('devices', distinct=True) device_count=Count('devices')
) )
filter = filters.RackFilter filter = filters.RackFilter
filter_form = forms.RackFilterForm filter_form = forms.RackFilterForm
@ -715,10 +715,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class DeviceRoleListView(ObjectListView): class DeviceRoleListView(ObjectListView):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.all()
device_count=Count('devices', distinct=True),
vm_count=Count('virtual_machines', distinct=True)
)
table = tables.DeviceRoleTable table = tables.DeviceRoleTable
template_name = 'dcim/devicerole_list.html' template_name = 'dcim/devicerole_list.html'
@ -756,10 +753,7 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class PlatformListView(ObjectListView): class PlatformListView(ObjectListView):
queryset = Platform.objects.annotate( queryset = Platform.objects.all()
device_count=Count('devices', distinct=True),
vm_count=Count('virtual_machines', distinct=True)
)
table = tables.PlatformTable table = tables.PlatformTable
template_name = 'dcim/platform_list.html' template_name = 'dcim/platform_list.html'

View File

@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
@admin.register(CustomField) @admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin): class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin] inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description'] list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
form = CustomFieldForm form = CustomFieldForm
def models(self, obj): def models(self, obj):

View File

@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
(CF_TYPE_SELECT, 'Selection'), (CF_TYPE_SELECT, 'Selection'),
) )
# Custom field filter logic choices
CF_FILTER_DISABLED = 0
CF_FILTER_LOOSE = 1
CF_FILTER_EXACT = 2
CF_FILTER_CHOICES = (
(CF_FILTER_DISABLED, 'Disabled'),
(CF_FILTER_LOOSE, 'Loose'),
(CF_FILTER_EXACT, 'Exact'),
)
# Graph types # Graph types
GRAPH_TYPE_INTERFACE = 100 GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200 GRAPH_TYPE_PROVIDER = 200
@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [
'cluster', 'virtualmachine', # Virtualization 'cluster', 'virtualmachine', # Virtualization
] ]
# Topology map types
TOPOLOGYMAP_TYPE_NETWORK = 1
TOPOLOGYMAP_TYPE_CONSOLE = 2
TOPOLOGYMAP_TYPE_POWER = 3
TOPOLOGYMAP_TYPE_CHOICES = (
(TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
(TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
)
# User action types # User action types
ACTION_CREATE = 1 ACTION_CREATE = 1
ACTION_IMPORT = 2 ACTION_IMPORT = 2

View File

@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from dcim.models import Site from dcim.models import Site
from .constants import CF_TYPE_SELECT from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
@ -14,8 +14,9 @@ class CustomFieldFilter(django_filters.Filter):
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. 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): def __init__(self, custom_field, *args, **kwargs):
self.cf_type = cf_type self.cf_type = custom_field.type
self.filter_logic = custom_field.filter_logic
super(CustomFieldFilter, self).__init__(*args, **kwargs) super(CustomFieldFilter, self).__init__(*args, **kwargs)
def filter(self, queryset, value): def filter(self, queryset, value):
@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter):
except ValueError: except ValueError:
return queryset.none() return queryset.none()
return queryset.filter( # Apply the assigned filter logic (exact or loose)
custom_field_values__field__name=self.name, queryset = queryset.filter(custom_field_values__field__name=self.name)
custom_field_values__serialized_value__icontains=value, if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
) return queryset.filter(custom_field_values__serialized_value=value)
else:
return queryset.filter(custom_field_values__serialized_value__icontains=value)
class CustomFieldFilterSet(django_filters.FilterSet): class CustomFieldFilterSet(django_filters.FilterSet):
@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
super(CustomFieldFilterSet, self).__init__(*args, **kwargs) super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
obj_type = ContentType.objects.get_for_model(self._meta.model) obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
for cf in custom_fields: for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type) self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf)
class GraphFilter(django_filters.FilterSet): class GraphFilter(django_filters.FilterSet):

View File

@ -6,7 +6,7 @@ from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .models import CustomField, CustomFieldValue, ImageAttachment from .models import CustomField, CustomFieldValue, ImageAttachment
@ -15,17 +15,17 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
Retrieve all CustomFields applicable to the given ContentType Retrieve all CustomFields applicable to the given ContentType
""" """
field_dict = OrderedDict() field_dict = OrderedDict()
kwargs = {'obj_type': content_type} custom_fields = CustomField.objects.filter(obj_type=content_type)
if filterable_only: if filterable_only:
kwargs['is_filterable'] = True custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
custom_fields = CustomField.objects.filter(**kwargs)
for cf in custom_fields: for cf in custom_fields:
field_name = 'cf_{}'.format(str(cf.name)) field_name = 'cf_{}'.format(str(cf.name))
initial = cf.default if not bulk_edit else None
# Integer # Integer
if cf.type == CF_TYPE_INTEGER: if cf.type == CF_TYPE_INTEGER:
field = forms.IntegerField(required=cf.required, initial=cf.default) field = forms.IntegerField(required=cf.required, initial=initial)
# Boolean # Boolean
elif cf.type == CF_TYPE_BOOLEAN: elif cf.type == CF_TYPE_BOOLEAN:
@ -34,18 +34,19 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
(1, 'True'), (1, 'True'),
(0, 'False'), (0, 'False'),
) )
if cf.default.lower() in ['true', 'yes', '1']: if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1 initial = 1
elif cf.default.lower() in ['false', 'no', '0']: elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0 initial = 0
else: else:
initial = None initial = None
field = forms.NullBooleanField(required=cf.required, initial=initial, field = forms.NullBooleanField(
widget=forms.Select(choices=choices)) required=cf.required, initial=initial, widget=forms.Select(choices=choices)
)
# Date # Date
elif cf.type == CF_TYPE_DATE: elif cf.type == CF_TYPE_DATE:
field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD") field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
# Select # Select
elif cf.type == CF_TYPE_SELECT: elif cf.type == CF_TYPE_SELECT:
@ -56,11 +57,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
# URL # URL
elif cf.type == CF_TYPE_URL: elif cf.type == CF_TYPE_URL:
field = LaxURLField(required=cf.required, initial=cf.default) field = LaxURLField(required=cf.required, initial=initial)
# Text # Text
else: else:
field = forms.CharField(max_length=255, required=cf.required, initial=cf.default) field = forms.CharField(max_length=255, required=cf.required, initial=initial)
field.model = cf field.model = cf
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize() field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()

View File

@ -4,14 +4,6 @@ from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
from extras.models import TopologyMap
def commas_to_semicolons(apps, schema_editor):
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
tm.device_patterns = tm.device_patterns.replace(',', ';')
tm.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -25,5 +17,4 @@ class Migration(migrations.Migration):
name='device_patterns', name='device_patterns',
field=models.TextField(help_text=b'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 within a line using semicolons. Devices will be rendered in the order they are defined.'), field=models.TextField(help_text=b'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 within a line using semicolons. Devices will be rendered in the order they are defined.'),
), ),
migrations.RunPython(commas_to_semicolons),
] ]

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-15 16:28
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0008_reports'),
]
operations = [
migrations.AddField(
model_name='topologymap',
name='type',
field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
),
]

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-21 19:48
from __future__ import unicode_literals
from django.db import migrations, models
from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
def is_filterable_to_filter_logic(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
# Select fields match on primary key only
CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
def filter_logic_to_is_filterable(apps, schema_editor):
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
class Migration(migrations.Migration):
dependencies = [
('extras', '0009_topologymap_type'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='filter_logic',
field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
),
migrations.AlterField(
model_name='customfield',
name='required',
field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
),
migrations.AlterField(
model_name='customfield',
name='weight',
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
),
migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
migrations.RemoveField(
model_name='customfield',
name='is_filterable',
),
]

View File

@ -16,6 +16,7 @@ from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import foreground_color from utilities.utils import foreground_color
from .constants import * from .constants import *
@ -54,22 +55,48 @@ class CustomFieldModel(object):
@python_2_unicode_compatible @python_2_unicode_compatible
class CustomField(models.Model): class CustomField(models.Model):
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)', obj_type = models.ManyToManyField(
limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, to=ContentType,
help_text="The object(s) to which this field applies.") related_name='custom_fields',
type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT) verbose_name='Object(s)',
name = models.CharField(max_length=50, unique=True) limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not " help_text='The object(s) to which this field applies.'
"provided, the field's name will be used)") )
description = models.CharField(max_length=100, blank=True) type = models.PositiveSmallIntegerField(
required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating " choices=CUSTOMFIELD_TYPE_CHOICES,
"new objects or editing an existing object.") default=CF_TYPE_TEXT
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 " name = models.CharField(
"\"false\" for booleans. N/A for selection " max_length=50,
"fields.") unique=True
weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a " )
"form") 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='If true, this field is required when creating new objects or editing an existing object.'
)
filter_logic = models.PositiveSmallIntegerField(
choices=CF_FILTER_CHOICES,
default=CF_FILTER_LOOSE,
help_text="Loose matches any instance of a given string; exact matches the entire field."
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
class Meta: class Meta:
ordering = ['weight', 'name'] ordering = ['weight', 'name']
@ -223,19 +250,25 @@ class ExportTemplate(models.Model):
def __str__(self): def __str__(self):
return '{}: {}'.format(self.content_type, self.name) return '{}: {}'.format(self.content_type, self.name)
def to_response(self, context_dict, filename): def render_to_response(self, queryset):
""" """
Render the template to an HTTP response, delivered as a named file attachment Render the template to an HTTP response, delivered as a named file attachment
""" """
template = Template(self.template_code) template = Template(self.template_code)
mime_type = 'text/plain' if not self.mime_type else self.mime_type mime_type = 'text/plain' if not self.mime_type else self.mime_type
output = template.render(Context(context_dict)) output = template.render(Context({'queryset': queryset}))
# Replace CRLF-style line terminators # Replace CRLF-style line terminators
output = output.replace('\r\n', '\n') output = output.replace('\r\n', '\n')
# Build the response
response = HttpResponse(output, content_type=mime_type) response = HttpResponse(output, content_type=mime_type)
if self.file_extension: filename = 'netbox_{}{}'.format(
filename += '.{}'.format(self.file_extension) queryset.model._meta.verbose_name_plural,
'.{}'.format(self.file_extension) if self.file_extension else ''
)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response return response
@ -247,7 +280,17 @@ class ExportTemplate(models.Model):
class TopologyMap(models.Model): class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE) type = models.PositiveSmallIntegerField(
choices=TOPOLOGYMAP_TYPE_CHOICES,
default=TOPOLOGYMAP_TYPE_NETWORK
)
site = models.ForeignKey(
to='dcim.Site',
related_name='topology_maps',
blank=True,
null=True,
on_delete=models.CASCADE
)
device_patterns = models.TextField( device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will " 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 within a line using semicolons. " "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@ -269,22 +312,26 @@ class TopologyMap(models.Model):
def render(self, img_format='png'): def render(self, img_format='png'):
from circuits.models import CircuitTermination from dcim.models import Device
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
# Construct the graph # Construct the graph
graph = graphviz.Graph() if self.type == TOPOLOGYMAP_TYPE_NETWORK:
graph.graph_attr['ranksep'] = '1' G = graphviz.Graph
else:
G = graphviz.Digraph
self.graph = G()
self.graph.graph_attr['ranksep'] = '1'
seen = set() seen = set()
for i, device_set in enumerate(self.device_sets): for i, device_set in enumerate(self.device_sets):
subgraph = graphviz.Graph(name='sg{}'.format(i)) subgraph = G(name='sg{}'.format(i))
subgraph.graph_attr['rank'] = 'same' subgraph.graph_attr['rank'] = 'same'
subgraph.graph_attr['directed'] = 'true'
# Add a pseudonode for each device_set to enforce hierarchical layout # Add a pseudonode for each device_set to enforce hierarchical layout
subgraph.node('set{}'.format(i), label='', shape='none', width='0') subgraph.node('set{}'.format(i), label='', shape='none', width='0')
if i: if i:
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis') self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph # Add each device to the graph
devices = [] devices = []
@ -302,31 +349,64 @@ class TopologyMap(models.Model):
for j in range(0, len(devices) - 1): for j in range(0, len(devices) - 1):
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis') subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
graph.subgraph(subgraph) self.graph.subgraph(subgraph)
# Compile list of all devices # Compile list of all devices
device_superset = Q() device_superset = Q()
for device_set in self.device_sets: for device_set in self.device_sets:
for query in device_set.split(';'): # Split regexes on semicolons for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query) device_superset = device_superset | Q(name__regex=query)
devices = Device.objects.filter(*(device_superset,))
# Draw edges depending on graph type
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
self.add_network_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
self.add_console_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_POWER:
self.add_power_connections(devices)
return self.graph.pipe(format=img_format)
def add_network_connections(self, devices):
from circuits.models import CircuitTermination
from dcim.models import InterfaceConnection
# Add all interface connections to the graph # Add all interface connections to the graph
devices = Device.objects.filter(*(device_superset,))
connections = InterfaceConnection.objects.filter( connections = InterfaceConnection.objects.filter(
interface_a__device__in=devices, interface_b__device__in=devices interface_a__device__in=devices, interface_b__device__in=devices
) )
for c in connections: for c in connections:
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style) self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
# Add all circuits to the graph # Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination() peer_termination = termination.get_peer_termination()
if (peer_termination is not None and peer_termination.interface is not None and if (peer_termination is not None and peer_termination.interface is not None and
peer_termination.interface.device in devices): peer_termination.interface.device in devices):
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue') self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
return graph.pipe(format=img_format) def add_console_connections(self, devices):
from dcim.models import ConsolePort
# Add all console connections to the graph
console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
for cp in console_ports:
style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
def add_power_connections(self, devices):
from dcim.models import PowerPort
# Add all power connections to the graph
power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
for pp in power_ports:
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
# #

View File

@ -57,7 +57,7 @@ class VRFCSVForm(forms.ModelForm):
class Meta: class Meta:
model = VRF model = VRF
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] fields = VRF.csv_headers
help_texts = { help_texts = {
'name': 'VRF name', 'name': 'VRF name',
} }
@ -102,7 +102,7 @@ class RIRCSVForm(forms.ModelForm):
class Meta: class Meta:
model = RIR model = RIR
fields = ['name', 'slug', 'is_private'] fields = RIR.csv_headers
help_texts = { help_texts = {
'name': 'RIR name', 'name': 'RIR name',
} }
@ -144,7 +144,7 @@ class AggregateCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description'] fields = Aggregate.csv_headers
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@ -185,7 +185,7 @@ class RoleCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Role model = Role
fields = ['name', 'slug'] fields = Role.csv_headers
help_texts = { help_texts = {
'name': 'Role name', 'name': 'Role name',
} }
@ -299,9 +299,7 @@ class PrefixCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Prefix model = Prefix
fields = [ fields = Prefix.csv_headers
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
]
def clean(self): def clean(self):
@ -609,10 +607,7 @@ class IPAddressCSVForm(forms.ModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = IPAddress.csv_headers
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
'description',
]
def clean(self): def clean(self):
@ -759,7 +754,7 @@ class VLANGroupCSVForm(forms.ModelForm):
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = ['site', 'name', 'slug'] fields = VLANGroup.csv_headers
help_texts = { help_texts = {
'name': 'Name of VLAN group', 'name': 'Name of VLAN group',
} }
@ -849,7 +844,7 @@ class VLANCSVForm(forms.ModelForm):
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] fields = VLAN.csv_headers
help_texts = { help_texts = {
'vid': 'Numeric VLAN ID (1-4095)', 'vid': 'Numeric VLAN ID (1-4095)',
'name': 'VLAN name', 'name': 'VLAN name',

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-07 18:37
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0020_ipaddress_add_role_carp'),
]
operations = [
migrations.AlterModelOptions(
name='vrf',
options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
),
]

View File

@ -14,7 +14,6 @@ from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .constants import * from .constants import *
from .fields import IPNetworkField, IPAddressField from .fields import IPNetworkField, IPAddressField
from .querysets import PrefixQuerySet from .querysets import PrefixQuerySet
@ -38,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
class Meta: class Meta:
ordering = ['name'] ordering = ['name', 'rd']
verbose_name = 'VRF' verbose_name = 'VRF'
verbose_name_plural = 'VRFs' verbose_name_plural = 'VRFs'
@ -49,13 +48,13 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
return reverse('ipam:vrf', args=[self.pk]) return reverse('ipam:vrf', args=[self.pk])
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.rd, self.rd,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.enforce_unique, self.enforce_unique,
self.description, self.description,
]) )
@property @property
def display_name(self): def display_name(self):
@ -75,6 +74,8 @@ class RIR(models.Model):
is_private = models.BooleanField(default=False, verbose_name='Private', is_private = models.BooleanField(default=False, verbose_name='Private',
help_text='IP space managed by this RIR is considered private') help_text='IP space managed by this RIR is considered private')
csv_headers = ['name', 'slug', 'is_private']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
verbose_name = 'RIR' verbose_name = 'RIR'
@ -86,6 +87,13 @@ class RIR(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug) return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
self.is_private,
)
@python_2_unicode_compatible @python_2_unicode_compatible
class Aggregate(CreatedUpdatedModel, CustomFieldModel): class Aggregate(CreatedUpdatedModel, CustomFieldModel):
@ -147,12 +155,12 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
super(Aggregate, self).save(*args, **kwargs) super(Aggregate, self).save(*args, **kwargs)
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.prefix, self.prefix,
self.rir.name, self.rir.name,
self.date_added.isoformat() if self.date_added else None, self.date_added,
self.description, self.description,
]) )
def get_utilization(self): def get_utilization(self):
""" """
@ -173,19 +181,20 @@ class Role(models.Model):
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
weight = models.PositiveSmallIntegerField(default=1000) weight = models.PositiveSmallIntegerField(default=1000)
csv_headers = ['name', 'slug', 'weight']
class Meta: class Meta:
ordering = ['weight', 'name'] ordering = ['weight', 'name']
def __str__(self): def __str__(self):
return self.name return self.name
@property def to_csv(self):
def count_prefixes(self): return (
return self.prefixes.count() self.name,
self.slug,
@property self.weight,
def count_vlans(self): )
return self.vlans.count()
@python_2_unicode_compatible @python_2_unicode_compatible
@ -262,7 +271,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
super(Prefix, self).save(*args, **kwargs) super(Prefix, self).save(*args, **kwargs)
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.prefix, self.prefix,
self.vrf.rd if self.vrf else None, self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
@ -273,7 +282,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
self.role.name if self.role else None, self.role.name if self.role else None,
self.is_pool, self.is_pool,
self.description, self.description,
]) )
def get_status_class(self): def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status] return STATUS_CHOICE_CLASSES[self.status]
@ -461,7 +470,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
else: else:
is_primary = False is_primary = False
return csv_format([ return (
self.address, self.address,
self.vrf.rd if self.vrf else None, self.vrf.rd if self.vrf else None,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
@ -472,7 +481,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
self.interface.name if self.interface else None, self.interface.name if self.interface else None,
is_primary, is_primary,
self.description, self.description,
]) )
@property @property
def device(self): def device(self):
@ -502,6 +511,8 @@ class VLANGroup(models.Model):
slug = models.SlugField() slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True) site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
csv_headers = ['name', 'slug', 'site']
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
unique_together = [ unique_together = [
@ -517,6 +528,13 @@ class VLANGroup(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
def to_csv(self):
return (
self.name,
self.slug,
self.site.name if self.site else None,
)
def get_next_available_vid(self): def get_next_available_vid(self):
""" """
Return the first available VLAN ID (1-4094) in the group. Return the first available VLAN ID (1-4094) in the group.
@ -577,7 +595,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
}) })
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.site.name if self.site else None, self.site.name if self.site else None,
self.group.name if self.group else None, self.group.name if self.group else None,
self.vid, self.vid,
@ -586,7 +604,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
self.get_status_display(), self.get_status_display(),
self.role.name if self.role else None, self.role.name if self.role else None,
self.description, self.description,
]) )
@property @property
def display_name(self): def display_name(self):

View File

@ -37,6 +37,14 @@ UTILIZATION_GRAPH = """
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %} {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
""" """
ROLE_PREFIX_COUNT = """
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
"""
ROLE_VLAN_COUNT = """
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
"""
ROLE_ACTIONS = """ ROLE_ACTIONS = """
{% if perms.ipam.change_role %} {% if perms.ipam.change_role %}
<a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@ -220,10 +228,18 @@ class AggregateDetailTable(AggregateTable):
class RoleTable(BaseTable): class RoleTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.Column(verbose_name='Name') prefix_count = tables.TemplateColumn(
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes') accessor=Accessor('prefixes.count'),
vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs') template_code=ROLE_PREFIX_COUNT,
slug = tables.Column(verbose_name='Slug') orderable=False,
verbose_name='Prefixes'
)
vlan_count = tables.TemplateColumn(
accessor=Accessor('vlans.count'),
template_code=ROLE_VLAN_COUNT,
orderable=False,
verbose_name='VLANs'
)
actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):

View File

@ -13,7 +13,7 @@ except ImportError:
) )
VERSION = '2.2.9' VERSION = '2.2.10'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -119,7 +119,7 @@ SEARCH_TYPES = OrderedDict((
}), }),
# Virtualization # Virtualization
('cluster', { ('cluster', {
'queryset': Cluster.objects.all(), 'queryset': Cluster.objects.select_related('type', 'group'),
'filter': ClusterFilter, 'filter': ClusterFilter,
'table': ClusterTable, 'table': ClusterTable,
'url': 'virtualization:cluster_list', 'url': 'virtualization:cluster_list',

View File

@ -47,7 +47,7 @@ class SecretRoleCSVForm(forms.ModelForm):
class Meta: class Meta:
model = SecretRole model = SecretRole
fields = ['name', 'slug'] fields = SecretRole.csv_headers
help_texts = { help_texts = {
'name': 'Name of secret role', 'name': 'Name of secret role',
} }
@ -98,7 +98,7 @@ class SecretCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Secret model = Secret
fields = ['device', 'role', 'name', 'plaintext'] fields = Secret.csv_headers
help_texts = { help_texts = {
'name': 'Name or username', 'name': 'Name or username',
} }

View File

@ -239,6 +239,8 @@ class SecretRole(models.Model):
users = models.ManyToManyField(User, related_name='secretroles', blank=True) users = models.ManyToManyField(User, related_name='secretroles', blank=True)
groups = models.ManyToManyField(Group, related_name='secretroles', blank=True) groups = models.ManyToManyField(Group, related_name='secretroles', blank=True)
csv_headers = ['name', 'slug']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -248,6 +250,12 @@ class SecretRole(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?role={}".format(reverse('secrets:secret_list'), self.slug) return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
)
def has_member(self, user): def has_member(self, user):
""" """
Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles. Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles.

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.circuits.add_circuit %} {% if perms.circuits.add_circuit %}
<a href="{% url 'circuits:circuit_add' %}" class="btn btn-primary"> {% add_button 'circuits:circuit_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'circuits:circuit_import' %}
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 %} {% endif %}
{% include 'inc/export_button.html' with obj_type='circuits' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Circuits{% endblock %}</h1> <h1>{% block title %}Circuits{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.circuits.add_circuittype %} {% if perms.circuits.add_circuittype %}
<a href="{% url 'circuits:circuittype_add' %}" class="btn btn-primary"> {% add_button 'circuits:circuittype_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'circuits:circuittype_import' %}
Add a circuit type
</a>
<a href="{% url 'circuits:circuittype_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import circuit types
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Circuit Types{% endblock %}</h1> <h1>{% block title %}Circuit Types{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.circuits.add_provider %} {% if perms.circuits.add_provider %}
<a href="{% url 'circuits:provider_add' %}" class="btn btn-primary"> {% add_button 'circuits:provider_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'circuits:provider_import' %}
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 %} {% endif %}
{% include 'inc/export_button.html' with obj_type='providers' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Providers{% endblock %}</h1> <h1>{% block title %}Providers{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,14 +1,12 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.change_consoleport %} {% if perms.dcim.change_consoleport %}
<a href="{% url 'dcim:console_connections_import' %}" class="btn btn-info"> {% import_button 'dcim:console_connections_import' %}
<span class="fa fa-download" aria-hidden="true"></span>
Import connections
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='connections' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Console Connections{% endblock %}</h1> <h1>{% block title %}Console Connections{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_device %} {% if perms.dcim.add_device %}
<a href="{% url 'dcim:device_add' %}" class="btn btn-primary"> {% add_button 'dcim:device_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:device_import' %}
Add a device
</a>
<a href="{% url 'dcim:device_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import devices
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='devices' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Devices{% endblock %}</h1> <h1>{% block title %}Devices{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_devicerole %} {% if perms.dcim.add_devicerole %}
<a href="{% url 'dcim:devicerole_add' %}" class="btn btn-primary"> {% add_button 'dcim:devicerole_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:devicerole_import' %}
Add a device role
</a>
<a href="{% url 'dcim:devicerole_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import device roles
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Device Roles{% endblock %}</h1> <h1>{% block title %}Device Roles{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_devicetype %} {% if perms.dcim.add_devicetype %}
<a href="{% url 'dcim:devicetype_add' %}" class="btn btn-primary"> {% add_button 'dcim:devicetype_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:devicetype_import' %}
Add a device type
</a>
<a href="{% url 'dcim:devicetype_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import device types
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='device types' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Device Types{% endblock %}</h1> <h1>{% block title %}Device Types{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -43,17 +43,23 @@
<h1>{{ device }}</h1> <h1>{{ device }}</h1>
{% include 'inc/created_updated.html' with obj=device %} {% include 'inc/created_updated.html' with obj=device %}
<ul class="nav nav-tabs" style="margin-bottom: 20px"> <ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li> <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li> <a href="{% url 'dcim:device' pk=device.pk %}">Info</a>
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
</li>
{% if perms.dcim.napalm_read %} {% if perms.dcim.napalm_read %}
{% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %} {% if device.status != 1 %}
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}><a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a></li> {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li> {% elif not device.platform %}
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}><a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a></li> {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
{% elif not device.platform.napalm_driver %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
{% elif not device.primary_ip %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
{% else %} {% else %}
<li role="presentation" class="disabled"><a href="#">Status</a></li> {% include 'dcim/inc/device_napalm_tabs.html' %}
<li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#">Configuration</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
</ul> </ul>

View File

@ -0,0 +1,15 @@
{% if not disabled_message %}
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a>
</li>
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a>
</li>
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a>
</li>
{% else %}
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
{% endif %}

View File

@ -0,0 +1,29 @@
<script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>

View File

@ -1,14 +1,12 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_interfaceconnection %} {% if perms.dcim.add_interfaceconnection %}
<a href="{% url 'dcim:interface_connections_import' %}" class="btn btn-info"> {% import_button 'dcim:interface_connections_import' %}
<span class="fa fa-download" aria-hidden="true"></span>
Import connections
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='connections' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Interface Connections{% endblock %}</h1> <h1>{% block title %}Interface Connections{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,15 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% load helpers %} {% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_devicetype %} {% if perms.dcim.add_devicetype %}
<a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-info"> {% import_button 'dcim:inventoryitem_import' %}
<span class="fa fa-download" aria-hidden="true"></span>
Import inventory items
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='inventory items' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Inventory Items{% endblock %}</h1> <h1>{% block title %}Inventory Items{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_manufacturer %} {% if perms.dcim.add_manufacturer %}
<a href="{% url 'dcim:manufacturer_add' %}" class="btn btn-primary"> {% add_button 'dcim:manufacturer_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:manufacturer_import' %}
Add a manufacturer
</a>
<a href="{% url 'dcim:manufacturer_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import manufacturers
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='manufacturers' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Manufacturers{% endblock %}</h1> <h1>{% block title %}Manufacturers{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_platform %} {% if perms.dcim.add_platform %}
<a href="{% url 'dcim:platform_add' %}" class="btn btn-primary"> {% add_button 'dcim:platform_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:platform_import' %}
Add a platform
</a>
<a href="{% url 'dcim:platform_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import platforms
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Platforms{% endblock %}</h1> <h1>{% block title %}Platforms{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,14 +1,12 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.change_powerport %} {% if perms.dcim.change_powerport %}
<a href="{% url 'dcim:power_connections_import' %}" class="btn btn-info"> {% import_button 'dcim:power_connections_import' %}
<span class="fa fa-download" aria-hidden="true"></span>
Import connections
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='connections' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Power Connections{% endblock %}</h1> <h1>{% block title %}Power Connections{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -45,9 +45,10 @@
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
<script type="text/javascript"> {% include 'dcim/inc/filter_rack_group.html' %}
$(function() { <script type="text/javascript">
$('[data-toggle="popover"]').popover() $(function() {
}) $('[data-toggle="popover"]').popover()
</script> })
</script>
{% endblock %} {% endblock %}

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_rack %} {% if perms.dcim.add_rack %}
<a href="{% url 'dcim:rack_add' %}" class="btn btn-primary"> {% add_button 'dcim:rack_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:rack_import' %}
Add a rack
</a>
<a href="{% url 'dcim:rack_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import racks
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='racks' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Racks{% endblock %}</h1> <h1>{% block title %}Racks{% endblock %}</h1>
<div class="row"> <div class="row">
@ -27,34 +21,6 @@
{% endblock %} {% endblock %}
{% block javascript %} {% block javascript %}
<script type="text/javascript"> {% include 'dcim/inc/filter_rack_group.html' %}
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_group_id');
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_rackgroup %} {% if perms.dcim.add_rackgroup %}
<a href="{% url 'dcim:rackgroup_add' %}" class="btn btn-primary"> {% add_button 'dcim:rackgroup_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:rackgroup_import' %}
Add a rack group
</a>
<a href="{% url 'dcim:rackgroup_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import rack groups
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='rack groups' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Rack Groups{% endblock %}</h1> <h1>{% block title %}Rack Groups{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_region %} {% if perms.dcim.add_region %}
<a href="{% url 'dcim:region_add' %}" class="btn btn-primary"> {% add_button 'dcim:region_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:region_import' %}
Add a region
</a>
<a href="{% url 'dcim:region_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import regions
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='regions' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Regions{% endblock %}</h1> <h1>{% block title %}Regions{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_site %} {% if perms.dcim.add_site %}
<a href="{% url 'dcim:site_add' %}" class="btn btn-primary"> {% add_button 'dcim:site_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'dcim:site_import' %}
Add a site
</a>
<a href="{% url 'dcim:site_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import sites
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='sites' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Sites{% endblock %}</h1> <h1>{% block title %}Sites{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,20 +0,0 @@
{% if export_templates %}
<div class="btn-group">
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="fa fa-upload" aria-hidden="true"></span>
Export {{ obj_type }} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export">CSV (default)</a></li>
<li class="divider"></li>
{% for et in export_templates %}
<li><a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
{% endfor %}
</ul>
</div>
{% else %}
<a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export" class="btn btn-success">
<span class="fa fa-upload" aria-hidden="true"></span>
Export {{ obj_type }}
</a>
{% endif %}

View File

@ -1,20 +1,14 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% load humanize %} {% load humanize %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.ipam.add_aggregate %} {% if perms.ipam.add_aggregate %}
<a href="{% url 'ipam:aggregate_add' %}" class="btn btn-primary"> {% add_button 'ipam:aggregate_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:aggregate_import' %}
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 %} {% endif %}
{% include 'inc/export_button.html' with obj_type='aggregates' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Aggregates{% endblock %}</h1> <h1>{% block title %}Aggregates{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -144,7 +144,7 @@
{% if duplicate_ips_table.rows %} {% if duplicate_ips_table.rows %}
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %} {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% endif %} {% endif %}
{% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %} {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}" class="btn btn-primary"> {% add_button 'ipam:ipaddress_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:ipaddress_import' %}
Add an IP {% endif %}
</a> {% export_button content_type %}
<a href="{% url 'ipam:ipaddress_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import IPs
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='IPs' %}
</div> </div>
<h1>{% block title %}IP Addresses{% endblock %}</h1> <h1>{% block title %}IP Addresses{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -136,7 +136,7 @@
{% if duplicate_prefix_table.rows %} {% if duplicate_prefix_table.rows %}
{% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
{% endif %} {% endif %}
{% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %} {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% load helpers %} {% load helpers %}
{% load form_helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
@ -9,16 +9,10 @@
<a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a> <a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a>
</div> </div>
{% if perms.ipam.add_prefix %} {% if perms.ipam.add_prefix %}
<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary"> {% add_button 'ipam:prefix_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:prefix_import' %}
Add a prefix {% endif %}
</a> {% export_button content_type %}
<a href="{% url 'ipam:prefix_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import prefixes
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='prefixes' %}
</div> </div>
<h1>{% block title %}Prefixes{% endblock %}</h1> <h1>{% block title %}Prefixes{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,6 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% load humanize %} {% load humanize %}
{% load helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
@ -16,15 +16,10 @@
</a> </a>
{% endif %} {% endif %}
{% if perms.ipam.add_rir %} {% if perms.ipam.add_rir %}
<a href="{% url 'ipam:rir_add' %}" class="btn btn-primary"> {% add_button 'ipam:rir_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:rir_import' %}
Add a RIR
</a>
<a href="{% url 'ipam:rir_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import RIRs
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}RIRs{% endblock %}</h1> <h1>{% block title %}RIRs{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.ipam.add_role %} {% if perms.ipam.add_role %}
<a href="{% url 'ipam:role_add' %}" class="btn btn-primary"> {% add_button 'ipam:role_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:role_import' %}
Add a role
</a>
<a href="{% url 'ipam:role_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import roles
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Prefix/VLAN Roles{% endblock %}</h1> <h1>{% block title %}Prefix/VLAN Roles{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,20 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% load form_helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.ipam.add_vlan %} {% if perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}" class="btn btn-primary"> {% add_button 'ipam:vlan_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:vlan_import' %}
Add a VLAN {% endif %}
</a> {% export_button content_type %}
<a href="{% url 'ipam:vlan_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import VLANs
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='VLANs' %}
</div> </div>
<h1>{% block title %}VLANs{% endblock %}</h1> <h1>{% block title %}VLANs{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.ipam.add_vlangroup %} {% if perms.ipam.add_vlangroup %}
<a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-primary"> {% add_button 'ipam:vlangroup_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:vlangroup_import' %}
Add a VLAN group
</a>
<a href="{% url 'ipam:vlangroup_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import VLAN groups
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}VLAN Groups{% endblock %}</h1> <h1>{% block title %}VLAN Groups{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,20 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% load form_helpers %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.ipam.add_vrf %} {% if perms.ipam.add_vrf %}
<a href="{% url 'ipam:vrf_add' %}" class="btn btn-primary"> {% add_button 'ipam:vrf_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'ipam:vrf_import' %}
Add a VRF {% endif %}
</a> {% export_button content_type %}
<a href="{% url 'ipam:vrf_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import VRFs
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='VRFs' %}
</div> </div>
<h1>{% block title %}VRFs{% endblock %}</h1> <h1>{% block title %}VRFs{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,13 +1,10 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.secrets.add_secret %} {% if perms.secrets.add_secret %}
<a href="{% url 'secrets:secret_import' %}" class="btn btn-info"> {% import_button 'secrets:secret_import' %}
<span class="fa fa-download" aria-hidden="true"></span>
Import secrets
</a>
{% endif %} {% endif %}
</div> </div>
<h1>{% block title %}Secrets{% endblock %}</h1> <h1>{% block title %}Secrets{% endblock %}</h1>

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.add_devicerole %} {% if perms.secrets.add_secretrole %}
<a href="{% url 'secrets:secretrole_add' %}" class="btn btn-primary"> {% add_button 'secrets:secretrole_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'secrets:secretrole_import' %}
Add a secret role
</a>
<a href="{% url 'secrets:secretrole_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import secret roles
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Secret Roles{% endblock %}</h1> <h1>{% block title %}Secret Roles{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,19 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.tenancy.add_tenant %} {% if perms.tenancy.add_tenant %}
<a href="{% url 'tenancy:tenant_add' %}" class="btn btn-primary"> {% add_button 'tenancy:tenant_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'tenancy:tenant_import' %}
Add a tenant
</a>
<a href="{% url 'tenancy:tenant_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import tenants
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='tenants' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Tenants{% endblock %}</h1> <h1>{% block title %}Tenants{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.tenancy.add_tenantgroup %} {% if perms.tenancy.add_tenantgroup %}
<a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-primary"> {% add_button 'tenancy:tenantgroup_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'tenancy:tenantgroup_import' %}
Add a tenant group
</a>
<a href="{% url 'tenancy:tenantgroup_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import tenant groups
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Tenant Groups{% endblock %}</h1> <h1>{% block title %}Tenant Groups{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.virtualization.add_cluster %} {% if perms.virtualization.add_cluster %}
<a href="{% url 'virtualization:cluster_add' %}" class="btn btn-primary"> {% add_button 'virtualization:cluster_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'virtualization:cluster_import' %}
Add a cluster
</a>
<a href="{% url 'virtualization:cluster_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import clusters
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='clusters' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Clusters{% endblock %}</h1> <h1>{% block title %}Clusters{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.virtualization.add_clustergroup %} {% if perms.virtualization.add_clustergroup %}
<a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-primary"> {% add_button 'virtualization:clustergroup_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'virtualization:clustergroup_import' %}
Add a cluster group
</a>
<a href="{% url 'virtualization:clustergroup_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import cluster groups
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Cluster Groups{% endblock %}</h1> <h1>{% block title %}Cluster Groups{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load helpers %} {% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.virtualization.add_clustertype %} {% if perms.virtualization.add_clustertype %}
<a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-primary"> {% add_button 'virtualization:clustertype_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'virtualization:clustertype_import' %}
Add a cluster type
</a>
<a href="{% url 'virtualization:clustertype_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import cluster types
</a>
{% endif %} {% endif %}
{% export_button content_type %}
</div> </div>
<h1>{% block title %}Cluster Types{% endblock %}</h1> <h1>{% block title %}Cluster Types{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -6,9 +6,7 @@
<div class="panel-heading"><strong>Virtual Machine</strong></div> <div class="panel-heading"><strong>Virtual Machine</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_field form.name %} {% render_field form.name %}
{% render_field form.status %}
{% render_field form.role %} {% render_field form.role %}
{% render_field form.platform %}
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">
@ -18,6 +16,15 @@
{% render_field form.cluster %} {% render_field form.cluster %}
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Management</strong></div>
<div class="panel-body">
{% render_field form.status %}
{% render_field form.platform %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
</div>
</div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Resources</strong></div> <div class="panel-heading"><strong>Resources</strong></div>
<div class="panel-body"> <div class="panel-body">

View File

@ -1,18 +1,13 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load buttons %}
{% block content %} {% block content %}
<div class="pull-right"> <div class="pull-right">
{% if perms.virtualization.add_virtualmachine %} {% if perms.virtualization.add_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_add' %}" class="btn btn-primary"> {% add_button 'virtualization:virtualmachine_add' %}
<span class="fa fa-plus" aria-hidden="true"></span> {% import_button 'virtualization:virtualmachine_import' %}
Add a virtual machine
</a>
<a href="{% url 'virtualization:virtualmachine_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import virtual machines
</a>
{% endif %} {% endif %}
{% include 'inc/export_button.html' with obj_type='virtual machines' %} {% export_button content_type %}
</div> </div>
<h1>{% block title %}Virtual Machines{% endblock %}</h1> <h1>{% block title %}Virtual Machines{% endblock %}</h1>
<div class="row"> <div class="row">

View File

@ -27,7 +27,7 @@ class TenantGroupCSVForm(forms.ModelForm):
class Meta: class Meta:
model = TenantGroup model = TenantGroup
fields = ['name', 'slug'] fields = TenantGroup.csv_headers
help_texts = { help_texts = {
'name': 'Group name', 'name': 'Group name',
} }
@ -60,7 +60,7 @@ class TenantCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['name', 'slug', 'group', 'description', 'comments'] fields = Tenant.csv_headers
help_texts = { help_texts = {
'name': 'Tenant name', 'name': 'Tenant name',
'comments': 'Free-form comments' 'comments': 'Free-form comments'

View File

@ -7,7 +7,6 @@ from django.utils.encoding import python_2_unicode_compatible
from extras.models import CustomFieldModel, CustomFieldValue from extras.models import CustomFieldModel, CustomFieldValue
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
@python_2_unicode_compatible @python_2_unicode_compatible
@ -18,6 +17,8 @@ class TenantGroup(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)
csv_headers = ['name', 'slug']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -27,6 +28,12 @@ class TenantGroup(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
)
@python_2_unicode_compatible @python_2_unicode_compatible
class Tenant(CreatedUpdatedModel, CustomFieldModel): class Tenant(CreatedUpdatedModel, CustomFieldModel):
@ -41,7 +48,7 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['name', 'slug', 'group', 'description'] csv_headers = ['name', 'slug', 'group', 'description', 'comments']
class Meta: class Meta:
ordering = ['group', 'name'] ordering = ['group', 'name']
@ -53,9 +60,10 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
return reverse('tenancy:tenant', args=[self.slug]) return reverse('tenancy:tenant', args=[self.slug])
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.slug, self.slug,
self.group.name if self.group else None, self.group.name if self.group else None,
self.description, self.description,
]) self.comments,
)

View File

@ -1,7 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import csv import csv
import itertools from io import StringIO
import re import re
from django import forms from django import forms
@ -245,14 +245,10 @@ class CSVDataField(forms.CharField):
def to_python(self, value): def to_python(self, value):
# Python 2's csv module has problems with Unicode
if not isinstance(value, str):
value = value.encode('utf-8')
records = [] records = []
reader = csv.reader(value.splitlines()) reader = csv.reader(StringIO(value))
# Consume and valdiate the first line of CSV data as column headers # Consume and validate the first line of CSV data as column headers
headers = next(reader) headers = next(reader)
for f in self.required_fields: for f in self.required_fields:
if f not in headers: if f not in headers:

View File

@ -0,0 +1,3 @@
<a href="{% url add_url %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span> Add
</a>

View File

@ -0,0 +1,19 @@
{% if export_templates %}
<div class="btn-group">
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="fa fa-upload" aria-hidden="true"></span>
Export <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">CSV (default)</a></li>
<li class="divider"></li>
{% for et in export_templates %}
<li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
{% endfor %}
</ul>
</div>
{% else %}
<a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export" class="btn btn-success">
<span class="fa fa-upload" aria-hidden="true"></span> Export
</a>
{% endif %}

View File

@ -0,0 +1,3 @@
<a href="{% url import_url %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span> Import
</a>

View File

@ -0,0 +1,26 @@
from __future__ import unicode_literals
from django import template
from extras.models import ExportTemplate
register = template.Library()
@register.inclusion_tag('buttons/add.html')
def add_button(url):
return {'add_url': url}
@register.inclusion_tag('buttons/import.html')
def import_button(url):
return {'import_url': url}
@register.inclusion_tag('buttons/export.html', takes_context=True)
def export_button(context, content_type=None):
export_templates = ExportTemplate.objects.filter(content_type=content_type)
return {
'url_params': context['request'].GET,
'export_templates': export_templates,
}

View File

@ -1,7 +1,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime
import six import six
from django.http import HttpResponse
def csv_format(data): def csv_format(data):
""" """
@ -15,12 +18,16 @@ def csv_format(data):
csv.append('') csv.append('')
continue continue
# Convert dates to ISO format
if isinstance(value, (datetime.date, datetime.datetime)):
value = value.isoformat()
# Force conversion to string first so we can check for any commas # Force conversion to string first so we can check for any commas
if not isinstance(value, six.string_types): if not isinstance(value, six.string_types):
value = '{}'.format(value) value = '{}'.format(value)
# Double-quote the value if it contains a comma # Double-quote the value if it contains a comma
if ',' in value: if ',' in value or '\n' in value:
csv.append('"{}"'.format(value)) csv.append('"{}"'.format(value))
else: else:
csv.append('{}'.format(value)) csv.append('{}'.format(value))
@ -28,6 +35,32 @@ def csv_format(data):
return ','.join(csv) return ','.join(csv)
def queryset_to_csv(queryset):
"""
Export a queryset of objects as CSV, using the model's to_csv() method.
"""
output = []
# Start with the column headers
headers = ','.join(queryset.model.csv_headers)
output.append(headers)
# Iterate through the queryset
for obj in queryset:
data = csv_format(obj.to_csv())
output.append(data)
# Build the HTTP response
response = HttpResponse(
'\n'.join(output),
content_type='text/csv'
)
filename = 'netbox_{}.csv'.format(queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
def foreground_color(bg_color): def foreground_color(bg_color):
""" """
Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format. Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.

View File

@ -10,7 +10,6 @@ from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError from django.template import TemplateSyntaxError
from django.urls import reverse from django.urls import reverse
@ -21,6 +20,7 @@ from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
from utilities.utils import queryset_to_csv
from utilities.forms import BootstrapMixin, CSVDataField from utilities.forms import BootstrapMixin, CSVDataField
from .error_handlers import handle_protectederror from .error_handlers import handle_protectederror
from .forms import ConfirmationForm from .forms import ConfirmationForm
@ -79,7 +79,7 @@ class ObjectListView(View):
def get(self, request): def get(self, request):
model = self.queryset.model model = self.queryset.model
object_ct = ContentType.objects.get_for_model(model) content_type = ContentType.objects.get_for_model(model)
if self.filter: if self.filter:
self.queryset = self.filter(request.GET, self.queryset).qs self.queryset = self.filter(request.GET, self.queryset).qs
@ -92,27 +92,18 @@ class ObjectListView(View):
# Check for export template rendering # Check for export template rendering
if request.GET.get('export'): if request.GET.get('export'):
et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export')) et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
try: try:
response = et.to_response(context_dict={'queryset': queryset}, return et.render_to_response(queryset)
filename='netbox_{}'.format(model._meta.verbose_name_plural))
return response
except TemplateSyntaxError: except TemplateSyntaxError:
messages.error(request, "There was an error rendering the selected export template ({})." messages.error(
.format(et.name)) request,
# Fall back to built-in CSV export "There was an error rendering the selected export template ({}).".format(et.name)
)
# Fall back to built-in CSV export if no template was specified
elif 'export' in request.GET and hasattr(model, 'to_csv'): elif 'export' in request.GET and hasattr(model, 'to_csv'):
headers = getattr(model, 'csv_headers', None) return queryset_to_csv(self.queryset)
output = ','.join(headers) + '\n' if headers else ''
output += '\n'.join([obj.to_csv() for obj in self.queryset])
response = HttpResponse(
output,
content_type='text/csv'
)
response['Content-Disposition'] = 'attachment; filename="netbox_{}.csv"'\
.format(self.queryset.model._meta.verbose_name_plural)
return response
# Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list
self.queryset = self.alter_queryset(request) self.queryset = self.alter_queryset(request)
@ -134,10 +125,10 @@ class ObjectListView(View):
RequestConfig(request, paginate).configure(table) RequestConfig(request, paginate).configure(table)
context = { context = {
'content_type': content_type,
'table': table, 'table': table,
'permissions': permissions, 'permissions': permissions,
'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None, 'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
'export_templates': ExportTemplate.objects.filter(content_type=object_ct),
} }
context.update(self.extra_context()) context.update(self.extra_context())

View File

@ -9,6 +9,7 @@ from dcim.constants import IFACE_FF_VIRTUAL
from dcim.formfields import MACAddressFormField from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
from ipam.models import IPAddress
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -41,7 +42,7 @@ class ClusterTypeCSVForm(forms.ModelForm):
class Meta: class Meta:
model = ClusterType model = ClusterType
fields = ['name', 'slug'] fields = ClusterType.csv_headers
help_texts = { help_texts = {
'name': 'Name of cluster type', 'name': 'Name of cluster type',
} }
@ -64,7 +65,7 @@ class ClusterGroupCSVForm(forms.ModelForm):
class Meta: class Meta:
model = ClusterGroup model = ClusterGroup
fields = ['name', 'slug'] fields = ClusterGroup.csv_headers
help_texts = { help_texts = {
'name': 'Name of cluster group', 'name': 'Name of cluster group',
} }
@ -112,7 +113,7 @@ class ClusterCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Cluster model = Cluster
fields = ['name', 'type', 'group', 'site', 'comments'] fields = Cluster.csv_headers
class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@ -246,8 +247,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
'comments', 'vcpus', 'memory', 'disk', 'comments',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -261,6 +262,41 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
super(VirtualMachineForm, self).__init__(*args, **kwargs) super(VirtualMachineForm, self).__init__(*args, **kwargs)
if self.instance.pk:
# Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]:
ip_choices = [(None, '---------')]
# Collect interface IPs
interface_ips = IPAddress.objects.select_related('interface').filter(
family=family, interface__virtual_machine=self.instance
)
if interface_ips:
ip_choices.append(
('Interface IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
])
)
# Collect NAT IPs
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
family=family, nat_inside__interface__virtual_machine=self.instance
)
if nat_ips:
ip_choices.append(
('NAT IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
])
)
self.fields['primary_ip{}'.format(family)].choices = ip_choices
else:
# An object that doesn't exist yet can't have any IPs assigned to it
self.fields['primary_ip4'].choices = []
self.fields['primary_ip4'].widget.attrs['readonly'] = True
self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True
class VirtualMachineCSVForm(forms.ModelForm): class VirtualMachineCSVForm(forms.ModelForm):
status = CSVChoiceField( status = CSVChoiceField(
@ -306,7 +342,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = ['name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] fields = VirtualMachine.csv_headers
class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

View File

@ -10,7 +10,6 @@ from django.utils.encoding import python_2_unicode_compatible
from dcim.models import Device from dcim.models import Device
from extras.models import CustomFieldModel, CustomFieldValue from extras.models import CustomFieldModel, CustomFieldValue
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
@ -31,6 +30,8 @@ class ClusterType(models.Model):
unique=True unique=True
) )
csv_headers = ['name', 'slug']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -40,6 +41,12 @@ class ClusterType(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug) return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
)
# #
# Cluster groups # Cluster groups
@ -58,6 +65,8 @@ class ClusterGroup(models.Model):
unique=True unique=True
) )
csv_headers = ['name', 'slug']
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -67,6 +76,12 @@ class ClusterGroup(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug) return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
def to_csv(self):
return (
self.name,
self.slug,
)
# #
# Clusters # Clusters
@ -109,9 +124,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
object_id_field='obj_id' object_id_field='obj_id'
) )
csv_headers = [ csv_headers = ['name', 'type', 'group', 'site', 'comments']
'name', 'type', 'group', 'site', 'comments',
]
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -135,13 +148,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
}) })
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.type.name, self.type.name,
self.group.name if self.group else None, self.group.name if self.group else None,
self.site.name if self.site else None, self.site.name if self.site else None,
self.comments, self.comments,
]) )
# #
@ -230,7 +243,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
) )
csv_headers = [ csv_headers = [
'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
] ]
class Meta: class Meta:
@ -243,9 +256,10 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
return reverse('virtualization:virtualmachine', args=[self.pk]) return reverse('virtualization:virtualmachine', args=[self.pk])
def to_csv(self): def to_csv(self):
return csv_format([ return (
self.name, self.name,
self.get_status_display(), self.get_status_display(),
self.role.name if self.role else None,
self.cluster.name, self.cluster.name,
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.platform.name if self.platform else None, self.platform.name if self.platform else None,
@ -253,7 +267,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
self.memory, self.memory,
self.disk, self.disk,
self.comments, self.comments,
]) )
def get_status_class(self): def get_status_class(self):
return VM_STATUS_CLASSES[self.status] return VM_STATUS_CLASSES[self.status]

View File

@ -80,8 +80,9 @@ class ClusterGroupTable(BaseTable):
class ClusterTable(BaseTable): class ClusterTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn() name = tables.LinkColumn()
device_count = tables.Column(verbose_name='Devices') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
vm_count = tables.Column(verbose_name='VMs') device_count = tables.Column(accessor=Accessor('devices.count'), orderable=False, verbose_name='Devices')
vm_count = tables.Column(accessor=Accessor('virtual_machines.count'), orderable=False, verbose_name='VMs')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Cluster model = Cluster

View File

@ -99,10 +99,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class ClusterListView(ObjectListView): class ClusterListView(ObjectListView):
queryset = Cluster.objects.annotate( queryset = Cluster.objects.select_related('type', 'group')
device_count=Count('devices', distinct=True),
vm_count=Count('virtual_machines', distinct=True)
)
table = tables.ClusterTable table = tables.ClusterTable
filter = filters.ClusterFilter filter = filters.ClusterFilter
filter_form = forms.ClusterFilterForm filter_form = forms.ClusterFilterForm
@ -162,10 +159,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_cluster' permission_required = 'virtualization.delete_cluster'
cls = Cluster cls = Cluster
queryset = Cluster.objects.annotate( queryset = Cluster.objects.all()
device_count=Count('devices', distinct=True),
vm_count=Count('virtual_machines', distinct=True)
)
table = tables.ClusterTable table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list' default_return_url = 'virtualization:cluster_list'