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 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/netbox/circuits/forms.py b/netbox/circuits/forms.py index 22bbbe464..29203fc8a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -44,7 +44,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', @@ -90,7 +90,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 06cd48a25..a65fe3063 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -10,7 +10,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 CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES @@ -30,7 +29,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'] @@ -42,13 +41,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 @@ -60,6 +62,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'] @@ -69,6 +73,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): @@ -88,7 +98,9 @@ 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', 'status', 'tenant', 'install_date', 'commit_rate', 'description'] + csv_headers = [ + 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + ] class Meta: ordering = ['provider', 'cid'] @@ -101,16 +113,17 @@ 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.get_status_display(), 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_status_class(self): return STATUS_CLASSES[self.status] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3179d39d6..36aa98a8d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -83,9 +83,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', @@ -153,10 +151,7 @@ class SiteCSVForm(forms.ModelForm): class Meta: model = Site - fields = [ - 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'description', 'physical_address', - 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone', 'comments', - ] + fields = Site.csv_headers help_texts = { 'name': 'Site name', 'slug': 'URL-friendly slug', @@ -224,9 +219,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', @@ -254,7 +247,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)' @@ -341,10 +334,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', @@ -478,9 +468,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', @@ -526,8 +514,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', @@ -692,7 +679,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)' @@ -716,7 +703,7 @@ class PlatformCSVForm(forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'manufacturer', 'napalm_driver'] + fields = Platform.csv_headers help_texts = { 'name': 'Platform name', 'manufacturer': 'Manufacturer name', @@ -970,7 +957,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): @@ -1019,7 +1006,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): @@ -2096,7 +2083,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): @@ -2238,7 +2225,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 e3ba1c0ac..7a315c5b9 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -23,7 +23,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 @@ -44,9 +43,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'] @@ -58,11 +55,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, - ]) + ) # @@ -102,8 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): objects = SiteManager() csv_headers = [ - 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'contact_name', - 'contact_phone', 'contact_email', + 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', + 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] class Meta: @@ -116,7 +113,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): return reverse('dcim:site', args=[self.slug]) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.get_status_display(), @@ -126,10 +123,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel): self.asn, self.time_zone, self.description, + self.physical_address, + self.shipping_address, self.contact_name, self.contact_phone, self.contact_email, - ]) + self.comments, + ) def get_status_class(self): return STATUS_CLASSES[self.status] @@ -175,9 +175,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'] @@ -193,11 +191,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 @@ -209,6 +207,8 @@ class RackRole(models.Model): slug = models.SlugField(unique=True) color = ColorField() + csv_headers = ['name', 'slug', 'color'] + class Meta: ordering = ['name'] @@ -218,6 +218,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): @@ -253,7 +260,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: @@ -303,7 +310,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, @@ -315,7 +322,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): self.width, self.u_height, self.desc_units, - ]) + self.comments, + ) @property def units(self): @@ -491,9 +499,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'] @@ -505,10 +511,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 @@ -551,7 +557,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: @@ -574,7 +580,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, @@ -586,7 +592,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): @@ -766,6 +773,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'] @@ -775,6 +784,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): @@ -805,6 +822,8 @@ class Platform(models.Model): verbose_name="Legacy RPC client" ) + csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] + class Meta: ordering = ['name'] @@ -814,6 +833,14 @@ 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.manufacturer.name if self.manufacturer else None, + self.napalm_driver, + ) + class DeviceManager(NaturalOrderByManager): @@ -892,7 +919,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: @@ -1049,7 +1076,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, @@ -1064,7 +1091,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): @@ -1158,15 +1186,14 @@ class ConsolePort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() - # 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(), - ]) + ) # @@ -1241,15 +1268,14 @@ class PowerPort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() - # 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(), - ]) + ) # @@ -1501,15 +1527,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(), - ]) + ) # @@ -1575,7 +1600,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: @@ -1589,15 +1614,16 @@ class InventoryItem(models.Model): return self.device.get_absolute_url() 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, + ) # @@ -1632,4 +1658,4 @@ class VirtualChassis(models.Model): if self.pk and self.master not in self.members.all(): raise ValidationError({ 'master': "The selected master is not assigned to this virtual chassis." - }) \ No newline at end of file + }) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index fc8efec27..aef6b3308 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -66,6 +66,10 @@ RACK_ROLE = """ {% endif %} """ +RACK_DEVICE_COUNT = """ +{{ value }} +""" + RACKRESERVATION_ACTIONS = """ {% if perms.dcim.change_rackreservation %} @@ -84,6 +88,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 %} @@ -211,12 +231,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', ) @@ -357,12 +381,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 @@ -375,10 +412,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 53d87d55e..2f56cd6b3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -321,7 +321,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 @@ -763,10 +763,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' @@ -804,10 +801,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/forms.py b/netbox/extras/forms.py index e39754ae0..22a604dd0 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -22,10 +22,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F 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 +35,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.lower() in ['true', 'yes', '1']: initial = 1 - elif cf.default.lower() in ['false', 'no', '0']: + elif 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 +58,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/models.py b/netbox/extras/models.py index 9e6624fc4..aa30f8cdc 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -223,19 +223,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 diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 0865cfa55..0517dce41 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/models.py b/netbox/ipam/models.py index 9b30586f2..3ed673c78 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 @@ -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/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..de9922313 100644 --- a/netbox/templates/circuits/circuit_list.html +++ b/netbox/templates/circuits/circuit_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}