diff --git a/.gitignore b/.gitignore index 2f957c678..b33d46a40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.pyc /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/reports/* +!/netbox/reports/__init__.py /netbox/static .idea /*.sh diff --git a/.travis.yml b/.travis.yml index 1576da4cf..94bfa0dab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,8 @@ sudo: required - services: - - docker - -env: - - DOCKER_TAG=$TRAVIS_TAG - + - postgresql +addons: + postgresql: "9.4" language: python python: - "2.7" @@ -13,9 +10,8 @@ python: install: - pip install -r requirements.txt - pip install pep8 +before_script: + - psql --version + - psql -U postgres -c 'SELECT version();' script: - ./scripts/cibuild.sh -after_success: - - if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then - ./scripts/docker-build.sh; - fi diff --git a/docs/api/overview.md b/docs/api/overview.md index 39a4109f9..db9c1de4d 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -88,10 +88,10 @@ The base serializer is used to represent the default view of a model. This inclu "vid": 101, "name": "Users-Floor1", "tenant": null, - "status": [ - 1, - "Active" - ], + "status": { + "value": 1, + "label": "Active" + }, "role": { "id": 9, "url": "http://localhost:8000/api/ipam/roles/9/", @@ -122,6 +122,37 @@ When a base serializer includes one or more nested serializers, the hierarchical } ``` +## Static Choice Fields + +Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL. + +Each choice includes a human-friendly label and its corresponding numeric value. For example, `GET /api/ipam/_choices/prefix:status/` will return: + +``` +[ + { + "value": 0, + "label": "Container" + }, + { + "value": 1, + "label": "Active" + }, + { + "value": 2, + "label": "Reserved" + }, + { + "value": 3, + "label": "Deprecated" + } +] +``` + +Thus, to set a prefix's status to "Reserved," it would be assigned the integer `2`. + +A request for `GET /api/ipam/_choices/` will return choices for _all_ fields belonging to models within the IPAM app. + # Pagination API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes: diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 9cc4e75fd..d34137ea0 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -145,7 +145,7 @@ An API consumer can request an arbitrary number of objects by appending the "lim Default: $BASE_DIR/netbox/media/ -The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media` directory within the base NetBox installation path. +The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path. --- @@ -191,14 +191,6 @@ The amount of time (in seconds) to wait for NAPALM to connect to a device. --- -## NETBOX_USERNAME (Deprecated) - -## NETBOX_PASSWORD (Deprecated) - -These settings have been deprecated and will be removed in NetBox v2.2. Please use `NAPALM_USERNAME` and `NAPALM_PASSWORD` instead. - ---- - ## PAGINATE_COUNT Default: 50 @@ -215,6 +207,14 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## REPORTS_ROOT + +Default: $BASE_DIR/netbox/reports/ + +The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. + +--- + ## TIME_ZONE Default: UTC diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md index 8b6d53184..6848804fd 100644 --- a/docs/data-model/ipam.md +++ b/docs/data-model/ipam.md @@ -96,4 +96,4 @@ VLAN groups can be employed for administrative organization within NetBox. Each # Services -A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) +A service represents a TCP or UDP service available on a device or virtual machine. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) diff --git a/docs/data-model/virtualization.md b/docs/data-model/virtualization.md new file mode 100644 index 000000000..d49f7b323 --- /dev/null +++ b/docs/data-model/virtualization.md @@ -0,0 +1,29 @@ +NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it. + +# Clusters + +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group. + +Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. + +### Cluster Types + +A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider. + +### Cluster Groups + +Cluster groups may be created for the purpose of organizing clusters. + +--- + +# Virtual Machines + +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster. + +Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays. + +The following resources can be defined for each VM: + +* vCPU count +* Memory (MB) +* Disk space (GB) diff --git a/docs/development/utility-views.md b/docs/development/utility-views.md new file mode 100644 index 000000000..a6e50f71e --- /dev/null +++ b/docs/development/utility-views.md @@ -0,0 +1,53 @@ +# Utility Views + +Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once. + +## Individual Views + +### ObjectListView + +Generates a paginated table of objects from a given queryset, which may optionally be filtered. + +### ObjectEditView + +Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created. + +### ObjectDeleteView + +Deletes an object. The user is redirected to a confirmation page before the deletion is executed. + +## Bulk Views + +### BulkCreateView + +Creates multiple objects at once based on a given pattern. Currently used only for IP addresses. + +### BulkImportView + +Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none. + +### BulkEditView + +Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects. + +### BulkDeleteView + +Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion. + +## Component Views + +### ComponentCreateView + +Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device). + +### ComponentEditView + +A subclass of `ObjectEditView`: Updates an individual component object. + +### ComponentDeleteView + +A subclass of `ObjectDeleteView`: Deletes an individual component object. + +### BulkComponentCreateView + +Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once. diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index d1aa5d001..b92fa3ad1 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -5,13 +5,15 @@ Python 3: ```no-highlight -# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python3 python3-dev python3-setuptools libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# easy_install3 pip ``` Python 2: ```no-highlight -# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python2.7 python-dev python-setuptools libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# easy_install pip ``` **CentOS** @@ -22,14 +24,14 @@ Python 3: # yum install -y epel-release # yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config # easy_install-3.4 pip -# ln -s -f python3.4 /usr/bin/python ``` Python 2: ```no-highlight # yum install -y epel-release -# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config +# yum install -y gcc python2 python-devel python-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config +# easy_install pip ``` You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. @@ -97,6 +99,9 @@ Python 2: # pip install -r requirements.txt ``` +!!! note + If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip -V` or `pip3 -V`. + ### NAPALM Automation As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: diff --git a/docs/installation/postgresql.md b/docs/installation/postgresql.md index 75c754707..592ba1f38 100644 --- a/docs/installation/postgresql.md +++ b/docs/installation/postgresql.md @@ -1,12 +1,17 @@ -NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).) +NetBox requires a PostgreSQL 9.4 or higher database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).) !!! note The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. +!!! warning + NetBox v2.2 or later requires PostgreSQL 9.4.0 or higher. + # Installation **Ubuntu** +If a recent enough version of PostgreSQL is not available through your distribution's package manager, consider installing from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt). + ```no-highlight # apt-get update # apt-get install -y postgresql libpq-dev @@ -41,7 +46,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a ```no-highlight # sudo -u postgres psql -psql (9.3.13) +psql (9.4.5) Type "help" for help. postgres=# CREATE DATABASE netbox; diff --git a/docs/miscellaneous/reports.md b/docs/miscellaneous/reports.md new file mode 100644 index 000000000..79e4fb085 --- /dev/null +++ b/docs/miscellaneous/reports.md @@ -0,0 +1,119 @@ +# NetBox Reports + +A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that: + +* All top-of-rack switches have a console connection +* Every router has a loopback interface with an IP address assigned +* Each interface description conforms to a standard format +* Every site has a minimum set of VLANs defined +* All IP addresses have a parent prefix + +...and so on. Reports are completely customizable, so there's practically no limit to what you can test for. + +## Writing Reports + +Reports must be saved as files in the `netbox/reports/` path within the NetBox installation path. Each file created within this path is considered a separate module. Each module holds one or more reports, each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. + +!!! warning + The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. + +For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`. + +``` +from extras.reports import Report + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + +class DeviceIPsReport(Report): + description = "Check that every device has a primary IP address assigned" +``` + +Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections. + +``` +from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE +from dcim.models import ConsolePort, Device, PowerPort +from extras.reports import Report + + +class DeviceConnectionsReport(Report): + description = "Validate the minimum physical connections for each device" + + def test_console_connection(self): + + # Check that every console port for every active device has a connection defined. + for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE): + if console_port.cs_port is None: + self.log_failure( + console_port.device, + "No console connection defined for {}".format(console_port.name) + ) + elif console_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + console_port.device, + "Console connection for {} marked as planned".format(console_port.name) + ) + else: + self.log_success(console_port.device) + + def test_power_connections(self): + + # Check that every active device has at least two connected power supplies. + for device in Device.objects.filter(status=STATUS_ACTIVE): + connected_ports = 0 + for power_port in PowerPort.objects.filter(device=device): + if power_port.power_outlet is not None: + connected_ports += 1 + if power_port.connection_status == CONNECTION_STATUS_PLANNED: + self.log_warning( + device, + "Power connection for {} marked as planned".format(power_port.name) + ) + if connected_ports < 2: + self.log_failure( + device, + "{} connected power supplies found (2 needed)".format(connected_ports) + ) + else: + self.log_success(device) +``` + +As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. + +!!! warning + Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data. + +The following methods are available to log results within a report: + +* log(message) +* log_success(object, message=None) +* log_info(object, message) +* log_warning(object, message) +* log_failure(object, message) + +The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. + +Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report. + +## Running Reports + +### Via the Web UI + +Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.) + +Once a report has been run, its associated results will be included in the report view. + +### Via the API + +To run a report via the API, simply issue a POST request. Reports are identified by their module and class name. + +``` + POST /api/extras/reports/./ +``` + +Our example report above would be called as: + +``` + POST /api/extras/reports/devices.DeviceConnectionsReport/ +``` diff --git a/mkdocs.yml b/mkdocs.yml index f204749d5..e3ac9f50b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ pages: - 'IPAM': 'data-model/ipam.md' - 'Secrets': 'data-model/secrets.md' - 'Tenancy': 'data-model/tenancy.md' + - 'Virtualization': 'data-model/virtualization.md' - 'Extras': 'data-model/extras.md' - 'API': - 'Overview': 'api/overview.md' @@ -26,6 +27,10 @@ pages: - 'Examples': 'api/examples.md' - 'Shell': - 'Introduction': 'shell/intro.md' + - 'Miscellaneous': + - 'Reports': 'miscellaneous/reports.md' + - 'Development': + - 'Utility Views': 'development/utility-views.md' markdown_extensions: - admonition: diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 25df44bfd..3fb4eda0a 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -16,6 +16,9 @@ class CircuitsRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = CircuitsRootView +# Field choices +router.register(r'_choices', views.CircuitsFieldChoicesViewSet, base_name='field-choice') + # Providers router.register(r'providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 685fa8f9e..9defb5060 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -11,10 +11,20 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from extras.models import Graph, GRAPH_TYPE_PROVIDER from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (CircuitTermination, ['term_side']), + ) + + # # Providers # diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 2a2cd6843..1e60cad57 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -85,6 +85,17 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class CircuitTypeCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = CircuitType + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of circuit type', + } + + # # Circuits # diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 7dd72ad9d..636ce5205 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ # Circuit types url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), url(r'^circuit-types/(?P[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 345e3379d..4a49e713a 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -52,7 +52,7 @@ class ProviderView(View): class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_provider' model = Provider - form_class = forms.ProviderForm + model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' default_return_url = 'circuits:provider_list' @@ -104,7 +104,7 @@ class CircuitTypeListView(ObjectListView): class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittype' model = CircuitType - form_class = forms.CircuitTypeForm + model_form = forms.CircuitTypeForm def get_return_url(self, request, obj): return reverse('circuits:circuittype_list') @@ -114,6 +114,13 @@ class CircuitTypeEditView(CircuitTypeCreateView): permission_required = 'circuits.change_circuittype' +class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'circuits.add_circuittype' + model_form = forms.CircuitTypeCSVForm + table = tables.CircuitTypeTable + default_return_url = 'circuits:circuittype_list' + + class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' cls = CircuitType @@ -160,7 +167,7 @@ class CircuitView(View): class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuit' model = Circuit - form_class = forms.CircuitForm + model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' default_return_url = 'circuits:circuit_list' @@ -253,7 +260,7 @@ def circuit_terminations_swap(request, pk): class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittermination' model = CircuitTermination - form_class = forms.CircuitTerminationForm + model_form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' def alter_obj(self, obj, request, url_args, url_kwargs): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ebfb781e0..fa0f6dc14 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -16,6 +16,7 @@ from dcim.models import ( from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.models import Cluster # @@ -141,8 +142,8 @@ class RackSerializer(CustomFieldModelSerializer): class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', + 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', + 'u_height', 'desc_units', 'comments', 'custom_fields', ] @@ -159,8 +160,8 @@ class WritableRackSerializer(CustomFieldModelSerializer): class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', - 'comments', 'custom_fields', + 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This # prevents facility_id from being interpreted as a required field. @@ -403,7 +404,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer): class Meta: model = DeviceRole - fields = ['id', 'name', 'slug', 'color'] + fields = ['id', 'name', 'slug', 'color', 'vm_role'] class NestedDeviceRoleSerializer(serializers.ModelSerializer): @@ -446,6 +447,15 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'address'] +# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency +class NestedClusterSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') + + class Meta: + model = Cluster + fields = ['id', 'url', 'name'] + + class DeviceSerializer(CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() @@ -459,13 +469,14 @@ class DeviceSerializer(CustomFieldModelSerializer): primary_ip4 = DeviceIPAddressSerializer() primary_ip6 = DeviceIPAddressSerializer() parent_device = serializers.SerializerMethodField() + cluster = NestedClusterSerializer() class Meta: model = Device fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'comments', 'custom_fields', + 'cluster', 'comments', 'custom_fields', ] def get_parent_device(self, obj): @@ -485,7 +496,7 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): model = Device fields = [ 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', - 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields', + 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields', ] validators = [] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 6f16310e5..a03432c61 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -16,6 +16,9 @@ class DCIMRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = DCIMRootView +# Field choices +router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice') + # Sites router.register(r'regions', views.RegionViewSet) router.register(r'sites', views.SiteViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 56d4221da..af41a2a83 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -20,11 +20,27 @@ from dcim import filters from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from utilities.api import IsAuthenticatedOrLoginNotRequired, ServiceUnavailable, WritableSerializerMixin +from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin from .exceptions import MissingFilterException from . import serializers +# +# Field choices +# + +class DCIMFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (Device, ['face', 'status']), + (ConsolePort, ['connection_status']), + (Interface, ['form_factor']), + (InterfaceConnection, ['connection_status']), + (InterfaceTemplate, ['form_factor']), + (PowerPort, ['connection_status']), + (Rack, ['type', 'width']), + ) + + # # Regions # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 9c2993b60..b355e6c85 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -66,6 +66,9 @@ IFACE_FF_10GE_X2 = 1320 IFACE_FF_25GE_SFP28 = 1350 IFACE_FF_40GE_QSFP_PLUS = 1400 IFACE_FF_100GE_CFP = 1500 +IFACE_FF_100GE_CFP2 = 1510 +IFACE_FF_100GE_CFP4 = 1520 +IFACE_FF_100GE_CPAK = 1550 IFACE_FF_100GE_QSFP28 = 1600 # Wireless IFACE_FF_80211A = 2600 @@ -99,7 +102,7 @@ IFACE_FF_CHOICES = [ [ [IFACE_FF_VIRTUAL, 'Virtual'], [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], - ] + ], ], [ 'Ethernet (fixed)', @@ -122,6 +125,9 @@ IFACE_FF_CHOICES = [ [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], [IFACE_FF_100GE_CFP, 'CFP (100GE)'], + [IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'], + [IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'], + [IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'], [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], ] ], diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index a9f58d2a4..6e0c6fa3d 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -10,6 +10,7 @@ from django.db.models import Q from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter +from virtualization.models import Cluster from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection, @@ -158,7 +159,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Rack - fields = ['type', 'width', 'u_height', 'desc_units'] + fields = ['serial', 'type', 'width', 'u_height', 'desc_units'] def search(self, queryset, name, value): if not value.strip(): @@ -166,6 +167,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter( Q(name__icontains=value) | Q(facility_id__icontains=value) | + Q(serial__icontains=value.strip()) | Q(comments__icontains=value) ) @@ -408,6 +410,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=Rack.objects.all(), label='Rack (ID)', ) + cluster_id = NullableModelMultipleChoiceFilter( + queryset=Cluster.objects.all(), + label='VM cluster (ID)', + ) model = django_filters.ModelMultipleChoiceFilter( name='device_type__slug', queryset=DeviceType.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3df486a1b..820477e24 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -12,10 +12,10 @@ from ipam.models import IPAddress from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, ArrayFieldSelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField, - FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, - FilterTreeNodeMultipleChoiceField, + APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, + ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, + SlugField, FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField from .models import ( @@ -24,7 +24,7 @@ from .models import ( IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN, - Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES, ) @@ -43,15 +43,6 @@ def get_device_by_name_or_pk(name): return device -class DeviceComponentForm(BootstrapMixin, forms.Form): - """ - Allow inclusion of the parent device as context for limiting field choices. - """ - def __init__(self, device, *args, **kwargs): - self.device = device - super(DeviceComponentForm, self).__init__(*args, **kwargs) - - # # Regions # @@ -64,6 +55,28 @@ class RegionForm(BootstrapMixin, forms.ModelForm): fields = ['parent', 'name', 'slug'] +class RegionCSVForm(forms.ModelForm): + parent = forms.ModelChoiceField( + queryset=Region.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent region', + error_messages={ + 'invalid_choice': 'Region not found.', + } + ) + + class Meta: + model = Region + fields = [ + 'name', 'slug', 'parent', + ] + help_texts = { + 'name': 'Region name', + 'slug': 'URL-friendly slug', + } + + # # Sites # @@ -162,6 +175,27 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): fields = ['site', 'name', 'slug'] +class RackGroupCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + + class Meta: + model = RackGroup + fields = [ + 'site', 'name', 'slug', + ] + help_texts = { + 'name': 'Name of rack group', + 'slug': 'URL-friendly slug', + } + + class RackGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug') @@ -178,6 +212,18 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'color'] +class RackRoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = RackRole + fields = ['name', 'slug', 'color'] + help_texts = { + 'name': 'Name of rack role', + 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + } + + # # Racks # @@ -198,8 +244,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = Rack fields = [ - 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units', 'comments', + 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', + 'u_height', 'desc_units', 'comments', ] help_texts = { 'site': "The site at which the rack exists", @@ -259,7 +305,8 @@ class RackCSVForm(forms.ModelForm): class Meta: model = Rack fields = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', + 'desc_units', ] help_texts = { 'name': 'Rack name', @@ -287,6 +334,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False) + serial = forms.CharField(max_length=50, required=False, label='Serial Number') type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type') width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width') u_height = forms.IntegerField(required=False, label='Height (U)') @@ -294,7 +342,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField(widget=SmallTextarea) class Meta: - nullable_fields = ['group', 'tenant', 'role', 'comments'] + nullable_fields = ['group', 'tenant', 'role', 'serial', 'comments'] class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -374,6 +422,18 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class ManufacturerCSVForm(forms.ModelForm): + class Meta: + model = Manufacturer + fields = [ + 'name', 'slug' + ] + help_texts = { + 'name': 'Manufacturer name', + 'slug': 'URL-friendly slug', + } + + # # Device types # @@ -390,6 +450,37 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm): } +class DeviceTypeCSVForm(forms.ModelForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=True, + to_field_name='name', + help_text='Manufacturer name', + error_messages={ + 'invalid_choice': 'Manufacturer not found.', + } + ) + subdevice_role = CSVChoiceField( + choices=SUBDEVICE_ROLE_CHOICES, + required=False, + help_text='Parent/child status' + ) + interface_ordering = CSVChoiceField( + choices=IFACE_ORDERING_CHOICES, + required=False, + help_text='Interface ordering' + ) + + 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'] + help_texts = { + 'model': 'Model name', + 'slug': 'URL-friendly slug', + } + + class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) @@ -446,7 +537,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortTemplateCreateForm(DeviceComponentForm): +class ConsolePortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -460,7 +551,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortTemplateCreateForm(DeviceComponentForm): +class ConsoleServerPortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -474,7 +565,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerPortTemplateCreateForm(DeviceComponentForm): +class PowerPortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -488,7 +579,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletTemplateCreateForm(DeviceComponentForm): +class PowerOutletTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -502,7 +593,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): } -class InterfaceTemplateCreateForm(DeviceComponentForm): +class InterfaceTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) mgmt_only = forms.BooleanField(required=False, label='OOB Management') @@ -527,7 +618,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayTemplateCreateForm(DeviceComponentForm): +class DeviceBayTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -540,7 +631,19 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color'] + fields = ['name', 'slug', 'color', 'vm_role'] + + +class DeviceRoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = DeviceRole + fields = ['name', 'slug', 'color', 'vm_role'] + help_texts = { + 'name': 'Name of device role', + 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + } # @@ -555,6 +658,17 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'napalm_driver', 'rpc_client'] +class PlatformCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = Platform + fields = ['name', 'slug', 'napalm_driver'] + help_texts = { + 'name': 'Platform name', + } + + # # Devices # @@ -861,7 +975,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): serial = forms.CharField(max_length=50, required=False, label='Serial Number') class Meta: - nullable_fields = ['tenant', 'platform'] + nullable_fields = ['tenant', 'platform', 'serial'] def device_status_choices(): @@ -921,11 +1035,12 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') -class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm): - - class Meta: - model = Interface - fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description'] +class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): + form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + enabled = forms.BooleanField(required=False, initial=True) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + mgmt_only = forms.BooleanField(required=False, label='OOB Management') + description = forms.CharField(max_length=100, required=False) # @@ -942,7 +1057,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): } -class ConsolePortCreateForm(DeviceComponentForm): +class ConsolePortCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1111,7 +1226,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): } -class ConsoleServerPortCreateForm(DeviceComponentForm): +class ConsoleServerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1203,7 +1318,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): } -class PowerPortCreateForm(DeviceComponentForm): +class PowerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1372,7 +1487,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): } -class PowerOutletCreateForm(DeviceComponentForm): +class PowerOutletCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') @@ -1477,7 +1592,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): ) -class InterfaceCreateForm(DeviceComponentForm): +class InterfaceCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) @@ -1496,9 +1611,9 @@ class InterfaceCreateForm(DeviceComponentForm): super(InterfaceCreateForm, self).__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device - if self.device is not None: + if self.parent is not None: self.fields['lag'].queryset = Interface.objects.order_naturally().filter( - device=self.device, form_factor=IFACE_FF_LAG + device=self.parent, form_factor=IFACE_FF_LAG ) else: self.fields['lag'].queryset = Interface.objects.none() @@ -1724,7 +1839,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): } -class DeviceBayCreateForm(DeviceComponentForm): +class DeviceBayCreateForm(ComponentForm): name_pattern = ExpandableNameField(label='Name') diff --git a/netbox/dcim/migrations/0044_virtualization.py b/netbox/dcim/migrations/0044_virtualization.py new file mode 100644 index 000000000..b1e250bc2 --- /dev/null +++ b/netbox/dcim/migrations/0044_virtualization.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 14:15 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_virtualization'), + ('dcim', '0043_device_component_name_lengths'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'), + ), + migrations.AddField( + model_name='interface', + name='virtual_machine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'), + ), + migrations.AlterField( + model_name='interface', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'), + ), + ] diff --git a/netbox/dcim/migrations/0045_devicerole_vm_role.py b/netbox/dcim/migrations/0045_devicerole_vm_role.py new file mode 100644 index 000000000..775effaf2 --- /dev/null +++ b/netbox/dcim/migrations/0045_devicerole_vm_role.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-29 16:09 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0044_virtualization'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='vm_role', + field=models.BooleanField(default=True, help_text='Virtual machines may be assigned to this role', verbose_name='VM Role'), + ), + ] diff --git a/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py new file mode 100644 index 000000000..d04006524 --- /dev/null +++ b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 17:43 +from __future__ import unicode_literals + +from django.db import migrations +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0045_devicerole_vm_role'), + ] + + operations = [ + migrations.AlterField( + model_name='rack', + name='facility_id', + field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'), + ), + ] diff --git a/netbox/dcim/migrations/0047_more_100ge_form_factors.py b/netbox/dcim/migrations/0047_more_100ge_form_factors.py new file mode 100644 index 000000000..dafa81a54 --- /dev/null +++ b/netbox/dcim/migrations/0047_more_100ge_form_factors.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 18:43 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0046_rack_lengthen_facility_id'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/migrations/0048_rack_serial.py b/netbox/dcim/migrations/0048_rack_serial.py new file mode 100644 index 000000000..8e060c865 --- /dev/null +++ b/netbox/dcim/migrations/0048_rack_serial.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 18:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0047_more_100ge_form_factors'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 311f02e99..336ae0c0b 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -13,7 +13,6 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q, ObjectDoesNotExist -from django.db.models.expressions import RawSQL from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible @@ -27,6 +26,7 @@ from utilities.models import CreatedUpdatedModel from utilities.utils import csv_format from .constants import * from .fields import ASNField, MACAddressField +from .querysets import InterfaceQuerySet # @@ -44,6 +44,10 @@ class Region(MPTTModel): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = [ + 'name', 'slug', 'parent', + ] + class MPTTMeta: order_insertion_by = ['name'] @@ -53,6 +57,13 @@ class Region(MPTTModel): def get_absolute_url(self): return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + def to_csv(self): + return csv_format([ + self.name, + self.slug, + self.parent.name if self.parent else None, + ]) + # # Sites @@ -149,6 +160,10 @@ class RackGroup(models.Model): slug = models.SlugField() site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) + csv_headers = [ + 'site', 'name', 'slug', + ] + class Meta: ordering = ['site', 'name'] unique_together = [ @@ -162,6 +177,13 @@ class RackGroup(models.Model): def get_absolute_url(self): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) + def to_csv(self): + return csv_format([ + self.site, + self.name, + self.slug, + ]) + @python_2_unicode_compatible class RackRole(models.Model): @@ -195,11 +217,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): Each Rack is assigned to a Site and (optionally) a RackGroup. """ name = models.CharField(max_length=50) - facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID') + facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID') site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT) group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT) role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT) + serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type') width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width', help_text='Rail-to-rail width') @@ -214,7 +237,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): objects = RackManager() csv_headers = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', + 'desc_units', ] class Meta: @@ -444,6 +468,10 @@ class Manufacturer(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = [ + 'name', 'slug', + ] + class Meta: ordering = ['name'] @@ -453,6 +481,12 @@ class Manufacturer(models.Model): def get_absolute_url(self): return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) + def to_csv(self): + return csv_format([ + self.name, + self.slug, + ]) + @python_2_unicode_compatible class DeviceType(models.Model, CustomFieldModel): @@ -492,6 +526,11 @@ class DeviceType(models.Model, CustomFieldModel): comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + csv_headers = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', + 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', + ] + class Meta: ordering = ['manufacturer', 'model'] unique_together = [ @@ -511,6 +550,21 @@ class DeviceType(models.Model, CustomFieldModel): def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) + def to_csv(self): + return csv_format([ + self.manufacturer.name, + self.model, + self.slug, + self.part_number, + self.u_height, + self.is_full_depth, + self.is_console_server, + self.is_pdu, + self.is_network_device, + self.get_subdevice_role_display() if self.subdevice_role else None, + self.get_interface_ordering_display(), + ]) + def clean(self): # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have @@ -633,72 +687,6 @@ class PowerOutletTemplate(models.Model): return self.name -class InterfaceQuerySet(models.QuerySet): - - def order_naturally(self, method=IFACE_ORDERING_POSITION): - """ - Naturally order interfaces by their type and numeric position. The sort method must be one of the defined - IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). - - To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), - slot, subslot, position, channel, and virtual circuit: - - {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc} - - Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3 - would be parsed as follows: - - name = 'GigabitEthernet' - slot = 1 - subslot = 2 - position = 3 - subposition = 0 - channel = None - vc = 0 - - The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of - the prescribed fields. - """ - sql_col = '{}.name'.format(self.model._meta.db_table) - ordering = { - IFACE_ORDERING_POSITION: ( - '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_type', '_id', 'name', - ), - IFACE_ORDERING_NAME: ( - '_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name', - ), - }[method] - - TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" - ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" - SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)" - SUBSLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer)" - POSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer)" - SUBPOSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer)" - CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)" - VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)" - - fields = { - '_type': RawSQL(TYPE_RE.format(sql_col), []), - '_id': RawSQL(ID_RE.format(sql_col), []), - '_slot': RawSQL(SLOT_RE.format(sql_col), []), - '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), - '_position': RawSQL(POSITION_RE.format(sql_col), []), - '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), - '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), - '_vc': RawSQL(VC_RE.format(sql_col), []), - } - - return self.annotate(**fields).order_by(*ordering) - - def connectable(self): - """ - Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or - wireless). - """ - return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) - - @python_2_unicode_compatible class InterfaceTemplate(models.Model): """ @@ -743,11 +731,17 @@ class DeviceBayTemplate(models.Model): class DeviceRole(models.Model): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a - color to be used when displaying rack elevations. + color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to + virtual machines as well. """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) color = ColorField() + vm_role = models.BooleanField( + default=True, + verbose_name="VM Role", + help_text="Virtual machines may be assigned to this role" + ) class Meta: ordering = ['name'] @@ -828,6 +822,13 @@ class Device(CreatedUpdatedModel, CustomFieldModel): 'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IPv6' ) + cluster = models.ForeignKey( + to='virtualization.Cluster', + on_delete=models.SET_NULL, + related_name='devices', + blank=True, + null=True + ) comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') images = GenericRelation(ImageAttachment) @@ -934,6 +935,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel): 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6), }) + # A Device can only be assigned to a Cluster in the same Site (or no Site) + if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: + raise ValidationError({ + 'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site) + }) + def save(self, *args, **kwargs): is_new = not bool(self.pk) @@ -1177,13 +1184,26 @@ class PowerOutlet(models.Model): @python_2_unicode_compatible class Interface(models.Model): """ - A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation - of an InterfaceConnection. + A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other + Interface via the creation of an InterfaceConnection. """ - device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) + device = models.ForeignKey( + to='Device', + on_delete=models.CASCADE, + related_name='interfaces', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces', + null=True, + blank=True + ) lag = models.ForeignKey( - 'self', - models.SET_NULL, + to='self', + on_delete=models.SET_NULL, related_name='member_interfaces', null=True, blank=True, @@ -1212,6 +1232,18 @@ class Interface(models.Model): def clean(self): + # An Interface must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("An interface cannot belong to both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("An interface must belong to either a device or a virtual machine.") + + # VM interfaces must be virtual + if self.virtual_machine and self.form_factor is not IFACE_FF_VIRTUAL: + raise ValidationError({ + 'form_factor': "Virtual machines can only have virtual interfaces." + }) + # Virtual interfaces cannot be connected if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected: raise ValidationError({ @@ -1241,6 +1273,10 @@ class Interface(models.Model): ) }) + @property + def parent(self): + return self.device or self.virtual_machine + @property def is_virtual(self): return self.form_factor in VIRTUAL_IFACE_TYPES diff --git a/netbox/dcim/querysets.py b/netbox/dcim/querysets.py new file mode 100644 index 000000000..69dbf99dc --- /dev/null +++ b/netbox/dcim/querysets.py @@ -0,0 +1,72 @@ +from __future__ import unicode_literals + +from django.db.models import QuerySet +from django.db.models.expressions import RawSQL + +from .constants import IFACE_ORDERING_NAME, IFACE_ORDERING_POSITION, NONCONNECTABLE_IFACE_TYPES + + +class InterfaceQuerySet(QuerySet): + + def order_naturally(self, method=IFACE_ORDERING_POSITION): + """ + Naturally order interfaces by their type and numeric position. The sort method must be one of the defined + IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). + + To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), + slot, subslot, position, channel, and virtual circuit: + + {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc} + + Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3 + would be parsed as follows: + + name = 'GigabitEthernet' + slot = 1 + subslot = 2 + position = 3 + subposition = 0 + channel = None + vc = 0 + + The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of + the prescribed fields. + """ + sql_col = '{}.name'.format(self.model._meta.db_table) + ordering = { + IFACE_ORDERING_POSITION: ( + '_slot', '_subslot', '_position', '_subposition', '_channel', '_type', '_vc', '_id', 'name', + ), + IFACE_ORDERING_NAME: ( + '_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name', + ), + }[method] + + TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" + ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" + SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)" + SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer), 0)" + POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)" + SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)" + CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)" + VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)" + + fields = { + '_type': RawSQL(TYPE_RE.format(sql_col), []), + '_id': RawSQL(ID_RE.format(sql_col), []), + '_slot': RawSQL(SLOT_RE.format(sql_col), []), + '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), + '_position': RawSQL(POSITION_RE.format(sql_col), []), + '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), + '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), + '_vc': RawSQL(VC_RE.format(sql_col), []), + } + + return self.annotate(**fields).order_by(*ordering) + + def connectable(self): + """ + Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or + wireless). + """ + return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 427f0bb42..2a0127389 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -362,14 +362,14 @@ class DeviceRoleTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') - color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color') + 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='') class Meta(BaseTable.Meta): model = DeviceRole - fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions') + fields = ('pk', 'name', 'device_count', 'color', 'vm_role', 'slug', 'actions') # @@ -381,13 +381,12 @@ class PlatformTable(BaseTable): name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') slug = tables.Column(verbose_name='Slug') - rpc_client = tables.Column(accessor='get_rpc_client_display', orderable=False, verbose_name='RPC Client') actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') class Meta(BaseTable.Meta): model = Platform - fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions') + fields = ('pk', 'name', 'device_count', 'slug', 'napalm_driver', 'actions') # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 172f634fb..633cf9a8b 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ # Regions url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'), + url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'), url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), @@ -31,12 +32,14 @@ urlpatterns = [ # Rack groups url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), url(r'^rack-groups/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), # Rack roles url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'), + url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), @@ -62,12 +65,14 @@ urlpatterns = [ # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), url(r'^manufacturers/(?P[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), # Device types url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), @@ -102,12 +107,14 @@ urlpatterns = [ # Device roles url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'), url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), url(r'^device-roles/(?P[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), # Platforms url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'), + url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'), url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), url(r'^platforms/(?P[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), @@ -126,7 +133,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), - url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'), + url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'), url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d2c75fc24..387af4b61 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,4 @@ from __future__ import unicode_literals -from copy import deepcopy import re from natsort import natsorted from operator import attrgetter @@ -23,7 +22,8 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, + ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import ( @@ -60,87 +60,6 @@ def expand_pattern(string): yield "{0}{1}".format(lead, i) -class ComponentCreateView(View): - parent_model = None - parent_field = None - model = None - form = None - model_form = None - - def get(self, request, pk): - - parent = get_object_or_404(self.parent_model, pk=pk) - form = self.form(parent, initial=request.GET) - - return render(request, 'dcim/device_component_add.html', { - 'parent': parent, - 'component_type': self.model._meta.verbose_name, - 'form': form, - 'return_url': parent.get_absolute_url(), - }) - - def post(self, request, pk): - - parent = get_object_or_404(self.parent_model, pk=pk) - - form = self.form(parent, request.POST) - if form.is_valid(): - - new_components = [] - data = deepcopy(form.cleaned_data) - - for name in form.cleaned_data['name_pattern']: - component_data = { - self.parent_field: parent.pk, - 'name': name, - } - # Replace objects with their primary key to keep component_form.clean() happy - for k, v in data.items(): - if hasattr(v, 'pk'): - component_data[k] = v.pk - else: - component_data[k] = v - component_form = self.model_form(component_data) - if component_form.is_valid(): - new_components.append(component_form.save(commit=False)) - else: - for field, errors in component_form.errors.as_data().items(): - # Assign errors on the child form's name field to name_pattern on the parent form - if field == 'name': - field = 'name_pattern' - for e in errors: - form.add_error(field, '{}: {}'.format(name, ', '.join(e))) - - if not form.errors: - self.model.objects.bulk_create(new_components) - messages.success(request, "Added {} {} to {}.".format( - len(new_components), self.model._meta.verbose_name_plural, parent - )) - if '_addanother' in request.POST: - return redirect(request.path) - else: - return redirect(parent.get_absolute_url()) - - return render(request, 'dcim/device_component_add.html', { - 'parent': parent, - 'component_type': self.model._meta.verbose_name, - 'form': form, - 'return_url': parent.get_absolute_url(), - }) - - -class ComponentEditView(ObjectEditView): - - def get_return_url(self, request, obj): - return obj.device.get_absolute_url() - - -class ComponentDeleteView(ObjectDeleteView): - - def get_return_url(self, request, obj): - return obj.device.get_absolute_url() - - class BulkDisconnectView(View): """ An extendable view for disconnection console/power/interface components in bulk. @@ -192,7 +111,7 @@ class RegionListView(ObjectListView): class RegionCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_region' model = Region - form_class = forms.RegionForm + model_form = forms.RegionForm def get_return_url(self, request, obj): return reverse('dcim:region_list') @@ -202,6 +121,13 @@ class RegionEditView(RegionCreateView): permission_required = 'dcim.change_region' +class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_region' + model_form = forms.RegionCSVForm + table = tables.RegionTable + default_return_url = 'dcim:region_list' + + class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' cls = Region @@ -250,7 +176,7 @@ class SiteView(View): class SiteCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_site' model = Site - form_class = forms.SiteForm + model_form = forms.SiteForm template_name = 'dcim/site_edit.html' default_return_url = 'dcim:site_list' @@ -297,7 +223,7 @@ class RackGroupListView(ObjectListView): class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackgroup' model = RackGroup - form_class = forms.RackGroupForm + model_form = forms.RackGroupForm def get_return_url(self, request, obj): return reverse('dcim:rackgroup_list') @@ -307,6 +233,13 @@ class RackGroupEditView(RackGroupCreateView): permission_required = 'dcim.change_rackgroup' +class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_rackgroup' + model_form = forms.RackGroupCSVForm + table = tables.RackGroupTable + default_return_url = 'dcim:rackgroup_list' + + class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' cls = RackGroup @@ -329,7 +262,7 @@ class RackRoleListView(ObjectListView): class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackrole' model = RackRole - form_class = forms.RackRoleForm + model_form = forms.RackRoleForm def get_return_url(self, request, obj): return reverse('dcim:rackrole_list') @@ -339,6 +272,13 @@ class RackRoleEditView(RackRoleCreateView): permission_required = 'dcim.change_rackrole' +class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_rackrole' + model_form = forms.RackRoleCSVForm + table = tables.RackRoleTable + default_return_url = 'dcim:rackrole_list' + + class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackrole' cls = RackRole @@ -432,7 +372,7 @@ class RackView(View): class RackCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rack' model = Rack - form_class = forms.RackForm + model_form = forms.RackForm template_name = 'dcim/rack_edit.html' default_return_url = 'dcim:rack_list' @@ -488,7 +428,7 @@ class RackReservationListView(ObjectListView): class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackreservation' model = RackReservation - form_class = forms.RackReservationForm + model_form = forms.RackReservationForm def alter_obj(self, obj, request, args, kwargs): if not obj.pk: @@ -532,7 +472,7 @@ class ManufacturerListView(ObjectListView): class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_manufacturer' model = Manufacturer - form_class = forms.ManufacturerForm + model_form = forms.ManufacturerForm def get_return_url(self, request, obj): return reverse('dcim:manufacturer_list') @@ -542,6 +482,13 @@ class ManufacturerEditView(ManufacturerCreateView): permission_required = 'dcim.change_manufacturer' +class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_manufacturer' + model_form = forms.ManufacturerCSVForm + table = tables.ManufacturerTable + default_return_url = 'dcim:manufacturer_list' + + class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_manufacturer' cls = Manufacturer @@ -617,7 +564,7 @@ class DeviceTypeView(View): class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicetype' model = DeviceType - form_class = forms.DeviceTypeForm + model_form = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' default_return_url = 'dcim:devicetype_list' @@ -632,6 +579,13 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:devicetype_list' +class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_devicetype' + model_form = forms.DeviceTypeCSVForm + table = tables.DeviceTypeTable + default_return_url = 'dcim:devicetype_list' + + class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' cls = DeviceType @@ -662,6 +616,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView model = ConsolePortTemplate form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm + template_name = 'dcim/device_component_add.html' class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -680,6 +635,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea model = ConsoleServerPortTemplate form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm + template_name = 'dcim/device_component_add.html' class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -696,6 +652,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): model = PowerPortTemplate form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm + template_name = 'dcim/device_component_add.html' class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -712,6 +669,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView model = PowerOutletTemplate form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm + template_name = 'dcim/device_component_add.html' class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -728,6 +686,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): model = InterfaceTemplate form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm + template_name = 'dcim/device_component_add.html' class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -752,6 +711,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): model = DeviceBayTemplate form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm + template_name = 'dcim/device_component_add.html' class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -774,7 +734,7 @@ class DeviceRoleListView(ObjectListView): class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicerole' model = DeviceRole - form_class = forms.DeviceRoleForm + model_form = forms.DeviceRoleForm def get_return_url(self, request, obj): return reverse('dcim:devicerole_list') @@ -784,6 +744,13 @@ class DeviceRoleEditView(DeviceRoleCreateView): permission_required = 'dcim.change_devicerole' +class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_devicerole' + model_form = forms.DeviceRoleCSVForm + table = tables.DeviceRoleTable + default_return_url = 'dcim:devicerole_list' + + class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicerole' cls = DeviceRole @@ -805,7 +772,7 @@ class PlatformListView(ObjectListView): class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_platform' model = Platform - form_class = forms.PlatformForm + model_form = forms.PlatformForm def get_return_url(self, request, obj): return reverse('dcim:platform_list') @@ -815,6 +782,13 @@ class PlatformEditView(PlatformCreateView): permission_required = 'dcim.change_platform' +class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_platform' + model_form = forms.PlatformCSVForm + table = tables.PlatformTable + default_return_url = 'dcim:platform_list' + + class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_platform' cls = Platform @@ -963,7 +937,7 @@ class DeviceConfigView(PermissionRequiredMixin, View): class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_device' model = Device - form_class = forms.DeviceForm + model_form = forms.DeviceForm template_name = 'dcim/device_edit.html' default_return_url = 'dcim:device_list' @@ -1035,6 +1009,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): model = ConsolePort form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_consoleport') @@ -1116,12 +1091,14 @@ def consoleport_disconnect(request, pk): class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort - form_class = forms.ConsolePortForm + parent_field = 'device' + model_form = forms.ConsolePortForm class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_consoleport' model = ConsolePort + parent_field = 'device' class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1149,6 +1126,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): model = ConsoleServerPort form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_consoleserverport') @@ -1233,12 +1211,14 @@ def consoleserverport_disconnect(request, pk): class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort - form_class = forms.ConsoleServerPortForm + parent_field = 'device' + model_form = forms.ConsoleServerPortForm class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_consoleserverport' model = ConsoleServerPort + parent_field = 'device' class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1268,6 +1248,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): model = PowerPort form = forms.PowerPortCreateForm model_form = forms.PowerPortForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_powerport') @@ -1349,12 +1330,14 @@ def powerport_disconnect(request, pk): class PowerPortEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_powerport' model = PowerPort - form_class = forms.PowerPortForm + parent_field = 'device' + model_form = forms.PowerPortForm class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_powerport' model = PowerPort + parent_field = 'device' class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1382,6 +1365,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): model = PowerOutlet form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm + template_name = 'dcim/device_component_add.html' @permission_required('dcim.change_poweroutlet') @@ -1466,12 +1450,14 @@ def poweroutlet_disconnect(request, pk): class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet - form_class = forms.PowerOutletForm + parent_field = 'device' + model_form = forms.PowerOutletForm class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_poweroutlet' model = PowerOutlet + parent_field = 'device' class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1503,17 +1489,20 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): model = Interface form = forms.InterfaceCreateForm model_form = forms.InterfaceForm + template_name = 'dcim/device_component_add.html' class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_interface' model = Interface - form_class = forms.InterfaceForm + parent_field = 'device' + model_form = forms.InterfaceForm class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_interface' model = Interface + parent_field = 'device' class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1554,17 +1543,20 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): model = DeviceBay form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm + template_name = 'dcim/device_component_add.html' class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_devicebay' model = DeviceBay - form_class = forms.DeviceBayForm + parent_field = 'device' + model_form = forms.DeviceBayForm class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_devicebay' model = DeviceBay + parent_field = 'device' @permission_required('dcim.change_devicebay') @@ -1625,109 +1617,73 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # -# Bulk device component creation +# Bulk Device component creation # -class DeviceBulkAddComponentView(View): - """ - Add one or more components (e.g. interfaces) to a selected set of Devices. - """ - form = forms.DeviceBulkAddComponentForm - model = None - model_form = None - - def get(self): - return redirect('dcim:device_list') - - def post(self, request): - - # Are we editing *all* objects in the queryset or just a selected subset? - if request.POST.get('_all'): - pk_list = [obj.pk for obj in filters.DeviceFilter(request.GET, Device.objects.all())] - else: - pk_list = [int(pk) for pk in request.POST.getlist('pk')] - - if '_create' in request.POST: - form = self.form(request.POST) - if form.is_valid(): - - new_components = [] - data = deepcopy(form.cleaned_data) - for device in data['pk']: - - names = data['name_pattern'] - for name in names: - component_data = { - 'device': device.pk, - 'name': name, - } - component_data.update(data) - component_form = self.model_form(component_data) - if component_form.is_valid(): - new_components.append(component_form.save(commit=False)) - else: - for field, errors in component_form.errors.as_data().items(): - for e in errors: - form.add_error(field, '{} {}: {}'.format(device, name, ', '.join(e))) - - if not form.errors: - self.model.objects.bulk_create(new_components) - messages.success(request, "Added {} {} to {} devices.".format( - len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk']) - )) - return redirect('dcim:device_list') - - else: - form = self.form(initial={'pk': pk_list}) - - selected_devices = Device.objects.filter(pk__in=pk_list) - if not selected_devices: - messages.warning(request, "No devices were selected.") - return redirect('dcim:device_list') - - return render(request, 'dcim/device_bulk_add_component.html', { - 'form': form, - 'component_name': self.model._meta.verbose_name_plural, - 'selected_devices': selected_devices, - 'return_url': reverse('dcim:device_list'), - }) - - -class DeviceBulkAddConsolePortView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_consoleport' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = ConsolePort model_form = forms.ConsolePortForm + table = tables.DeviceTable + default_return_url = 'dcim:device_list' -class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_consoleserverport' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = ConsoleServerPort model_form = forms.ConsoleServerPortForm + table = tables.DeviceTable + default_return_url = 'dcim:device_list' -class DeviceBulkAddPowerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_powerport' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = PowerPort model_form = forms.PowerPortForm + table = tables.DeviceTable + default_return_url = 'dcim:device_list' -class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_poweroutlet' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = PowerOutlet model_form = forms.PowerOutletForm + table = tables.DeviceTable + default_return_url = 'dcim:device_list' -class DeviceBulkAddInterfaceView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_interface' + parent_model = Device + parent_field = 'device' form = forms.DeviceBulkAddInterfaceForm model = Interface model_form = forms.InterfaceForm + table = tables.DeviceTable + default_return_url = 'dcim:device_list' -class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, DeviceBulkAddComponentView): +class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_devicebay' + parent_model = Device + parent_field = 'device' + form = forms.DeviceBulkAddComponentForm model = DeviceBay model_form = forms.DeviceBayForm + table = tables.DeviceTable + default_return_url = 'dcim:device_list' # @@ -1871,7 +1827,8 @@ class InterfaceConnectionsListView(ObjectListView): class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem - form_class = forms.InventoryItemForm + parent_field = 'device' + model_form = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): if 'device' in url_kwargs: @@ -1882,3 +1839,4 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem + parent_field = 'device' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 9d396dd3d..752a6ccaa 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -7,6 +7,18 @@ from django.utils.safestring import mark_safe from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction +def order_content_types(field): + """ + Order the list of available ContentTypes by application + """ + queryset = field.queryset.order_by('app_label', 'model') + field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] + + +# +# Custom fields +# + class CustomFieldForm(forms.ModelForm): class Meta: @@ -16,9 +28,7 @@ class CustomFieldForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(CustomFieldForm, self).__init__(*args, **kwargs) - # Organize the available ContentTypes - queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model') - self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] + order_content_types(self.fields['obj_type']) class CustomFieldChoiceAdmin(admin.TabularInline): @@ -36,16 +46,43 @@ class CustomFieldAdmin(admin.ModelAdmin): return ', '.join([ct.name for ct in obj.obj_type.all()]) +# +# Graphs +# + @admin.register(Graph) class GraphAdmin(admin.ModelAdmin): list_display = ['name', 'type', 'weight', 'source'] +# +# Export templates +# + +class ExportTemplateForm(forms.ModelForm): + + class Meta: + model = ExportTemplate + exclude = [] + + def __init__(self, *args, **kwargs): + super(ExportTemplateForm, self).__init__(*args, **kwargs) + + # Format ContentType choices + order_content_types(self.fields['content_type']) + self.fields['content_type'].choices.insert(0, ('', '---------')) + + @admin.register(ExportTemplate) class ExportTemplateAdmin(admin.ModelAdmin): list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension'] + form = ExportTemplateForm +# +# Topology maps +# + @admin.register(TopologyMap) class TopologyMapAdmin(admin.ModelAdmin): list_display = ['name', 'slug', 'site'] @@ -54,6 +91,10 @@ class TopologyMapAdmin(admin.ModelAdmin): } +# +# User actions +# + @admin.register(UserAction) class UserActionAdmin(admin.ModelAdmin): actions = None diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0eeab49ec..8998b509b 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -7,7 +7,7 @@ from rest_framework import serializers from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.models import Device, Rack, Site from extras.models import ( - ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction, + ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, ReportResult, TopologyMap, UserAction, ) from users.api.serializers import NestedUserSerializer from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer @@ -127,6 +127,41 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer): return data +# +# Reports +# + +class ReportResultSerializer(serializers.ModelSerializer): + + class Meta: + model = ReportResult + fields = ['created', 'user', 'failed', 'data'] + + +class NestedReportResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:report-detail', + lookup_field='report', + lookup_url_kwarg='pk' + ) + + class Meta: + model = ReportResult + fields = ['url', 'created', 'user', 'failed'] + + +class ReportSerializer(serializers.Serializer): + module = serializers.CharField(max_length=255) + name = serializers.CharField(max_length=255) + description = serializers.CharField(max_length=255, required=False) + test_methods = serializers.ListField(child=serializers.CharField(max_length=255)) + result = NestedReportResultSerializer() + + +class ReportDetailSerializer(ReportSerializer): + result = ReportResultSerializer() + + # # User actions # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index c5268318c..cc278644d 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -16,6 +16,9 @@ class ExtrasRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = ExtrasRootView +# Field choices +router.register(r'_choices', views.ExtrasFieldChoicesViewSet, base_name='field-choice') + # Graphs router.register(r'graphs', views.GraphViewSet) @@ -28,6 +31,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet) # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) +# Reports +router.register(r'reports', views.ReportViewSet, base_name='report') + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 37112f2c6..689780879 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,18 +1,36 @@ from __future__ import unicode_literals from rest_framework.decorators import detail_route -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet from django.contrib.contenttypes.models import ContentType -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction -from utilities.api import WritableSerializerMixin +from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from extras.reports import get_report, get_reports +from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (CustomField, ['type']), + (Graph, ['type']), + ) + + +# +# Custom fields +# + class CustomFieldModelViewSet(ModelViewSet): """ Include the applicable set of CustomFields in the ModelViewSet context. @@ -43,6 +61,10 @@ class CustomFieldModelViewSet(ModelViewSet): return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') +# +# Graphs +# + class GraphViewSet(WritableSerializerMixin, ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer @@ -50,12 +72,20 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet): filter_class = filters.GraphFilter +# +# Export templates +# + class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer filter_class = filters.ExportTemplateFilter +# +# Topology maps +# + class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer @@ -82,12 +112,96 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): return response +# +# Image attachments +# + class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer write_serializer_class = serializers.WritableImageAttachmentSerializer +# +# Reports +# + +class ReportViewSet(ViewSet): + permission_classes = [IsAuthenticatedOrLoginNotRequired] + _ignore_model_permissions = True + exclude_from_schema = True + lookup_value_regex = '[^/]+' # Allow dots + + def _retrieve_report(self, pk): + + # Read the PK as "." + if '.' not in pk: + raise Http404 + module_name, report_name = pk.split('.', 1) + + # Raise a 404 on an invalid Report module/name + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + return report + + def list(self, request): + """ + Compile all reports and their related results (if any). Result data is deferred in the list view. + """ + report_list = [] + + # Iterate through all available Reports. + for module_name, reports in get_reports(): + for report in reports: + + # Attach the relevant ReportResult (if any) to each Report. + report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first() + report_list.append(report) + + serializer = serializers.ReportSerializer(report_list, many=True, context={ + 'request': request, + }) + + return Response(serializer.data) + + def retrieve(self, request, pk): + """ + Retrieve a single Report identified as ".". + """ + + # Retrieve the Report and ReportResult, if any. + report = self._retrieve_report(pk) + report.result = ReportResult.objects.filter(report=report.full_name).first() + + serializer = serializers.ReportDetailSerializer(report) + + return Response(serializer.data) + + @detail_route(methods=['post']) + def run(self, request, pk): + """ + Run a Report and create a new ReportResult, overwriting any previous result for the Report. + """ + + # Check that the user has permission to run reports. + if not request.user.has_perm('extras.add_reportresult'): + raise PermissionDenied("This user does not have permission to run reports.") + + # Retrieve and run the Report. This will create a new ReportResult. + report = self._retrieve_report(pk) + report.run() + + serializer = serializers.ReportDetailSerializer(report) + + return Response(serializer.data) + + +# +# User activity +# + class RecentActivityViewSet(ReadOnlyModelViewSet): """ List all UserActions to provide a log of recent activity. diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 86da90895..808413ba2 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -3,10 +3,11 @@ from __future__ import unicode_literals # Models which support custom fields CUSTOMFIELD_MODELS = ( + 'provider', 'circuit', # Circuits 'site', 'rack', 'devicetype', 'device', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM - 'provider', 'circuit', # Circuits - 'tenant', # Tenants + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization ) # Custom field types @@ -37,10 +38,12 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates EXPORTTEMPLATE_MODELS = [ - 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM 'provider', 'circuit', # Circuits - 'tenant', # Tenants + 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM + 'consoleport', 'powerport', 'interfaceconnection', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization ] # User action types @@ -60,3 +63,17 @@ ACTION_CHOICES = ( (ACTION_DELETE, 'deleted'), (ACTION_BULK_DELETE, 'bulk deleted'), ) + +# Report logging levels +LOG_DEFAULT = 0 +LOG_SUCCESS = 10 +LOG_INFO = 20 +LOG_WARNING = 30 +LOG_FAILURE = 40 +LOG_LEVEL_CODES = { + LOG_DEFAULT: 'default', + LOG_SUCCESS: 'success', + LOG_INFO: 'info', + LOG_WARNING: 'warning', + LOG_FAILURE: 'failure', +} diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index a50b1384d..d016c7f57 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand from django.db.models import Model -APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users'] +APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] BANNER_TEXT = """### NetBox interactive shell ({node}) ### Python {python} | Django {django} | NetBox {netbox} diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py new file mode 100644 index 000000000..9adf6b130 --- /dev/null +++ b/netbox/extras/management/commands/runreport.py @@ -0,0 +1,48 @@ +from __future__ import unicode_literals + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from extras.models import ReportResult +from extras.reports import get_reports + + +class Command(BaseCommand): + help = "Run a report to validate data in NetBox" + + def add_arguments(self, parser): + parser.add_argument('reports', nargs='+', help="Report(s) to run") + # parser.add_argument('--verbose', action='store_true', default=False, help="Print all logs") + + def handle(self, *args, **options): + + # Gather all available reports + reports = get_reports() + + # Run reports + for module_name, report_list in reports: + for report in report_list: + if module_name in options['reports'] or report.full_namel in options['reports']: + + # Run the report and create a new ReportResult + self.stdout.write( + "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) + ) + report.run() + + # Report on success/failure + status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') + for test_name, attrs in report.result.data.items(): + self.stdout.write( + "\t{}: {} success, {} info, {} warning, {} failure".format( + test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure'] + ) + ) + self.stdout.write( + "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) + ) + + # Wrap things up + self.stdout.write( + "[{:%H:%M:%S}] Finished".format(timezone.now()) + ) diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py new file mode 100644 index 000000000..c9fc16cc3 --- /dev/null +++ b/netbox/extras/migrations/0008_reports.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-26 21:25 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0007_unicode_literals'), + ] + + operations = [ + migrations.CreateModel( + name='ReportResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('report', models.CharField(max_length=255, unique=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('failed', models.BooleanField()), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['report'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 4afc3afcf..5181e88e9 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -6,6 +6,7 @@ import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField from django.core.validators import ValidationError from django.db import models from django.db.models import Q @@ -388,6 +389,24 @@ class ImageAttachment(models.Model): return None +# +# Report results +# + +class ReportResult(models.Model): + """ + This model stores the results from running a user-defined report. + """ + report = models.CharField(max_length=255, unique=True) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + failed = models.BooleanField() + data = JSONField() + + class Meta: + ordering = ['report'] + + # # User actions # diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py new file mode 100644 index 000000000..409aaaf30 --- /dev/null +++ b/netbox/extras/reports.py @@ -0,0 +1,179 @@ +from __future__ import unicode_literals +from collections import OrderedDict +import importlib +import inspect +import pkgutil + +from django.conf import settings +from django.utils import timezone + +from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING +from .models import ReportResult + + +def is_report(obj): + """ + Returns True if the given object is a Report. + """ + if obj in Report.__subclasses__(): + return True + return False + + +def get_report(module_name, report_name): + """ + Return a specific report from within a module. + """ + module = importlib.import_module('reports.{}'.format(module_name)) + report = getattr(module, report_name, None) + if report is None: + return None + return report() + + +def get_reports(): + """ + Compile a list of all reports available across all modules in the reports path. Returns a list of tuples: + + [ + (module_name, (report, report, report, ...)), + (module_name, (report, report, report, ...)), + ... + ] + """ + module_list = [] + + # Iterate through all modules within the reports path. These are the user-created files in which reports are + # defined. + for importer, module_name, is_pkg in pkgutil.walk_packages([settings.REPORTS_ROOT]): + module = importlib.import_module('reports.{}'.format(module_name)) + report_list = [cls() for _, cls in inspect.getmembers(module, is_report)] + module_list.append((module_name, report_list)) + + return module_list + + +class Report(object): + """ + NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each + report must have one or more test methods named `test_*`. + + The `_results` attribute of a completed report will take the following form: + + { + 'test_bar': { + 'failures': 42, + 'log': [ + (, , , ), + ... + ] + }, + 'test_foo': { + 'failures': 0, + 'log': [ + (, , , ), + ... + ] + } + } + """ + description = None + + def __init__(self): + + self._results = OrderedDict() + self.active_test = None + self.failed = False + + # Compile test methods and initialize results skeleton + test_methods = [] + for method in dir(self): + if method.startswith('test_') and callable(getattr(self, method)): + test_methods.append(method) + self._results[method] = OrderedDict([ + ('success', 0), + ('info', 0), + ('warning', 0), + ('failure', 0), + ('log', []), + ]) + if not test_methods: + raise Exception("A report must contain at least one test method.") + self.test_methods = test_methods + + @property + def module(self): + return self.__module__.rsplit('.', 1)[1] + + @property + def name(self): + return self.__class__.__name__ + + @property + def full_name(self): + return '.'.join([self.module, self.name]) + + def _log(self, obj, message, level=LOG_DEFAULT): + """ + Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. + """ + if level not in LOG_LEVEL_CODES: + raise Exception("Unknown logging level: {}".format(level)) + self._results[self.active_test]['log'].append(( + timezone.now().isoformat(), + LOG_LEVEL_CODES.get(level), + str(obj) if obj else None, + obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None, + message, + )) + + def log(self, message): + """ + Log a message which is not associated with a particular object. + """ + self._log(None, message, level=LOG_DEFAULT) + + def log_success(self, obj, message=None): + """ + Record a successful test against an object. Logging a message is optional. + """ + if message: + self._log(obj, message, level=LOG_SUCCESS) + self._results[self.active_test]['success'] += 1 + + def log_info(self, obj, message): + """ + Log an informational message. + """ + self._log(obj, message, level=LOG_INFO) + self._results[self.active_test]['info'] += 1 + + def log_warning(self, obj, message): + """ + Log a warning. + """ + self._log(obj, message, level=LOG_WARNING) + self._results[self.active_test]['warning'] += 1 + + def log_failure(self, obj, message): + """ + Log a failure. Calling this method will automatically mark the report as failed. + """ + self._log(obj, message, level=LOG_FAILURE) + self._results[self.active_test]['failure'] += 1 + self.failed = True + + def run(self): + """ + Run the report and return its results. Each test method will be executed in order. + """ + for method_name in self.test_methods: + self.active_test = method_name + test_method = getattr(self, method_name) + test_method() + + # Delete any previous ReportResult and create a new one to record the result. + ReportResult.objects.filter(report=self.full_name).delete() + result = ReportResult(report=self.full_name, failed=self.failed, data=self._results) + result.save() + self.result = result diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py index 613bdb743..d2d4835ea 100644 --- a/netbox/extras/rpc.py +++ b/netbox/extras/rpc.py @@ -20,19 +20,6 @@ class RPCClient(object): except AttributeError: raise Exception("Specified device ({}) does not have a primary IP defined.".format(device)) - def get_lldp_neighbors(self): - """ - Returns a list of dictionaries, each representing an LLDP neighbor adjacency. - - { - 'local-interface': , - 'name': , - 'remote-interface': , - 'chassis-id': , - } - """ - raise NotImplementedError("Feature not implemented for this platform.") - def get_inventory(self): """ Returns a dictionary representing the device chassis and installed inventory items. @@ -123,30 +110,6 @@ class JunosNC(RPCClient): # Close the connection to the device self.manager.close_session() - def get_lldp_neighbors(self): - - rpc_reply = self.manager.dispatch('get-lldp-neighbors-information') - lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information'] - - result = [] - for neighbor_raw in lldp_neighbors_raw: - neighbor = dict() - neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id') - name = neighbor_raw.get('lldp-remote-system-name') - if name: - neighbor['name'] = name.split('.')[0] # Split hostname from domain if one is present - else: - neighbor['name'] = '' - try: - neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description'] - except KeyError: - # Older versions of Junos report on interface ID instead of description - neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id') - neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id') - result.append(neighbor) - - return result - def get_inventory(self): def glean_items(node, depth=0): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f980158e8..1ac7fce6c 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -12,4 +12,9 @@ urlpatterns = [ url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + # Reports + url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), + url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), + url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), + ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 06c852d68..e92b28187 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,17 +1,27 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin -from django.shortcuts import get_object_or_404 +from django.contrib import messages +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.safestring import mark_safe +from django.views.generic import View +from utilities.forms import ConfirmationForm from utilities.views import ObjectDeleteView, ObjectEditView from .forms import ImageAttachmentForm -from .models import ImageAttachment +from .models import ImageAttachment, ReportResult, UserAction +from .reports import get_report, get_reports +# +# Image attachments +# + class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'extras.change_imageattachment' model = ImageAttachment - form_class = ImageAttachmentForm + model_form = ImageAttachmentForm def alter_obj(self, imageattachment, request, args, kwargs): if not imageattachment.pk: @@ -30,3 +40,79 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): def get_return_url(self, request, imageattachment): return imageattachment.parent.get_absolute_url() + + +# +# Reports +# + +class ReportListView(View): + """ + Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. + """ + + def get(self, request): + + reports = get_reports() + results = {r.report: r for r in ReportResult.objects.all()} + + ret = [] + for module, report_list in reports: + module_reports = [] + for report in report_list: + report.result = results.get(report.full_name, None) + module_reports.append(report) + ret.append((module, module_reports)) + + return render(request, 'extras/report_list.html', { + 'reports': ret, + }) + + +class ReportView(View): + """ + Display a single Report and its associated ReportResult (if any). + """ + + def get(self, request, name): + + # Retrieve the Report by "." + module_name, report_name = name.split('.') + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + # Attach the ReportResult (if any) + report.result = ReportResult.objects.filter(report=report.full_name).first() + + return render(request, 'extras/report.html', { + 'report': report, + 'run_form': ConfirmationForm(), + }) + + +class ReportRunView(PermissionRequiredMixin, View): + """ + Run a Report and record a new ReportResult. + """ + permission_required = 'extras.add_reportresult' + + def post(self, request, name): + + # Retrieve the Report by "." + module_name, report_name = name.split('.') + report = get_report(module_name, report_name) + if report is None: + raise Http404 + + form = ConfirmationForm(request.POST) + if form.is_valid(): + + # Run the Report. A new ReportResult is created. + report.run() + result = 'failed' if report.failed else 'passed' + msg = "Ran report {} ({})".format(report.full_name, result) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_create(request.user, report.result, msg) + + return redirect('extras:report', name=report.full_name) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3ef152ebe..a5b64fca6 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -12,6 +12,7 @@ from ipam.models import ( ) from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.api.serializers import NestedVirtualMachineSerializer # @@ -295,12 +296,13 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() + virtual_machine = NestedVirtualMachineSerializer() protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) ipaddresses = NestedIPAddressSerializer(many=True) class Meta: model = Service - fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] # TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError. @@ -308,4 +310,4 @@ class WritableServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e6b1bb13d..ca046cd93 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -16,6 +16,9 @@ class IPAMRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = IPAMRootView +# Field choices +router.register(r'_choices', views.IPAMFieldChoicesViewSet, base_name='field-choice') + # VRFs router.register(r'vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index abff09c15..dabb518ae 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -12,10 +12,24 @@ from django.shortcuts import get_object_or_404 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters from extras.api.views import CustomFieldModelViewSet -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class IPAMFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (Aggregate, ['family']), + (Prefix, ['family', 'status']), + (IPAddress, ['family', 'status', 'role']), + (VLAN, ['status']), + (Service, ['protocol']), + ) + + # # VRFs # @@ -134,7 +148,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): # class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet): - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') + queryset = IPAddress.objects.select_related( + 'vrf__tenant', 'tenant', 'nat_inside' + ).prefetch_related( + 'interface__device' + ) serializer_class = serializers.IPAddressSerializer write_serializer_class = serializers.WritableIPAddressSerializer filter_class = filters.IPAddressFilter diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 3beb18823..8413d38c3 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -39,6 +39,7 @@ IPADDRESS_ROLE_VIP = 40 IPADDRESS_ROLE_VRRP = 41 IPADDRESS_ROLE_HSRP = 42 IPADDRESS_ROLE_GLBP = 43 +IPADDRESS_ROLE_CARP = 44 IPADDRESS_ROLE_CHOICES = ( (IPADDRESS_ROLE_LOOPBACK, 'Loopback'), (IPADDRESS_ROLE_SECONDARY, 'Secondary'), @@ -47,6 +48,7 @@ IPADDRESS_ROLE_CHOICES = ( (IPADDRESS_ROLE_VRRP, 'VRRP'), (IPADDRESS_ROLE_HSRP, 'HSRP'), (IPADDRESS_ROLE_GLBP, 'GLBP'), + (IPADDRESS_ROLE_CARP, 'CARP'), ) # VLAN statuses diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 045ca1df4..05ab873a3 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -10,6 +10,7 @@ from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from virtualization.models import VirtualMachine from .models import ( Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, @@ -237,6 +238,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + name='interface__virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + name='interface__virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) interface_id = django_filters.ModelMultipleChoiceFilter( queryset=Interface.objects.all(), label='Interface (ID)', @@ -372,6 +384,16 @@ class ServiceFilter(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + name='virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) class Meta: model = Service diff --git a/netbox/ipam/fixtures/ipam.json b/netbox/ipam/fixtures/ipam.json index 1a981a941..10e22b2d7 100644 --- a/netbox/ipam/fixtures/ipam.json +++ b/netbox/ipam/fixtures/ipam.json @@ -70,7 +70,7 @@ "family": 4, "address": "10.0.255.1/32", "vrf": null, - "interface": 3, + "interface_id": 3, "nat_inside": null, "description": "" } @@ -84,7 +84,7 @@ "family": 4, "address": "169.254.254.1/31", "vrf": null, - "interface": 4, + "interface_id": 4, "nat_inside": null, "description": "" } @@ -98,7 +98,7 @@ "family": 4, "address": "10.0.255.2/32", "vrf": null, - "interface": 185, + "interface_id": 185, "nat_inside": null, "description": "" } @@ -112,7 +112,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, - "interface": 213, + "interface_id": 213, "nat_inside": null, "description": "" } @@ -126,7 +126,7 @@ "family": 4, "address": "10.0.254.1/24", "vrf": null, - "interface": 12, + "interface_id": 12, "nat_inside": null, "description": "" } @@ -140,7 +140,7 @@ "family": 4, "address": "10.15.21.1/31", "vrf": null, - "interface": 218, + "interface_id": 218, "nat_inside": null, "description": "" } @@ -154,7 +154,7 @@ "family": 4, "address": "10.15.21.2/31", "vrf": null, - "interface": 9, + "interface_id": 9, "nat_inside": null, "description": "" } @@ -168,7 +168,7 @@ "family": 4, "address": "10.15.22.1/31", "vrf": null, - "interface": 8, + "interface_id": 8, "nat_inside": null, "description": "" } @@ -182,7 +182,7 @@ "family": 4, "address": "10.15.20.1/31", "vrf": null, - "interface": 7, + "interface_id": 7, "nat_inside": null, "description": "" } @@ -196,7 +196,7 @@ "family": 4, "address": "10.16.20.1/31", "vrf": null, - "interface": 216, + "interface_id": 216, "nat_inside": null, "description": "" } @@ -210,7 +210,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, - "interface": 206, + "interface_id": 206, "nat_inside": null, "description": "" } @@ -224,7 +224,7 @@ "family": 4, "address": "10.16.22.1/31", "vrf": null, - "interface": 217, + "interface_id": 217, "nat_inside": null, "description": "" } @@ -238,7 +238,7 @@ "family": 4, "address": "10.16.22.2/31", "vrf": null, - "interface": 205, + "interface_id": 205, "nat_inside": null, "description": "" } @@ -252,7 +252,7 @@ "family": 4, "address": "10.16.20.2/31", "vrf": null, - "interface": 211, + "interface_id": 211, "nat_inside": null, "description": "" } @@ -266,7 +266,7 @@ "family": 4, "address": "10.15.22.2/31", "vrf": null, - "interface": 212, + "interface_id": 212, "nat_inside": null, "description": "" } @@ -280,7 +280,7 @@ "family": 4, "address": "10.0.254.2/32", "vrf": null, - "interface": 188, + "interface_id": 188, "nat_inside": null, "description": "" } @@ -294,7 +294,7 @@ "family": 4, "address": "169.254.1.1/31", "vrf": null, - "interface": 200, + "interface_id": 200, "nat_inside": null, "description": "" } @@ -308,7 +308,7 @@ "family": 4, "address": "169.254.1.2/31", "vrf": null, - "interface": 194, + "interface_id": 194, "nat_inside": null, "description": "" } diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 4152b44c1..5ed405c8d 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -13,6 +13,7 @@ from utilities.forms import ( ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, ) +from virtualization.models import VirtualMachine from .models import ( Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, @@ -96,6 +97,17 @@ class RIRForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'is_private'] +class RIRCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = RIR + fields = ['name', 'slug', 'is_private'] + help_texts = { + 'name': 'RIR name', + } + + class RIRFilterForm(BootstrapMixin, forms.Form): is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[ ('', '---------'), @@ -168,6 +180,17 @@ class RoleForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class RoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = Role + fields = ['name', 'slug'] + help_texts = { + 'name': 'Role name', + } + + # # Prefixes # @@ -374,50 +397,9 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): # class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm): - interface_site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='Site', - widget=forms.Select( - attrs={'filter-for': 'interface_rack'} - ) - ) - interface_rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'interface_site'), - ), - required=False, - label='Rack', - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{interface_site}}', - display_field='display_name', - attrs={'filter-for': 'interface_device', 'nullable': 'true'} - ) - ) - interface_device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'interface_site'), - ('rack', 'interface_rack'), - ), - required=False, - label='Device', - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}', - display_field='display_name', - attrs={'filter-for': 'interface'} - ) - ) - interface = ChainedModelChoiceField( + interface = forms.ModelChoiceField( queryset=Interface.objects.all(), - chains=( - ('device', 'interface_device'), - ), - required=False, - widget=APISelect( - api_url='/api/dcim/interfaces/?device_id={{interface_device}}' - ) + required=False ) nat_site = forms.ModelChoiceField( queryset=Site.objects.all(), @@ -476,13 +458,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) obj_label='address' ) ) - primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device') + primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM') class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack', - 'nat_inside', 'tenant_group', 'tenant', + 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site', + 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', ] def __init__(self, *args, **kwargs): @@ -490,10 +472,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) # Initialize helper selectors instance = kwargs.get('instance') initial = kwargs.get('initial', {}).copy() - if instance and instance.interface is not None: - initial['interface_site'] = instance.interface.device.site - initial['interface_rack'] = instance.interface.device.rack - initial['interface_device'] = instance.interface.device if instance and instance.nat_inside and instance.nat_inside.device is not None: initial['nat_site'] = instance.nat_inside.device.site initial['nat_rack'] = instance.nat_inside.device.rack @@ -504,22 +482,30 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) self.fields['vrf'].empty_label = 'Global' - # Initialize primary_for_device if IP address is already assigned - if self.instance.interface is not None: - device = self.instance.interface.device + # Limit interface selections to those belonging to the parent device/VM + if self.instance and self.instance.interface: + self.fields['interface'].queryset = Interface.objects.filter( + device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine + ) + else: + self.fields['interface'].choices = [] + + # Initialize primary_for_parent if IP address is already assigned + if self.instance.pk and self.instance.interface is not None: + parent = self.instance.interface.parent if ( - self.instance.address.version == 4 and device.primary_ip4 == self.instance or - self.instance.address.version == 6 and device.primary_ip6 == self.instance + self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or + self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk ): - self.initial['primary_for_device'] = True + self.initial['primary_for_parent'] = True def clean(self): super(IPAddressForm, self).clean() # Primary IP assignment is only available if an interface has been assigned. - if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'): + if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'): self.add_error( - 'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs." + 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." ) def save(self, *args, **kwargs): @@ -527,13 +513,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) ipaddress = super(IPAddressForm, self).save(*args, **kwargs) # Assign this IPAddress as the primary for the associated Device. - if self.cleaned_data['primary_for_device']: - device = self.cleaned_data['interface'].device + if self.cleaned_data['primary_for_parent']: + parent = self.cleaned_data['interface'].parent if ipaddress.address.version == 4: - device.primary_ip4 = ipaddress + parent.primary_ip4 = ipaddress else: - device.primary_ip6 = ipaddress - device.save() + parent.primary_ip6 = ipaddress + parent.save() # Clear assignment as primary for device if set. else: @@ -551,7 +537,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) return ipaddress -class IPAddressPatternForm(BootstrapMixin, forms.Form): +class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): pattern = ExpandableIPAddressField(label='Address pattern') @@ -603,6 +589,15 @@ class IPAddressCSVForm(forms.ModelForm): 'invalid_choice': 'Device not found.', } ) + virtual_machine = forms.ModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned virtual machine', + error_messages={ + 'invalid_choice': 'Virtual machine not found.', + } + ) interface_name = forms.CharField( help_text='Name of assigned interface', required=False @@ -614,30 +609,47 @@ class IPAddressCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description'] + fields = [ + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', + 'description', + ] def clean(self): super(IPAddressCSVForm, self).clean() device = self.cleaned_data.get('device') + virtual_machine = self.cleaned_data.get('virtual_machine') interface_name = self.cleaned_data.get('interface_name') is_primary = self.cleaned_data.get('is_primary') # Validate interface - if device and interface_name: + if interface_name and device: try: self.instance.interface = Interface.objects.get(device=device, name=interface_name) except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device)) - elif device and not interface_name: - raise forms.ValidationError("Device set ({}) but interface missing".format(device)) - elif interface_name and not device: - raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name)) + raise forms.ValidationError("Invalid interface {} for device {}".format( + interface_name, device + )) + elif interface_name and virtual_machine: + try: + self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface {} for virtual machine {}".format( + interface_name, virtual_machine + )) + elif interface_name: + raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format( + interface_name + )) + elif device: + raise forms.ValidationError("Device specified ({}) but interface missing".format(device)) + elif virtual_machine: + raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine)) # Validate is_primary - if is_primary and not device: - raise forms.ValidationError("No device specified; cannot set as primary IP") + if is_primary and not device and not virtual_machine: + raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP") def save(self, *args, **kwargs): @@ -647,17 +659,22 @@ class IPAddressCSVForm(forms.ModelForm): device=self.cleaned_data['device'], name=self.cleaned_data['interface_name'] ) + elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']: + self.instance.interface = Interface.objects.get( + virtual_machine=self.cleaned_data['virtual_machine'], + name=self.cleaned_data['interface_name'] + ) ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs) - # Set as primary for device + # Set as primary for device/VM if self.cleaned_data['is_primary']: - device = self.cleaned_data['device'] + parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] if self.instance.address.version == 4: - device.primary_ip4 = ipaddress + parent.primary_ip4 = ipaddress elif self.instance.address.version == 6: - device.primary_ip6 = ipaddress - device.save() + parent.primary_ip6 = ipaddress + parent.save() return ipaddress @@ -723,6 +740,26 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): fields = ['site', 'name', 'slug'] +class VLANGroupCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + slug = SlugField() + + class Meta: + model = VLANGroup + fields = ['site', 'name', 'slug'] + help_texts = { + 'name': 'Name of VLAN group', + } + + class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), @@ -895,5 +932,14 @@ class ServiceForm(BootstrapMixin, forms.ModelForm): super(ServiceForm, self).__init__(*args, **kwargs) - # Limit IP address choices to those assigned to interfaces of the parent device - self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device) + # Limit IP address choices to those assigned to interfaces of the parent device/VM + if self.instance.device: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + interface__device=self.instance.device + ) + elif self.instance.virtual_machine: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + interface__virtual_machine=self.instance.virtual_machine + ) + else: + self.fields['ipaddresses'].choices = [] diff --git a/netbox/ipam/migrations/0019_virtualization.py b/netbox/ipam/migrations/0019_virtualization.py new file mode 100644 index 000000000..955ff8b4a --- /dev/null +++ b/netbox/ipam/migrations/0019_virtualization.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 15:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_virtualization'), + ('ipam', '0018_remove_service_uniqueness_constraint'), + ] + + operations = [ + migrations.AlterModelOptions( + name='service', + options={'ordering': ['protocol', 'port']}, + ), + migrations.AddField( + model_name='service', + name='virtual_machine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'), + ), + migrations.AlterField( + model_name='service', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'), + ), + ] diff --git a/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py new file mode 100644 index 000000000..9d16be049 --- /dev/null +++ b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-09 20:02 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0019_virtualization'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='role', + field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 214293b7d..791ba1b1a 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -3,6 +3,7 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -14,10 +15,10 @@ from dcim.models import Interface from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel -from utilities.sql import NullsFirstQuerySet from utilities.utils import csv_format from .constants import * from .fields import IPNetworkField, IPAddressField +from .querysets import PrefixQuerySet @python_2_unicode_compatible @@ -188,41 +189,6 @@ class Role(models.Model): return self.vlans.count() -class PrefixQuerySet(NullsFirstQuerySet): - - def annotate_depth(self, limit=None): - """ - Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable - to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces - performance issues at scale. - - Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet - modifications. - """ - queryset = self - stack = [] - for p in queryset: - try: - prev_p = stack[-1] - except IndexError: - prev_p = None - if prev_p is not None: - while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix: - stack.pop() - try: - prev_p = stack[-1] - except IndexError: - prev_p = None - break - if prev_p is not None: - prev_p.has_children = True - stack.append(p) - p.depth = len(stack) - 1 - if limit is None: - return queryset - return list(filter(lambda p: p.depth <= limit, queryset)) - - @python_2_unicode_compatible class Prefix(CreatedUpdatedModel, CustomFieldModel): """ @@ -418,7 +384,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): objects = IPAddressManager() csv_headers = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description', + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', + 'description', ] class Meta: @@ -473,6 +440,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): self.get_status_display(), self.get_role_display(), self.device.identifier if self.device else None, + self.virtual_machine.name if self.device else None, self.interface.name if self.interface else None, is_primary, self.description, @@ -596,20 +564,59 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible class Service(CreatedUpdatedModel): """ - A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied - to one or more specific IPAddresses belonging to the Device. + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may + optionally be tied to one or more specific IPAddresses belonging to its parent. """ - device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device') - name = models.CharField(max_length=30) - protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES) - port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], - verbose_name='Port number') - ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True, - verbose_name='IP addresses') - description = models.CharField(max_length=100, blank=True) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='services', + verbose_name='device', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='services', + null=True, + blank=True + ) + name = models.CharField( + max_length=30 + ) + protocol = models.PositiveSmallIntegerField( + choices=IP_PROTOCOL_CHOICES + ) + port = models.PositiveIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(65535)], + verbose_name='Port number' + ) + ipaddresses = models.ManyToManyField( + to='ipam.IPAddress', + related_name='services', + blank=True, + verbose_name='IP addresses' + ) + description = models.CharField( + max_length=100, + blank=True + ) class Meta: - ordering = ['device', 'protocol', 'port'] + ordering = ['protocol', 'port'] def __str__(self): return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + + @property + def parent(self): + return self.device or self.virtual_machine + + def clean(self): + + # A Service must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("A service cannot be associated with both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("A service must be associated with either a device or a virtual machine.") diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py new file mode 100644 index 000000000..f606ab1b4 --- /dev/null +++ b/netbox/ipam/querysets.py @@ -0,0 +1,38 @@ +from __future__ import unicode_literals + +from utilities.sql import NullsFirstQuerySet + + +class PrefixQuerySet(NullsFirstQuerySet): + + def annotate_depth(self, limit=None): + """ + Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable + to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces + performance issues at scale. + + Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet + modifications. + """ + queryset = self + stack = [] + for p in queryset: + try: + prev_p = stack[-1] + except IndexError: + prev_p = None + if prev_p is not None: + while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix: + stack.pop() + try: + prev_p = stack[-1] + except IndexError: + prev_p = None + break + if prev_p is not None: + prev_p.has_children = True + stack.append(p) + p.depth = len(stack) - 1 + if limit is None: + return queryset + return list(filter(lambda p: p.depth <= limit, queryset)) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 72c760d34..bd81b005d 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -77,9 +77,9 @@ IPADDRESS_LINK = """ {% endif %} """ -IPADDRESS_DEVICE = """ +IPADDRESS_PARENT = """ {% if record.interface %} - {{ record.interface.device }} + {{ record.interface.parent }} {% else %} — {% endif %} @@ -272,12 +272,12 @@ class IPAddressTable(BaseTable): status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK) - device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False) + parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False) interface = tables.Column(orderable=False) class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description') + fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } @@ -290,7 +290,7 @@ class IPAddressDetailTable(IPAddressTable): class Meta(IPAddressTable.Meta): fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description', ) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 15634c0ee..c5723ef94 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ # RIRs url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'), + url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'), url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), url(r'^rirs/(?P[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), @@ -37,6 +38,7 @@ urlpatterns = [ # Roles url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'), + url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'), url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), url(r'^roles/(?P[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), @@ -65,6 +67,7 @@ urlpatterns = [ # VLAN groups url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'), url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), url(r'^vlan-groups/(?P\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 4152cae91..7cf24614f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -10,11 +10,12 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views.generic import View -from dcim.models import Device +from dcim.models import Device, Interface from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) +from virtualization.models import VirtualMachine from . import filters, forms, tables from .constants import IPADDRESS_ROLE_ANYCAST from .models import ( @@ -118,7 +119,7 @@ class VRFView(View): class VRFCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vrf' model = VRF - form_class = forms.VRFForm + model_form = forms.VRFForm template_name = 'ipam/vrf_edit.html' default_return_url = 'ipam:vrf_list' @@ -250,7 +251,7 @@ class RIRListView(ObjectListView): class RIRCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_rir' model = RIR - form_class = forms.RIRForm + model_form = forms.RIRForm def get_return_url(self, request, obj): return reverse('ipam:rir_list') @@ -260,6 +261,13 @@ class RIREditView(RIRCreateView): permission_required = 'ipam.change_rir' +class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_rir' + model_form = forms.RIRCSVForm + table = tables.RIRTable + default_return_url = 'ipam:rir_list' + + class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_rir' cls = RIR @@ -342,7 +350,7 @@ class AggregateView(View): class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_aggregate' model = Aggregate - form_class = forms.AggregateForm + model_form = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' default_return_url = 'ipam:aggregate_list' @@ -396,7 +404,7 @@ class RoleListView(ObjectListView): class RoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_role' model = Role - form_class = forms.RoleForm + model_form = forms.RoleForm def get_return_url(self, request, obj): return reverse('ipam:role_list') @@ -406,6 +414,13 @@ class RoleEditView(RoleCreateView): permission_required = 'ipam.change_role' +class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_role' + model_form = forms.RoleCSVForm + table = tables.RoleTable + default_return_url = 'ipam:role_list' + + class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_role' cls = Role @@ -549,7 +564,7 @@ class PrefixIPAddressesView(View): class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_prefix' model = Prefix - form_class = forms.PrefixForm + model_form = forms.PrefixForm template_name = 'ipam/prefix_edit.html' default_return_url = 'ipam:prefix_list' @@ -596,7 +611,11 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class IPAddressListView(ObjectListView): - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') + queryset = IPAddress.objects.select_related( + 'vrf__tenant', 'tenant', 'nat_inside' + ).prefetch_related( + 'interface__device', 'interface__virtual_machine' + ) filter = filters.IPAddressFilter filter_form = forms.IPAddressFilterForm table = tables.IPAddressDetailTable @@ -607,7 +626,7 @@ class IPAddressView(View): def get(self, request, pk): - ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk) + ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk) # Parent prefixes table parent_prefixes = Prefix.objects.filter( @@ -624,7 +643,9 @@ class IPAddressView(View): ).exclude( pk=ipaddress.pk ).select_related( - 'interface__device', 'nat_inside' + 'nat_inside' + ).prefetch_related( + 'interface__device' ) # Exclude anycast IPs if this IP is anycast if ipaddress.role == IPADDRESS_ROLE_ANYCAST: @@ -632,7 +653,7 @@ class IPAddressView(View): duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) # Related IP table - related_ips = IPAddress.objects.select_related( + related_ips = IPAddress.objects.prefetch_related( 'interface__device' ).exclude( address=str(ipaddress.address) @@ -652,10 +673,21 @@ class IPAddressView(View): class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_ipaddress' model = IPAddress - form_class = forms.IPAddressForm + model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' default_return_url = 'ipam:ipaddress_list' + def alter_obj(self, obj, request, url_args, url_kwargs): + + interface_id = request.GET.get('interface') + if interface_id: + try: + obj.interface = Interface.objects.get(pk=interface_id) + except (ValueError, Interface.DoesNotExist): + pass + + return obj + class IPAddressEditView(IPAddressCreateView): permission_required = 'ipam.change_ipaddress' @@ -669,7 +701,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): permission_required = 'ipam.add_ipaddress' - pattern_form = forms.IPAddressPatternForm + form = forms.IPAddressBulkCreateForm model_form = forms.IPAddressBulkAddForm pattern_target = 'address' template_name = 'ipam/ipaddress_bulk_add.html' @@ -686,7 +718,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_ipaddress' cls = IPAddress - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable form = forms.IPAddressBulkEditForm @@ -696,7 +728,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' cls = IPAddress - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' @@ -717,7 +749,7 @@ class VLANGroupListView(ObjectListView): class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlangroup' model = VLANGroup - form_class = forms.VLANGroupForm + model_form = forms.VLANGroupForm def get_return_url(self, request, obj): return reverse('ipam:vlangroup_list') @@ -727,6 +759,13 @@ class VLANGroupEditView(VLANGroupCreateView): permission_required = 'ipam.change_vlangroup' +class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_vlangroup' + model_form = forms.VLANGroupCSVForm + table = tables.VLANGroupTable + default_return_url = 'ipam:vlangroup_list' + + class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlangroup' cls = VLANGroup @@ -768,7 +807,7 @@ class VLANView(View): class VLANCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlan' model = VLAN - form_class = forms.VLANForm + model_form = forms.VLANForm template_name = 'ipam/vlan_edit.html' default_return_url = 'ipam:vlan_list' @@ -816,16 +855,18 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_service' model = Service - form_class = forms.ServiceForm + model_form = forms.ServiceForm template_name = 'ipam/service_edit.html' def alter_obj(self, obj, request, url_args, url_kwargs): if 'device' in url_kwargs: obj.device = get_object_or_404(Device, pk=url_kwargs['device']) + elif 'virtualmachine' in url_kwargs: + obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine']) return obj def get_return_url(self, request, obj): - return obj.device.get_absolute_url() + return obj.parent.get_absolute_url() class ServiceEditView(ServiceCreateView): diff --git a/netbox/netbox/__init__.py b/netbox/netbox/__init__.py index e69de29bb..361659e58 100644 --- a/netbox/netbox/__init__.py +++ b/netbox/netbox/__init__.py @@ -0,0 +1,18 @@ +from distutils.version import StrictVersion + +from django.db import connection +from django.db.utils import OperationalError + + +# NetBox v2.2 and later requires PostgreSQL 9.4 or higher. +try: + with connection.cursor() as cursor: + cursor.execute("SELECT VERSION()") + row = cursor.fetchone() + pg_version = row[0].split()[1] + if StrictVersion(pg_version) < StrictVersion('9.4.0'): + raise Exception("PostgreSQL 9.4.0 or higher is required. ({} found)".format(pg_version)) + +# Skip if the database is missing (e.g. for CI testing) or misconfigured. +except OperationalError: + pass diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py new file mode 100644 index 000000000..0bac06977 --- /dev/null +++ b/netbox/netbox/api.py @@ -0,0 +1,147 @@ +from __future__ import unicode_literals + +from rest_framework import authentication, exceptions +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS +from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework.utils import formatting + +from users.models import Token + + +# +# Renderers +# + +class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): + """ + Override the built-in BrowsableAPIRenderer to disable HTML forms. + """ + def show_form_for_method(self, *args, **kwargs): + return False + + +# +# Authentication +# + +class TokenAuthentication(authentication.TokenAuthentication): + """ + A custom authentication scheme which enforces Token expiration times. + """ + model = Token + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.select_related('user').get(key=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid token") + + # Enforce the Token's expiration time, if one has been set. + if token.is_expired: + raise exceptions.AuthenticationFailed("Token expired") + + if not token.user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return token.user, token + + +class TokenPermissions(DjangoModelPermissions): + """ + Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability + for unsafe requests (POST/PUT/PATCH/DELETE). + """ + def __init__(self): + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. + from django.conf import settings + self.authenticated_users_only = settings.LOGIN_REQUIRED + super(TokenPermissions, self).__init__() + + def has_permission(self, request, view): + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). + if request.method not in SAFE_METHODS and isinstance(request.auth, Token): + if not request.auth.write_enabled: + return False + return super(TokenPermissions, self).has_permission(request, view) + + +# +# Pagination +# + +class OptionalLimitOffsetPagination(LimitOffsetPagination): + """ + Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects + matching a query, but retains the same format as a paginated request. The limit can only be disabled if + MAX_PAGE_SIZE has been set to 0 or None. + """ + + def paginate_queryset(self, queryset, request, view=None): + + try: + self.count = queryset.count() + except (AttributeError, TypeError): + self.count = len(queryset) + self.limit = self.get_limit(request) + self.offset = self.get_offset(request) + self.request = request + + if self.limit and self.count > self.limit and self.template is not None: + self.display_page_controls = True + + if self.count == 0 or self.offset > self.count: + return list() + + if self.limit: + return list(queryset[self.offset:self.offset + self.limit]) + else: + return list(queryset[self.offset:]) + + def get_limit(self, request): + + from django.conf import settings + + if self.limit_query_param: + try: + limit = int(request.query_params[self.limit_query_param]) + if limit < 0: + raise ValueError() + # Enforce maximum page size, if defined + if settings.MAX_PAGE_SIZE: + if limit == 0: + return settings.MAX_PAGE_SIZE + else: + return min(limit, settings.MAX_PAGE_SIZE) + return limit + except (KeyError, ValueError): + pass + + return self.default_limit + + +# +# Miscellaneous +# + +def get_view_name(view_cls, suffix=None): + """ + Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. + """ + if hasattr(view_cls, 'queryset'): + # Determine the model name from the queryset. + name = view_cls.queryset.model._meta.verbose_name + name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word + + else: + # Replicate DRF's built-in behavior. + name = view_cls.__name__ + name = formatting.remove_trailing_string(name, 'View') + name = formatting.remove_trailing_string(name, 'ViewSet') + name = formatting.camelcase_to_spaces(name) + + if suffix: + name += ' ' + suffix + + return name diff --git a/netbox/netbox/configuration.docker.py b/netbox/netbox/configuration.docker.py deleted file mode 100644 index 56f9da366..000000000 --- a/netbox/netbox/configuration.docker.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -######################### -# # -# Required settings # -# # -######################### - -# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write -# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name. -# -# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] -ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ') - -# PostgreSQL database configuration. -DATABASE = { - 'NAME': os.environ.get('DB_NAME', 'netbox'), # Database name - 'USER': os.environ.get('DB_USER', ''), # PostgreSQL username - 'PASSWORD': os.environ.get('DB_PASSWORD', ''), # PostgreSQL password - 'HOST': os.environ.get('DB_HOST', 'localhost'), # Database server - 'PORT': os.environ.get('DB_PORT', ''), # Database port (leave blank for default) -} - -# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file. -# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and -# symbols. NetBox will not run without this defined. For more information, see -# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY -SECRET_KEY = os.environ.get('SECRET_KEY', '') - -######################### -# # -# Optional settings # -# # -######################### - -# Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of -# application errors (assuming correct email settings are provided). -ADMINS = [ - # ['John Doe', 'jdoe@example.com'], -] - -# Email settings -EMAIL = { - 'SERVER': os.environ.get('EMAIL_SERVER', 'localhost'), - 'PORT': os.environ.get('EMAIL_PORT', 25), - 'USERNAME': os.environ.get('EMAIL_USERNAME', ''), - 'PASSWORD': os.environ.get('EMAIL_PASSWORD', ''), - 'TIMEOUT': os.environ.get('EMAIL_TIMEOUT', 10), # seconds - 'FROM_EMAIL': os.environ.get('EMAIL_FROM', ''), -} - -# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users -# are permitted to access most data in NetBox (excluding secrets) but not make any changes. -LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False) - -# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: -# BASE_PATH = 'netbox/' -BASE_PATH = os.environ.get('BASE_PATH', '') - -# Setting this to True will display a "maintenance mode" banner at the top of every page. -MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False) - -# Credentials that NetBox will use to access live devices. -NAPALM_USERNAME = os.environ.get('NAPALM_USERNAME', '') -NAPALM_PASSWORD = os.environ.get('NAPALM_PASSWORD', '') - -# Determine how many objects to display per page within a list. (Default: 50) -PAGINATE_COUNT = os.environ.get('PAGINATE_COUNT', 50) - -# Time zone (default: UTC) -TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC') - -# Date/time formatting. See the following link for supported formats: -# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date -DATE_FORMAT = os.environ.get('DATE_FORMAT', 'N j, Y') -SHORT_DATE_FORMAT = os.environ.get('SHORT_DATE_FORMAT', 'Y-m-d') -TIME_FORMAT = os.environ.get('TIME_FORMAT', 'g:i a') -SHORT_TIME_FORMAT = os.environ.get('SHORT_TIME_FORMAT', 'H:i:s') -DATETIME_FORMAT = os.environ.get('DATETIME_FORMAT', 'N j, Y g:i a') -SHORT_DATETIME_FORMAT = os.environ.get('SHORT_DATETIME_FORMAT', 'Y-m-d H:i') diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index ce7a62464..4c4cf4277 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -118,6 +118,10 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. PREFER_IPV4 = False +# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of +# this setting is derived from the installed location. +# REPORTS_ROOT = '/opt/netbox/netbox/reports' + # Time zone (default: UTC) TIME_ZONE = 'UTC' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 61eed1c62..7272da8ae 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.1.7-dev' +VERSION = '2.2.0-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -52,10 +52,9 @@ NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) -NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '') # Deprecated -NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '') # Deprecated PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) +REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') @@ -64,19 +63,6 @@ TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS -# Check for deprecated configuration parameters -config_logger = logging.getLogger('configuration') -config_logger.addHandler(logging.StreamHandler()) -config_logger.setLevel(logging.WARNING) -if NETBOX_USERNAME: - config_logger.warning('NETBOX_USERNAME is deprecated and will be removed in v2.2. Please use NAPALM_USERNAME instead.') - if not NAPALM_USERNAME: - NAPALM_USERNAME = NETBOX_USERNAME -if NETBOX_PASSWORD: - config_logger.warning('NETBOX_PASSWORD is deprecated and will be removed in v2.2. Please use NAPALM_PASSWORD instead.') - if not NAPALM_PASSWORD: - NAPALM_PASSWORD = NETBOX_PASSWORD - # Attempt to import LDAP configuration if it has been defined LDAP_IGNORE_CERT_ERRORS = False try: @@ -147,6 +133,7 @@ INSTALLED_APPS = ( 'tenancy', 'users', 'utilities', + 'virtualization', ) # Middleware @@ -225,34 +212,26 @@ REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', - 'utilities.api.TokenAuthentication', + 'netbox.api.TokenAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', ), - 'DEFAULT_PAGINATION_CLASS': 'utilities.api.OptionalLimitOffsetPagination', + 'DEFAULT_PAGINATION_CLASS': 'netbox.api.OptionalLimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( - 'utilities.api.TokenPermissions', + 'netbox.api.TokenPermissions', ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', - 'utilities.api.FormlessBrowsableAPIRenderer', + 'netbox.api.FormlessBrowsableAPIRenderer', ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'PAGE_SIZE': PAGINATE_COUNT, - 'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name', + 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } # Django debug toolbar -# Disable the templates panel by default due to a performance issue in Django 1.11; see -# https://github.com/jazzband/django-debug-toolbar/issues/910 -DEBUG_TOOLBAR_CONFIG = { - 'DISABLE_PANELS': [ - 'debug_toolbar.panels.redirects.RedirectsPanel', - 'debug_toolbar.panels.templates.TemplatesPanel', - ], -} INTERNAL_IPS = ( '127.0.0.1', '::1', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index ddddf27a2..1c8051c06 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -32,6 +32,7 @@ _patterns = [ url(r'^secrets/', include('secrets.urls')), url(r'^tenancy/', include('tenancy.urls')), url(r'^user/', include('users.urls')), + url(r'^virtualization/', include('virtualization.urls')), # API url(r'^api/$', APIRootView.as_view(), name='api-root'), @@ -41,6 +42,7 @@ _patterns = [ url(r'^api/ipam/', include('ipam.api.urls')), url(r'^api/secrets/', include('secrets.api.urls')), url(r'^api/tenancy/', include('tenancy.api.urls')), + url(r'^api/virtualization/', include('virtualization.api.urls')), url(r'^api/docs/', swagger_view, name='api_docs'), # Serving static media in Django to pipe it through LoginRequiredMiddleware diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 72bb8dcec..5ec81cb25 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -25,6 +25,9 @@ from secrets.tables import SecretTable from tenancy.filters import TenantFilter from tenancy.models import Tenant from tenancy.tables import TenantTable +from virtualization.filters import ClusterFilter, VirtualMachineFilter +from virtualization.models import Cluster, VirtualMachine +from virtualization.tables import ClusterTable, VirtualMachineTable from .forms import SearchForm @@ -90,7 +93,7 @@ SEARCH_TYPES = OrderedDict(( 'url': 'ipam:prefix_list', }), ('ipaddress', { - 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), + 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'), 'filter': IPAddressFilter, 'table': IPAddressTable, 'url': 'ipam:ipaddress_list', @@ -115,6 +118,19 @@ SEARCH_TYPES = OrderedDict(( 'table': TenantTable, 'url': 'tenancy:tenant_list', }), + # Virtualization + ('cluster', { + 'queryset': Cluster.objects.all(), + 'filter': ClusterFilter, + 'table': ClusterTable, + 'url': 'virtualization:cluster_list', + }), + ('virtualmachine', { + 'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'), + 'filter': VirtualMachineFilter, + 'table': VirtualMachineTable, + 'url': 'virtualization:virtualmachine_list', + }), )) @@ -150,6 +166,10 @@ class HomeView(View): # Secrets 'secret_count': Secret.objects.count(), + # Virtualization + 'cluster_count': Cluster.objects.count(), + 'virtualmachine_count': VirtualMachine.objects.count(), + } return render(request, self.template_name, { @@ -216,14 +236,15 @@ class APIRootView(APIView): def get(self, request, format=None): - return Response({ - 'circuits': reverse('circuits-api:api-root', request=request, format=format), - 'dcim': reverse('dcim-api:api-root', request=request, format=format), - 'extras': reverse('extras-api:api-root', request=request, format=format), - 'ipam': reverse('ipam-api:api-root', request=request, format=format), - 'secrets': reverse('secrets-api:api-root', request=request, format=format), - 'tenancy': reverse('tenancy-api:api-root', request=request, format=format), - }) + return Response(OrderedDict(( + ('circuits', reverse('circuits-api:api-root', request=request, format=format)), + ('dcim', reverse('dcim-api:api-root', request=request, format=format)), + ('extras', reverse('extras-api:api-root', request=request, format=format)), + ('ipam', reverse('ipam-api:api-root', request=request, format=format)), + ('secrets', reverse('secrets-api:api-root', request=request, format=format)), + ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), + ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), + ))) def handle_500(request): diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index b013aab97..b65f188d8 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -85,6 +85,19 @@ footer p { li.subnav > a { padding-left: 30px; } +ul.dropdown-menu { + width: 250px; +} +ul.dropdown-menu div.buttons { + padding-right: 10px; + padding-top: 2px; +} +ul.dropdown-menu div.buttons a { + width: 26px; +} +ul.dropdown-menu > li > a { + clear: left; +} /* Forms */ label { @@ -339,6 +352,18 @@ table.component-list td.subtable td { padding-top: 6px; } +/* Reports */ +table.reports td.method { + font-family: monospace; + padding-left: 30px; +} +table.reports td.stats label { + display: inline-block; + line-height: 14px; + margin-bottom: 0; + min-width: 40px; +} + /* AJAX loader */ .loading { position: fixed; diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 5959335db..945a94d45 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -77,7 +77,9 @@ $(document).ready(function() { // Wipe out any existing options within the child field and create a default option child_field.empty(); - child_field.append($("").attr("value", "").text("---------")); + if (!child_field.attr('multiple')) { + child_field.append($("").attr("value", "").text("---------")); + } if ($(this).val() || $(this).attr('nullable') == 'true') { var api_url = child_field.attr('api-url') + '&limit=1000'; diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index d1ec3d883..a592330fd 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -18,7 +18,7 @@ $(document).ready(function() { $('form').submit(function(event) { $(this).find('.requires-session-key').each(function() { if (this.value && document.cookie.indexOf('session_key') == -1) { - console.log('Field ' + this.value + ' requires a session key'); + console.log('Field ' + this.name + ' requires a session key'); $('#privkey_modal').modal('show'); event.preventDefault(); return false; diff --git a/netbox/reports/__init__.py b/netbox/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 3b1e7d3d0..2a24c445a 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -16,6 +16,9 @@ class SecretsRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = SecretsRootView +# Field choices +router.register(r'_choices', views.SecretsFieldChoicesViewSet, base_name='field-choice') + # Secrets router.register(r'secret-roles', views.SecretRoleViewSet) router.register(r'secrets', views.SecretViewSet) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 52a77b87c..07897aa19 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers @@ -22,6 +22,14 @@ ERR_PRIVKEY_MISSING = "Private key was not provided." ERR_PRIVKEY_INVALID = "Invalid private key." +# +# Field choices +# + +class SecretsFieldChoicesViewSet(FieldChoicesViewSet): + fields = () + + # # Secret Roles # diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index e71bb92fa..4e8d6667c 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -43,6 +43,17 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug', 'users', 'groups'] +class SecretRoleCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = SecretRole + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of secret role', + } + + # # Secrets # diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index e8eb2ff45..ded93ed58 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals import os -from Crypto.Cipher import AES, PKCS1_OAEP, XOR +from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.PublicKey import RSA +from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password @@ -16,6 +17,7 @@ from dcim.models import Device from utilities.models import CreatedUpdatedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher +from .querysets import UserKeyQuerySet def generate_random_key(bits=256): @@ -45,24 +47,6 @@ def decrypt_master_key(master_key_cipher, private_key): return cipher.decrypt(master_key_cipher) -def xor_keys(key_a, key_b): - """ - Return the binary XOR of two given keys. - """ - xor = XOR.new(key_a) - return xor.encrypt(key_b) - - -class UserKeyQuerySet(models.QuerySet): - - def active(self): - return self.filter(master_key_cipher__isnull=False) - - def delete(self): - # Disable bulk deletion to avoid accidentally wiping out all copies of the master key. - raise Exception("Bulk deletion has been disabled.") - - @python_2_unicode_compatible class UserKey(CreatedUpdatedModel): """ @@ -98,7 +82,7 @@ class UserKey(CreatedUpdatedModel): # Validate the public key format try: - pubkey = RSA.importKey(self.public_key) + pubkey = RSA.import_key(self.public_key) except ValueError: raise ValidationError({ 'public_key': "Invalid RSA key format." @@ -108,7 +92,7 @@ class UserKey(CreatedUpdatedModel): "uploading a valid RSA public key in PEM format (no SSH/PGP).") # Validate the public key length - pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus + pubkey_length = pubkey.size_in_bits() if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE: raise ValidationError({ 'public_key': "Insufficient key length. Keys must be at least {} bits long.".format( @@ -214,7 +198,7 @@ class SessionKey(models.Model): self.hash = make_password(self.key) # Encrypt master key using the session key - self.cipher = xor_keys(self.key, master_key) + self.cipher = strxor.strxor(self.key, master_key) super(SessionKey, self).save(*args, **kwargs) @@ -225,14 +209,14 @@ class SessionKey(models.Model): raise InvalidKey("Invalid session key") # Decrypt master key using provided session key - master_key = xor_keys(session_key, bytes(self.cipher)) + master_key = strxor.strxor(session_key, bytes(self.cipher)) return master_key def get_session_key(self, master_key): # Recover session key using the master key - session_key = xor_keys(master_key, bytes(self.cipher)) + session_key = strxor.strxor(master_key, bytes(self.cipher)) # Validate the recovered session key if not check_password(session_key, self.hash): diff --git a/netbox/secrets/querysets.py b/netbox/secrets/querysets.py new file mode 100644 index 000000000..c5595e1d3 --- /dev/null +++ b/netbox/secrets/querysets.py @@ -0,0 +1,13 @@ +from __future__ import unicode_literals + +from django.db.models import QuerySet + + +class UserKeyQuerySet(QuerySet): + + def active(self): + return self.filter(master_key_cipher__isnull=False) + + def delete(self): + # Disable bulk deletion to avoid accidentally wiping out all copies of the master key. + raise Exception("Bulk deletion has been disabled.") diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 961e0f0ed..ecba5741f 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ # Secret roles url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'), url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), url(r'^secret-roles/(?P[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 71cf42c13..e7f6939a9 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -42,7 +42,7 @@ class SecretRoleListView(ObjectListView): class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'secrets.add_secretrole' model = SecretRole - form_class = forms.SecretRoleForm + model_form = forms.SecretRoleForm def get_return_url(self, request, obj): return reverse('secrets:secretrole_list') @@ -52,6 +52,13 @@ class SecretRoleEditView(SecretRoleCreateView): permission_required = 'secrets.change_secretrole' +class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'secrets.add_secretrole' + model_form = forms.SecretRoleCSVForm + table = tables.SecretRoleTable + default_return_url = 'secrets:secretrole_list' + + class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secretrole' cls = SecretRole @@ -196,7 +203,9 @@ class SecretBulkImportView(BulkImportView): permission_required = 'ipam.add_vlan' model_form = forms.SecretCSVForm table = tables.SecretTable + template_name = 'secrets/secret_import.html' default_return_url = 'secrets:secret_list' + widget_attrs = {'class': 'requires-session-key'} master_key = None diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 66f23a4cb..1a187a681 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -12,267 +12,7 @@ - + {% include 'inc/nav_menu.html' %}
{% if settings.BANNER_TOP %} {% with device.get_custom_fields as custom_fields %} @@ -196,7 +208,7 @@ {% if services %} {% for service in services %} - {% include 'dcim/inc/service.html' %} + {% include 'ipam/inc/service.html' %} {% endfor %}
{% else %} @@ -206,7 +218,7 @@ {% endif %} {% if perms.ipam.add_service %} diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index 46d41782a..f246f60b5 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -8,7 +8,12 @@ Add a device type + + + Import device types + {% endif %} + {% include 'inc/export_button.html' with obj_type='devicetypes' %}

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

diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 3845c8759..7e133da8d 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -64,7 +64,7 @@ {% endif %} {% endif %} {% if perms.ipam.add_ipaddress %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 049dcbc61..e6b721db4 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -26,7 +26,7 @@
  • {% ifequal u.device.face face_id %} + data-content="{{ u.device.device_role }}
    {{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}
    {{ u.device.asset_tag }}{% endif %}"> {{ u.device.name|default:u.device.device_role }} {% if u.device.devicebay_count %} ({{ u.device.get_children.count }}/{{ u.device.devicebay_count }}) diff --git a/netbox/templates/dcim/manufacturer_list.html b/netbox/templates/dcim/manufacturer_list.html index c697127b1..ff6025af5 100644 --- a/netbox/templates/dcim/manufacturer_list.html +++ b/netbox/templates/dcim/manufacturer_list.html @@ -8,7 +8,12 @@ Add a manufacturer
    + + + Import manufacturers + {% endif %} + {% include 'inc/export_button.html' with obj_type='manufacturers' %}
  • {% block title %}Manufacturers{% endblock %}

    diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 2adb78e92..4463ee21b 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -112,6 +112,29 @@ {% endif %} + + Serial Number + + {% if rack.serial %} + {{ rack.serial }} + {% else %} + N/A + {% endif %} + + + + Devices + + {{ rack.devices.count }} + + + +
    +
    +
    + Dimensions +
    + - - - -
    Type @@ -130,12 +153,6 @@ Height {{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %})
    Devices - {{ rack.devices.count }} -
    {% with rack.get_custom_fields as custom_fields %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index d7a6f0dfb..4ab129a1d 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -10,6 +10,7 @@ {% render_field form.facility_id %} {% render_field form.group %} {% render_field form.role %} + {% render_field form.serial %}
    diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html index cf19005b7..f8b53df57 100644 --- a/netbox/templates/dcim/rackgroup_list.html +++ b/netbox/templates/dcim/rackgroup_list.html @@ -8,7 +8,12 @@ Add a rack group + + + Import rack groups + {% endif %} + {% include 'inc/export_button.html' with obj_type='rackgroups' %}

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

    diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html index df7da82c8..73fddbb0a 100644 --- a/netbox/templates/dcim/region_list.html +++ b/netbox/templates/dcim/region_list.html @@ -8,7 +8,12 @@ Add a region + + + Import regions + {% endif %} + {% include 'inc/export_button.html' with obj_type='regions' %}

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

    diff --git a/netbox/templates/extras/inc/report_label.html b/netbox/templates/extras/inc/report_label.html new file mode 100644 index 000000000..67fc0556b --- /dev/null +++ b/netbox/templates/extras/inc/report_label.html @@ -0,0 +1,7 @@ +{% if report.result.failed %} + +{% elif report.result %} + +{% else %} + +{% endif %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html new file mode 100644 index 000000000..2c087e437 --- /dev/null +++ b/netbox/templates/extras/report.html @@ -0,0 +1,92 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ report.name }}{% endblock %} + +{% block content %} +
    +
    + +
    +
    + {% if perms.extras.add_reportresult %} +
    +
    + {% csrf_token %} + {{ run_form }} + +
    +
    + {% endif %} +

    {{ report.name }}{% include 'extras/inc/report_label.html' %}

    +
    +
    + {% if report.description %} +

    {{ report.description }}

    + {% endif %} + {% if report.result %} +

    Last run: {{ report.result.created }}

    + {% else %} +

    Last run: Never

    + {% endif %} +
    +
    + {% if report.result %} + + + + + + + + + + {% for method, data in report.result.data.items %} + + + + {% for time, level, obj, url, message in data.log %} + + + + + + + {% endfor %} + {% endfor %} +
    TimeLevelObjectMessage
    {{ method }}
    {{ time }} + + + {% if obj and url %} + {{ obj }} + {% elif obj %} + {{ obj }} + {% endif %} + {{ message }}
    + {% else %} +
    No results are available for this report. Please run the report first.
    + {% endif %} +
    +
    + {% if report.result %} +
    +
    + Methods +
    +
      + {% for method, data in report.result.data.items %} +
    • + {{ method }} + {{ data.log|length }} +
    • + {% endfor %} +
    +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html new file mode 100644 index 000000000..9cba1a71b --- /dev/null +++ b/netbox/templates/extras/report_list.html @@ -0,0 +1,82 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +

    {% block title %}Reports{% endblock %}

    +
    +
    + {% if reports %} + {% for module, module_reports in reports %} +

    {{ module|bettertitle }}

    + + + + + + + + + + + {% for report in module_reports %} + + + + + {% if report.result %} + + {% else %} + + {% endif %} + + {% for method, stats in report.result.data.items %} + + + + + {% endfor %} + {% endfor %} + +
    NameStatusDescriptionLast Run
    + {{ report.name }} + + {% include 'extras/inc/report_label.html' %} + {{ report.description|default:"" }}{{ report.result.created }}Never
    + {{ method }} + + + + + +
    + {% endfor %} + {% else %} +
    +

    No reports found.

    +

    Reports should be saved to {{ settings.REPORTS_ROOT }}. (This path can be changed by setting REPORTS_ROOT in NetBox's configuration.)

    +
    + {% endif %} +
    +
    + {% if reports %} +
    + {% for module, module_reports in reports %} +
    + {{ module|bettertitle }} +
    + + {% endfor %} +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 0c3c7d13b..beb157f1a 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -1,14 +1,6 @@ {% extends '_base.html' %} {% block content %} -{% if settings.NETBOX_USERNAME or settings.NETBOX_PASSWORD %} - -{% endif %} {% include 'search_form.html' %}
    @@ -55,20 +47,23 @@
    - {% if perms.secrets %} -
    -
    - Secrets +
    +
    + Virtualization +
    +
    +
    + {{ stats.cluster_count }} +

    Clusters

    +

    Clusters of physical hosts in which VMs reside

    -
    -
    - {{ stats.secret_count }} -

    Secrets

    -

    Sensitive data (such as passwords) which has been stored securely

    -
    +
    + {{ stats.virtualmachine_count }} +

    Virtual Machines

    +

    Virtual compute instances running inside clusters

    - {% endif %} +
    @@ -120,6 +115,20 @@
    + {% if perms.secrets %} +
    +
    + Secrets +
    +
    +
    + {{ stats.secret_count }} +

    Secrets

    +

    Sensitive data (such as passwords) which has been stored securely

    +
    +
    +
    + {% endif %}
    diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html new file mode 100644 index 000000000..c1dc9dc67 --- /dev/null +++ b/netbox/templates/inc/nav_menu.html @@ -0,0 +1,409 @@ +{% load static from staticfiles %} +{% load helpers %} + diff --git a/netbox/templates/dcim/inc/service.html b/netbox/templates/ipam/inc/service.html similarity index 74% rename from netbox/templates/dcim/inc/service.html rename to netbox/templates/ipam/inc/service.html index 1e42a1811..a88aff4ce 100644 --- a/netbox/templates/dcim/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -14,12 +14,12 @@ {% if perms.ipam.change_service %} - + {% endif %} {% if perms.ipam.delete_service %} - - + + {% endif %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 51a2fb23e..855fc3a98 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -104,7 +104,7 @@ Assignment {% if ipaddress.interface %} - {{ ipaddress.interface.device }} ({{ ipaddress.interface }}) + {{ ipaddress.interface.parent }} ({{ ipaddress.interface }}) {% else %} None {% endif %} @@ -116,7 +116,7 @@ {% if ipaddress.nat_inside %} {{ ipaddress.nat_inside }} {% if ipaddress.nat_inside.interface %} - ({{ ipaddress.nat_inside.interface.device }}) + ({{ ipaddress.nat_inside.interface.parent }}) {% endif %} {% else %} None diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index 78406a3f2..17d48388c 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -12,7 +12,7 @@
    IP Addresses
    - {% render_field pattern_form.pattern %} + {% render_field form.pattern %} {% render_field model_form.status %} {% render_field model_form.role %} {% render_field model_form.vrf %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 5a625e03c..d0dad69ee 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -1,6 +1,7 @@ {% extends 'utilities/obj_edit.html' %} {% load static from staticfiles %} {% load form_helpers %} +{% load helpers %} {% block tabs %} {% if not obj.pk %} @@ -26,18 +27,25 @@ {% render_field form.tenant %}
    -
    -
    - Interface Assignment + {% if obj.interface %} +
    +
    + Interface Assignment +
    +
    +
    + + +
    + {% render_field form.interface %} + {% render_field form.primary_for_parent %} +
    -
    - {% render_field form.interface_site %} - {% render_field form.interface_rack %} - {% render_field form.interface_device %} - {% render_field form.interface %} - {% render_field form.primary_for_device %} -
    -
    + {% endif %}
    NAT IP (Inside)
    diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index b5593de0d..e1db968dd 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -5,12 +5,21 @@
    Service
    -
    - -
    -

    {{ obj.device }}

    + {% if obj.device %} +
    + +
    +

    {{ obj.device }}

    +
    -
    + {% else %} +
    + +
    +

    {{ obj.virtual_machine }}

    +
    +
    + {% endif %} {% render_field form.name %}
    diff --git a/netbox/templates/rest_framework/api.html b/netbox/templates/rest_framework/api.html new file mode 100644 index 000000000..76f43629b --- /dev/null +++ b/netbox/templates/rest_framework/api.html @@ -0,0 +1,5 @@ +{% extends 'rest_framework/base.html' %} + +{% block branding %} + NetBox +{% endblock %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index 1f12494ec..a460f80e8 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -1,70 +1,8 @@ -{% extends '_base.html' %} +{% extends 'utilities/obj_import.html' %} {% load static from staticfiles %} -{% load form_helpers %} {% block content %} -

    {% block title %}Secret Import{% endblock %}

    -
    -
    - {% if form.non_field_errors %} -
    -
    Errors
    -
    - {{ form.non_field_errors }} -
    -
    - {% endif %} -
    - {% csrf_token %} - {% render_form form %} -
    -
    - - {% if return_url %} - Cancel - {% endif %} -
    -
    -
    -
    -
    -

    CSV Format

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    FieldDescriptionExample
    DeviceName of the parent deviceedge-router1
    RoleFunctional roleLogin Credentials
    Name (optional)Username or other labelroot
    SecretSecret dataMyP@ssw0rd!
    -

    Example

    -
    edge-router1,Login Credentials,root,MyP@ssw0rd!
    -
    -
    - +{{ block.super }} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 2c893adae..c19195246 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -124,6 +124,10 @@

    {{ stats.circuit_count }}

    Circuits

    +
    +

    {{ stats.virtualmachine_count }}

    +

    Virtual machines

    +
    diff --git a/netbox/templates/utilities/confirmation_form.html b/netbox/templates/utilities/confirmation_form.html index 9f3f4b8e6..af15f7ab9 100644 --- a/netbox/templates/utilities/confirmation_form.html +++ b/netbox/templates/utilities/confirmation_form.html @@ -4,7 +4,7 @@ {% block content %}
    -
    + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/templates/dcim/device_bulk_add_component.html b/netbox/templates/utilities/obj_bulk_add_component.html similarity index 69% rename from netbox/templates/dcim/device_bulk_add_component.html rename to netbox/templates/utilities/obj_bulk_add_component.html index 697942998..93c7060a4 100644 --- a/netbox/templates/dcim/device_bulk_add_component.html +++ b/netbox/templates/utilities/obj_bulk_add_component.html @@ -14,21 +14,7 @@
    -
    Selected Devices
    - - - - - - - {% for device in selected_devices %} - - - - - - {% endfor %} -
    DeviceTypeRole
    {{ device }}{{ device.device_type.full_name }}{{ device.device_role }}
    + {% include 'inc/table.html' %}
    diff --git a/netbox/templates/utilities/obj_bulk_delete.html b/netbox/templates/utilities/obj_bulk_delete.html index 5ec26a9c8..373d43d28 100644 --- a/netbox/templates/utilities/obj_bulk_delete.html +++ b/netbox/templates/utilities/obj_bulk_delete.html @@ -23,7 +23,7 @@
    - + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/templates/utilities/obj_bulk_edit.html b/netbox/templates/utilities/obj_bulk_edit.html index e2c501aba..abdbec261 100644 --- a/netbox/templates/utilities/obj_bulk_edit.html +++ b/netbox/templates/utilities/obj_bulk_edit.html @@ -4,7 +4,7 @@ {% block content %}

    {% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}

    - + {% csrf_token %} {% if request.POST.return_url %} diff --git a/netbox/templates/utilities/obj_bulk_remove.html b/netbox/templates/utilities/obj_bulk_remove.html new file mode 100644 index 000000000..a850cf914 --- /dev/null +++ b/netbox/templates/utilities/obj_bulk_remove.html @@ -0,0 +1,38 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %} + +{% block content %} +
    +
    +
    +
    Confirm Bulk Removal
    +
    + Warning: The following operation will remove {{ table.rows|length }} {{ obj_type_plural }} from {{ parent_obj }}. Please carefully review the {{ obj_type_plural }} to be removed and confirm below. +
    +
    +
    +
    +
    +
    +
    + {% include 'inc/table.html' %} +
    +
    +
    +
    +
    + + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    + + Cancel +
    + +
    +
    +{% endblock %} diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 07a39634d..2b24208fd 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -2,7 +2,7 @@ {% load form_helpers %} {% block content %} -
    + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 90cb81a61..ced3ee733 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -15,7 +15,7 @@
    {% endif %} - + {% csrf_token %} {% render_form form %}
    diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html new file mode 100644 index 000000000..08251e2fa --- /dev/null +++ b/netbox/templates/virtualization/cluster.html @@ -0,0 +1,127 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
    +
    + +
    +
    + +
    + + + + +
    + +
    +
    +
    + {% if perms.virtualization.change_cluster %} + + + Edit this cluster + + {% endif %} + {% if perms.dcim.delete_cluster %} + + + Delete this cluster + + {% endif %} +
    +

    {% block title %}{{ cluster }}{% endblock %}

    +{% include 'inc/created_updated.html' with obj=cluster %} +
    +
    +
    +
    + Cluster +
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ cluster.name }}
    Type{{ cluster.type }}
    Group + {% if cluster.group %} + {{ cluster.group }} + {% else %} + None + {% endif %} +
    Site + {% if cluster.site %} + {{ cluster.site }} + {% else %} + None + {% endif %} +
    Virtual Machines{{ cluster.virtual_machines.count }}
    +
    + {% include 'inc/custom_fields_panel.html' with custom_fields=cluster.get_custom_fields %} +
    +
    + Comments +
    +
    + {% if cluster.comments %} + {{ cluster.comments|gfm }} + {% else %} + None + {% endif %} +
    +
    +
    +
    +
    +
    + Host Devices +
    + {% if perms.virtualization.change_cluster %} +
    + {% csrf_token %} + {% endif %} + {% include 'responsive_table.html' with table=device_table %} + {% if perms.virtualization.change_cluster %} + +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html new file mode 100644 index 000000000..2029ea5b8 --- /dev/null +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -0,0 +1,81 @@ +{% extends '_base.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block content %} +
    + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    +
    +

    {% block title %}Add Devices to Cluster {{ cluster }}{% endblock %}

    + {% if form.non_field_errors %} +
    +
    Errors
    +
    + {{ form.non_field_errors }} +
    +
    + {% endif %} +
    +
    Device Selection
    +
    + +
    + +
    + {% render_field form.region %} + {% render_field form.site %} + {% render_field form.rack %} +
    +
    + {% render_field form.devices %} +
    +
    +
    +
    +
    +
    + + Cancel +
    +
    +
    +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html new file mode 100644 index 000000000..dad7a9e49 --- /dev/null +++ b/netbox/templates/virtualization/cluster_list.html @@ -0,0 +1,26 @@ +{% extends '_base.html' %} + +{% block content %} +
    + {% if perms.virtualization.add_cluster %} + + + Add a cluster + + + + Import clusters + + {% endif %} + {% include 'inc/export_button.html' with obj_type='clusters' %} +
    +

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

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_edit_url='virtualization:cluster_bulk_edit' bulk_delete_url='virtualization:cluster_bulk_delete' %} +
    +
    + {% include 'inc/search_panel.html' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/clustergroup_list.html b/netbox/templates/virtualization/clustergroup_list.html new file mode 100644 index 000000000..c237a61b5 --- /dev/null +++ b/netbox/templates/virtualization/clustergroup_list.html @@ -0,0 +1,19 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
    + {% if perms.virtualization.add_clustergroup %} + + + Add a cluster group + + {% endif %} +
    +

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

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustergroup_bulk_delete' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/clustertype_list.html b/netbox/templates/virtualization/clustertype_list.html new file mode 100644 index 000000000..a7082ebe8 --- /dev/null +++ b/netbox/templates/virtualization/clustertype_list.html @@ -0,0 +1,19 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
    + {% if perms.virtualization.add_clustertype %} + + + Add a cluster type + + {% endif %} +
    +

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

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustertype_bulk_delete' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/inc/interface.html b/netbox/templates/virtualization/inc/interface.html new file mode 100644 index 000000000..ef37561bb --- /dev/null +++ b/netbox/templates/virtualization/inc/interface.html @@ -0,0 +1,70 @@ + + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} + + + + {% endif %} + + {{ iface.name }} + {% if iface.description %} + + {% endif %} + + {{ iface.mtu|default:"" }} + {{ iface.mac_address|default:"" }} + + {% if perms.ipam.add_ipaddress %} + + + + {% endif %} + {% if perms.dcim.change_interface %} + + + + {% endif %} + {% if perms.dcim.delete_interface %} + + + + {% endif %} + + +{% for ip in iface.ip_addresses.all %} + + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + {{ ip }} + {% if ip.description %} + + {% endif %} + {% if vm.primary_ip4 == ip or vm.primary_ip6 == ip %} + Primary + {% endif %} + + + {% if ip.vrf %} + {{ ip.vrf }} + {% else %} + Global + {% endif %} + + + {{ ip.get_status_display }} + + + {% if perms.ipam.change_ipaddress %} + + + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} + + +{% endfor %} diff --git a/netbox/templates/virtualization/inc/virtualmachine_table.html b/netbox/templates/virtualization/inc/virtualmachine_table.html new file mode 100644 index 000000000..ce249593e --- /dev/null +++ b/netbox/templates/virtualization/inc/virtualmachine_table.html @@ -0,0 +1,14 @@ +{% extends 'utilities/obj_table.html' %} + +{% block extra_actions %} + {% if perms.virtualization.change_virtualmachine %} +
    + + +
    + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html new file mode 100644 index 000000000..cb83142ea --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine.html @@ -0,0 +1,300 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% if perms.virtualization.change_virtualmachine %} + + + Edit this VM + + {% endif %} + {% if perms.virtualization.delete_virtualmachine %} + + + Delete this VM + + {% endif %} +
    +

    {% block title %}{{ vm }}{% endblock %}

    +{% include 'inc/created_updated.html' with obj=vm %} +
    +
    +
    +
    + Virtual Machine +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ vm.name }}
    Status + {{ vm.get_status_display }} +
    Role + {% if vm.role %} + {{ vm.role }} + {% else %} + None + {% endif %} +
    Platform + {% if vm.platform %} + {{ vm.platform }} + {% else %} + None + {% endif %} +
    Tenant + {% if vm.tenant %} + {% if vm.tenant.group %} + {{ vm.tenant.group.name }} + + {% endif %} + {{ vm.tenant }} + {% else %} + None + {% endif %} +
    Primary IPv4 + {% if vm.primary_ip4 %} + {{ vm.primary_ip4.address.ip }} + {% if vm.primary_ip4.nat_inside %} + (NAT for {{ vm.primary_ip4.nat_inside.address.ip }}) + {% elif vm.primary_ip4.nat_outside %} + (NAT: {{ vm.primary_ip4.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
    Primary IPv6 + {% if vm.primary_ip6 %} + {{ vm.primary_ip6.address.ip }} + {% if vm.primary_ip6.nat_inside %} + (NAT for {{ vm.primary_ip6.nat_inside.address.ip }}) + {% elif vm.primary_ip6.nat_outside %} + (NAT: {{ vm.primary_ip6.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
    +
    +
    +
    + Cluster +
    + + + + + + + + + +
    Cluster + {% if vm.cluster.group %} + {{ vm.cluster.group }} + + {% endif %} + {{ vm.cluster }} +
    Cluster Type{{ vm.cluster.type }}
    +
    +
    +
    + Resources +
    + + + + + + + + + + + + + +
    Virtual CPUs + {% if vm.vcpus %} + {{ vm.vcpus }} + {% else %} + N/A + {% endif %} +
    Memory + {% if vm.memory %} + {{ vm.memory }} MB + {% else %} + N/A + {% endif %} +
    Disk Space + {% if vm.disk %} + {{ vm.disk }} GB + {% else %} + N/A + {% endif %} +
    +
    +
    +
    + Services +
    + {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
    + {% else %} +
    + None +
    + {% endif %} + {% if perms.ipam.add_service %} + + {% endif %} +
    + {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %} +
    +
    + Comments +
    +
    + {% if vm.comments %} + {{ vm.comments|gfm }} + {% else %} + None + {% endif %} +
    +
    +
    +
    + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} +
    + {% csrf_token %} + + {% endif %} +
    +
    + Interfaces +
    + + {% if perms.dcim.change_interface and interfaces|length > 1 %} + + {% endif %} + {% if perms.dcim.add_interface and interfaces|length > 10 %} + + Add interfaces + + {% endif %} +
    +
    + + {% for iface in interfaces %} + {% include 'virtualization/inc/interface.html' with selectable=True %} + {% empty %} + + + + {% endfor %} +
    No interfaces defined
    + {% if perms.dcim.add_interface or perms.dcim.delete_interface %} + + {% endif %} +
    + {% if perms.dcim.delete_interface %} +
    + {% endif %} +
    +
    +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html new file mode 100644 index 000000000..7cac56705 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -0,0 +1,44 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} + +{% block content %} +
    + {% csrf_token %} +
    +
    + {% if form.non_field_errors %} +
    +
    Errors
    +
    + {{ form.non_field_errors }} +
    +
    + {% endif %} +
    +
    + {{ component_type|bettertitle }} +
    +
    +
    + +
    +

    {{ parent }}

    +
    +
    + {% render_form form %} +
    +
    +
    +
    + + + Cancel +
    +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html new file mode 100644 index 000000000..7c240857f --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine_edit.html @@ -0,0 +1,50 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
    +
    Virtual Machine
    +
    + {% render_field form.name %} + {% render_field form.status %} + {% render_field form.role %} + {% render_field form.platform %} +
    +
    +
    +
    Cluster
    +
    + {% render_field form.cluster_group %} + {% render_field form.cluster %} +
    +
    +
    +
    Resources
    +
    + {% render_field form.vcpus %} + {% render_field form.memory %} + {% render_field form.disk %} +
    +
    +
    +
    Tenancy
    +
    + {% render_field form.tenant_group %} + {% render_field form.tenant %} +
    +
    + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html new file mode 100644 index 000000000..a771dfbd9 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -0,0 +1,55 @@ +{% extends '_base.html' %} + +{% block content %} +
    + {% if perms.virtualization.add_virtualmachine %} + + + Add a virtual machine + + + + Import virtual machines + + {% endif %} + {% include 'inc/export_button.html' with obj_type='virtual machines' %} +
    +

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

    +
    +
    + {% include 'virtualization/inc/virtualmachine_table.html' with bulk_edit_url='virtualization:virtualmachine_bulk_edit' bulk_delete_url='virtualization:virtualmachine_bulk_delete' %} +
    +
    + {% include 'inc/search_panel.html' %} +
    +
    +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index a3ce7774a..a36a1ec3d 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -16,6 +16,9 @@ class TenancyRootView(routers.APIRootView): router = routers.DefaultRouter() router.APIRootView = TenancyRootView +# Field choices +router.register(r'_choices', views.TenancyFieldChoicesViewSet, base_name='field-choice') + # Tenants router.register(r'tenant-groups', views.TenantGroupViewSet) router.register(r'tenants', views.TenantViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 3c930bf73..c1f7d990d 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -5,10 +5,18 @@ from rest_framework.viewsets import ModelViewSet from extras.api.views import CustomFieldModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup -from utilities.api import WritableSerializerMixin +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin from . import serializers +# +# Field choices +# + +class TenancyFieldChoicesViewSet(FieldChoicesViewSet): + fields = () + + # # Tenant Groups # diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 969050841..5eb3bda61 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -22,6 +22,17 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): fields = ['name', 'slug'] +class TenantGroupCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = TenantGroup + fields = ['name', 'slug'] + help_texts = { + 'name': 'Group name', + } + + # # Tenants # diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 76d800344..a0337a938 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ # Tenant groups url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'), url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), url(r'^tenant-groups/(?P[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index e176075cb..32709dfb7 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -12,6 +12,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) +from virtualization.models import VirtualMachine from .models import Tenant, TenantGroup from . import filters, forms, tables @@ -29,7 +30,7 @@ class TenantGroupListView(ObjectListView): class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenantgroup' model = TenantGroup - form_class = forms.TenantGroupForm + model_form = forms.TenantGroupForm def get_return_url(self, request, obj): return reverse('tenancy:tenantgroup_list') @@ -39,6 +40,13 @@ class TenantGroupEditView(TenantGroupCreateView): permission_required = 'tenancy.change_tenantgroup' +class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'tenancy.add_tenantgroup' + model_form = forms.TenantGroupCSVForm + table = tables.TenantGroupTable + default_return_url = 'tenancy:tenantgroup_list' + + class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenantgroup' cls = TenantGroup @@ -79,6 +87,7 @@ class TenantView(View): ).count(), 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), + 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), } return render(request, 'tenancy/tenant.html', { @@ -90,7 +99,7 @@ class TenantView(View): class TenantCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenant' model = Tenant - form_class = forms.TenantForm + model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 584b00fc3..d9ae855ea 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,17 +1,15 @@ from __future__ import unicode_literals +from collections import OrderedDict from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.http import Http404 -from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException -from rest_framework.pagination import LimitOffsetPagination -from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS -from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework.permissions import BasePermission +from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError -from rest_framework.utils import formatting - -from users.models import Token +from rest_framework.viewsets import ViewSet WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] @@ -26,47 +24,6 @@ class ServiceUnavailable(APIException): # Authentication # -class TokenAuthentication(authentication.TokenAuthentication): - """ - A custom authentication scheme which enforces Token expiration times. - """ - model = Token - - def authenticate_credentials(self, key): - model = self.get_model() - try: - token = model.objects.select_related('user').get(key=key) - except model.DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid token") - - # Enforce the Token's expiration time, if one has been set. - if token.is_expired: - raise exceptions.AuthenticationFailed("Token expired") - - if not token.user.is_active: - raise exceptions.AuthenticationFailed("User inactive") - - return token.user, token - - -class TokenPermissions(DjangoModelPermissions): - """ - Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability - for unsafe requests (POST/PUT/PATCH/DELETE). - """ - def __init__(self): - # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. - self.authenticated_users_only = settings.LOGIN_REQUIRED - super(TokenPermissions, self).__init__() - - def has_permission(self, request, view): - # If token authentication is in use, verify that the token allows write operations (for unsafe methods). - if request.method not in SAFE_METHODS and isinstance(request.auth, Token): - if not request.auth.write_enabled: - return False - return super(TokenPermissions, self).has_permission(request, view) - - class IsAuthenticatedOrLoginNotRequired(BasePermission): """ Returns True if the user is authenticated or LOGIN_REQUIRED is False. @@ -140,6 +97,55 @@ class ContentTypeFieldSerializer(Field): raise ValidationError("Invalid content type") +# +# Views +# + +class FieldChoicesViewSet(ViewSet): + """ + Expose the built-in numeric values which represent static choices for a model's field. + """ + permission_classes = [IsAuthenticatedOrLoginNotRequired] + fields = [] + + def __init__(self, *args, **kwargs): + super(FieldChoicesViewSet, self).__init__(*args, **kwargs) + + # Compile a dict of all fields in this view + self._fields = OrderedDict() + for cls, field_list in self.fields: + for field_name in field_list: + model_name = cls._meta.verbose_name.lower().replace(' ', '-') + key = ':'.join([model_name, field_name]) + choices = [] + for k, v in cls._meta.get_field(field_name).choices: + if type(v) in [list, tuple]: + for k2, v2 in v: + choices.append({ + 'value': k2, + 'label': v2, + }) + else: + choices.append({ + 'value': k, + 'label': v, + }) + self._fields[key] = choices + + def list(self, request): + return Response(self._fields) + + def retrieve(self, request, pk): + + if pk not in self._fields: + raise Http404 + + return Response(self._fields[pk]) + + def get_view_name(self): + return "Field Choices" + + # # Mixins # @@ -152,93 +158,3 @@ class WritableSerializerMixin(object): if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): return self.write_serializer_class return self.serializer_class - - -# -# Pagination -# - -class OptionalLimitOffsetPagination(LimitOffsetPagination): - """ - Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects - matching a query, but retains the same format as a paginated request. The limit can only be disabled if - MAX_PAGE_SIZE has been set to 0 or None. - """ - - def paginate_queryset(self, queryset, request, view=None): - - try: - self.count = queryset.count() - except (AttributeError, TypeError): - self.count = len(queryset) - self.limit = self.get_limit(request) - self.offset = self.get_offset(request) - self.request = request - - if self.limit and self.count > self.limit and self.template is not None: - self.display_page_controls = True - - if self.count == 0 or self.offset > self.count: - return list() - - if self.limit: - return list(queryset[self.offset:self.offset + self.limit]) - else: - return list(queryset[self.offset:]) - - def get_limit(self, request): - - if self.limit_query_param: - try: - limit = int(request.query_params[self.limit_query_param]) - if limit < 0: - raise ValueError() - # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) - return limit - except (KeyError, ValueError): - pass - - return self.default_limit - - -# -# Renderers -# - -class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): - """ - Override the built-in BrowsableAPIRenderer to disable HTML forms. - """ - def show_form_for_method(self, *args, **kwargs): - return False - - -# -# Miscellaneous -# - -def get_view_name(view_cls, suffix=None): - """ - Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. - """ - if hasattr(view_cls, 'queryset'): - # Determine the model name from the queryset. - name = view_cls.queryset.model._meta.verbose_name - name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word - - else: - # Replicate DRF's built-in behavior. - name = view_cls.__name__ - name = formatting.remove_trailing_string(name, 'View') - name = formatting.remove_trailing_string(name, 'ViewSet') - name = formatting.camelcase_to_spaces(name) - - if suffix: - name += ' ' + suffix - - return name diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 36894ef26..f63c0c11d 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -188,6 +188,10 @@ class APISelect(SelectWithDisabled): self.attrs['disabled-indicator'] = disabled_indicator +class APISelectMultiple(APISelect): + allow_multiple_selected = True + + class Livesearch(forms.TextInput): """ A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search @@ -386,6 +390,15 @@ class ChainedModelChoiceField(forms.ModelChoiceField): super(ChainedModelChoiceField, self).__init__(*args, **kwargs) +class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + See ChainedModelChoiceField + """ + def __init__(self, chains=None, *args, **kwargs): + self.chains = chains + super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs) + + class SlugField(forms.SlugField): def __init__(self, slug_source='name', *args, **kwargs): @@ -508,6 +521,15 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) +class ComponentForm(BootstrapMixin, forms.Form): + """ + Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices. + """ + def __init__(self, parent, *args, **kwargs): + self.parent = parent + super(ComponentForm, self).__init__(*args, **kwargs) + + class BulkEditForm(forms.Form): def __init__(self, model, *args, **kwargs): diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 0228df037..23fcfcca5 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -46,6 +46,22 @@ def gfm(value): return mark_safe(html) +@register.filter() +def model_name(obj): + """ + Return the name of the model of the given object + """ + return obj._meta.verbose_name + + +@register.filter() +def model_name_plural(obj): + """ + Return the plural name of the model of the given object + """ + return obj._meta.verbose_name_plural + + @register.filter() def contains(value, arg): """ diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index ec91399df..d7b82236c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals from collections import OrderedDict +from copy import deepcopy from django_tables2 import RequestConfig @@ -9,7 +10,7 @@ from django.contrib.contenttypes.models import ContentType 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, TypedChoiceField +from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.template import TemplateSyntaxError @@ -155,12 +156,12 @@ class ObjectEditView(GetReturnURLMixin, View): Create or edit a single object. model: The model of the object being edited - form_class: The form used to create or edit the object + model_form: The form used to create or edit the object template_name: The name of the template default_return_url: The name of the URL used to display a list of this object type """ model = None - form_class = None + model_form = None template_name = 'utilities/obj_edit.html' def get_object(self, kwargs): @@ -182,7 +183,7 @@ class ObjectEditView(GetReturnURLMixin, View): obj = self.alter_obj(obj, request, args, kwargs) # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} - form = self.form_class(instance=obj, initial=initial_data) + form = self.model_form(instance=obj, initial=initial_data) return render(request, self.template_name, { 'obj': obj, @@ -195,7 +196,7 @@ class ObjectEditView(GetReturnURLMixin, View): obj = self.get_object(kwargs) obj = self.alter_obj(obj, request, args, kwargs) - form = self.form_class(request.POST, request.FILES, instance=obj) + form = self.model_form(request.POST, request.FILES, instance=obj) if form.is_valid(): obj_created = not form.instance.pk @@ -214,7 +215,7 @@ class ObjectEditView(GetReturnURLMixin, View): UserAction.objects.log_edit(request.user, obj, msg) if '_addanother' in request.POST: - return redirect(request.path) + return redirect(request.get_full_path()) return_url = form.cleaned_data.get('return_url') if return_url is not None and is_safe_url(url=return_url, host=request.get_host()): @@ -294,12 +295,12 @@ class BulkCreateView(View): """ Create new objects in bulk. - pattern_form: Form class which provides the `pattern` field + form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects template_name: The name of the template default_return_url: Name of the URL to which the user is redirected after creating the objects """ - pattern_form = None + form = None model_form = None pattern_target = '' template_name = None @@ -307,12 +308,12 @@ class BulkCreateView(View): def get(self, request): - pattern_form = self.pattern_form() + form = self.form() model_form = self.model_form() return render(request, self.template_name, { 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'pattern_form': pattern_form, + 'form': form, 'model_form': model_form, 'return_url': reverse(self.default_return_url), }) @@ -320,12 +321,12 @@ class BulkCreateView(View): def post(self, request): model = self.model_form._meta.model - pattern_form = self.pattern_form(request.POST) + form = self.form(request.POST) model_form = self.model_form(request.POST) - if pattern_form.is_valid(): + if form.is_valid(): - pattern = pattern_form.cleaned_data['pattern'] + pattern = form.cleaned_data['pattern'] new_objs = [] try: @@ -347,7 +348,7 @@ class BulkCreateView(View): # Copy any errors on the pattern target field to the pattern form. errors = model_form.errors.as_data() if errors.get(self.pattern_target): - pattern_form.add_error('pattern', errors[self.pattern_target]) + form.add_error('pattern', errors[self.pattern_target]) # Raise an IntegrityError to break the for loop and abort the transaction. raise IntegrityError() @@ -364,7 +365,7 @@ class BulkCreateView(View): pass return render(request, self.template_name, { - 'pattern_form': pattern_form, + 'form': form, 'model_form': model_form, 'obj_type': model._meta.verbose_name, 'return_url': reverse(self.default_return_url), @@ -379,11 +380,13 @@ class BulkImportView(View): table: The django-tables2 Table used to render the list of imported objects template_name: The name of the template default_return_url: The name of the URL to use for the cancel button + widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) """ model_form = None table = None default_return_url = None template_name = 'utilities/obj_import.html' + widget_attrs = {} def _import_form(self, *args, **kwargs): @@ -391,7 +394,7 @@ class BulkImportView(View): required_fields = [name for name, field in self.model_form().fields.items() if field.required] class ImportForm(BootstrapMixin, Form): - csv = CSVDataField(fields=fields, required_fields=required_fields) + csv = CSVDataField(fields=fields, required_fields=required_fields, widget=Textarea(attrs=self.widget_attrs)) return ImportForm(*args, **kwargs) @@ -700,3 +703,173 @@ class BulkDeleteView(View): if self.form: return self.form return BulkDeleteForm + + +# +# Device/VirtualMachine components +# + +class ComponentCreateView(View): + """ + Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. + """ + parent_model = None + parent_field = None + model = None + form = None + model_form = None + template_name = None + + def get(self, request, pk): + + parent = get_object_or_404(self.parent_model, pk=pk) + form = self.form(parent, initial=request.GET) + + return render(request, self.template_name, { + 'parent': parent, + 'component_type': self.model._meta.verbose_name, + 'form': form, + 'return_url': parent.get_absolute_url(), + }) + + def post(self, request, pk): + + parent = get_object_or_404(self.parent_model, pk=pk) + + form = self.form(parent, request.POST) + if form.is_valid(): + + new_components = [] + data = deepcopy(form.cleaned_data) + + for name in form.cleaned_data['name_pattern']: + component_data = { + self.parent_field: parent.pk, + 'name': name, + } + # Replace objects with their primary key to keep component_form.clean() happy + for k, v in data.items(): + if hasattr(v, 'pk'): + component_data[k] = v.pk + else: + component_data[k] = v + component_form = self.model_form(component_data) + if component_form.is_valid(): + new_components.append(component_form.save(commit=False)) + else: + for field, errors in component_form.errors.as_data().items(): + # Assign errors on the child form's name field to name_pattern on the parent form + if field == 'name': + field = 'name_pattern' + for e in errors: + form.add_error(field, '{}: {}'.format(name, ', '.join(e))) + + if not form.errors: + self.model.objects.bulk_create(new_components) + messages.success(request, "Added {} {} to {}.".format( + len(new_components), self.model._meta.verbose_name_plural, parent + )) + if '_addanother' in request.POST: + return redirect(request.path) + else: + return redirect(parent.get_absolute_url()) + + return render(request, self.template_name, { + 'parent': parent, + 'component_type': self.model._meta.verbose_name, + 'form': form, + 'return_url': parent.get_absolute_url(), + }) + + +class ComponentEditView(ObjectEditView): + parent_field = None + + def get_return_url(self, request, obj): + return getattr(obj, self.parent_field).get_absolute_url() + + +class ComponentDeleteView(ObjectDeleteView): + parent_field = None + + def get_return_url(self, request, obj): + return getattr(obj, self.parent_field).get_absolute_url() + + +class BulkComponentCreateView(View): + """ + Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. + """ + parent_model = None + parent_field = None + form = None + model = None + model_form = None + filter = None + table = None + template_name = 'utilities/obj_bulk_add_component.html' + default_return_url = 'home' + + def post(self, request): + + # Are we editing *all* objects in the queryset or just a selected subset? + if request.POST.get('_all') and self.filter is not None: + pk_list = [obj.pk for obj in self.filter(request.GET, self.model.objects.only('pk')).qs] + else: + pk_list = [int(pk) for pk in request.POST.getlist('pk')] + + # Determine URL to redirect users upon modification of objects + posted_return_url = request.POST.get('return_url') + if posted_return_url and is_safe_url(url=posted_return_url, host=request.get_host()): + return_url = posted_return_url + else: + return_url = reverse(self.default_return_url) + + selected_objects = self.parent_model.objects.filter(pk__in=pk_list) + if not selected_objects: + messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural)) + return redirect(return_url) + table = self.table(selected_objects) + + if '_create' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + + new_components = [] + data = deepcopy(form.cleaned_data) + for obj in data['pk']: + + names = data['name_pattern'] + for name in names: + component_data = { + self.parent_field: obj.pk, + 'name': name, + } + component_data.update(data) + component_form = self.model_form(component_data) + if component_form.is_valid(): + new_components.append(component_form.save(commit=False)) + else: + for field, errors in component_form.errors.as_data().items(): + for e in errors: + form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + + if not form.errors: + self.model.objects.bulk_create(new_components) + messages.success(request, "Added {} {} to {} {}.".format( + len(new_components), + self.model._meta.verbose_name_plural, + len(form.cleaned_data['pk']), + self.parent_model._meta.verbose_name_plural + )) + return redirect(return_url) + + else: + form = self.form(initial={'pk': pk_list}) + + return render(request, self.template_name, { + 'form': form, + 'component_name': self.model._meta.verbose_name_plural, + 'table': table, + 'return_url': reverse(self.default_return_url), + }) diff --git a/netbox/virtualization/__init__.py b/netbox/virtualization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/virtualization/api/__init__.py b/netbox/virtualization/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py new file mode 100644 index 000000000..b53734734 --- /dev/null +++ b/netbox/virtualization/api/serializers.py @@ -0,0 +1,148 @@ +from __future__ import unicode_literals + +from rest_framework import serializers + +from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer +from dcim.constants import IFACE_FF_VIRTUAL +from dcim.models import Interface +from extras.api.customfields import CustomFieldModelSerializer +from tenancy.api.serializers import NestedTenantSerializer +from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.constants import STATUS_CHOICES +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +# +# Cluster types +# + +class ClusterTypeSerializer(ValidatedModelSerializer): + + class Meta: + model = ClusterType + fields = ['id', 'name', 'slug'] + + +class NestedClusterTypeSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') + + class Meta: + model = ClusterType + fields = ['id', 'url', 'name', 'slug'] + + +# +# Cluster groups +# + +class ClusterGroupSerializer(ValidatedModelSerializer): + + class Meta: + model = ClusterGroup + fields = ['id', 'name', 'slug'] + + +class NestedClusterGroupSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') + + class Meta: + model = ClusterGroup + fields = ['id', 'url', 'name', 'slug'] + + +# +# Clusters +# + +class ClusterSerializer(CustomFieldModelSerializer): + type = NestedClusterTypeSerializer() + group = NestedClusterGroupSerializer() + site = NestedSiteSerializer() + + class Meta: + model = Cluster + fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields'] + + +class NestedClusterSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') + + class Meta: + model = Cluster + fields = ['id', 'url', 'name'] + + +class WritableClusterSerializer(CustomFieldModelSerializer): + + class Meta: + model = Cluster + fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields'] + + +# +# Virtual machines +# + +class VirtualMachineSerializer(CustomFieldModelSerializer): + status = ChoiceFieldSerializer(choices=STATUS_CHOICES) + cluster = NestedClusterSerializer() + role = NestedDeviceRoleSerializer() + tenant = NestedTenantSerializer() + platform = NestedPlatformSerializer() + + class Meta: + model = VirtualMachine + fields = [ + 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', + 'memory', 'disk', 'comments', 'custom_fields', + ] + + +class NestedVirtualMachineSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') + + class Meta: + model = VirtualMachine + fields = ['id', 'url', 'name'] + + +class WritableVirtualMachineSerializer(CustomFieldModelSerializer): + + class Meta: + model = VirtualMachine + fields = [ + 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', + 'memory', 'disk', 'comments', 'custom_fields', + ] + + +# +# VM interfaces +# + +class InterfaceSerializer(serializers.ModelSerializer): + virtual_machine = NestedVirtualMachineSerializer() + + class Meta: + model = Interface + fields = [ + 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description', + ] + + +class NestedInterfaceSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') + + class Meta: + model = Interface + fields = ['id', 'url', 'name'] + + +class WritableInterfaceSerializer(ValidatedModelSerializer): + form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL) + + class Meta: + model = Interface + fields = [ + 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', + ] diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py new file mode 100644 index 000000000..45db6aa6a --- /dev/null +++ b/netbox/virtualization/api/urls.py @@ -0,0 +1,32 @@ +from __future__ import unicode_literals + +from rest_framework import routers + +from . import views + + +class VirtualizationRootView(routers.APIRootView): + """ + Virtualization API root view + """ + def get_view_name(self): + return 'Virtualization' + + +router = routers.DefaultRouter() +router.APIRootView = VirtualizationRootView + +# Field choices +router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, base_name='field-choice') + +# Clusters +router.register(r'cluster-types', views.ClusterTypeViewSet) +router.register(r'cluster-groups', views.ClusterGroupViewSet) +router.register(r'clusters', views.ClusterViewSet) + +# VirtualMachines +router.register(r'virtual-machines', views.VirtualMachineViewSet) +router.register(r'interfaces', views.InterfaceViewSet) + +app_name = 'virtualization-api' +urlpatterns = router.urls diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py new file mode 100644 index 000000000..2b7ce4b60 --- /dev/null +++ b/netbox/virtualization/api/views.py @@ -0,0 +1,59 @@ +from __future__ import unicode_literals + +from rest_framework.viewsets import ModelViewSet + +from dcim.models import Interface +from extras.api.views import CustomFieldModelViewSet +from utilities.api import FieldChoicesViewSet, WritableSerializerMixin +from virtualization import filters +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from . import serializers + + +# +# Field choices +# + +class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (VirtualMachine, ['status']), + ) + + +# +# Clusters +# + +class ClusterTypeViewSet(ModelViewSet): + queryset = ClusterType.objects.all() + serializer_class = serializers.ClusterTypeSerializer + + +class ClusterGroupViewSet(ModelViewSet): + queryset = ClusterGroup.objects.all() + serializer_class = serializers.ClusterGroupSerializer + + +class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet): + queryset = Cluster.objects.select_related('type', 'group') + serializer_class = serializers.ClusterSerializer + write_serializer_class = serializers.WritableClusterSerializer + filter_class = filters.ClusterFilter + + +# +# Virtual machines +# + +class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet): + queryset = VirtualMachine.objects.all() + serializer_class = serializers.VirtualMachineSerializer + write_serializer_class = serializers.WritableVirtualMachineSerializer + filter_class = filters.VirtualMachineFilter + + +class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): + queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine') + serializer_class = serializers.InterfaceSerializer + write_serializer_class = serializers.WritableInterfaceSerializer + filter_class = filters.InterfaceFilter diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py new file mode 100644 index 000000000..768508cfb --- /dev/null +++ b/netbox/virtualization/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class VirtualizationConfig(AppConfig): + name = 'virtualization' diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py new file mode 100644 index 000000000..f1d9e6c39 --- /dev/null +++ b/netbox/virtualization/constants.py @@ -0,0 +1,18 @@ +from __future__ import unicode_literals + +from dcim.constants import STATUS_ACTIVE, STATUS_OFFLINE, STATUS_STAGED + + +# VirtualMachine statuses (replicated from Device statuses) +STATUS_CHOICES = [ + [STATUS_ACTIVE, 'Active'], + [STATUS_OFFLINE, 'Offline'], + [STATUS_STAGED, 'Staged'], +] + +# Bootstrap CSS classes for VirtualMachine statuses +VM_STATUS_CLASSES = { + 0: 'warning', + 1: 'success', + 3: 'primary', +} diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py new file mode 100644 index 000000000..870ac85c6 --- /dev/null +++ b/netbox/virtualization/filters.py @@ -0,0 +1,163 @@ +from __future__ import unicode_literals + +import django_filters +from netaddr import EUI +from netaddr.core import AddrFormatError + +from django.db.models import Q + +from dcim.models import DeviceRole, Interface, Platform, Site +from extras.filters import CustomFieldFilterSet +from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from .constants import STATUS_CHOICES +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +class ClusterFilter(CustomFieldFilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = NullableModelMultipleChoiceFilter( + queryset=ClusterGroup.objects.all(), + label='Parent group (ID)', + ) + group = NullableModelMultipleChoiceFilter( + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + label='Parent group (slug)', + ) + type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ClusterType.objects.all(), + label='Cluster type (ID)', + ) + type = django_filters.ModelMultipleChoiceFilter( + name='type__slug', + queryset=ClusterType.objects.all(), + to_field_name='slug', + label='Cluster type (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = Cluster + fields = ['name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(comments__icontains=value) + ) + + +class VirtualMachineFilter(CustomFieldFilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') + q = django_filters.CharFilter( + method='search', + label='Search', + ) + status = django_filters.MultipleChoiceFilter( + choices=STATUS_CHOICES + ) + cluster_group_id = NullableModelMultipleChoiceFilter( + name='cluster__group', + queryset=ClusterGroup.objects.all(), + label='Cluster group (ID)', + ) + cluster_group = NullableModelMultipleChoiceFilter( + name='cluster__group__slug', + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + label='Cluster group (slug)', + ) + cluster_id = django_filters.ModelMultipleChoiceFilter( + queryset=Cluster.objects.all(), + label='Cluster (ID)', + ) + role_id = NullableModelMultipleChoiceFilter( + name='role_id', + queryset=DeviceRole.objects.all(), + label='Role (ID)', + ) + role = NullableModelMultipleChoiceFilter( + name='role__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + tenant_id = NullableModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label='Tenant (ID)', + ) + tenant = NullableModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) + platform_id = NullableModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label='Platform (ID)', + ) + platform = NullableModelMultipleChoiceFilter( + name='platform', + queryset=Platform.objects.all(), + to_field_name='slug', + label='Platform (slug)', + ) + + class Meta: + model = VirtualMachine + fields = ['name', 'cluster'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(comments__icontains=value) + ) + + +class InterfaceFilter(django_filters.FilterSet): + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + name='virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + name='virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine', + ) + mac_address = django_filters.CharFilter( + method='_mac_address', + label='MAC address', + ) + + class Meta: + model = Interface + fields = ['name', 'enabled', 'mtu'] + + def _mac_address(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + try: + mac = EUI(value.strip()) + return queryset.filter(mac_address=mac) + except AddrFormatError: + return queryset.none() diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py new file mode 100644 index 000000000..63561a08c --- /dev/null +++ b/netbox/virtualization/forms.py @@ -0,0 +1,423 @@ +from __future__ import unicode_literals + +from mptt.forms import TreeNodeChoiceField + +from django import forms +from django.core.exceptions import ValidationError +from django.db.models import Count + +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 tenancy.forms import TenancyForm +from tenancy.models import Tenant +from utilities.forms import ( + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, +) +from .constants import STATUS_CHOICES +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +VIFACE_FF_CHOICES = ( + (IFACE_FF_VIRTUAL, 'Virtual'), +) + + +# +# Cluster types +# + +class ClusterTypeForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = ClusterType + fields = ['name', 'slug'] + + +class ClusterTypeCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = ClusterType + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of cluster type', + } + + +# +# Cluster groups +# + +class ClusterGroupForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = ClusterGroup + fields = ['name', 'slug'] + + +class ClusterGroupCSVForm(forms.ModelForm): + slug = SlugField() + + class Meta: + model = ClusterGroup + fields = ['name', 'slug'] + help_texts = { + 'name': 'Name of cluster group', + } + + +# +# Clusters +# + +class ClusterForm(BootstrapMixin, CustomFieldForm): + comments = CommentField(widget=SmallTextarea) + + class Meta: + model = Cluster + fields = ['name', 'type', 'group', 'site', 'comments'] + + +class ClusterCSVForm(forms.ModelForm): + type = forms.ModelChoiceField( + queryset=ClusterType.objects.all(), + to_field_name='name', + help_text='Name of cluster type', + error_messages={ + 'invalid_choice': 'Invalid cluster type name.', + } + ) + group = forms.ModelChoiceField( + queryset=ClusterGroup.objects.all(), + to_field_name='name', + required=False, + help_text='Name of cluster group', + error_messages={ + 'invalid_choice': 'Invalid cluster group name.', + } + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Name of assigned site', + error_messages={ + 'invalid_choice': 'Invalid site name.', + } + ) + + class Meta: + model = Cluster + fields = ['name', 'type', 'group', 'site', 'comments'] + + +class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput) + type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False) + group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) + comments = CommentField(widget=SmallTextarea) + + class Meta: + nullable_fields = ['group', 'site', 'comments'] + + +class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Cluster + q = forms.CharField(required=False, label='Search') + type = FilterChoiceField( + queryset=ClusterType.objects.annotate(filter_count=Count('clusters')), + to_field_name='slug', + required=False, + ) + group = FilterChoiceField( + queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')), + to_field_name='slug', + null_option=(0, 'None'), + required=False, + ) + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('clusters')), + to_field_name='slug', + null_option=(0, 'None'), + required=False, + ) + + +class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): + region = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'site', 'nullable': 'true'} + ) + ) + site = ChainedModelChoiceField( + queryset=Site.objects.all(), + chains=( + ('region', 'region'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/sites/?region_id={{region}}', + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + attrs={'filter-for': 'devices', 'nullable': 'true'} + ) + ) + devices = ChainedModelMultipleChoiceField( + queryset=Device.objects.filter(cluster__isnull=True), + chains=( + ('site', 'site'), + ('rack', 'rack'), + ), + widget=APISelectMultiple( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + disabled_indicator='cluster' + ) + ) + + class Meta: + fields = ['region', 'site', 'rack', 'devices'] + + def __init__(self, cluster, *args, **kwargs): + + self.cluster = cluster + + super(ClusterAddDevicesForm, self).__init__(*args, **kwargs) + + self.fields['devices'].choices = [] + + def clean(self): + + super(ClusterAddDevicesForm, self).clean() + + # If the Cluster is assigned to a Site, all Devices must be assigned to that Site. + if self.cluster.site is not None: + for device in self.cleaned_data.get('devices'): + if device.site != self.cluster.site: + raise ValidationError({ + 'devices': "{} belongs to a different site ({}) than the cluster ({})".format( + device, device.site, self.cluster.site + ) + }) + + +class ClusterRemoveDevicesForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + +# +# Virtual Machines +# + +class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): + cluster_group = forms.ModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'cluster', 'nullable': 'true'} + ) + ) + cluster = ChainedModelChoiceField( + queryset=Cluster.objects.all(), + chains=( + ('group', 'cluster_group'), + ), + widget=APISelect( + api_url='/api/virtualization/clusters/?group_id={{cluster_group}}' + ) + ) + + class Meta: + model = VirtualMachine + fields = [ + 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'comments', + ] + + def __init__(self, *args, **kwargs): + + # Initialize helper selector + instance = kwargs.get('instance') + if instance.pk and instance.cluster is not None: + initial = kwargs.get('initial', {}).copy() + initial['cluster_group'] = instance.cluster.group + kwargs['initial'] = initial + + super(VirtualMachineForm, self).__init__(*args, **kwargs) + + +class VirtualMachineCSVForm(forms.ModelForm): + status = CSVChoiceField( + choices=STATUS_CHOICES, + required=False, + help_text='Operational status of device' + ) + cluster = forms.ModelChoiceField( + queryset=Cluster.objects.all(), + to_field_name='name', + help_text='Name of parent cluster', + error_messages={ + 'invalid_choice': 'Invalid cluster name.', + } + ) + role = forms.ModelChoiceField( + queryset=DeviceRole.objects.filter(vm_role=True), + required=False, + to_field_name='name', + help_text='Name of functional role', + error_messages={ + 'invalid_choice': 'Invalid role name.' + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) + platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned platform', + error_messages={ + 'invalid_choice': 'Invalid platform.', + } + ) + + class Meta: + model = VirtualMachine + fields = ['name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + + +class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) + status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='') + cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) + role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False) + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) + vcpus = forms.IntegerField(required=False, label='vCPUs') + memory = forms.IntegerField(required=False, label='Memory (MB)') + disk = forms.IntegerField(required=False, label='Disk (GB)') + comments = CommentField(widget=SmallTextarea) + + class Meta: + nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + + +def vm_status_choices(): + status_counts = {} + for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'): + status_counts[status['status']] = status['count'] + return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES] + + +class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = VirtualMachine + q = forms.CharField(required=False, label='Search') + cluster_group = FilterChoiceField( + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + null_option=(0, 'None') + ) + cluster_id = FilterChoiceField( + queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), + label='Cluster' + ) + role = FilterChoiceField( + queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')), + to_field_name='slug', + null_option=(0, 'None') + ) + status = forms.MultipleChoiceField(choices=vm_status_choices, required=False) + tenant = FilterChoiceField( + queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')), + to_field_name='slug', + null_option=(0, 'None') + ) + platform = FilterChoiceField( + queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')), + to_field_name='slug', + null_option=(0, 'None') + ) + + +# +# VM interfaces +# + +class InterfaceForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = Interface + fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description'] + widgets = { + 'virtual_machine': forms.HiddenInput(), + 'form_factor': forms.HiddenInput(), + } + + +class InterfaceCreateForm(ComponentForm): + name_pattern = ExpandableNameField(label='Name') + form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) + enabled = forms.BooleanField(required=False) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + mac_address = MACAddressFormField(required=False, label='MAC Address') + description = forms.CharField(max_length=100, required=False) + + def __init__(self, *args, **kwargs): + + # Set interfaces enabled by default + kwargs['initial'] = kwargs.get('initial', {}).copy() + kwargs['initial'].update({'enabled': True}) + + super(InterfaceCreateForm, self).__init__(*args, **kwargs) + + +class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) + virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput) + enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + description = forms.CharField(max_length=100, required=False) + + class Meta: + nullable_fields = ['mtu', 'description'] + + +# +# Bulk VirtualMachine component creation +# + +class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) + name_pattern = ExpandableNameField(label='Name') + + +class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): + form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) + enabled = forms.BooleanField(required=False, initial=True) + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + description = forms.CharField(max_length=100, required=False) diff --git a/netbox/virtualization/migrations/0001_virtualization.py b/netbox/virtualization/migrations/0001_virtualization.py new file mode 100644 index 000000000..cb553cf95 --- /dev/null +++ b/netbox/virtualization/migrations/0001_virtualization.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 14:15 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import extras.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('ipam', '0018_remove_service_uniqueness_constraint'), + ('dcim', '0043_device_component_name_lengths'), + ('tenancy', '0003_unicode_literals'), + ] + + operations = [ + migrations.CreateModel( + name='Cluster', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + bases=(models.Model, extras.models.CustomFieldModel), + ), + migrations.CreateModel( + name='ClusterGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ClusterType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='VirtualMachine', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=64, unique=True)), + ('vcpus', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='vCPUs')), + ('memory', models.PositiveIntegerField(blank=True, null=True, verbose_name='Memory (MB)')), + ('disk', models.PositiveIntegerField(blank=True, null=True, verbose_name='Disk (GB)')), + ('comments', models.TextField(blank=True)), + ('cluster', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.Cluster')), + ('platform', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='virtual_machines', to='dcim.Platform')), + ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv4')), + ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv6')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='tenancy.Tenant')), + ], + options={ + 'ordering': ['name'], + }, + bases=(models.Model, extras.models.CustomFieldModel), + ), + migrations.AddField( + model_name='cluster', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterGroup'), + ), + migrations.AddField( + model_name='cluster', + name='type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterType'), + ), + ] diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py new file mode 100644 index 000000000..5b03b6e33 --- /dev/null +++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 17:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_virtualization'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [3, 'Staged']], default=1, verbose_name='Status'), + ), + ] diff --git a/netbox/virtualization/migrations/0003_cluster_add_site.py b/netbox/virtualization/migrations/0003_cluster_add_site.py new file mode 100644 index 000000000..5ac3c578b --- /dev/null +++ b/netbox/virtualization/migrations/0003_cluster_add_site.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-22 16:30 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0044_virtualization'), + ('virtualization', '0002_virtualmachine_add_status'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='dcim.Site'), + ), + ] diff --git a/netbox/virtualization/migrations/0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py new file mode 100644 index 000000000..10dec60fa --- /dev/null +++ b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-29 14:32 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0044_virtualization'), + ('virtualization', '0003_cluster_add_site'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'), + ), + ] diff --git a/netbox/virtualization/migrations/__init__.py b/netbox/virtualization/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py new file mode 100644 index 000000000..8fb13c7cc --- /dev/null +++ b/netbox/virtualization/models.py @@ -0,0 +1,257 @@ +from __future__ import unicode_literals + +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +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 + + +# +# Cluster types +# + +@python_2_unicode_compatible +class ClusterType(models.Model): + """ + A type of Cluster. + """ + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug) + + +# +# Cluster groups +# + +@python_2_unicode_compatible +class ClusterGroup(models.Model): + """ + An organizational group of Clusters. + """ + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug) + + +# +# Clusters +# + +@python_2_unicode_compatible +class Cluster(CreatedUpdatedModel, CustomFieldModel): + """ + A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. + """ + name = models.CharField( + max_length=100, + unique=True + ) + type = models.ForeignKey( + to=ClusterType, + on_delete=models.PROTECT, + related_name='clusters' + ) + group = models.ForeignKey( + to=ClusterGroup, + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='clusters', + blank=True, + null=True + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to=CustomFieldValue, + content_type_field='obj_type', + object_id_field='obj_id' + ) + + csv_headers = [ + 'name', 'type', 'group', 'site', 'comments', + ] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('virtualization:cluster', args=[self.pk]) + + def clean(self): + + # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. + if self.pk and self.site: + nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() + if nonsite_devices: + raise ValidationError({ + 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( + nonsite_devices, self.site + ) + }) + + def to_csv(self): + return csv_format([ + self.name, + self.type.name, + self.group.name if self.group else None, + self.comments, + ]) + + +# +# Virtual machines +# + +@python_2_unicode_compatible +class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): + """ + A virtual machine which runs inside a Cluster. + """ + cluster = models.ForeignKey( + to=Cluster, + on_delete=models.PROTECT, + related_name='virtual_machines' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) + platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.SET_NULL, + related_name='virtual_machines', + blank=True, + null=True + ) + name = models.CharField( + max_length=64, + unique=True + ) + status = models.PositiveSmallIntegerField( + choices=STATUS_CHOICES, + default=STATUS_ACTIVE, + verbose_name='Status' + ) + role = models.ForeignKey( + to='dcim.DeviceRole', + limit_choices_to={'vm_role': True}, + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) + primary_ip4 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv4' + ) + primary_ip6 = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name='Primary IPv6' + ) + vcpus = models.PositiveSmallIntegerField( + blank=True, + null=True, + verbose_name='vCPUs' + ) + memory = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Memory (MB)' + ) + disk = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Disk (GB)' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to=CustomFieldValue, + content_type_field='obj_type', + object_id_field='obj_id' + ) + + csv_headers = [ + 'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('virtualization:virtualmachine', args=[self.pk]) + + def to_csv(self): + return csv_format([ + self.name, + self.get_status_display(), + self.cluster.name, + self.tenant.name if self.tenant else None, + self.platform.name if self.platform else None, + self.vcpus, + 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 new file mode 100644 index 000000000..f7b7ffbe9 --- /dev/null +++ b/netbox/virtualization/tables.py @@ -0,0 +1,121 @@ +from __future__ import unicode_literals + +import django_tables2 as tables +from django_tables2.utils import Accessor + +from dcim.models import Interface +from utilities.tables import BaseTable, ToggleColumn +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +CLUSTERTYPE_ACTIONS = """ +{% if perms.virtualization.change_clustertype %} + +{% endif %} +""" + +CLUSTERGROUP_ACTIONS = """ +{% if perms.virtualization.change_clustergroup %} + +{% endif %} +""" + +VIRTUALMACHINE_STATUS = """ +{{ record.get_status_display }} +""" + +VIRTUALMACHINE_PRIMARY_IP = """ +{{ record.primary_ip6.address.ip|default:"" }} +{% if record.primary_ip6 and record.primary_ip4 %}
    {% endif %} +{{ record.primary_ip4.address.ip|default:"" }} +""" + + +# +# Cluster types +# + +class ClusterTypeTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + cluster_count = tables.Column(verbose_name='Clusters') + actions = tables.TemplateColumn( + template_code=CLUSTERTYPE_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = ClusterType + fields = ('pk', 'name', 'cluster_count', 'actions') + + +# +# Cluster groups +# + +class ClusterGroupTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + cluster_count = tables.Column(verbose_name='Clusters') + actions = tables.TemplateColumn( + template_code=CLUSTERGROUP_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = ClusterGroup + fields = ('pk', 'name', 'cluster_count', 'actions') + + +# +# Clusters +# + +class ClusterTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + device_count = tables.Column(verbose_name='Devices') + vm_count = tables.Column(verbose_name='VMs') + + class Meta(BaseTable.Meta): + model = Cluster + fields = ('pk', 'name', 'type', 'group', 'site', 'device_count', 'vm_count') + + +# +# Virtual machines +# + +class VirtualMachineTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS) + cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')]) + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + + class Meta(BaseTable.Meta): + model = VirtualMachine + fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk') + + +class VirtualMachineDetailTable(VirtualMachineTable): + primary_ip = tables.TemplateColumn( + orderable=False, verbose_name='IP Address', template_code=VIRTUALMACHINE_PRIMARY_IP + ) + + class Meta(BaseTable.Meta): + model = VirtualMachine + fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip') + + +# +# VM components +# + +class InterfaceTable(BaseTable): + + class Meta(BaseTable.Meta): + model = Interface + fields = ('name', 'enabled', 'description') diff --git a/netbox/virtualization/tests/__init__.py b/netbox/virtualization/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py new file mode 100644 index 000000000..9d3e01091 --- /dev/null +++ b/netbox/virtualization/tests/test_api.py @@ -0,0 +1,297 @@ +from __future__ import unicode_literals + +from rest_framework import status +from rest_framework.test import APITestCase + +from django.contrib.auth.models import User +from django.urls import reverse + +from users.models import Token +from utilities.tests import HttpStatusMixin +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine + + +class ClusterTypeTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2') + self.clustertype3 = ClusterType.objects.create(name='Test Cluster Type 3', slug='test-cluster-type-3') + + def test_get_clustertype(self): + + url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.clustertype1.name) + + def test_list_clustertypes(self): + + url = reverse('virtualization-api:clustertype-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_clustertype(self): + + data = { + 'name': 'Test Cluster Type 4', + 'slug': 'test-cluster-type-4', + } + + url = reverse('virtualization-api:clustertype-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ClusterType.objects.count(), 4) + clustertype4 = ClusterType.objects.get(pk=response.data['id']) + self.assertEqual(clustertype4.name, data['name']) + self.assertEqual(clustertype4.slug, data['slug']) + + def test_update_clustertype(self): + + data = { + 'name': 'Test Cluster Type X', + 'slug': 'test-cluster-type-x', + } + + url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ClusterType.objects.count(), 3) + clustertype1 = ClusterType.objects.get(pk=response.data['id']) + self.assertEqual(clustertype1.name, data['name']) + self.assertEqual(clustertype1.slug, data['slug']) + + def test_delete_clustertype(self): + + url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ClusterType.objects.count(), 2) + + +class ClusterGroupTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') + self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2') + self.clustergroup3 = ClusterGroup.objects.create(name='Test Cluster Group 3', slug='test-cluster-group-3') + + def test_get_clustergroup(self): + + url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.clustergroup1.name) + + def test_list_clustergroups(self): + + url = reverse('virtualization-api:clustergroup-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_clustergroup(self): + + data = { + 'name': 'Test Cluster Group 4', + 'slug': 'test-cluster-group-4', + } + + url = reverse('virtualization-api:clustergroup-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ClusterGroup.objects.count(), 4) + clustergroup4 = ClusterGroup.objects.get(pk=response.data['id']) + self.assertEqual(clustergroup4.name, data['name']) + self.assertEqual(clustergroup4.slug, data['slug']) + + def test_update_clustergroup(self): + + data = { + 'name': 'Test Cluster Group X', + 'slug': 'test-cluster-group-x', + } + + url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ClusterGroup.objects.count(), 3) + clustergroup1 = ClusterGroup.objects.get(pk=response.data['id']) + self.assertEqual(clustergroup1.name, data['name']) + self.assertEqual(clustergroup1.slug, data['slug']) + + def test_delete_clustergroup(self): + + url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ClusterGroup.objects.count(), 2) + + +class ClusterTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') + + self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group) + self.cluster2 = Cluster.objects.create(name='Test Cluster 2', type=cluster_type, group=cluster_group) + self.cluster3 = Cluster.objects.create(name='Test Cluster 3', type=cluster_type, group=cluster_group) + + def test_get_cluster(self): + + url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.cluster1.name) + + def test_list_clusters(self): + + url = reverse('virtualization-api:cluster-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_cluster(self): + + data = { + 'name': 'Test Cluster 4', + 'type': ClusterType.objects.first().pk, + 'group': ClusterGroup.objects.first().pk, + } + + url = reverse('virtualization-api:cluster-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cluster.objects.count(), 4) + cluster4 = Cluster.objects.get(pk=response.data['id']) + self.assertEqual(cluster4.name, data['name']) + self.assertEqual(cluster4.type.pk, data['type']) + self.assertEqual(cluster4.group.pk, data['group']) + + def test_update_cluster(self): + + cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2') + cluster_group2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2') + data = { + 'name': 'Test Cluster X', + 'type': cluster_type2.pk, + 'group': cluster_group2.pk, + } + + url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Cluster.objects.count(), 3) + cluster1 = Cluster.objects.get(pk=response.data['id']) + self.assertEqual(cluster1.name, data['name']) + self.assertEqual(cluster1.type.pk, data['type']) + self.assertEqual(cluster1.group.pk, data['group']) + + def test_delete_cluster(self): + + url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Cluster.objects.count(), 2) + + +class VirtualMachineTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') + cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group) + + self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster) + self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster) + self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster) + + def test_get_virtualmachine(self): + + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.virtualmachine1.name) + + def test_list_virtualmachines(self): + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_virtualmachine(self): + + data = { + 'name': 'Test Virtual Machine 4', + 'cluster': Cluster.objects.first().pk, + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(VirtualMachine.objects.count(), 4) + virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id']) + self.assertEqual(virtualmachine4.name, data['name']) + self.assertEqual(virtualmachine4.cluster.pk, data['cluster']) + + def test_update_virtualmachine(self): + + cluster2 = Cluster.objects.create( + name='Test Cluster 2', + type=ClusterType.objects.first(), + group=ClusterGroup.objects.first() + ) + data = { + 'name': 'Test Virtual Machine X', + 'cluster': cluster2.pk, + } + + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(VirtualMachine.objects.count(), 3) + virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id']) + self.assertEqual(virtualmachine1.name, data['name']) + self.assertEqual(virtualmachine1.cluster.pk, data['cluster']) + + def test_delete_virtualmachine(self): + + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(VirtualMachine.objects.count(), 2) diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py new file mode 100644 index 000000000..ca794cdd8 --- /dev/null +++ b/netbox/virtualization/urls.py @@ -0,0 +1,57 @@ +from __future__ import unicode_literals + +from django.conf.urls import url + +from ipam.views import ServiceCreateView +from . import views + + +app_name = 'virtualization' +urlpatterns = [ + + # Cluster types + url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'), + url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'), + url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), + url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), + url(r'^cluster-types/(?P[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), + + # Cluster groups + url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'), + url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'), + url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), + url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), + url(r'^cluster-groups/(?P[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), + + # Clusters + url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'), + url(r'^clusters/add/$', views.ClusterCreateView.as_view(), name='cluster_add'), + url(r'^clusters/import/$', views.ClusterBulkImportView.as_view(), name='cluster_import'), + url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), + url(r'^clusters/delete/$', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), + url(r'^clusters/(?P\d+)/$', views.ClusterView.as_view(), name='cluster'), + url(r'^clusters/(?P\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'), + url(r'^clusters/(?P\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'), + url(r'^clusters/(?P\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), + url(r'^clusters/(?P\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), + + # Virtual machines + url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), + url(r'^virtual-machines/add/$', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'), + url(r'^virtual-machines/import/$', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'), + url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), + url(r'^virtual-machines/delete/$', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), + url(r'^virtual-machines/(?P\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'), + url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), + url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), + url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), + + # VM interfaces + url(r'^virtual-machines/interfaces/add/$', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), + url(r'^virtual-machines/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), + url(r'^virtual-machines/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + url(r'^virtual-machines/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + url(r'^vm-interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), + url(r'^vm-interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), + +] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py new file mode 100644 index 000000000..ad49b7dec --- /dev/null +++ b/netbox/virtualization/views.py @@ -0,0 +1,375 @@ +from __future__ import unicode_literals + +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Count +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.generic import View + +from dcim.models import Device, Interface +from dcim.tables import DeviceTable +from ipam.models import Service +from utilities.views import ( + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, + ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, +) +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from . import filters +from . import forms +from . import tables + + +# +# Cluster types +# + +class ClusterTypeListView(ObjectListView): + queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) + table = tables.ClusterTypeTable + template_name = 'virtualization/clustertype_list.html' + + +class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'virtualization.add_clustertype' + model = ClusterType + model_form = forms.ClusterTypeForm + + def get_return_url(self, request, obj): + return reverse('virtualization:clustertype_list') + + +class ClusterTypeEditView(ClusterTypeCreateView): + permission_required = 'virtualization.change_clustertype' + + +class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'virtualization.add_clustertype' + model_form = forms.ClusterTypeCSVForm + table = tables.ClusterTypeTable + default_return_url = 'virtualization:clustertype_list' + + +class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'virtualization.delete_clustertype' + cls = ClusterType + queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) + table = tables.ClusterTypeTable + default_return_url = 'virtualization:clustertype_list' + + +# +# Cluster groups +# + +class ClusterGroupListView(ObjectListView): + queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) + table = tables.ClusterGroupTable + template_name = 'virtualization/clustergroup_list.html' + + +class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'virtualization.add_clustergroup' + model = ClusterGroup + model_form = forms.ClusterGroupForm + + def get_return_url(self, request, obj): + return reverse('virtualization:clustergroup_list') + + +class ClusterGroupEditView(ClusterGroupCreateView): + permission_required = 'virtualization.change_clustergroup' + + +class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'virtualization.add_clustergroup' + model_form = forms.ClusterGroupCSVForm + table = tables.ClusterGroupTable + default_return_url = 'virtualization:clustergroup_list' + + +class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'virtualization.delete_clustergroup' + cls = ClusterGroup + queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) + table = tables.ClusterGroupTable + default_return_url = 'virtualization:clustergroup_list' + + +# +# Clusters +# + +class ClusterListView(ObjectListView): + queryset = Cluster.objects.annotate( + device_count=Count('devices', distinct=True), + vm_count=Count('virtual_machines', distinct=True) + ) + table = tables.ClusterTable + filter = filters.ClusterFilter + filter_form = forms.ClusterFilterForm + template_name = 'virtualization/cluster_list.html' + + +class ClusterView(View): + + def get(self, request, pk): + + cluster = get_object_or_404(Cluster, pk=pk) + devices = Device.objects.filter(cluster=cluster).select_related( + 'site', 'rack', 'tenant', 'device_type__manufacturer' + ) + device_table = DeviceTable(list(devices), orderable=False) + if request.user.has_perm('virtualization:change_cluster'): + device_table.columns.show('pk') + + return render(request, 'virtualization/cluster.html', { + 'cluster': cluster, + 'device_table': device_table, + }) + + +class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'virtualization.add_cluster' + model = Cluster + model_form = forms.ClusterForm + + +class ClusterEditView(ClusterCreateView): + permission_required = 'virtualization.change_cluster' + + +class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'virtualization.delete_cluster' + model = Cluster + default_return_url = 'virtualization:cluster_list' + + +class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'virtualization.add_cluster' + model_form = forms.ClusterCSVForm + table = tables.ClusterTable + default_return_url = 'virtualization:cluster_list' + + +class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'virtualization.change_cluster' + cls = Cluster + filter = filters.ClusterFilter + table = tables.ClusterTable + form = forms.ClusterBulkEditForm + default_return_url = 'virtualization:cluster_list' + + +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) + ) + table = tables.ClusterTable + default_return_url = 'virtualization:cluster_list' + + +class ClusterAddDevicesView(PermissionRequiredMixin, View): + permission_required = 'virtualization.change_cluster' + form = forms.ClusterAddDevicesForm + template_name = 'virtualization/cluster_add_devices.html' + + def get(self, request, pk): + + cluster = get_object_or_404(Cluster, pk=pk) + form = self.form(cluster) + + return render(request, self.template_name, { + 'cluster': cluster, + 'form': form, + 'return_url': reverse('virtualization:cluster', kwargs={'pk': pk}), + }) + + def post(self, request, pk): + + cluster = get_object_or_404(Cluster, pk=pk) + form = self.form(cluster, request.POST) + + if form.is_valid(): + + # Assign the selected Devices to the Cluster + devices = form.cleaned_data['devices'] + Device.objects.filter(pk__in=devices).update(cluster=cluster) + + messages.success(request, "Added {} devices to cluster {}".format( + len(devices), cluster + )) + return redirect(cluster.get_absolute_url()) + + return render(request, self.template_name, { + 'cluser': cluster, + 'form': form, + 'return_url': cluster.get_absolute_url(), + }) + + +class ClusterRemoveDevicesView(PermissionRequiredMixin, View): + permission_required = 'virtualization.change_cluster' + form = forms.ClusterRemoveDevicesForm + template_name = 'utilities/obj_bulk_remove.html' + + def post(self, request, pk): + + cluster = get_object_or_404(Cluster, pk=pk) + + if '_confirm' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + + # Remove the selected Devices from the Cluster + devices = form.cleaned_data['pk'] + Device.objects.filter(pk__in=devices).update(cluster=None) + + messages.success(request, "Removed {} devices from cluster {}".format( + len(devices), cluster + )) + return redirect(cluster.get_absolute_url()) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + + selected_objects = Device.objects.filter(pk__in=form.initial['pk']) + device_table = DeviceTable(list(selected_objects), orderable=False) + + return render(request, self.template_name, { + 'form': form, + 'parent_obj': cluster, + 'table': device_table, + 'obj_type_plural': 'devices', + 'return_url': cluster.get_absolute_url(), + }) + + +# +# Virtual machines +# + +class VirtualMachineListView(ObjectListView): + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'primary_ip4', 'primary_ip6') + filter = filters.VirtualMachineFilter + filter_form = forms.VirtualMachineFilterForm + table = tables.VirtualMachineDetailTable + template_name = 'virtualization/virtualmachine_list.html' + + +class VirtualMachineView(View): + + def get(self, request, pk): + + vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk) + interfaces = Interface.objects.filter(virtual_machine=vm) + services = Service.objects.filter(virtual_machine=vm) + + return render(request, 'virtualization/virtualmachine.html', { + 'vm': vm, + 'interfaces': interfaces, + 'services': services, + }) + + +class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'virtualization.add_virtualmachine' + model = VirtualMachine + model_form = forms.VirtualMachineForm + template_name = 'virtualization/virtualmachine_edit.html' + default_return_url = 'virtualization:virtualmachine_list' + + +class VirtualMachineEditView(VirtualMachineCreateView): + permission_required = 'virtualization.change_virtualmachine' + + +class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'virtualization.delete_virtualmachine' + model = VirtualMachine + default_return_url = 'virtualization:virtualmachine_list' + + +class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'virtualization.add_virtualmachine' + model_form = forms.VirtualMachineCSVForm + table = tables.VirtualMachineTable + default_return_url = 'virtualization:virtualmachine_list' + + +class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'virtualization.change_virtualmachine' + cls = VirtualMachine + queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + filter = filters.VirtualMachineFilter + table = tables.VirtualMachineTable + form = forms.VirtualMachineBulkEditForm + default_return_url = 'virtualization:virtualmachine_list' + + +class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'virtualization.delete_virtualmachine' + cls = VirtualMachine + queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + table = tables.VirtualMachineTable + default_return_url = 'virtualization:virtualmachine_list' + + +# +# VM interfaces +# + +class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_interface' + parent_model = VirtualMachine + parent_field = 'virtual_machine' + model = Interface + form = forms.InterfaceCreateForm + model_form = forms.InterfaceForm + template_name = 'virtualization/virtualmachine_component_add.html' + + +class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): + permission_required = 'dcim.change_interface' + model = Interface + parent_field = 'virtual_machine' + model_form = forms.InterfaceForm + + +class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): + permission_required = 'dcim.delete_interface' + model = Interface + parent_field = 'virtual_machine' + + +class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_interface' + cls = Interface + parent_cls = VirtualMachine + table = tables.InterfaceTable + form = forms.InterfaceBulkEditForm + + +class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_interface' + cls = Interface + parent_cls = VirtualMachine + table = tables.InterfaceTable + + +# +# Bulk Device component creation +# + +class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView): + permission_required = 'dcim.add_interface' + parent_model = VirtualMachine + parent_field = 'virtual_machine' + form = forms.VirtualMachineBulkAddInterfaceForm + model = Interface + model_form = forms.InterfaceForm + table = tables.VirtualMachineTable + default_return_url = 'virtualization:virtualmachine_list' diff --git a/old_requirements.txt b/old_requirements.txt new file mode 100644 index 000000000..904545b4f --- /dev/null +++ b/old_requirements.txt @@ -0,0 +1 @@ +pycrypto diff --git a/requirements.txt b/requirements.txt index 8cd72419f..cdda3cf1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,11 @@ -cffi>=1.8 -cryptography>=1.8 -Django>=1.11 -django-cors-headers>=2.0 -django-debug-toolbar>=1.7 -django-filter>=1.0.2 +Django>=1.11,<2.0 +django-cors-headers>=2.1 +django-debug-toolbar>=1.8 +django-filter>=1.0.4 django-mptt==0.8.7 django-rest-swagger>=2.1.0 -django-tables2>=1.7.0 -djangorestframework>=3.6.2 +django-tables2>=1.10.0 +djangorestframework>=3.6.4 graphviz>=0.6 Markdown>=2.6.7 natsort>=5.0.0 @@ -15,8 +13,7 @@ ncclient==0.5.3 netaddr==0.7.18 paramiko>=2.0.0 Pillow>=4.0.0 -psycopg2>=2.6.1 +psycopg2>=2.7.3 py-gfm>=0.1.3 -pycrypto>=2.6.1 -sqlparse>=0.2 +pycryptodome>=3.4.7 xmltodict>=0.10.2 diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh deleted file mode 100755 index f8691461b..000000000 --- a/scripts/docker-build.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" -if [ $? -ne 0 ]; then - echo "docker login failed." - exit 1 -fi - -docker build -t "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG" . -if [ $? -ne 0 ]; then - echo "docker build failed." - exit 1 -fi - -docker push "$DOCKER_REPOSITORY/$DOCKER_IMAGE_NAME:$DOCKER_TAG" -if [ $? -ne 0 ]; then - echo "docker push failed." - exit 1 -fi - -exit 0 diff --git a/upgrade.sh b/upgrade.sh index 24189a424..a1930eb3d 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -39,6 +39,11 @@ COMMAND="${PREFIX}find . -name \"*.pyc\" -delete" echo "Cleaning up stale Python bytecode ($COMMAND)..." eval $COMMAND +# Uninstall any Python packages which are no longer needed +COMMAND="${PREFIX}${PIP} uninstall -r old_requirements.txt -y" +echo "Removing old Python packages ($COMMAND)..." +eval $COMMAND + # Install any new Python packages COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade" echo "Updating required Python packages ($COMMAND)..."