diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ce2d519a..4820e5a85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 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/). -You can connect to Freenode at irc.freenode.net using an IRC client, or you can -use their [webchat client](https://webchat.freenode.net/). +For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/). ## Reporting Bugs -* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) of -NetBox. If you're running an older version, it's possible that the bug has +* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) +of NetBox. If you're running an older version, it's possible that the bug has 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 -been reported. If you think you may be experiencing a reported issue that -hasn't already been resolved, please click "add a reaction" in the top right -corner of the issue and add a thumbs up (+1). You mightalso want to add a -comment describing how it's affecting your installation. This will allow us to -prioritize bugs based on how many users are affected. +* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) +to see if the bug you've found has already been reported. If you think you may +be experiencing a reported issue that hasn't already been resolved, please +click "add a reaction" in the top right corner of the issue and add a thumbs +up (+1). You mightalso want to add a comment describing how it's affecting your +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, 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. 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 work is required to resolve them. It may take some time for someone to address @@ -52,15 +51,15 @@ your issue. ## Feature Requests -* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're requesting -is already listed. (Be sure to search closed issues as well, since some -feature requests have been rejected.) If the feature you'd like to see has -already been requested and is open, click "add a reaction" in the top right -corner of the issue and add a thumbs up (+1). This ensures that the issue has -a better chance of receiving attention. Also feel free to add a comment with -any additional justification for the feature. (However, note that comments with -no substance other than a "+1" will be deleted. Please use GitHub's reactions -feature to indicate your support.) +* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) +to see if the feature you're requesting is already listed. (Be sure to search +closed issues as well, since some feature requests have been rejected.) If the +feature you'd like to see has already been requested and is open, click "add a +reaction" in the top right corner of the issue and add a thumbs up (+1). This +ensures that the issue has a better chance of receiving attention. Also feel +free to add a comment with any additional justification for the feature. +(However, note that comments with no substance other than a "+1" will be +deleted. Please use GitHub's reactions feature to indicate your support.) * Due to an excessive backlog of feature requests, we are not currently 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 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 @@ -109,3 +108,10 @@ these checks): * All tests pass when run with `./manage.py test` * PEP 8 compliance is enforced, with the exception that lines may be 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. diff --git a/README.md b/README.md index 26aa0ccfc..9d3bbe0e8 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,18 @@ ![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/). -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 @@ -27,7 +33,9 @@ NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended. # 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 diff --git a/docs/api/examples.md b/docs/api/examples.md index 76471c4eb..dce088cb6 100644 --- a/docs/api/examples.md +++ b/docs/api/examples.md @@ -5,7 +5,7 @@ Supported HTTP methods: * `GET`: Retrieve an object or list of objects * `POST`: Create a new object * `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 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 ``` -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. diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index 5aeec0eb1..d8053da48 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/ldap.md @@ -87,7 +87,7 @@ AUTH_LDAP_USER_ATTR_MAP = { 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 -# heirarchy. +# hierarchy. AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE, "(objectClass=group)") AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() diff --git a/docs/miscellaneous/shell.md b/docs/miscellaneous/shell.md index df92cb7cd..5afd7876d 100644 --- a/docs/miscellaneous/shell.md +++ b/docs/miscellaneous/shell.md @@ -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 @@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object 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') diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 8acad4bb9..7afd1476e 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -43,7 +43,7 @@ class ProviderCSVForm(forms.ModelForm): class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments'] + fields = Provider.csv_headers help_texts = { 'name': 'Provider name', 'asn': '32-bit autonomous system number', @@ -89,7 +89,7 @@ class CircuitTypeCSVForm(forms.ModelForm): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = CircuitType.csv_headers help_texts = { 'name': 'Name of circuit type', } diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index fd8a2b2f6..e3a688ee5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -9,7 +9,6 @@ from dcim.fields import ASNField from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import * @@ -29,7 +28,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) 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: ordering = ['name'] @@ -41,13 +40,16 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): return reverse('circuits:provider', args=[self.slug]) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.asn, self.account, self.portal_url, - ]) + self.noc_contact, + self.admin_contact, + self.comments, + ) @python_2_unicode_compatible @@ -59,6 +61,8 @@ class CircuitType(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -68,6 +72,12 @@ class CircuitType(models.Model): def get_absolute_url(self): return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + @python_2_unicode_compatible class Circuit(CreatedUpdatedModel, CustomFieldModel): @@ -86,7 +96,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) 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: ordering = ['provider', 'cid'] @@ -99,15 +109,16 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): return reverse('circuits:circuit', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.cid, self.provider.name, self.type.name, 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.description, - ]) + self.comments, + ) def _get_termination(self, side): for ct in self.terminations.all(): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 98c075cd3..84aec50d5 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -72,9 +72,7 @@ class RegionCSVForm(forms.ModelForm): class Meta: model = Region - fields = [ - 'name', 'slug', 'parent', - ] + fields = Region.csv_headers help_texts = { 'name': 'Region name', 'slug': 'URL-friendly slug', @@ -136,10 +134,7 @@ class SiteCSVForm(forms.ModelForm): class Meta: model = Site - fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', - ] + fields = Site.csv_headers help_texts = { 'name': 'Site name', 'slug': 'URL-friendly slug', @@ -196,9 +191,7 @@ class RackGroupCSVForm(forms.ModelForm): class Meta: model = RackGroup - fields = [ - 'site', 'name', 'slug', - ] + fields = RackGroup.csv_headers help_texts = { 'name': 'Name of rack group', 'slug': 'URL-friendly slug', @@ -226,7 +219,7 @@ class RackRoleCSVForm(forms.ModelForm): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = RackRole.csv_headers help_texts = { 'name': 'Name of rack role', 'color': 'RGB color in hexadecimal (e.g. 00ff00)' @@ -313,10 +306,7 @@ class RackCSVForm(forms.ModelForm): class Meta: model = Rack - fields = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', - 'desc_units', - ] + fields = Rack.csv_headers help_texts = { 'name': 'Rack name', 'u_height': 'Height in rack units', @@ -444,9 +434,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): class ManufacturerCSVForm(forms.ModelForm): class Meta: model = Manufacturer - fields = [ - 'name', 'slug' - ] + fields = Manufacturer.csv_headers help_texts = { 'name': 'Manufacturer name', 'slug': 'URL-friendly slug', @@ -492,8 +480,7 @@ class DeviceTypeCSVForm(forms.ModelForm): class Meta: model = DeviceType - fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] + fields = DeviceType.csv_headers help_texts = { 'model': 'Model name', 'slug': 'URL-friendly slug', @@ -658,7 +645,7 @@ class DeviceRoleCSVForm(forms.ModelForm): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color', 'vm_role'] + fields = DeviceRole.csv_headers help_texts = { 'name': 'Name of device role', 'color': 'RGB color in hexadecimal (e.g. 00ff00)' @@ -682,7 +669,7 @@ class PlatformCSVForm(forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'napalm_driver'] + fields = Platform.csv_headers help_texts = { 'name': 'Platform name', } @@ -932,7 +919,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ '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): @@ -981,7 +968,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ '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): @@ -1061,6 +1048,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): ) status = forms.MultipleChoiceField(choices=device_status_choices, required=False) 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: 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 = { 'device': forms.HiddenInput(), } @@ -1636,7 +1632,11 @@ class InterfaceCreateForm(ComponentForm): 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') 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) def __init__(self, *args, **kwargs): @@ -1808,7 +1808,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm): class Meta: model = InterfaceConnection - fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] + fields = InterfaceConnection.csv_headers def clean_interface_a(self): @@ -1951,7 +1951,7 @@ class InventoryItemCSVForm(forms.ModelForm): class Meta: model = InventoryItem - fields = ['device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + fields = InventoryItem.csv_headers class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2b1f403e7..2b33ba6fe 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -22,7 +22,6 @@ from tenancy.models import Tenant from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import * from .fields import ASNField, MACAddressField from .querysets import InterfaceQuerySet @@ -43,9 +42,7 @@ class Region(MPTTModel): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - csv_headers = [ - 'name', 'slug', 'parent', - ] + csv_headers = ['name', 'slug', 'parent'] class MPTTMeta: order_insertion_by = ['name'] @@ -57,11 +54,11 @@ class Region(MPTTModel): return "{}?region={}".format(reverse('dcim:site_list'), self.slug) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.parent.name if self.parent else None, - ]) + ) # @@ -98,7 +95,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): objects = SiteManager() 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: @@ -111,17 +109,20 @@ class Site(CreatedUpdatedModel, CustomFieldModel): return reverse('dcim:site', args=[self.slug]) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.region.name if self.region else None, self.tenant.name if self.tenant else None, self.facility, self.asn, + self.physical_address, + self.shipping_address, self.contact_name, self.contact_phone, self.contact_email, - ]) + self.comments, + ) @property def count_prefixes(self): @@ -164,9 +165,7 @@ class RackGroup(models.Model): slug = models.SlugField() site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) - csv_headers = [ - 'site', 'name', 'slug', - ] + csv_headers = ['site', 'name', 'slug'] class Meta: ordering = ['site', 'name'] @@ -182,11 +181,11 @@ class RackGroup(models.Model): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) def to_csv(self): - return csv_format([ + return ( self.site, self.name, self.slug, - ]) + ) @python_2_unicode_compatible @@ -198,6 +197,8 @@ class RackRole(models.Model): slug = models.SlugField(unique=True) color = ColorField() + csv_headers = ['name', 'slug', 'color'] + class Meta: ordering = ['name'] @@ -207,6 +208,13 @@ class RackRole(models.Model): def get_absolute_url(self): return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + self.color, + ) + class RackManager(NaturalOrderByManager): @@ -242,7 +250,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', - 'desc_units', + 'desc_units', 'comments', ] class Meta: @@ -292,7 +300,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): Device.objects.filter(rack=self).update(site_id=self.site.pk) def to_csv(self): - return csv_format([ + return ( self.site.name, self.group.name if self.group else None, self.name, @@ -304,7 +312,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): self.width, self.u_height, self.desc_units, - ]) + self.comments, + ) @property def units(self): @@ -479,9 +488,7 @@ class Manufacturer(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - csv_headers = [ - 'name', 'slug', - ] + csv_headers = ['name', 'slug'] class Meta: ordering = ['name'] @@ -493,10 +500,10 @@ class Manufacturer(models.Model): return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, - ]) + ) @python_2_unicode_compatible @@ -539,7 +546,7 @@ class DeviceType(models.Model, CustomFieldModel): csv_headers = [ '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: @@ -562,7 +569,7 @@ class DeviceType(models.Model, CustomFieldModel): return reverse('dcim:devicetype', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.manufacturer.name, self.model, self.slug, @@ -574,7 +581,8 @@ class DeviceType(models.Model, CustomFieldModel): self.is_network_device, self.get_subdevice_role_display() if self.subdevice_role else None, self.get_interface_ordering_display(), - ]) + self.comments, + ) def clean(self): @@ -754,6 +762,8 @@ class DeviceRole(models.Model): help_text="Virtual machines may be assigned to this role" ) + csv_headers = ['name', 'slug', 'color', 'vm_role'] + class Meta: ordering = ['name'] @@ -763,6 +773,14 @@ class DeviceRole(models.Model): def get_absolute_url(self): 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 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, verbose_name='Legacy RPC client') + csv_headers = ['name', 'slug', 'napalm_driver'] + class Meta: ordering = ['name'] @@ -787,6 +807,13 @@ class Platform(models.Model): def get_absolute_url(self): return "{}?platform={}".format(reverse('dcim:device_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + self.napalm_driver, + ) + class DeviceManager(NaturalOrderByManager): @@ -848,7 +875,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): csv_headers = [ '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: @@ -989,7 +1016,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack) def to_csv(self): - return csv_format([ + return ( self.name or '', self.device_role.name, 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.position, self.get_face_display(), - ]) + self.comments, + ) @property def display_name(self): @@ -1076,15 +1104,14 @@ class ConsolePort(models.Model): def __str__(self): return self.name - # Used for connections export def to_csv(self): - return csv_format([ + return ( self.cs_port.device.identifier if self.cs_port else None, self.cs_port.name if self.cs_port else None, self.device.identifier, self.name, self.get_connection_status_display(), - ]) + ) # @@ -1153,15 +1180,14 @@ class PowerPort(models.Model): def __str__(self): return self.name - # Used for connections export def to_csv(self): - return csv_format([ + return ( self.power_outlet.device.identifier if self.power_outlet else None, self.power_outlet.name if self.power_outlet else None, self.device.identifier, self.name, self.get_connection_status_display(), - ]) + ) # @@ -1382,15 +1408,14 @@ class InterfaceConnection(models.Model): except ObjectDoesNotExist: pass - # Used for connections export def to_csv(self): - return csv_format([ + return ( self.interface_a.device.identifier, self.interface_a.name, self.interface_b.device.identifier, self.interface_b.name, self.get_connection_status_display(), - ]) + ) # @@ -1453,7 +1478,7 @@ class InventoryItem(models.Model): description = models.CharField(max_length=100, blank=True) csv_headers = [ - 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', ] class Meta: @@ -1464,12 +1489,13 @@ class InventoryItem(models.Model): return self.name def to_csv(self): - return csv_format([ + return ( self.device.name or '{' + self.device.pk + '}', self.name, self.manufacturer.name if self.manufacturer else None, self.part_id, self.serial, self.asset_tag, - self.description - ]) + self.discovered, + self.description, + ) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 0349396fa..b9efb3a28 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -65,6 +65,10 @@ RACK_ROLE = """ {% endif %} """ +RACK_DEVICE_COUNT = """ +{{ value }} +""" + RACKRESERVATION_ACTIONS = """ {% if perms.dcim.change_rackreservation %} @@ -83,6 +87,22 @@ MANUFACTURER_ACTIONS = """ {% endif %} """ +DEVICEROLE_DEVICE_COUNT = """ +{{ value }} +""" + +DEVICEROLE_VM_COUNT = """ +{{ value }} +""" + +PLATFORM_DEVICE_COUNT = """ +{{ value }} +""" + +PLATFORM_VM_COUNT = """ +{{ value }} +""" + PLATFORM_ACTIONS = """ {% if perms.dcim.change_platform %} @@ -218,12 +238,16 @@ class RackTable(BaseTable): 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') class Meta(RackTable.Meta): 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): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') - device_count = tables.Column(verbose_name='Devices') - vm_count = tables.Column(verbose_name='VMs') + device_count = tables.TemplateColumn( + 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') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + actions = tables.TemplateColumn( + template_code=DEVICEROLE_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = DeviceRole @@ -380,10 +417,18 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') - device_count = tables.Column(verbose_name='Devices') - vm_count = tables.Column(verbose_name='VMs') - slug = tables.Column(verbose_name='Slug') + device_count = tables.TemplateColumn( + template_code=PLATFORM_DEVICE_COUNT, + accessor=Accessor('devices.count'), + 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( template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a66cee593..6ac52f58d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -276,7 +276,7 @@ class RackListView(ObjectListView): ).prefetch_related( 'devices__device_type' ).annotate( - device_count=Count('devices', distinct=True) + device_count=Count('devices') ) filter = filters.RackFilter filter_form = forms.RackFilterForm @@ -715,10 +715,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceRoleListView(ObjectListView): - queryset = DeviceRole.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable template_name = 'dcim/devicerole_list.html' @@ -756,10 +753,7 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class PlatformListView(ObjectListView): - queryset = Platform.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = Platform.objects.all() table = tables.PlatformTable template_name = 'dcim/platform_list.html' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 752a6ccaa..07b5a9ae7 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline): @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): inlines = [CustomFieldChoiceAdmin] - list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description'] + list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description'] form = CustomFieldForm def models(self, obj): diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 808413ba2..94f58c2d1 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = ( (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_TYPE_INTERFACE = 100 GRAPH_TYPE_PROVIDER = 200 @@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [ '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 ACTION_CREATE = 1 ACTION_IMPORT = 2 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 5713d4af4..f21c228db 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType 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 @@ -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. """ - def __init__(self, cf_type, *args, **kwargs): - self.cf_type = cf_type + def __init__(self, custom_field, *args, **kwargs): + self.cf_type = custom_field.type + self.filter_logic = custom_field.filter_logic super(CustomFieldFilter, self).__init__(*args, **kwargs) def filter(self, queryset, value): @@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter): except ValueError: return queryset.none() - return queryset.filter( - custom_field_values__field__name=self.name, - custom_field_values__serialized_value__icontains=value, - ) + # Apply the assigned filter logic (exact or loose) + queryset = queryset.filter(custom_field_values__field__name=self.name) + 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): @@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet): super(CustomFieldFilterSet, self).__init__(*args, **kwargs) obj_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) + custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED) 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): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index e39754ae0..a923ae596 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -6,7 +6,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType 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 @@ -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 """ field_dict = OrderedDict() - kwargs = {'obj_type': content_type} + custom_fields = CustomField.objects.filter(obj_type=content_type) if filterable_only: - kwargs['is_filterable'] = True - custom_fields = CustomField.objects.filter(**kwargs) + custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED) for cf in custom_fields: field_name = 'cf_{}'.format(str(cf.name)) + initial = cf.default if not bulk_edit else None # 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 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'), (0, 'False'), ) - if cf.default.lower() in ['true', 'yes', '1']: + if initial is not None and initial.lower() in ['true', 'yes', '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 else: initial = None - field = forms.NullBooleanField(required=cf.required, initial=initial, - widget=forms.Select(choices=choices)) + field = forms.NullBooleanField( + required=cf.required, initial=initial, widget=forms.Select(choices=choices) + ) # Date elif cf.type == CF_TYPE_DATE: - field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD") + field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD") # 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 elif cf.type == CF_TYPE_URL: - field = LaxURLField(required=cf.required, initial=cf.default) + field = LaxURLField(required=cf.required, initial=initial) # Text 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.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize() diff --git a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py index bf2711c43..ee838046d 100644 --- a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py +++ b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py @@ -4,14 +4,6 @@ from __future__ import unicode_literals 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): @@ -25,5 +17,4 @@ class Migration(migrations.Migration): 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.'), ), - migrations.RunPython(commas_to_semicolons), ] diff --git a/netbox/extras/migrations/0009_topologymap_type.py b/netbox/extras/migrations/0009_topologymap_type.py new file mode 100644 index 000000000..b062c58af --- /dev/null +++ b/netbox/extras/migrations/0009_topologymap_type.py @@ -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), + ), + ] diff --git a/netbox/extras/migrations/0010_customfield_filter_logic.py b/netbox/extras/migrations/0010_customfield_filter_logic.py new file mode 100644 index 000000000..e35a2f835 --- /dev/null +++ b/netbox/extras/migrations/0010_customfield_filter_logic.py @@ -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', + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 9e6624fc4..341405016 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -16,6 +16,7 @@ from django.template import Template, Context from django.utils.encoding import python_2_unicode_compatible from django.utils.safestring import mark_safe +from dcim.constants import CONNECTION_STATUS_CONNECTED from utilities.utils import foreground_color from .constants import * @@ -54,22 +55,48 @@ class CustomFieldModel(object): @python_2_unicode_compatible class CustomField(models.Model): - obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)', - limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, - help_text="The object(s) to which this field applies.") - type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT) - name = models.CharField(max_length=50, unique=True) - label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not " - "provided, the field's name will be used)") - description = models.CharField(max_length=100, blank=True) - required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating " - "new objects or editing an existing object.") - is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.") - default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or " - "\"false\" for booleans. N/A for selection " - "fields.") - weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a " - "form") + obj_type = models.ManyToManyField( + to=ContentType, + related_name='custom_fields', + verbose_name='Object(s)', + limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, + help_text='The object(s) to which this field applies.' + ) + type = models.PositiveSmallIntegerField( + choices=CUSTOMFIELD_TYPE_CHOICES, + default=CF_TYPE_TEXT + ) + name = models.CharField( + max_length=50, + unique=True + ) + label = models.CharField( + max_length=50, + blank=True, + help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)' + ) + description = models.CharField( + max_length=100, + blank=True + ) + required = models.BooleanField( + default=False, + help_text='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: ordering = ['weight', 'name'] @@ -223,19 +250,25 @@ class ExportTemplate(models.Model): def __str__(self): 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 """ template = Template(self.template_code) 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 output = output.replace('\r\n', '\n') + + # Build the response response = HttpResponse(output, content_type=mime_type) - if self.file_extension: - filename += '.{}'.format(self.file_extension) + filename = 'netbox_{}{}'.format( + queryset.model._meta.verbose_name_plural, + '.{}'.format(self.file_extension) if self.file_extension else '' + ) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response @@ -247,7 +280,17 @@ class ExportTemplate(models.Model): class TopologyMap(models.Model): name = models.CharField(max_length=50, 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( 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. " @@ -269,22 +312,26 @@ class TopologyMap(models.Model): def render(self, img_format='png'): - from circuits.models import CircuitTermination - from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection + from dcim.models import Device # Construct the graph - graph = graphviz.Graph() - graph.graph_attr['ranksep'] = '1' + if self.type == TOPOLOGYMAP_TYPE_NETWORK: + G = graphviz.Graph + else: + G = graphviz.Digraph + self.graph = G() + self.graph.graph_attr['ranksep'] = '1' seen = set() 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['directed'] = 'true' # Add a pseudonode for each device_set to enforce hierarchical layout subgraph.node('set{}'.format(i), label='', shape='none', width='0') 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 devices = [] @@ -302,31 +349,64 @@ class TopologyMap(models.Model): for j in range(0, len(devices) - 1): subgraph.edge(devices[j].name, devices[j + 1].name, style='invis') - graph.subgraph(subgraph) + self.graph.subgraph(subgraph) # Compile list of all devices device_superset = Q() for device_set in self.device_sets: for query in device_set.split(';'): # Split regexes on semicolons 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 - devices = Device.objects.filter(*(device_superset,)) connections = InterfaceConnection.objects.filter( interface_a__device__in=devices, interface_b__device__in=devices ) for c in connections: 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 for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): peer_termination = termination.get_peer_termination() if (peer_termination is not None and peer_termination.interface is not None and 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) # diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index c67921e3e..afd533906 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -57,7 +57,7 @@ class VRFCSVForm(forms.ModelForm): class Meta: model = VRF - fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = VRF.csv_headers help_texts = { 'name': 'VRF name', } @@ -102,7 +102,7 @@ class RIRCSVForm(forms.ModelForm): class Meta: model = RIR - fields = ['name', 'slug', 'is_private'] + fields = RIR.csv_headers help_texts = { 'name': 'RIR name', } @@ -144,7 +144,7 @@ class AggregateCSVForm(forms.ModelForm): class Meta: model = Aggregate - fields = ['prefix', 'rir', 'date_added', 'description'] + fields = Aggregate.csv_headers class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -185,7 +185,7 @@ class RoleCSVForm(forms.ModelForm): class Meta: model = Role - fields = ['name', 'slug'] + fields = Role.csv_headers help_texts = { 'name': 'Role name', } @@ -299,9 +299,7 @@ class PrefixCSVForm(forms.ModelForm): class Meta: model = Prefix - fields = [ - 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', - ] + fields = Prefix.csv_headers def clean(self): @@ -609,10 +607,7 @@ class IPAddressCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', - 'description', - ] + fields = IPAddress.csv_headers def clean(self): @@ -759,7 +754,7 @@ class VLANGroupCSVForm(forms.ModelForm): class Meta: model = VLANGroup - fields = ['site', 'name', 'slug'] + fields = VLANGroup.csv_headers help_texts = { 'name': 'Name of VLAN group', } @@ -849,7 +844,7 @@ class VLANCSVForm(forms.ModelForm): class Meta: model = VLAN - fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + fields = VLAN.csv_headers help_texts = { 'vid': 'Numeric VLAN ID (1-4095)', 'name': 'VLAN name', diff --git a/netbox/ipam/migrations/0021_vrf_ordering.py b/netbox/ipam/migrations/0021_vrf_ordering.py new file mode 100644 index 000000000..878c02d8c --- /dev/null +++ b/netbox/ipam/migrations/0021_vrf_ordering.py @@ -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'}, + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 9b30586f2..d8e2aae97 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,7 +14,6 @@ from dcim.models import Interface from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet @@ -38,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] class Meta: - ordering = ['name'] + ordering = ['name', 'rd'] verbose_name = 'VRF' verbose_name_plural = 'VRFs' @@ -49,13 +48,13 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): return reverse('ipam:vrf', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.name, self.rd, self.tenant.name if self.tenant else None, self.enforce_unique, self.description, - ]) + ) @property def display_name(self): @@ -75,6 +74,8 @@ class RIR(models.Model): is_private = models.BooleanField(default=False, verbose_name='Private', help_text='IP space managed by this RIR is considered private') + csv_headers = ['name', 'slug', 'is_private'] + class Meta: ordering = ['name'] verbose_name = 'RIR' @@ -86,6 +87,13 @@ class RIR(models.Model): def get_absolute_url(self): 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 class Aggregate(CreatedUpdatedModel, CustomFieldModel): @@ -147,12 +155,12 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): super(Aggregate, self).save(*args, **kwargs) def to_csv(self): - return csv_format([ + return ( self.prefix, self.rir.name, - self.date_added.isoformat() if self.date_added else None, + self.date_added, self.description, - ]) + ) def get_utilization(self): """ @@ -173,19 +181,20 @@ class Role(models.Model): slug = models.SlugField(unique=True) weight = models.PositiveSmallIntegerField(default=1000) + csv_headers = ['name', 'slug', 'weight'] + class Meta: ordering = ['weight', 'name'] def __str__(self): return self.name - @property - def count_prefixes(self): - return self.prefixes.count() - - @property - def count_vlans(self): - return self.vlans.count() + def to_csv(self): + return ( + self.name, + self.slug, + self.weight, + ) @python_2_unicode_compatible @@ -262,7 +271,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): super(Prefix, self).save(*args, **kwargs) def to_csv(self): - return csv_format([ + return ( self.prefix, self.vrf.rd if self.vrf 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.is_pool, self.description, - ]) + ) def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] @@ -461,7 +470,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): else: is_primary = False - return csv_format([ + return ( self.address, self.vrf.rd if self.vrf 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, is_primary, self.description, - ]) + ) @property def device(self): @@ -502,6 +511,8 @@ class VLANGroup(models.Model): slug = models.SlugField() site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True) + csv_headers = ['name', 'slug', 'site'] + class Meta: ordering = ['site', 'name'] unique_together = [ @@ -517,6 +528,13 @@ class VLANGroup(models.Model): def get_absolute_url(self): 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): """ Return the first available VLAN ID (1-4094) in the group. @@ -577,7 +595,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): }) def to_csv(self): - return csv_format([ + return ( self.site.name if self.site else None, self.group.name if self.group else None, self.vid, @@ -586,7 +604,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): self.get_status_display(), self.role.name if self.role else None, self.description, - ]) + ) @property def display_name(self): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 32f04c223..bfc65dacc 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -37,6 +37,14 @@ UTILIZATION_GRAPH = """ {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %} """ +ROLE_PREFIX_COUNT = """ +{{ value }} +""" + +ROLE_VLAN_COUNT = """ +{{ value }} +""" + ROLE_ACTIONS = """ {% if perms.ipam.change_role %} @@ -220,10 +228,18 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() - name = tables.Column(verbose_name='Name') - prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes') - vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs') - slug = tables.Column(verbose_name='Slug') + prefix_count = tables.TemplateColumn( + accessor=Accessor('prefixes.count'), + template_code=ROLE_PREFIX_COUNT, + 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='') class Meta(BaseTable.Meta): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 35f8264b8..c0d2f2655 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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__))) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index e5fb311db..0f240fff3 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -119,7 +119,7 @@ SEARCH_TYPES = OrderedDict(( }), # Virtualization ('cluster', { - 'queryset': Cluster.objects.all(), + 'queryset': Cluster.objects.select_related('type', 'group'), 'filter': ClusterFilter, 'table': ClusterTable, 'url': 'virtualization:cluster_list', diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index af0ad92cc..bcc79e2a5 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -47,7 +47,7 @@ class SecretRoleCSVForm(forms.ModelForm): class Meta: model = SecretRole - fields = ['name', 'slug'] + fields = SecretRole.csv_headers help_texts = { 'name': 'Name of secret role', } @@ -98,7 +98,7 @@ class SecretCSVForm(forms.ModelForm): class Meta: model = Secret - fields = ['device', 'role', 'name', 'plaintext'] + fields = Secret.csv_headers help_texts = { 'name': 'Name or username', } diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index d4e9874b3..e1f367d03 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -239,6 +239,8 @@ class SecretRole(models.Model): users = models.ManyToManyField(User, related_name='secretroles', blank=True) groups = models.ManyToManyField(Group, related_name='secretroles', blank=True) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -248,6 +250,12 @@ class SecretRole(models.Model): def get_absolute_url(self): return "{}?role={}".format(reverse('secrets:secret_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + def has_member(self, user): """ Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles. diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html index 418459a15..f05552f7d 100644 --- a/netbox/templates/circuits/circuit_list.html +++ b/netbox/templates/circuits/circuit_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.circuits.add_circuit %} - - - Add a circuit - - - - Import circuits - + {% add_button 'circuits:circuit_add' %} + {% import_button 'circuits:circuit_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='circuits' %} + {% export_button content_type %}

{% block title %}Circuits{% endblock %}

diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html index ce9cdf385..2b9469042 100644 --- a/netbox/templates/circuits/circuittype_list.html +++ b/netbox/templates/circuits/circuittype_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.circuits.add_circuittype %} - - - Add a circuit type - - - - Import circuit types - + {% add_button 'circuits:circuittype_add' %} + {% import_button 'circuits:circuittype_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Circuit Types{% endblock %}

diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html index 9ba8bb838..cb7aab406 100644 --- a/netbox/templates/circuits/provider_list.html +++ b/netbox/templates/circuits/provider_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.circuits.add_provider %} - - - Add a provider - - - - Import providers - + {% add_button 'circuits:provider_add' %} + {% import_button 'circuits:provider_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='providers' %} + {% export_button content_type %}

{% block title %}Providers{% endblock %}

diff --git a/netbox/templates/dcim/console_connections_list.html b/netbox/templates/dcim/console_connections_list.html index 57d3435e5..89eb0822d 100644 --- a/netbox/templates/dcim/console_connections_list.html +++ b/netbox/templates/dcim/console_connections_list.html @@ -1,14 +1,12 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.change_consoleport %} - - - Import connections - + {% import_button 'dcim:console_connections_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='connections' %} + {% export_button content_type %}

{% block title %}Console Connections{% endblock %}

diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 34b143fc5..f96b27309 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_device %} - - - Add a device - - - - Import devices - + {% add_button 'dcim:device_add' %} + {% import_button 'dcim:device_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='devices' %} + {% export_button content_type %}

{% block title %}Devices{% endblock %}

diff --git a/netbox/templates/dcim/devicerole_list.html b/netbox/templates/dcim/devicerole_list.html index 871e62806..6dd95b86d 100644 --- a/netbox/templates/dcim/devicerole_list.html +++ b/netbox/templates/dcim/devicerole_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_devicerole %} - - - Add a device role - - - - Import device roles - + {% add_button 'dcim:devicerole_add' %} + {% import_button 'dcim:devicerole_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Device Roles{% endblock %}

diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index 69807fcff..91745082a 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_devicetype %} - - - Add a device type - - - - Import device types - + {% add_button 'dcim:devicetype_add' %} + {% import_button 'dcim:devicetype_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='device types' %} + {% export_button content_type %}

{% block title %}Device Types{% endblock %}

diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 73b5845ef..92acd297d 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -43,17 +43,23 @@

{{ device }}

{% include 'inc/created_updated.html' with obj=device %} diff --git a/netbox/templates/dcim/inc/device_napalm_tabs.html b/netbox/templates/dcim/inc/device_napalm_tabs.html new file mode 100644 index 000000000..073f2fb9b --- /dev/null +++ b/netbox/templates/dcim/inc/device_napalm_tabs.html @@ -0,0 +1,15 @@ +{% if not disabled_message %} + + + +{% else %} + + + +{% endif %} diff --git a/netbox/templates/dcim/inc/filter_rack_group.html b/netbox/templates/dcim/inc/filter_rack_group.html new file mode 100644 index 000000000..9c5582f87 --- /dev/null +++ b/netbox/templates/dcim/inc/filter_rack_group.html @@ -0,0 +1,29 @@ + diff --git a/netbox/templates/dcim/interface_connections_list.html b/netbox/templates/dcim/interface_connections_list.html index a7355a449..950eb2f0b 100644 --- a/netbox/templates/dcim/interface_connections_list.html +++ b/netbox/templates/dcim/interface_connections_list.html @@ -1,14 +1,12 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_interfaceconnection %} - - - Import connections - + {% import_button 'dcim:interface_connections_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='connections' %} + {% export_button content_type %}

{% block title %}Interface Connections{% endblock %}

diff --git a/netbox/templates/dcim/inventoryitem_list.html b/netbox/templates/dcim/inventoryitem_list.html index 612534d98..5662b51d9 100644 --- a/netbox/templates/dcim/inventoryitem_list.html +++ b/netbox/templates/dcim/inventoryitem_list.html @@ -1,15 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_devicetype %} - - - Import inventory items - + {% import_button 'dcim:inventoryitem_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='inventory items' %} + {% export_button content_type %}

{% block title %}Inventory Items{% endblock %}

diff --git a/netbox/templates/dcim/manufacturer_list.html b/netbox/templates/dcim/manufacturer_list.html index ff6025af5..09b06ef29 100644 --- a/netbox/templates/dcim/manufacturer_list.html +++ b/netbox/templates/dcim/manufacturer_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_manufacturer %} - - - Add a manufacturer - - - - Import manufacturers - + {% add_button 'dcim:manufacturer_add' %} + {% import_button 'dcim:manufacturer_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='manufacturers' %} + {% export_button content_type %}

{% block title %}Manufacturers{% endblock %}

diff --git a/netbox/templates/dcim/platform_list.html b/netbox/templates/dcim/platform_list.html index dc8d43660..123c863ea 100644 --- a/netbox/templates/dcim/platform_list.html +++ b/netbox/templates/dcim/platform_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_platform %} - - - Add a platform - - - - Import platforms - + {% add_button 'dcim:platform_add' %} + {% import_button 'dcim:platform_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Platforms{% endblock %}

diff --git a/netbox/templates/dcim/power_connections_list.html b/netbox/templates/dcim/power_connections_list.html index 50e983bec..4e351eb6a 100644 --- a/netbox/templates/dcim/power_connections_list.html +++ b/netbox/templates/dcim/power_connections_list.html @@ -1,14 +1,12 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.change_powerport %} - - - Import connections - + {% import_button 'dcim:power_connections_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='connections' %} + {% export_button content_type %}

{% block title %}Power Connections{% endblock %}

diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 5304538c1..38a821750 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -45,9 +45,10 @@ {% endblock %} {% block javascript %} - + {% include 'dcim/inc/filter_rack_group.html' %} + {% endblock %} diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html index 88b5a2f9d..d5734ee2b 100644 --- a/netbox/templates/dcim/rack_list.html +++ b/netbox/templates/dcim/rack_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_rack %} - - - Add a rack - - - - Import racks - + {% add_button 'dcim:rack_add' %} + {% import_button 'dcim:rack_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='racks' %} + {% export_button content_type %}

{% block title %}Racks{% endblock %}

@@ -27,34 +21,6 @@ {% endblock %} {% block javascript %} - + {% include 'dcim/inc/filter_rack_group.html' %} {% endblock %} diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html index d5853f11c..c16b1605f 100644 --- a/netbox/templates/dcim/rackgroup_list.html +++ b/netbox/templates/dcim/rackgroup_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_rackgroup %} - - - Add a rack group - - - - Import rack groups - + {% add_button 'dcim:rackgroup_add' %} + {% import_button 'dcim:rackgroup_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='rack groups' %} + {% export_button content_type %}

{% block title %}Rack Groups{% endblock %}

diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html index 4d61b4acb..0f6d39c15 100644 --- a/netbox/templates/dcim/region_list.html +++ b/netbox/templates/dcim/region_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_region %} - - - Add a region - - - - Import regions - + {% add_button 'dcim:region_add' %} + {% import_button 'dcim:region_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='regions' %} + {% export_button content_type %}

{% block title %}Regions{% endblock %}

diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html index 6d3e2f7f7..7baa76dad 100644 --- a/netbox/templates/dcim/site_list.html +++ b/netbox/templates/dcim/site_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_site %} - - - Add a site - - - - Import sites - + {% add_button 'dcim:site_add' %} + {% import_button 'dcim:site_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='sites' %} + {% export_button content_type %}

{% block title %}Sites{% endblock %}

diff --git a/netbox/templates/inc/export_button.html b/netbox/templates/inc/export_button.html deleted file mode 100644 index 8851fe1f7..000000000 --- a/netbox/templates/inc/export_button.html +++ /dev/null @@ -1,20 +0,0 @@ -{% if export_templates %} -
- - -
-{% else %} - - - Export {{ obj_type }} - -{% endif %} diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index df8e4772e..73da9695d 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -1,20 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load humanize %} -{% load helpers %} {% block content %}
{% if perms.ipam.add_aggregate %} - - - Add an aggregate - - - - Import aggregates - + {% add_button 'ipam:aggregate_add' %} + {% import_button 'ipam:aggregate_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='aggregates' %} + {% export_button content_type %}

{% block title %}Aggregates{% endblock %}

diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 855fc3a98..1509f35cb 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -144,7 +144,7 @@ {% if duplicate_ips_table.rows %} {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %} {% 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' %}
{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html index 2d273145d..5f8fdeb88 100644 --- a/netbox/templates/ipam/ipaddress_list.html +++ b/netbox/templates/ipam/ipaddress_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.ipam.add_ipaddress %} - - - Add an IP - - - - Import IPs - - {% endif %} - {% include 'inc/export_button.html' with obj_type='IPs' %} + {% add_button 'ipam:ipaddress_add' %} + {% import_button 'ipam:ipaddress_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}IP Addresses{% endblock %}

diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 5c168e247..11c5fc405 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -136,7 +136,7 @@ {% if duplicate_prefix_table.rows %} {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} {% 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' %}
{% endblock %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index 4747731f8..d65904595 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -1,6 +1,6 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} -{% load form_helpers %} {% block content %}
@@ -9,16 +9,10 @@ Expand
{% if perms.ipam.add_prefix %} - - - Add a prefix - - - - Import prefixes - - {% endif %} - {% include 'inc/export_button.html' with obj_type='prefixes' %} + {% add_button 'ipam:prefix_add' %} + {% import_button 'ipam:prefix_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}Prefixes{% endblock %}

diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html index bd2a30c2a..67356b3cb 100644 --- a/netbox/templates/ipam/rir_list.html +++ b/netbox/templates/ipam/rir_list.html @@ -1,6 +1,6 @@ {% extends '_base.html' %} +{% load buttons %} {% load humanize %} -{% load helpers %} {% block content %}
@@ -16,15 +16,10 @@ {% endif %} {% if perms.ipam.add_rir %} - - - Add a RIR - - - - Import RIRs - + {% add_button 'ipam:rir_add' %} + {% import_button 'ipam:rir_import' %} {% endif %} + {% export_button content_type %}

{% block title %}RIRs{% endblock %}

diff --git a/netbox/templates/ipam/role_list.html b/netbox/templates/ipam/role_list.html index 54b0a8925..cd6fcd7aa 100644 --- a/netbox/templates/ipam/role_list.html +++ b/netbox/templates/ipam/role_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.ipam.add_role %} - - - Add a role - - - - Import roles - + {% add_button 'ipam:role_add' %} + {% import_button 'ipam:role_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Prefix/VLAN Roles{% endblock %}

diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html index b5db84e52..24e12595b 100644 --- a/netbox/templates/ipam/vlan_list.html +++ b/netbox/templates/ipam/vlan_list.html @@ -1,20 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} -{% load form_helpers %} +{% load buttons %} {% block content %}
{% if perms.ipam.add_vlan %} - - - Add a VLAN - - - - Import VLANs - - {% endif %} - {% include 'inc/export_button.html' with obj_type='VLANs' %} + {% add_button 'ipam:vlan_add' %} + {% import_button 'ipam:vlan_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}VLANs{% endblock %}

diff --git a/netbox/templates/ipam/vlangroup_list.html b/netbox/templates/ipam/vlangroup_list.html index 77d8e9b30..9333f95c7 100644 --- a/netbox/templates/ipam/vlangroup_list.html +++ b/netbox/templates/ipam/vlangroup_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.ipam.add_vlangroup %} - - - Add a VLAN group - - - - Import VLAN groups - + {% add_button 'ipam:vlangroup_add' %} + {% import_button 'ipam:vlangroup_import' %} {% endif %} + {% export_button content_type %}

{% block title %}VLAN Groups{% endblock %}

diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html index 029426a14..23bd16495 100644 --- a/netbox/templates/ipam/vrf_list.html +++ b/netbox/templates/ipam/vrf_list.html @@ -1,20 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} -{% load form_helpers %} +{% load buttons %} {% block content %}
{% if perms.ipam.add_vrf %} - - - Add a VRF - - - - Import VRFs - - {% endif %} - {% include 'inc/export_button.html' with obj_type='VRFs' %} + {% add_button 'ipam:vrf_add' %} + {% import_button 'ipam:vrf_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}VRFs{% endblock %}

diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html index 5293b5a6d..6dd92cd89 100644 --- a/netbox/templates/secrets/secret_list.html +++ b/netbox/templates/secrets/secret_list.html @@ -1,13 +1,10 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.secrets.add_secret %} - - - Import secrets - + {% import_button 'secrets:secret_import' %} {% endif %}

{% block title %}Secrets{% endblock %}

diff --git a/netbox/templates/secrets/secretrole_list.html b/netbox/templates/secrets/secretrole_list.html index ccaa20730..e968630f6 100644 --- a/netbox/templates/secrets/secretrole_list.html +++ b/netbox/templates/secrets/secretrole_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
- {% if perms.dcim.add_devicerole %} - - - Add a secret role - - - - Import secret roles - + {% if perms.secrets.add_secretrole %} + {% add_button 'secrets:secretrole_add' %} + {% import_button 'secrets:secretrole_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Secret Roles{% endblock %}

diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html index afc1a154f..e6fd61c37 100644 --- a/netbox/templates/tenancy/tenant_list.html +++ b/netbox/templates/tenancy/tenant_list.html @@ -1,19 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.tenancy.add_tenant %} - - - Add a tenant - - - - Import tenants - + {% add_button 'tenancy:tenant_add' %} + {% import_button 'tenancy:tenant_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='tenants' %} + {% export_button content_type %}

{% block title %}Tenants{% endblock %}

diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html index 3bffb5c6b..a62594994 100644 --- a/netbox/templates/tenancy/tenantgroup_list.html +++ b/netbox/templates/tenancy/tenantgroup_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.tenancy.add_tenantgroup %} - - - Add a tenant group - - - - Import tenant groups - + {% add_button 'tenancy:tenantgroup_add' %} + {% import_button 'tenancy:tenantgroup_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Tenant Groups{% endblock %}

diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html index dad7a9e49..08f62e6ba 100644 --- a/netbox/templates/virtualization/cluster_list.html +++ b/netbox/templates/virtualization/cluster_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.virtualization.add_cluster %} - - - Add a cluster - - - - Import clusters - + {% add_button 'virtualization:cluster_add' %} + {% import_button 'virtualization:cluster_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='clusters' %} + {% export_button content_type %}

{% block title %}Clusters{% endblock %}

diff --git a/netbox/templates/virtualization/clustergroup_list.html b/netbox/templates/virtualization/clustergroup_list.html index 6d28400c0..d724c2c43 100644 --- a/netbox/templates/virtualization/clustergroup_list.html +++ b/netbox/templates/virtualization/clustergroup_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.virtualization.add_clustergroup %} - - - Add a cluster group - - - - Import cluster groups - + {% add_button 'virtualization:clustergroup_add' %} + {% import_button 'virtualization:clustergroup_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Cluster Groups{% endblock %}

diff --git a/netbox/templates/virtualization/clustertype_list.html b/netbox/templates/virtualization/clustertype_list.html index 7d905ba06..37f8cc31b 100644 --- a/netbox/templates/virtualization/clustertype_list.html +++ b/netbox/templates/virtualization/clustertype_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} -{% load helpers %} +{% load buttons %} {% block content %}
{% if perms.virtualization.add_clustertype %} - - - Add a cluster type - - - - Import cluster types - + {% add_button 'virtualization:clustertype_add' %} + {% import_button 'virtualization:clustertype_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Cluster Types{% endblock %}

diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html index 7c240857f..706591ab4 100644 --- a/netbox/templates/virtualization/virtualmachine_edit.html +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -6,9 +6,7 @@
Virtual Machine
{% render_field form.name %} - {% render_field form.status %} {% render_field form.role %} - {% render_field form.platform %}
@@ -18,6 +16,15 @@ {% render_field form.cluster %}
+
+
Management
+
+ {% render_field form.status %} + {% render_field form.platform %} + {% render_field form.primary_ip4 %} + {% render_field form.primary_ip6 %} +
+
Resources
diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index a771dfbd9..30ed76dae 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.virtualization.add_virtualmachine %} - - - Add a virtual machine - - - - Import virtual machines - + {% add_button 'virtualization:virtualmachine_add' %} + {% import_button 'virtualization:virtualmachine_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='virtual machines' %} + {% export_button content_type %}

{% block title %}Virtual Machines{% endblock %}

diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 00194d4e8..4ea6c57ba 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -27,7 +27,7 @@ class TenantGroupCSVForm(forms.ModelForm): class Meta: model = TenantGroup - fields = ['name', 'slug'] + fields = TenantGroup.csv_headers help_texts = { 'name': 'Group name', } @@ -60,7 +60,7 @@ class TenantCSVForm(forms.ModelForm): class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments'] + fields = Tenant.csv_headers help_texts = { 'name': 'Tenant name', 'comments': 'Free-form comments' diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index a43327a06..1fea2ceaf 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -7,7 +7,6 @@ from django.utils.encoding import python_2_unicode_compatible from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format @python_2_unicode_compatible @@ -18,6 +17,8 @@ class TenantGroup(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -27,6 +28,12 @@ class TenantGroup(models.Model): def get_absolute_url(self): return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + @python_2_unicode_compatible class Tenant(CreatedUpdatedModel, CustomFieldModel): @@ -41,7 +48,7 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) 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: ordering = ['group', 'name'] @@ -53,9 +60,10 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): return reverse('tenancy:tenant', args=[self.slug]) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.group.name if self.group else None, self.description, - ]) + self.comments, + ) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1817cd9a9..a20825d13 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import csv -import itertools +from io import StringIO import re from django import forms @@ -245,14 +245,10 @@ class CSVDataField(forms.CharField): 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 = [] - 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) for f in self.required_fields: if f not in headers: diff --git a/netbox/utilities/templates/buttons/add.html b/netbox/utilities/templates/buttons/add.html new file mode 100644 index 000000000..a5278ae12 --- /dev/null +++ b/netbox/utilities/templates/buttons/add.html @@ -0,0 +1,3 @@ + + Add + diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html new file mode 100644 index 000000000..ee76dae6c --- /dev/null +++ b/netbox/utilities/templates/buttons/export.html @@ -0,0 +1,19 @@ +{% if export_templates %} +
+ + +
+{% else %} + + Export + +{% endif %} \ No newline at end of file diff --git a/netbox/utilities/templates/buttons/import.html b/netbox/utilities/templates/buttons/import.html new file mode 100644 index 000000000..67be77871 --- /dev/null +++ b/netbox/utilities/templates/buttons/import.html @@ -0,0 +1,3 @@ + + Import + diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py new file mode 100644 index 000000000..3090f4538 --- /dev/null +++ b/netbox/utilities/templatetags/buttons.py @@ -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, + } diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 72da778a5..c08bfef8c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,7 +1,10 @@ from __future__ import unicode_literals +import datetime import six +from django.http import HttpResponse + def csv_format(data): """ @@ -15,12 +18,16 @@ def csv_format(data): csv.append('') 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 if not isinstance(value, six.string_types): value = '{}'.format(value) # Double-quote the value if it contains a comma - if ',' in value: + if ',' in value or '\n' in value: csv.append('"{}"'.format(value)) else: csv.append('{}'.format(value)) @@ -28,6 +35,32 @@ def csv_format(data): 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): """ Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format. diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d37332bef..7e1a34e19 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -10,7 +10,6 @@ from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.db.models import ProtectedError 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.template import TemplateSyntaxError from django.urls import reverse @@ -21,6 +20,7 @@ from django.views.generic import View from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction +from utilities.utils import queryset_to_csv from utilities.forms import BootstrapMixin, CSVDataField from .error_handlers import handle_protectederror from .forms import ConfirmationForm @@ -79,7 +79,7 @@ class ObjectListView(View): def get(self, request): model = self.queryset.model - object_ct = ContentType.objects.get_for_model(model) + content_type = ContentType.objects.get_for_model(model) if self.filter: self.queryset = self.filter(request.GET, self.queryset).qs @@ -92,27 +92,18 @@ class ObjectListView(View): # Check for export template rendering if request.GET.get('export'): - et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export')) + 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 try: - response = et.to_response(context_dict={'queryset': queryset}, - filename='netbox_{}'.format(model._meta.verbose_name_plural)) - return response + return et.render_to_response(queryset) except TemplateSyntaxError: - messages.error(request, "There was an error rendering the selected export template ({})." - .format(et.name)) - # Fall back to built-in CSV export + messages.error( + request, + "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'): - headers = getattr(model, 'csv_headers', None) - 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 + return queryset_to_csv(self.queryset) # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list self.queryset = self.alter_queryset(request) @@ -134,10 +125,10 @@ class ObjectListView(View): RequestConfig(request, paginate).configure(table) context = { + 'content_type': content_type, 'table': table, 'permissions': permissions, '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()) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index d697de755..ae2987775 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -9,6 +9,7 @@ from dcim.constants import IFACE_FF_VIRTUAL from dcim.formfields import MACAddressFormField from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm +from ipam.models import IPAddress from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -41,7 +42,7 @@ class ClusterTypeCSVForm(forms.ModelForm): class Meta: model = ClusterType - fields = ['name', 'slug'] + fields = ClusterType.csv_headers help_texts = { 'name': 'Name of cluster type', } @@ -64,7 +65,7 @@ class ClusterGroupCSVForm(forms.ModelForm): class Meta: model = ClusterGroup - fields = ['name', 'slug'] + fields = ClusterGroup.csv_headers help_texts = { 'name': 'Name of cluster group', } @@ -112,7 +113,7 @@ class ClusterCSVForm(forms.ModelForm): class Meta: model = Cluster - fields = ['name', 'type', 'group', 'site', 'comments'] + fields = Cluster.csv_headers class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -246,8 +247,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'comments', + 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', + 'vcpus', 'memory', 'disk', 'comments', ] def __init__(self, *args, **kwargs): @@ -261,6 +262,41 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): 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): status = CSVChoiceField( @@ -306,7 +342,7 @@ class VirtualMachineCSVForm(forms.ModelForm): class Meta: model = VirtualMachine - fields = ['name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + fields = VirtualMachine.csv_headers class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8152c5d57..5552fb089 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -10,7 +10,6 @@ from django.utils.encoding import python_2_unicode_compatible from dcim.models import Device from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES @@ -31,6 +30,8 @@ class ClusterType(models.Model): unique=True ) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -40,6 +41,12 @@ class ClusterType(models.Model): def get_absolute_url(self): return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + # # Cluster groups @@ -58,6 +65,8 @@ class ClusterGroup(models.Model): unique=True ) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -67,6 +76,12 @@ class ClusterGroup(models.Model): def get_absolute_url(self): return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + # # Clusters @@ -109,9 +124,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) - csv_headers = [ - 'name', 'type', 'group', 'site', 'comments', - ] + csv_headers = ['name', 'type', 'group', 'site', 'comments'] class Meta: ordering = ['name'] @@ -135,13 +148,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): }) def to_csv(self): - return csv_format([ + return ( self.name, self.type.name, self.group.name if self.group else None, self.site.name if self.site else None, self.comments, - ]) + ) # @@ -230,7 +243,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): ) csv_headers = [ - 'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] class Meta: @@ -243,9 +256,10 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): return reverse('virtualization:virtualmachine', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.name, self.get_status_display(), + self.role.name if self.role else None, self.cluster.name, self.tenant.name if self.tenant else None, self.platform.name if self.platform else None, @@ -253,7 +267,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): self.memory, self.disk, self.comments, - ]) + ) def get_status_class(self): return VM_STATUS_CLASSES[self.status] diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 2ace86d77..4d38a3fe5 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -80,8 +80,9 @@ class ClusterGroupTable(BaseTable): class ClusterTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - device_count = tables.Column(verbose_name='Devices') - vm_count = tables.Column(verbose_name='VMs') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + 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): model = Cluster diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4f2981748..119388dd9 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -99,10 +99,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ClusterListView(ObjectListView): - queryset = Cluster.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = Cluster.objects.select_related('type', 'group') table = tables.ClusterTable filter = filters.ClusterFilter filter_form = forms.ClusterFilterForm @@ -162,10 +159,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_cluster' cls = Cluster - queryset = Cluster.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = Cluster.objects.all() table = tables.ClusterTable default_return_url = 'virtualization:cluster_list'