diff --git a/README.md b/README.md index 9fa3acb94..28673bf36 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode ### Build Status -NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended. +NetBox is built against both Python 2.7 and 3.5. Python 3.5 or higher is strongly recommended. | | status | |-------------|------------| diff --git a/base_requirements.txt b/base_requirements.txt new file mode 100644 index 000000000..6012ffa6c --- /dev/null +++ b/base_requirements.txt @@ -0,0 +1,25 @@ +# django-filter-1.1.0 breaks with Django-2.1 +Django>=1.11,<2.1 +django-cors-headers +django-debug-toolbar +# django-filter-2.0.0 drops Python 2 support (blocked by #2000) +django-filter==1.1.0 +django-mptt +django-tables2 +django-taggit +django-taggit-serializer +django-timezone-field +# https://github.com/encode/django-rest-framework/issues/6053 +djangorestframework==3.8.1 +drf-yasg[validation] +graphviz +Markdown +natsort +ncclient +netaddr +paramiko +Pillow +psycopg2-binary +py-gfm +pycryptodome +xmltodict diff --git a/docs/additional-features/change-logging.md b/docs/additional-features/change-logging.md new file mode 100644 index 000000000..b359f9b26 --- /dev/null +++ b/docs/additional-features/change-logging.md @@ -0,0 +1,9 @@ +# Change Logging + +Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object is saved to the database, along with meta data including the current time and the user associated with the change. These records form a running changelog both for each individual object as well as NetBox as a whole (Organization > Changelog). + +A serialized representation is included for each object in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant. + +When a request is made, a random request ID is generated and attached to any change records resulting from the request. For example, editing multiple objects in bulk will create a change record for each object, and each of those objects will be assigned the same request ID. This makes it easy to identify all the change records associated with a particular request. + +Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported in CSV format. diff --git a/docs/additional-features/context-data.md b/docs/additional-features/context-data.md new file mode 100644 index 000000000..cd9f1ceaa --- /dev/null +++ b/docs/additional-features/context-data.md @@ -0,0 +1,3 @@ +# Contextual Configuration Data + +Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md new file mode 100644 index 000000000..01414062c --- /dev/null +++ b/docs/additional-features/custom-fields.md @@ -0,0 +1,26 @@ +# Custom Fields + +Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. + +However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. + +Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types: + +* Free-form text (up to 255 characters) +* Integer +* Boolean (true/false) +* Date +* URL +* Selection + +Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. + +Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) + +When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. + +## Using Custom Fields + +When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object. + +When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md new file mode 100644 index 000000000..41bc11a6e --- /dev/null +++ b/docs/additional-features/export-templates.md @@ -0,0 +1,52 @@ +# Export Templates + +NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. + +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. + +Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: + +``` +{% for rack in queryset %} +Rack: {{ rack.name }} +Site: {{ rack.site.name }} +Height: {{ rack.u_height }}U +{% endfor %} +``` + +To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. + +A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. + +## Example + +Here's an example device export template that will generate a simple Nagios configuration from a list of devices. + +``` +{% for device in queryset %}{% if device.status and device.primary_ip %}define host{ + use generic-switch + host_name {{ device.name }} + address {{ device.primary_ip.address.ip }} +} +{% endif %}{% endfor %} +``` + +The generated output will look something like this: + +``` +define host{ + use generic-switch + host_name switch1 + address 192.0.2.1 +} +define host{ + use generic-switch + host_name switch2 + address 192.0.2.2 +} +define host{ + use generic-switch + host_name switch3 + address 192.0.2.3 +} +``` diff --git a/docs/additional-features/graphs.md b/docs/additional-features/graphs.md new file mode 100644 index 000000000..7b37276e8 --- /dev/null +++ b/docs/additional-features/graphs.md @@ -0,0 +1,25 @@ +# Graphs + +NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters: + +* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed. +* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name. +* **Name:** The title to display above the graph. +* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. +* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. + +## Examples + +You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this: + +``` +https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m +``` + +You can define several graphs to provide multiple contexts when viewing an object. For example: + +``` +https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m +https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h +https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m +``` diff --git a/docs/miscellaneous/shell.md b/docs/additional-features/netbox-shell.md similarity index 100% rename from docs/miscellaneous/shell.md rename to docs/additional-features/netbox-shell.md diff --git a/docs/miscellaneous/reports.md b/docs/additional-features/reports.md similarity index 100% rename from docs/miscellaneous/reports.md rename to docs/additional-features/reports.md diff --git a/docs/additional-features/tags.md b/docs/additional-features/tags.md new file mode 100644 index 000000000..18edcad12 --- /dev/null +++ b/docs/additional-features/tags.md @@ -0,0 +1,24 @@ +# Tags + +Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand as they are assigned to objects. Use commas to separate tags when adding multiple tags to an object 9for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`. + +Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters. + +Objects can be filtered by the tags they have applied. For example, the following API request will retrieve all devices tagged as "monitored": + +``` +GET /api/dcim/devices/?tag=monitored +``` + +Tags are included in the API representation of an object as a list of plain strings: + +``` +{ + ... + "tags": [ + "Core Switch", + "Monitored" + ], + ... +} +``` diff --git a/docs/additional-features/topology-maps.md b/docs/additional-features/topology-maps.md new file mode 100644 index 000000000..21bbe404d --- /dev/null +++ b/docs/additional-features/topology-maps.md @@ -0,0 +1,17 @@ +# Topology Maps + +NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. + +Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure). + +To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`. + +Each line of the **device patterns** field represents a hierarchical layer within the topology map. For example, you might map a traditional network with core, distribution, and access tiers like this: + +``` +core-switch-[abcd] +dist-switch\d +access-switch\d+;oob-switch\d+ +``` + +Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md new file mode 100644 index 000000000..0e74640fa --- /dev/null +++ b/docs/additional-features/webhooks.md @@ -0,0 +1,57 @@ +# Webhooks + +A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. Webhooks are configured via the admin UI under Extras > Webhooks. + +An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content. + +## Requests + +The webhook POST request is structured as so (assuming `application/json` as the Content-Type): + +```no-highlight +{ + "event": "created", + "signal_received_timestamp": 1508769597, + "model": "Site" + "data": { + ... + } +} +``` + +`data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be: + +```no-highlight +{ + "event": "deleted", + "signal_received_timestamp": 1508781858.544069, + "model": "Site", + "data": { + "asn": None, + "comments": "", + "contact_email": "", + "contact_name": "", + "contact_phone": "", + "count_circuits": 0, + "count_devices": 0, + "count_prefixes": 0, + "count_racks": 0, + "count_vlans": 0, + "custom_fields": {}, + "facility": "", + "id": 54, + "name": "test", + "physical_address": "", + "region": None, + "shipping_address": "", + "slug": "test", + "tenant": None + } +} +``` + +A request is considered successful if the response status code is any one of a list of "good" statuses defined in the [requests library](https://github.com/requests/requests/blob/205755834d34a8a6ecf2b0b5b2e9c3e6a7f4e4b6/requests/models.py#L688), otherwise the request is marked as having failed. The user may manually retry a failed request. + +## Backend Status + +Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/. diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md new file mode 100644 index 000000000..c4a916865 --- /dev/null +++ b/docs/administration/replicating-netbox.md @@ -0,0 +1,55 @@ +# Replicating the Database + +NetBox uses [PostgreSQL](https://www.postgresql.org/) for its database, so general PostgreSQL best practices will apply to NetBox. You can dump and restore the database using the `pg_dump` and `psql` utilities, respectively. + +!!! note + The examples below assume that your database is named `netbox`. + +## Export the Database + +```no-highlight +pg_dump netbox > netbox.sql +``` + +## Load an Exported Database + +!!! warning + This will destroy and replace any existing instance of the database. + +```no-highlight +psql -c 'drop database netbox' +psql -c 'create database netbox' +psql netbox < netbox.sql +``` + +Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. + +## Export the Database Schema + +If you want to export only the database schema, and not the data itself (e.g. for development reference), do the following: + +```no-highlight +pg_dump -s netbox > netbox_schema.sql +``` + +--- + +# Replicating Media + +NetBox stored uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. + +## Archive the Media Directory + +Execute the following command from the root of the NetBox installation path (typically `/opt/netbox`): + +```no-highlight +tar -czf netbox_media.tar.gz netbox/media/ +``` + +## Restore the Media Directory + +To extract the saved archive into a new installation, run the following from the installation root: + +```no-highlight +tar -xf netbox_media.tar.gz +``` diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 000000000..97857a4c4 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,16 @@ +# NetBox Configuration + +NetBox's local configuration is stored in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. + +While NetBox has many configuration settings, only a few of them must be defined at the time of installation. + +* [Required settings](required-settings.md) +* [Optional settings](optional-settings.md) + +## Changing the Configuration + +Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect: + +```no-highlight +# sudo supervsiorctl restart netbox +``` diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index d34137ea0..b4de6fe7b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -1,4 +1,4 @@ -The following are optional settings which may be declared in `netbox/netbox/configuration.py`. +# Optional Configuration Settings ## ADMINS @@ -44,6 +44,14 @@ BASE_PATH = 'netbox/' --- +## CHANGELOG_RETENTION + +Default: 90 + +The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain changes in the database indefinitely. (Warning: This will greatly increase database size over time.) + +--- + ## CORS_ORIGIN_ALLOW_ALL Default: False @@ -223,6 +231,14 @@ The time zone NetBox will use when dealing with dates and times. It is recommend --- +## WEBHOOKS_ENABLED + +Default: False + +Enable this option to run the webhook backend. See the docs section on the webhook backend [here](../miscellaneous/webhooks/) for more information on setup and use. + +--- + ## Date and Time Formatting You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date). @@ -237,3 +253,49 @@ SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00 DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m. SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-27 13:23 ``` + +--- + +## Redis Connection Settings + +[Redis](https://redis.io/) is a key-value store which functions as a very lightweight database. It is required when enabling NetBox [webhooks](../miscellaneous/webhooks/). A Redis connection is configured using a dictionary similar to the following: + +``` +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, +} +``` + +### DATABASE + +Default: 0 + +The Redis database ID. + +### DEFAULT_TIMEOUT + +Default: 300 + +The timeout value to use when connecting to the Redis server (in seconds). + +### HOST + +Default: localhost + +The hostname or IP address of the Redis server. + +### PORT + +Default: 6379 + +The TCP port to use when connecting to the Redis server. + +### PASSWORD + +Default: None + +The password to use when authenticating to the Redis server (optional). diff --git a/docs/configuration/mandatory-settings.md b/docs/configuration/required-settings.md similarity index 89% rename from docs/configuration/mandatory-settings.md rename to docs/configuration/required-settings.md index 8d96cf3a7..fb08e643b 100644 --- a/docs/configuration/mandatory-settings.md +++ b/docs/configuration/required-settings.md @@ -1,4 +1,4 @@ -NetBox's local configuration is held in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. +# Required Configuration Settings ## ALLOWED_HOSTS diff --git a/docs/data-model/circuits.md b/docs/core-functionality/circuits.md similarity index 68% rename from docs/data-model/circuits.md rename to docs/core-functionality/circuits.md index 301400c38..e56c9d8c6 100644 --- a/docs/data-model/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,18 +1,16 @@ -The circuits component of NetBox deals with the management of long-haul Internet and private transit links and providers. - # Providers A provider is any entity which provides some form of connectivity. While this obviously includes carriers which offer Internet and private transit service, it might also include Internet exchange (IX) points and even organizations with whom you peer directly. -Each provider may be assigned an autonomous system number (ASN), an account number, and contact information. +Each provider may be assigned an autonomous system number (ASN), an account number, and relevant contact information. --- # Circuits -A circuit represents a single physical data link connecting two endpoints. Each circuit belongs to a provider and must be assigned a circuit ID which is unique to that provider. +A circuit represents a single _physical_ link connecting exactly two endpoints. (A circuit with more than two endpoints is a virtual circuit, which is not currently supported by NetBox.) Each circuit belongs to a provider and must be assigned a circuit ID which is unique to that provider. -### Circuit Types +## Circuit Types Circuits are classified by type. For example, you might define circuit types for: @@ -23,7 +21,7 @@ Circuits are classified by type. For example, you might define circuit types for Circuit types are fully customizable. -### Circuit Terminations +## Circuit Terminations A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. @@ -31,3 +29,6 @@ Each circuit termination is tied to a site, and optionally to a specific device !!! note A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit. + +!!! note + A circuit may terminate only to a physical interface. Circuits may not terminate to LAG interfaces, which are virtual interfaces: You must define each physical circuit within a service bundle separately and terminate it to its actual physical interface. diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md new file mode 100644 index 000000000..0a03efb4c --- /dev/null +++ b/docs/core-functionality/devices.md @@ -0,0 +1,120 @@ +# Device Types + +A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, and network interfaces). + +Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type at the time of creation. (However, changes made to a device type will **not** apply to instances of that device type retroactively.) + +The device type model includes three flags which inform what type of components may be added to it: + +* `is_console_server`: This device type has console server ports +* `is_pdu`: This device type has power outlets +* `is_network_device`: This device type has network interfaces + +Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: + +* A parent device (which has device bays) +* A child device (which must be installed in a device bay) +* Neither + +!!! note + This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. + +## Manufacturers + +Each device type must be assigned to a manufacturer. The model number of a device type must be unique to its manufacturer. + +## Component Templates + +Each device type is assigned a number of component templates which define the physical components within a device. These are: + +* Console ports +* Console server ports +* Power ports +* Power outlets +* Network interfaces +* Device bays (which house child devices) + +Whenever a new device is created, its components are automatically created per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates defined: + +* One template for a console port ("Console") +* Two templates for power ports ("PSU0" and "PSU1") +* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") +* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") + +Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. + +!!! note + Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices. + +--- + +# Devices + +Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and can be half depth or full depth. A device may have a height of 0U: These devices do not consume vertical rack space and cannot be assigned to a particular rack unit. A common example of a 0U device is a vertically-mounted PDU. + +When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 is said to be mounted in U8. This logic applies to racks with both ascending and descending unit numbering. + +A device is said to be full depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow. + +## Device Roles + +Devices can be organized by functional roles. These roles are fully cusomizable. For example, you might create roles for core switches, distribution switches, and access switches. + +--- + +# Device Components + +There are six types of device components which comprise all of the interconnection logic with NetBox: + +* Console ports +* Console server ports +* Power ports +* Power outlets +* Network interfaces +* Device bays + +## Console + +Console ports connect only to console server ports. Console connections can be marked as either *planned* or *connected*. + +## Power + +Power ports connect only to power outlets. Power connections can be marked as either *planned* or *connected*. + +## Interfaces + +Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*. + +Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. + +Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address. + +VLANs can be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) + +## Device Bays + +Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. + +--- + +# Platforms + +A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. + +The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration. + +The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. + +--- + +# Inventory Items + +Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Like device types, each item can optionally be assigned a manufacturer. + +--- + +# Virtual Chassis + +A virtual chassis represents a set of devices which share a single control plane: a stack of switches which are managed as a single device, for example. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management. + +It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently. diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md new file mode 100644 index 000000000..27bec8e8e --- /dev/null +++ b/docs/core-functionality/ipam.md @@ -0,0 +1,93 @@ +# Aggregates + +The first step to documenting your IP space is to define its scope by creating aggregates. Aggregates establish the root of your IP address hierarchy by defining the top-level allocations that you're interested in managing. Most organizations will want to track some commonly-used private IP spaces, such as: + +* 10.0.0.0/8 (RFC 1918) +* 100.64.0.0/10 (RFC 6598) +* 172.16.0.0/20 (RFC 1918) +* 192.168.0.0/16 (RFC 1918) +* One or more /48s within fd00::/8 (IPv6 unique local addressing) + +In addition to one or more of these, you'll want to create an aggregate for each globally-routable space your organization has been allocated. These aggregates should match the allocations recorded in public WHOIS databases. + +Each IP prefix will be automatically arranged under its parent aggregate if one exists. Note that it's advised to create aggregates only for IP ranges actually allocated to your organization (or marked for private use): There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example. + +Aggregates cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy. + +## Regional Internet Registries (RIRs) + +[Regional Internet registries](https://en.wikipedia.org/wiki/Regional_Internet_registry) are responsible for the allocation of globally-routable address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for internal use, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. There also exist lower-tier registries which serve a particular geographic area. + +Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). The RIR model includes a boolean flag which indicates whether the RIR allocates only private IP space. + +For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named ARIN and RFC 1918, then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR. + +--- + +# Prefixes + +A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address: All bits in the address not covered by the mask must be zero. (In other words, a prefix cannot be a specific IP address.) + +Prefixes are automatically arranged by their parent aggregates. Additionally, each prefix can be assigned to a particular site and VRF (routing table). All prefixes not assigned to a VRF will appear in the "global" table. + +Each prefix can be assigned a status and a role. These terms are often used interchangeably so it's important to recognize the difference between them. The **status** defines a prefix's operational state. Statuses are hard-coded in NetBox and can be one of the following: + +* Container - A summary of child prefixes +* Active - Provisioned and in use +* Reserved - Designated for future use +* Deprecated - No longer in use + +On the other hand, a prefix's **role** defines its function. Role assignment is optional and roles are fully customizable. For example, you might create roles to differentiate between production and development infrastructure. + +A prefix may also be assigned to a VLAN. This association is helpful for identifying which prefixes are included when reviewing a list of VLANs. + +The prefix model include a "pool" flag. If enabled, NetBox will treat this prefix as a range (such as a NAT pool) wherein every IP address is valid and assignable. This logic is used for identifying available IP addresses within a prefix. If this flag is disabled, NetBox will assume that the first and last (broadcast) address within the prefix are unusable. + +--- + +# IP Addresses + +An IP address comprises a single host address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world. + +Like prefixes, an IP address can optionally be assigned to a VRF (otherwise, it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. + +Also like prefixes, each IP address can be assigned a status and a role. Statuses are hard-coded in NetBox and include the following: + +* Active +* Reserved +* Deprecated +* DHCP + +Each IP address can optionally be assigned a special role. Roles are used to indicate some special attribute of an IP address: for example, it is used as a loopback, or is a virtual IP maintained using VRRP. (Note that this differs in purpose from a _functional_ role, and thus cannot be customized.) Available roles include: + +* Loopback +* Secondary +* Anycast +* VIP +* VRRP +* HSRP +* GLBP + +An IP address can be assigned to a device or virtual machine interface, and an interface may have multiple IP addresses assigned to it. Further, each device and virtual machine may have one of its interface IPs designated as its primary IP address (one for IPv4 and one for IPv6). + +## Network Address Translation (NAT) + +An IP address can be designated as the network address translation (NAT) inside IP address for exactly one other IP address. This is useful primarily to denote a translation between public and private IP addresses. This relationship is followed in both directions: For example, if 10.0.0.1 is assigned as the inside IP for 192.0.2.1, 192.0.2.1 will be displayed as the outside IP for 10.0.0.1. + +!!! note + NetBox does not support tracking one-to-many NAT relationships (also called port address translation). This type of policy requires additional logic to model and cannot be fully represented by IP address alone. + +--- + +# Virtual Routing and Forwarding (VRF) + +A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space). + +Each VRF is assigned a unique name and route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced. + +Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any IP prefix or address not assigned to a VRF is said to belong to the "global" table. + +By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This behavior can be disabled by setting the "enforce unique" flag on the VRF model. + +!!! note + Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. diff --git a/docs/data-model/secrets.md b/docs/core-functionality/secrets.md similarity index 81% rename from docs/data-model/secrets.md rename to docs/core-functionality/secrets.md index 31c73bc92..36b232648 100644 --- a/docs/data-model/secrets.md +++ b/docs/core-functionality/secrets.md @@ -1,14 +1,12 @@ -"Secrets" are small amounts of data that must be kept confidential; for example, passwords and SNMP community strings. NetBox provides encrypted storage of secret data. - # Secrets -A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext. +A secret represents a single credential or other sensitive string of characters which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext. Each secret can also store an optional name parameter, which is not encrypted. This may be useful for storing user names. -### Roles +## Roles -Each secret is assigned a functional role which indicates what it is used for. Typical roles might include: +Each secret is assigned a functional role which indicates what it is used for. Secret roles are customizable. Typical roles might include: * Login credentials * SNMP community strings diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md new file mode 100644 index 000000000..057544a91 --- /dev/null +++ b/docs/core-functionality/services.md @@ -0,0 +1,5 @@ +# Services + +A service represents a layer four TCP or UDP service available on a device or virtual machine. For example, you might want to document that an HTTP service is running on a device. Each service includes a name, protocol, and port number; for example, "SSH (TCP/22)" or "DNS (UDP/53)." + +A service may optionally be bound to one or more specific IP addresses belonging to its parent device or VM. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md new file mode 100644 index 000000000..bf3c473fd --- /dev/null +++ b/docs/core-functionality/sites-and-racks.md @@ -0,0 +1,49 @@ +# Sites + +How you choose to use sites will depend on the nature of your organization, but typically a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. + +Each site must be assigned one of the following operational statuses: + +* Active +* Planned +* Retired + +The site model provides a facility ID field which can be used to annotate a facility ID (such as a datacenter name) associated with the site. Each site may also have an autonomous system (AS) number and time zone associated with it. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) + +The site model also includes several fields for storing contact and address information. + +## Regions + +Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. + +--- + +# Racks + +The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack must be assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending or descending order. + +Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." A unique serial number may also be associated with each rack. + +A rack must be designated as one of the following types: + +* 2-post frame +* 4-post frame +* 4-post cabinet +* Wall-mounted frame +* Wall-mounted cabinet + +Each rack has two faces (front and rear) on which devices can be mounted. Rail-to-rail width may be 19 or 23 inches. + +## Rack Groups + +Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. + +Each rack group must be assigned to a parent site. Hierarchical recursion of rack groups is not currently supported. + +## Rack Roles + +Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable. + +## Rack Space Reservations + +Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks). A rack reservation may optionally designate a specific tenant. diff --git a/docs/core-functionality/tenancy.md b/docs/core-functionality/tenancy.md new file mode 100644 index 000000000..e41d4a5b6 --- /dev/null +++ b/docs/core-functionality/tenancy.md @@ -0,0 +1,20 @@ +# Tenants + +A tenant represents a discrete entity for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. The following objects can be assigned to tenants: + +* Sites +* Racks +* Rack reservations +* Devices +* VRFs +* Prefixes +* IP addresses +* VLANs +* Circuits +* Virtual machines + +Tenant assignment is used to signify ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate. + +### Tenant Groups + +Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. diff --git a/docs/core-functionality/virtual-machines.md b/docs/core-functionality/virtual-machines.md new file mode 100644 index 000000000..1957c114e --- /dev/null +++ b/docs/core-functionality/virtual-machines.md @@ -0,0 +1,27 @@ +# 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 to a group and/or site. + +Physical devices 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. The assignment of clusters to groups is optional. + +--- + +# 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 be assigned a platform and have interfaces created on it. VM interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they cannot be connected to other interfaces. Unlike physical devices, VMs cannot be assigned console or power ports, device bays, or inventory items. + +The following resources can be defined for each VM: + +* vCPU count +* Memory (MB) +* Disk space (GB) diff --git a/docs/core-functionality/vlans.md b/docs/core-functionality/vlans.md new file mode 100644 index 000000000..a6babed44 --- /dev/null +++ b/docs/core-functionality/vlans.md @@ -0,0 +1,15 @@ +# VLANs + +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group. + +Each VLAN must be assigned one of the following operational statuses: + +* Active +* Reserved +* Deprecated + +Each VLAN may also be assigned a functional role. Prefixes and VLANs share the same set of customizable roles. + +## VLAN Groups + +VLAN groups can be used to organize VLANs within NetBox. Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md deleted file mode 100644 index 5c9097569..000000000 --- a/docs/data-model/dcim.md +++ /dev/null @@ -1,124 +0,0 @@ -Data center infrastructure management (DCIM) entails all physical assets: sites, racks, devices, cabling, etc. - -# Sites - -How you choose to use sites will depend on the nature of your organization, but typically a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. - -Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment, and an Autonomous System (AS) number. - -### Regions - -Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. - ---- - -# Racks - -The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack is assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U, but NetBox allows you to define racks of arbitrary height. Each rack has two faces (front and rear) on which devices can be mounted. - -Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number. - -The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches. - -### Rack Groups - -Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. - -Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported. - -### Rack Roles - -Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable. - -### Rack Space Reservations - -Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks). - ---- - -# Device Types - -A device type represents a particular hardware model that exists in the real world. Device types describe the physical attributes of a device (rack height and depth), its class (e.g. console server, PDU, etc.), and its individual components (console, power, and data). - -Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type. - -A device type can be a parent, child, or neither. Parent devices house child devices in device bays. This relationship is used to model things like blade servers, where child devices function independently but share physical resources like rack space and power. Note that this is **not** intended to model chassis-based devices, wherein child members share a common control plane. - -### Manufacturers - -Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. The model number of a device type must be unique to its manufacturer. - -### Component Templates - -Each device type is assigned a number of component templates which define the physical interfaces a device has. These are: - -* Console ports -* Console server ports -* Power ports -* Power outlets -* Interfaces -* Device bays - -Whenever a new device is created, it is automatically assigned components per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates: - -* One template for a console port ("Console") -* Two templates for power ports ("PSU0" and "PSU1") -* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") -* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") - -Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. - -!!! note - Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components of existing devices individually. - ---- - -# Devices - -Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined. - -When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8. This logic applies to racks with both ascending and descending unit numbering. - -A device is said to be "full depth" if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede air flow. - -### Roles - -NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, a device can belong to only one role. - -### Platforms - -A device's platform is used to denote the type of software running on it. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. - -The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. - -### Inventory Items - -Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each item can optionally be assigned a manufacturer. - -!!! note - Prior to version 2.0, inventory items were called modules. - -### Components - -There are six types of device components which comprise all of the interconnection logic with NetBox: - -* Console ports -* Console server ports -* Power ports -* Power outlets -* Interfaces -* Device bays - -Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.) Each type of connection can be classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. - -Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description. - -Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. - ---- - -# Virtual Chassis - -A virtual chassis represents a set of devices which share a single control plane: for example, a stack of switches which are managed as a single device. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management. - -It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently. diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md deleted file mode 100644 index f4654c0dd..000000000 --- a/docs/data-model/extras.md +++ /dev/null @@ -1,132 +0,0 @@ -This section entails features of NetBox which are not crucial to its primary functions, but provide additional value. - -# Custom Fields - -Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. - -However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. - -Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types: - -* Free-form text (up to 255 characters) -* Integer -* Boolean (true/false) -* Date -* URL -* Selection - -Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. - -Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) - -When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. - -## Using Custom Fields - -When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object. - -When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. - -# Export Templates - -NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. - -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. - -Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: - -``` -{% for rack in queryset %} -Rack: {{ rack.name }} -Site: {{ rack.site.name }} -Height: {{ rack.u_height }}U -{% endfor %} -``` - -To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. - -A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. - -## Example - -Here's an example device export template that will generate a simple Nagios configuration from a list of devices. - -``` -{% for device in queryset %}{% if device.status and device.primary_ip %}define host{ - use generic-switch - host_name {{ device.name }} - address {{ device.primary_ip.address.ip }} -} -{% endif %}{% endfor %} -``` - -The generated output will look something like this: - -``` -define host{ - use generic-switch - host_name switch1 - address 192.0.2.1 -} -define host{ - use generic-switch - host_name switch2 - address 192.0.2.2 -} -define host{ - use generic-switch - host_name switch3 - address 192.0.2.3 -} -``` - -# Graphs - -NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters: - -* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed. -* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name. -* **Name:** The title to display above the graph. -* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. -* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. - -## Examples - -You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this: - -``` -https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m -``` - -You can define several graphs to provide multiple contexts when viewing an object. For example: - -``` -https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m -https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h -https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m -``` - -# Topology Maps - -NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. - -Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure). - -To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`. - -Each line of the **device patterns** field represents a hierarchical layer within the topology map. For example, you might map a traditional network with core, distribution, and access tiers like this: - -``` -core-switch-[abcd] -dist-switch\d -access-switch\d+;oob-switch\d+ -``` - -Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. - -# Image Attachments - -Certain objects within NetBox (namely sites, racks, and devices) can have photos or other images attached to them. (Note that _only_ image files are supported.) Each attachment may optionally be assigned a name; if omitted, the attachment will be represented by its file name. - -!!! note - If you experience a server error while attempting to upload an image attachment, verify that the system user NetBox runs as has write permission to the media root directory (`netbox/media/`). diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md deleted file mode 100644 index 6848804fd..000000000 --- a/docs/data-model/ipam.md +++ /dev/null @@ -1,99 +0,0 @@ -IP address management (IPAM) entails the allocation of IP networks, addresses, and related numeric resources. - -# VRFs - -A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain within a network. Each VRF is essentially a separate routing table: the same IP prefix or address can exist in multiple VRFs. VRFs are commonly used to isolate customers or organizations from one another within a network. - -Each VRF is assigned a name and a unique route distinguisher (RD). VRFs are an optional feature of NetBox: Any IP prefix or address not assigned to a VRF is said to belong to the "global" table. - -!!! note - By default, NetBox allows for overlapping IP space both in the global table and within each VRF. Unique space enforcement can be toggled per-VRF as well as in the global table using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. - ---- - -# Aggregates - -IP address space is organized as a hierarchy, with more-specific (smaller) prefixes arranged as child nodes under less-specific (larger) prefixes. For example: - -* 10.0.0.0/8 - * 10.1.0.0/16 - * 10.1.2.0/24 - -The root of the IPv4 hierarchy is 0.0.0.0/0, which encompasses all possible IPv4 addresses (and similarly, ::/0 for IPv6). However, even the largest organizations use only a small fraction of the global address space. Therefore, it makes sense to track in NetBox only the address space which is of interest to your organization. - -Aggregates serve as arbitrary top-level nodes in the IP space hierarchy. They allow you to easily construct your IP scheme without any clutter of unused address space. For instance, most organizations utilize some portion of the private IPv4 space set aside in RFC 1918. So, you might define three aggregates for this space: - -* 10.0.0.0/8 -* 172.16.0.0/12 -* 192.168.0.0/16 - -Additionally, you might define an aggregate for each large swath of public IPv4 space your organization uses. You'd also create aggregates for both globally routable and unique local IPv6 space. (Most organizations will not have a need to track IPv6 link local space.) - -Prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation. Total utilization for each aggregate is displayed in the aggregates list. - -Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8. - -### RIRs - -Regional Internet Registries (RIRs) are responsible for the allocation of global address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for private or internal use only, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. - -Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). Each RIR can be annotated as representing only private space. - ---- - -# Prefixes - -A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address; all bits in the address not covered by the mask must be zero. - -Each prefix may be assigned to one VRF; prefixes not assigned to a VRF are assigned to the "global" table. Prefixes are also organized under their respective aggregates, irrespective of VRF assignment. - -A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefixes assigned to it. Each prefix may also be assigned a short description. - -### Statuses - -Each prefix is assigned an operational status. This is one of the following: - -* Container - A summary of child prefixes -* Active - Provisioned and in use -* Reserved - Designated for future use -* Deprecated - No longer in use - -### Roles - -Whereas a status describes a prefix's operational state, a role describes its function. For example, roles might include: - -* Access segment -* Infrastructure -* NAT -* Lab -* Out-of-band - -Role assignment is optional and roles are fully customizable. - ---- - -# IP Addresses - -An IP address comprises a single address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world. - -Like prefixes, an IP address can optionally be assigned to a VRF (or it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. Each IP address can also be assigned a short description. - -An IP address can be assigned to a device's interface; an interface may have multiple IP addresses assigned to it. Further, each device may have one of its interface IPs designated as its primary IP address (for both IPv4 and IPv6). - -One IP address can be designated as the network address translation (NAT) IP address for exactly one other IP address. This is useful primarily to denote the public address for a private internal IP. Tracking one-to-many NAT (or PAT) assignments is not supported. - ---- - -# VLANs - -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role, and may include a short description. - -### VLAN Groups - -VLAN groups can be employed for administrative organization within NetBox. Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs, including within a site. - ---- - -# Services - -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/tenancy.md b/docs/data-model/tenancy.md deleted file mode 100644 index eb5fda168..000000000 --- a/docs/data-model/tenancy.md +++ /dev/null @@ -1,20 +0,0 @@ -NetBox supports the assignment of resources to tenant organizations. Typically, these are used to represent individual customers of or internal departments within the organization using NetBox. - -# Tenants - -A tenant represents a discrete organization. The following objects can be assigned to tenants: - -* Sites -* Racks -* Devices -* VRFs -* Prefixes -* IP addresses -* VLANs -* Circuits - -If a prefix or IP address is not assigned to a tenant, it will appear to inherit the tenant to which its parent VRF is assigned, if any. - -### Tenant Groups - -Tenants can be grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. diff --git a/docs/data-model/virtualization.md b/docs/data-model/virtualization.md deleted file mode 100644 index d49f7b323..000000000 --- a/docs/data-model/virtualization.md +++ /dev/null @@ -1,29 +0,0 @@ -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/index.md b/docs/development/index.md new file mode 100644 index 000000000..91086c61e --- /dev/null +++ b/docs/development/index.md @@ -0,0 +1,37 @@ +# NetBox Development + +NetBox is maintained as a [GitHub project](https://github.com/digitalocean/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox. + +## Communication + +Communication among developers should always occur via public channels: + +* [GitHub issues](https://github.com/digitalocean/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. +* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. +* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. + +## Governance + +NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions (in other words, avoid scope creep). + +## Project Structure + +All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. + +NetBox components are arranged into functional subsections called _apps_ (a carryover from Django verancular). Each app holds the models, views, and templates relevant to a particular function: + +* `circuits`: Communications circuits and providers (not to be confused with power circuits) +* `dcim`: Datacenter infrastructure management (sites, racks, and devices) +* `extras`: Additional features not considered part of the core data model +* `ipam`: IP address management (VRFs, prefixes, IP addresses, and VLANs) +* `secrets`: Encrypted storage of sensitive data (e.g. login credentials) +* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned +* `utilities`: Resources which are not user-facing (extendable classes, etc.) +* `virtualization`: Virtual machines and clusters + +## Style Guide + +NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). The following exceptions are noted: + +* [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`. +* Constants may be imported via wildcard (for example, `from .constants import *`). diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md new file mode 100644 index 000000000..cce096b48 --- /dev/null +++ b/docs/development/release-checklist.md @@ -0,0 +1,81 @@ +# Minor Version Bumps + +## Update Requirements + +Required Python packages are maintained in two files. `base_requirements.txt` contains a list of all the packages required by NetBox. Some of them may be pinned to a specific version of the package due to a known issue. For example: + +``` +# https://github.com/encode/django-rest-framework/issues/6053 +djangorestframework==3.8.1 +``` + +The other file is `requirements.txt`, which lists each of the required packages pinned to its current stable version. When NetBox is installed, the Python environment is configured to match this file. This helps ensure that a new release of a dependency doesn't break NetBox. + +Every minor version release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this: + +1. Create a new virtual environment. +2. Install the latest version of all required packages via pip: + +``` +pip install -U -r base_requirements.txt +``` + +3. Run all tests and check that the UI and API function as expected. +4. Update the package versions in `requirements.txt` as appropriate. + +## Update Static Libraries + +Update the following static libraries to their most recent stable release: + +* Bootstrap 3 +* Font Awesome 4 +* jQuery +* jQuery UI + +## Manually Perform a New Install + +Create a new installation of NetBox by following [the current documentation](http://netbox.readthedocs.io/en/latest/). This should be a manual process, so that issues with the documentation can be identified and corrected. + +## Close the Release Milestone + +Close the release milestone on GitHub. Ensure that there are no remaining open issues associated with it. + +--- + +# All Releases + +## Verify CI Build Status + +Ensure that continuous integration testing on the `develop` branch is completing successfully. + +## Update VERSION + +Update the `VERSION` constant in `settings.py` to the new release. + +## Submit a Pull Request + +Submit a pull request title **"Release vX.Y.X"** to merge the `develop` branch into `master`. Include a brief change log listing the features, improvements, and/or bugs addressed in the release. + +Once CI has completed on the PR, merge it. + +## Create a New Release + +Draft a [new release](https://github.com/digitalocean/netbox/releases/new) with the following parameters. + +* **Tag:** Current version (e.g. `v2.3.4`) +* **Target:** `master` +* **Title:** Version and date (e.g. `v2.3.4 - 2018-08-02`) + +Copy the description from the pull request into the release notes. + +## Update the Development Version + +On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.3.4, set: + +``` +VERSION = 'v2.3.5-dev' +``` + +## Announce the Release + +Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes. diff --git a/docs/index.md b/docs/index.md index 2e8c8b3dc..84c39ccde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,5 @@ +![NetBox](netbox_logo.png "NetBox logo") + # What is NetBox? NetBox is an open source web application designed to help manage and document computer networks. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. It encompasses the following aspects of network management: @@ -10,7 +12,7 @@ NetBox is an open source web application designed to help manage and document co * **Data circuits** - Long-haul communications circuits and providers * **Secrets** - Encrypted storage of sensitive credentials -# What NetBox Isn't +# What NetBox Is Not While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide: @@ -42,13 +44,15 @@ When given a choice between a relatively simple [80% solution](https://en.wikipe NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server. -| Function | Component | -|--------------|-------------------| -| HTTP Service | nginx or Apache | -| WSGI Service | gunicorn or uWSGI | -| Application | Django/Python | -| Database | PostgreSQL 9.4+ | +| Function | Component | +|--------------------|-------------------| +| HTTP service | nginx or Apache | +| WSGI service | gunicorn or uWSGI | +| Application | Django/Python | +| Database | PostgreSQL 9.4+ | +| Task queuing | Redis/django-rq | +| Live device access | NAPALM | # Getting Started -See the [installation guide](installation/postgresql.md) for help getting NetBox up and running quickly. +See the [installation guide](installation/index.md) for help getting NetBox up and running quickly. diff --git a/docs/installation/postgresql.md b/docs/installation/1-postgresql.md similarity index 100% rename from docs/installation/postgresql.md rename to docs/installation/1-postgresql.md diff --git a/docs/installation/netbox.md b/docs/installation/2-netbox.md similarity index 84% rename from docs/installation/netbox.md rename to docs/installation/2-netbox.md index e7daba3cd..8f59adc29 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/2-netbox.md @@ -2,43 +2,21 @@ This section of the documentation discusses installing and configuring the NetBox application. -!!! note - Python 3 is strongly encouraged for new installations. Support for Python 2 will be discontinued in the near future. This documentation includes a guide on [migrating from Python 2 to Python 3](migrating-to-python3). - **Ubuntu** -Python 3: - ```no-highlight # apt-get install -y python3 python3-dev python3-setuptools build-essential 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-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev -# easy_install pip -``` - **CentOS** -Python 3: - ```no-highlight # 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 ``` -Python 2: - -```no-highlight -# yum install -y epel-release -# 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. ## Option A: Download a Release @@ -97,29 +75,43 @@ Checking connectivity... done. Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) -Python 3: - ```no-highlight # pip3 install -r requirements.txt ``` -Python 2: - -```no-highlight -# 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`. + 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 `pip3 -V`. -### NAPALM Automation +### NAPALM Automation (Optional) -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: +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: ```no-highlight # pip3 install napalm ``` +### Webhooks (Optional) + +[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one. + +**Ubuntu** + +```no-highlight +# apt-get install -y redis-server +``` + +**CentOS** + +```no-highlight +# yum install -y redis +``` + +Enabling webhooks also requires installing the [`django-rq`](https://github.com/ui/django-rq) package. This allows NetBox to use the Redis database as a queue for outgoing webhooks. + +```no-highlight +# pip3 install django-rq +``` + # Configuration Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. @@ -170,10 +162,22 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a !!! note In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state. -# Run Database Migrations +## Webhooks Configuration -!!! warning - The examples on the rest of this page call the `python3` executable. Replace this with `python2` or `python` if you're using Python 2. +If you have opted to enable the webhooks, set `WEBHOOKS_ENABLED = True` and define the relevant `REDIS` database parameters. Below is an example: + +```python +WEBHOOKS_ENABLED = True +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, +} +``` + +# Run Database Migrations Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): diff --git a/docs/installation/web-server.md b/docs/installation/3-http-daemon.md similarity index 97% rename from docs/installation/web-server.md rename to docs/installation/3-http-daemon.md index 39235200b..9ed8fdd74 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/3-http-daemon.md @@ -102,7 +102,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https # gunicorn Installation -Install gunicorn using `pip3` (Python 3) or `pip` (Python 2): +Install gunicorn: ```no-highlight # pip3 install gunicorn @@ -133,6 +133,11 @@ Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi directory = /opt/netbox/netbox/ user = www-data + +[program:netbox-rqworker] +command = python3 /opt/netbox/netbox/manage.py rqworker +directory = /opt/netbox/netbox/ +user = www-data ``` Then, restart the supervisor service to detect and run the gunicorn service: diff --git a/docs/installation/ldap.md b/docs/installation/4-ldap.md similarity index 97% rename from docs/installation/ldap.md rename to docs/installation/4-ldap.md index 54419412e..8f4501d57 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/4-ldap.md @@ -7,13 +7,13 @@ This guide explains how to implement LDAP authentication using an external serve On Ubuntu: ```no-highlight -sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev +sudo apt-get install -y libldap2-dev libsasl2-dev libssl-dev ``` On CentOS: ```no-highlight -sudo yum install -y python-devel openldap-devel +sudo yum install -y openldap-devel ``` ## Install django-auth-ldap diff --git a/docs/installation/index.md b/docs/installation/index.md new file mode 100644 index 000000000..ae2ffb612 --- /dev/null +++ b/docs/installation/index.md @@ -0,0 +1,14 @@ +# Installation + +The following sections detail how to set up a new instance of NetBox: + +1. [PostgreSQL database](1-postgresql.md) +2. [NetBox components](2-netbox.md) +3. [HTTP dameon](3-http-daemon.md) +4. [LDAP authentication](4-ldap.md) (optional) + +# Upgrading + +If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md). + +NetBox v2.5 and later requires Python 3. Please see the instruction for [migrating to Python 3](migrating-to-python3.md) if you are still using Python 2. diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md index e99018252..b2efadea1 100644 --- a/docs/installation/migrating-to-python3.md +++ b/docs/installation/migrating-to-python3.md @@ -1,33 +1,38 @@ # Migration -Remove Python 2 packages +!!! warning + Beginning with v2.5, NetBox will no longer support Python 2. It is strongly recommended that you upgrade to Python 3 as soon as possible. + +## Ubuntu + +Remove the Python2 version of gunicorn: ```no-highlight -# apt-get remove --purge -y python-dev python-pip +# pip uninstall -y gunicorn ``` -Install Python 3 packages +Install Python3 and pip3, Python's package management tool: ```no-highlight -# apt-get install -y python3 python3-dev python3-pip +# apt-get update +# apt-get install -y python3 python3-dev python3-setuptools +# easy_install3 pip ``` -Install Python Packages +Install the Python3 packages required by NetBox: ```no-highlight -# cd /opt/netbox # pip3 install -r requirements.txt ``` -Gunicorn Update +Replace gunicorn with the Python3 version: ```no-highlight -# pip uninstall gunicorn # pip3 install gunicorn ``` -Re-install LDAP Module (optional if using LDAP for auth) +If using LDAP authentication, install the `django-auth-ldap` package: ```no-highlight -sudo pip3 install django-auth-ldap +# pip3 install django-auth-ldap ``` diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index b2700596e..bca60ca89 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -65,7 +65,7 @@ Once the new code is in place, run the upgrade script (which may need to be run ``` !!! warning - The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below. + The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below. Note that Python 2 will no longer be supported in NetBox v2.5. ```no-highlight # ./upgrade.sh -2 @@ -92,3 +92,9 @@ Finally, restart the WSGI service to run the new code. If you followed this guid ```no-highlight # sudo supervisorctl restart netbox ``` + +If using webhooks, also restart the Redis worker: + +```no-highlight +# sudo supervisorctl restart netbox-rqworker +``` diff --git a/mkdocs.yml b/mkdocs.yml index 430a05ac7..532c60a70 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,35 +1,51 @@ site_name: NetBox +repo_url: https://github.com/digitalocean/netbox pages: - - 'Introduction': 'index.md' - - 'Installation': - - 'PostgreSQL': 'installation/postgresql.md' - - 'NetBox': 'installation/netbox.md' - - 'Web Server': 'installation/web-server.md' - - 'LDAP (Optional)': 'installation/ldap.md' - - 'Upgrading': 'installation/upgrading.md' - - 'Migrating to Python3': 'installation/migrating-to-python3.md' - - 'Configuration': - - 'Mandatory Settings': 'configuration/mandatory-settings.md' - - 'Optional Settings': 'configuration/optional-settings.md' - - 'Data Model': - - 'Circuits': 'data-model/circuits.md' - - 'DCIM': 'data-model/dcim.md' - - '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' - - 'Authentication': 'api/authentication.md' - - 'Working with Secrets': 'api/working-with-secrets.md' - - 'Examples': 'api/examples.md' - - 'Miscellaneous': - - 'Reports': 'miscellaneous/reports.md' - - 'Shell': 'miscellaneous/shell.md' - - 'Development': - - 'Utility Views': 'development/utility-views.md' + - Introduction: 'index.md' + - Installation: + - Installing NetBox: 'installation/index.md' + - 1. PostgreSQL: 'installation/1-postgresql.md' + - 2. NetBox: 'installation/2-netbox.md' + - 3. HTTP Daemon: 'installation/3-http-daemon.md' + - 4. LDAP (Optional): 'installation/4-ldap.md' + - Upgrading NetBox: 'installation/upgrading.md' + - Migrating to Python3: 'installation/migrating-to-python3.md' + - Configuration: + - Configuring NetBox: 'configuration/index.md' + - Required Settings: 'configuration/required-settings.md' + - Optional Settings: 'configuration/optional-settings.md' + - Core Functionality: + - IP Address Management: 'core-functionality/ipam.md' + - VLANs: 'core-functionality/vlans.md' + - Sites and Racks: 'core-functionality/sites-and-racks.md' + - Devices: 'core-functionality/devices.md' + - Virtual Machines: 'core-functionality/virtual-machines.md' + - Services: 'core-functionality/services.md' + - Circuits: 'core-functionality/circuits.md' + - Secrets: 'core-functionality/secrets.md' + - Tenancy: 'core-functionality/tenancy.md' + - Additional Features: + - Tags: 'additional-features/tags.md' + - Custom Fields: 'additional-features/custom-fields.md' + - Context Data: 'additional-features/context-data.md' + - Export Templates: 'additional-features/export-templates.md' + - Graphs: 'additional-features/graphs.md' + - Topology Maps: 'additional-features/topology-maps.md' + - Reports: 'additional-features/reports.md' + - Webhooks: 'additional-features/webhooks.md' + - Change Logging: 'additional-features/change-logging.md' + - Administration: + - Replicating NetBox: 'administration/replicating-netbox.md' + - API: + - Overview: 'api/overview.md' + - Authentication: 'api/authentication.md' + - Working with Secrets: 'api/working-with-secrets.md' + - Examples: 'api/examples.md' + - Development: + - Introduction: 'development/index.md' + - Utility Views: 'development/utility-views.md' + - Release Checklist: 'development/release-checklist.md' markdown_extensions: - admonition: diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index db550a63b..739fbf8ff 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,30 +1,32 @@ from __future__ import unicode_literals from rest_framework import serializers +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.constants import CIRCUIT_STATUS_CHOICES from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer # # Providers # -class ProviderSerializer(CustomFieldModelSerializer): +class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): + tags = TagListSerializerField(required=False) class Meta: model = Provider fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] -class NestedProviderSerializer(serializers.ModelSerializer): +class NestedProviderSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') class Meta: @@ -32,16 +34,6 @@ class NestedProviderSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableProviderSerializer(CustomFieldModelSerializer): - - class Meta: - model = Provider - fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_fields', 'created', 'last_updated', - ] - - # # Circuit types # @@ -53,7 +45,7 @@ class CircuitTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedCircuitTypeSerializer(serializers.ModelSerializer): +class NestedCircuitTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') class Meta: @@ -65,21 +57,22 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer): # Circuits # -class CircuitSerializer(CustomFieldModelSerializer): +class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): provider = NestedProviderSerializer() - status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES) + status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False) type = NestedCircuitTypeSerializer() - tenant = NestedTenantSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'comments', 'custom_fields', 'created', 'last_updated', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] -class NestedCircuitSerializer(serializers.ModelSerializer): +class NestedCircuitSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') class Meta: @@ -87,33 +80,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'cid'] -class WritableCircuitSerializer(CustomFieldModelSerializer): - - class Meta: - model = Circuit - fields = [ - 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'comments', 'custom_fields', 'created', 'last_updated', - ] - - # # Circuit Terminations # -class CircuitTerminationSerializer(serializers.ModelSerializer): +class CircuitTerminationSerializer(ValidatedModelSerializer): circuit = NestedCircuitSerializer() site = NestedSiteSerializer() - interface = InterfaceSerializer() - - class Meta: - model = CircuitTermination - fields = [ - 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - ] - - -class WritableCircuitTerminationSerializer(ValidatedModelSerializer): + interface = InterfaceSerializer(required=False, allow_null=True) class Meta: model = CircuitTermination diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 479c21add..3b1623da4 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from django.shortcuts import get_object_or_404 -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.response import Response from circuits import filters @@ -31,10 +31,9 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer - write_serializer_class = serializers.WritableProviderSerializer filter_class = filters.ProviderFilter - @detail_route() + @action(detail=True) def graphs(self, request, pk=None): """ A convenience method for rendering graphs for a particular provider. @@ -62,7 +61,6 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer - write_serializer_class = serializers.WritableCircuitSerializer filter_class = filters.CircuitFilter @@ -73,5 +71,4 @@ class CircuitViewSet(CustomFieldModelViewSet): class CircuitTerminationViewSet(ModelViewSet): queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') serializer_class = serializers.CircuitTerminationSerializer - write_serializer_class = serializers.WritableCircuitTerminationSerializer filter_class = filters.CircuitTerminationFilter diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index ca66be406..79efdc950 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -28,6 +28,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Provider @@ -103,6 +106,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Circuit diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index bfcfa7187..aae8bb5f6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,9 +2,10 @@ from __future__ import unicode_literals from django import forms from django.db.models import Count +from taggit.forms import TagField from dcim.models import Site, Device, Interface, Rack -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -22,10 +23,11 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags'] widgets = { 'noc_contact': SmallTextarea(attrs={'rows': 5}), 'admin_contact': SmallTextarea(attrs={'rows': 5}), @@ -53,7 +55,7 @@ class ProviderCSVForm(forms.ModelForm): } -class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) asn = forms.IntegerField(required=False, label='ASN') account = forms.CharField(max_length=30, required=False, label='Account number') @@ -102,12 +104,13 @@ class CircuitTypeCSVForm(forms.ModelForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() + tags = TagField(required=False) class Meta: model = Circuit fields = [ 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', + 'comments', 'tags', ] help_texts = { 'cid': "Unique circuit ID", @@ -155,7 +158,7 @@ class CircuitCSVForm(forms.ModelForm): ] -class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) diff --git a/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py new file mode 100644 index 000000000..1ae1c5d45 --- /dev/null +++ b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:25 +from __future__ import unicode_literals + +import dcim.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('circuits', '0001_initial'), ('circuits', '0002_auto_20160622_1821'), ('circuits', '0003_provider_32bit_asn_support'), ('circuits', '0004_circuit_add_tenant'), ('circuits', '0005_circuit_add_upstream_speed'), ('circuits', '0006_terminations'), ('circuits', '0007_circuit_add_description'), ('circuits', '0008_circuittermination_interface_protect_on_delete'), ('circuits', '0009_unicode_literals'), ('circuits', '0010_circuit_status')] + + dependencies = [ + ('dcim', '0001_initial'), + ('dcim', '0022_color_names_to_rgb'), + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Provider', + 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=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('asn', dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN')), + ('account', models.CharField(blank=True, max_length=30, verbose_name='Account number')), + ('portal_url', models.URLField(blank=True, verbose_name='Portal')), + ('noc_contact', models.TextField(blank=True, verbose_name='NOC contact')), + ('admin_contact', models.TextField(blank=True, verbose_name='Admin contact')), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='CircuitType', + 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='Circuit', + 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)), + ('cid', models.CharField(max_length=50, verbose_name='Circuit ID')), + ('install_date', models.DateField(blank=True, null=True, verbose_name='Date installed')), + ('commit_rate', models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')), + ('comments', models.TextField(blank=True)), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.Provider')), + ('type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.CircuitType')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant')), + ('description', models.CharField(blank=True, max_length=100)), + ('status', models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1)) + ], + options={ + 'ordering': ['provider', 'cid'], + }, + ), + migrations.AlterUniqueTogether( + name='circuit', + unique_together=set([('provider', 'cid')]), + ), + migrations.CreateModel( + name='CircuitTermination', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('term_side', models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination')), + ('port_speed', models.PositiveIntegerField(verbose_name='Port speed (Kbps)')), + ('upstream_speed', models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)')), + ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID')), + ('pp_info', models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)')), + ('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.Circuit')), + ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.Site')), + ], + options={ + 'ordering': ['circuit', 'term_side'], + }, + ), + migrations.AlterUniqueTogether( + name='circuittermination', + unique_together=set([('circuit', 'term_side')]), + ), + ] diff --git a/netbox/circuits/migrations/0011_tags.py b/netbox/circuits/migrations/0011_tags.py new file mode 100644 index 000000000..b3510f8f4 --- /dev/null +++ b/netbox/circuits/migrations/0011_tags.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:04 +from __future__ import unicode_literals + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('circuits', '0010_circuit_status'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='provider', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/netbox/circuits/migrations/0012_change_logging.py b/netbox/circuits/migrations/0012_change_logging.py new file mode 100644 index 000000000..db5057858 --- /dev/null +++ b/netbox/circuits/migrations/0012_change_logging.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0011_tags'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='circuittype', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='circuit', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuit', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='provider', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='provider', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index a65fe3063..6a2e55afc 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,30 +4,61 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from taggit.managers import TaggableManager from dcim.constants import STATUS_CLASSES from dcim.fields import ASNField -from extras.models import CustomFieldModel, CustomFieldValue -from tenancy.models import Tenant -from utilities.models import CreatedUpdatedModel +from extras.models import CustomFieldModel, ObjectChange +from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES @python_2_unicode_compatible -class Provider(CreatedUpdatedModel, CustomFieldModel): +class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - asn = ASNField(blank=True, null=True, verbose_name='ASN') - account = models.CharField(max_length=30, blank=True, verbose_name='Account number') - portal_url = models.URLField(blank=True, verbose_name='Portal') - noc_contact = models.TextField(blank=True, verbose_name='NOC contact') - admin_contact = models.TextField(blank=True, verbose_name='Admin contact') - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN' + ) + account = models.CharField( + max_length=30, + blank=True, + verbose_name='Account number' + ) + portal_url = models.URLField( + blank=True, + verbose_name='Portal' + ) + noc_contact = models.TextField( + blank=True, + verbose_name='NOC contact' + ) + admin_contact = models.TextField( + blank=True, + verbose_name='Admin contact' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] @@ -54,13 +85,18 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class CircuitType(models.Model): +class CircuitType(ChangeLoggedModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) csv_headers = ['name', 'slug'] @@ -81,22 +117,60 @@ class CircuitType(models.Model): @python_2_unicode_compatible -class Circuit(CreatedUpdatedModel, CustomFieldModel): +class Circuit(ChangeLoggedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device interface, but this is not required. Circuit port speed and commit rate are measured in Kbps. """ - cid = models.CharField(max_length=50, verbose_name='Circuit ID') - provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) - type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) - status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE) - tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT) - install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') - commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)') - description = models.CharField(max_length=100, blank=True) - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + cid = models.CharField( + max_length=50, + verbose_name='Circuit ID' + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='circuits' + ) + type = models.ForeignKey( + to='CircuitType', + on_delete=models.PROTECT, + related_name='circuits' + ) + status = models.PositiveSmallIntegerField( + choices=CIRCUIT_STATUS_CHOICES, + default=CIRCUIT_STATUS_ACTIVE + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='circuits', + blank=True, + null=True + ) + install_date = models.DateField( + blank=True, + null=True, + verbose_name='Date installed' + ) + commit_rate = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Commit rate (Kbps)') + description = models.CharField( + max_length=100, + blank=True + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -145,19 +219,47 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible class CircuitTermination(models.Model): - circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE) - term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination') - site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT) - interface = models.OneToOneField( - 'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT + circuit = models.ForeignKey( + to='circuits.Circuit', + on_delete=models.CASCADE, + related_name='terminations' + ) + term_side = models.CharField( + max_length=1, + choices=TERM_SIDE_CHOICES, + verbose_name='Termination' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='circuit_terminations' + ) + interface = models.OneToOneField( + to='dcim.Interface', + on_delete=models.PROTECT, + related_name='circuit_termination', + blank=True, + null=True + ) + port_speed = models.PositiveIntegerField( + verbose_name='Port speed (Kbps)' ) - port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)') upstream_speed = models.PositiveIntegerField( - blank=True, null=True, verbose_name='Upstream speed (Kbps)', + blank=True, + null=True, + verbose_name='Upstream speed (Kbps)', help_text='Upstream speed, if different from port speed' ) - xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') - pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') + xconnect_id = models.CharField( + max_length=50, + blank=True, + verbose_name='Cross-connect ID' + ) + pp_info = models.CharField( + max_length=100, + blank=True, + verbose_name='Patch panel/port(s)' + ) class Meta: ordering = ['circuit', 'term_side'] @@ -166,6 +268,19 @@ class CircuitTermination(models.Model): def __str__(self): return '{} (Side {})'.format(self.circuit, self.get_term_side_display()) + def log_change(self, user, request_id, action): + """ + Reference the parent circuit when recording the change. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.circuit, + action=action, + object_data=serialize_object(self) + ).save() + def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' try: diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 46dac3c31..6bf3114d9 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -9,6 +9,9 @@ from utilities.tables import BaseTable, ToggleColumn from .models import Circuit, CircuitType, Provider CIRCUITTYPE_ACTIONS = """ + + + {% if perms.circuit.change_circuittype %} {% endif %} diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 1228aafaa..a67dbc4ab 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,26 +1,21 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase -from circuits.constants import TERM_SIDE_A, TERM_SIDE_Z +from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site from extras.constants import GRAPH_TYPE_PROVIDER from extras.models import Graph -from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import APITestCase -class ProviderTest(HttpStatusMixin, APITestCase): +class ProviderTest(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)} + super(ProviderTest, self).setUp() self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') @@ -128,13 +123,11 @@ class ProviderTest(HttpStatusMixin, APITestCase): self.assertEqual(Provider.objects.count(), 2) -class CircuitTypeTest(HttpStatusMixin, APITestCase): +class CircuitTypeTest(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)} + super(CircuitTypeTest, self).setUp() self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1') self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2') @@ -195,13 +188,11 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase): self.assertEqual(CircuitType.objects.count(), 2) -class CircuitTest(HttpStatusMixin, APITestCase): +class CircuitTest(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)} + super(CircuitTest, self).setUp() self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') @@ -231,6 +222,7 @@ class CircuitTest(HttpStatusMixin, APITestCase): 'cid': 'TEST0004', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, } url = reverse('circuits-api:circuit-list') @@ -250,16 +242,19 @@ class CircuitTest(HttpStatusMixin, APITestCase): 'cid': 'TEST0004', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, { 'cid': 'TEST0005', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, { 'cid': 'TEST0006', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, ] @@ -299,13 +294,11 @@ class CircuitTest(HttpStatusMixin, APITestCase): self.assertEqual(Circuit.objects.count(), 2) -class CircuitTerminationTest(HttpStatusMixin, APITestCase): +class CircuitTerminationTest(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)} + super(CircuitTerminationTest, self).setUp() provider = Provider.objects.create(name='Test Provider', slug='test-provider') circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 569c1eb9a..449da3964 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from . import views +from .models import Circuit, CircuitType, Provider app_name = 'circuits' urlpatterns = [ @@ -16,6 +18,7 @@ urlpatterns = [ url(r'^providers/(?P[\w-]+)/$', views.ProviderView.as_view(), name='provider'), url(r'^providers/(?P[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), url(r'^providers/(?P[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), + url(r'^providers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), @@ -23,6 +26,7 @@ urlpatterns = [ 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'), + url(r'^circuit-types/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), @@ -33,6 +37,7 @@ urlpatterns = [ url(r'^circuits/(?P\d+)/$', views.CircuitView.as_view(), name='circuit'), url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), + url(r'^circuits/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 87747f36f..e116e4556 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -6,7 +6,6 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction 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 extras.models import Graph, GRAPH_TYPE_PROVIDER @@ -77,7 +76,7 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_provider' - cls = Provider + queryset = Provider.objects.all() filter = filters.ProviderFilter table = tables.ProviderTable form = forms.ProviderBulkEditForm @@ -86,7 +85,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_provider' - cls = Provider + queryset = Provider.objects.all() filter = filters.ProviderFilter table = tables.ProviderTable default_return_url = 'circuits:provider_list' @@ -106,9 +105,7 @@ class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.add_circuittype' model = CircuitType model_form = forms.CircuitTypeForm - - def get_return_url(self, request, obj): - return reverse('circuits:circuittype_list') + default_return_url = 'circuits:circuittype_list' class CircuitTypeEditView(CircuitTypeCreateView): @@ -124,7 +121,6 @@ class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' - cls = CircuitType queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -196,7 +192,6 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_circuit' - cls = Circuit queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter table = tables.CircuitTable @@ -206,7 +201,6 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' - cls = Circuit queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter table = tables.CircuitTable diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 24fe63457..12981c6e3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,9 +1,8 @@ from __future__ import unicode_literals -from collections import OrderedDict - from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.models import Circuit, CircuitTermination from dcim.constants import ( @@ -20,7 +19,10 @@ from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer +from utilities.api import ( + ChoiceField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, +) from virtualization.models import Cluster @@ -28,7 +30,7 @@ from virtualization.models import Cluster # Regions # -class NestedRegionSerializer(serializers.ModelSerializer): +class NestedRegionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') class Meta: @@ -37,14 +39,7 @@ class NestedRegionSerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer): - parent = NestedRegionSerializer() - - class Meta: - model = Region - fields = ['id', 'name', 'slug', 'parent'] - - -class WritableRegionSerializer(ValidatedModelSerializer): + parent = NestedRegionSerializer(required=False, allow_null=True) class Meta: model = Region @@ -55,23 +50,24 @@ class WritableRegionSerializer(ValidatedModelSerializer): # Sites # -class SiteSerializer(CustomFieldModelSerializer): - status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES) - region = NestedRegionSerializer() - tenant = NestedTenantSerializer() +class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): + status = ChoiceField(choices=SITE_STATUS_CHOICES, required=False) + region = NestedRegionSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) + tags = TagListSerializerField(required=False) class Meta: model = Site fields = [ 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', - 'count_circuits', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', + 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', ] -class NestedSiteSerializer(serializers.ModelSerializer): +class NestedSiteSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') class Meta: @@ -79,23 +75,11 @@ class NestedSiteSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableSiteSerializer(CustomFieldModelSerializer): - time_zone = TimeZoneField(required=False, allow_null=True) - - class Meta: - model = Site - fields = [ - 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'custom_fields', 'created', 'last_updated', - ] - - # # Rack groups # -class RackGroupSerializer(serializers.ModelSerializer): +class RackGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() class Meta: @@ -103,7 +87,7 @@ class RackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site'] -class NestedRackGroupSerializer(serializers.ModelSerializer): +class NestedRackGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') class Meta: @@ -111,13 +95,6 @@ class NestedRackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableRackGroupSerializer(ValidatedModelSerializer): - - class Meta: - model = RackGroup - fields = ['id', 'name', 'slug', 'site'] - - # # Rack roles # @@ -129,7 +106,7 @@ class RackRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class NestedRackRoleSerializer(serializers.ModelSerializer): +class NestedRackRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') class Meta: @@ -141,23 +118,42 @@ class NestedRackRoleSerializer(serializers.ModelSerializer): # Racks # -class RackSerializer(CustomFieldModelSerializer): +class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer() - group = NestedRackGroupSerializer() - tenant = NestedTenantSerializer() - role = NestedRackRoleSerializer() - type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) - width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) + group = NestedRackGroupSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + role = NestedRackRoleSerializer(required=False, allow_null=True) + type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False) + width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) + tags = TagListSerializerField(required=False) class Meta: model = Rack fields = [ 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', + 'u_height', 'desc_units', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This + # prevents facility_id from being interpreted as a required field. + validators = [ + UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'name')) ] + def validate(self, data): -class NestedRackSerializer(serializers.ModelSerializer): + # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta. + if data.get('facility_id', None): + validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id')) + validator.set_context(self) + validator(data) + + # Enforce model validation + super(RackSerializer, self).validate(data) + + return data + + +class NestedRackSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') class Meta: @@ -165,39 +161,11 @@ class NestedRackSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'display_name'] -class WritableRackSerializer(CustomFieldModelSerializer): - - class Meta: - model = Rack - fields = [ - 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', - ] - # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This - # prevents facility_id from being interpreted as a required field. - validators = [ - UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'name')) - ] - - def validate(self, data): - - # Validate uniqueness of (site, facility_id) since we omitted the automatically-created validator from Meta. - if data.get('facility_id', None): - validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id')) - validator.set_context(self) - validator(data) - - # Enforce model validation - super(WritableRackSerializer, self).validate(data) - - return data - - # # Rack units # -class NestedDeviceSerializer(serializers.ModelSerializer): +class NestedDeviceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') class Meta: @@ -219,23 +187,16 @@ class RackUnitSerializer(serializers.Serializer): # Rack reservations # -class RackReservationSerializer(serializers.ModelSerializer): +class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() - tenant = NestedTenantSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = RackReservation fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] -class WritableRackReservationSerializer(ValidatedModelSerializer): - - class Meta: - model = RackReservation - fields = ['id', 'rack', 'units', 'user', 'tenant', 'description'] - - # # Manufacturers # @@ -247,7 +208,7 @@ class ManufacturerSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedManufacturerSerializer(serializers.ModelSerializer): +class NestedManufacturerSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') class Meta: @@ -259,46 +220,36 @@ class NestedManufacturerSerializer(serializers.ModelSerializer): # Device types # -class DeviceTypeSerializer(CustomFieldModelSerializer): +class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES) - subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES) + interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False) + subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False) instance_count = serializers.IntegerField(source='instances.count', read_only=True) + tags = TagListSerializerField(required=False) class Meta: model = DeviceType fields = [ 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', - 'instance_count', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'instance_count', ] -class NestedDeviceTypeSerializer(serializers.ModelSerializer): +class NestedDeviceTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') - manufacturer = NestedManufacturerSerializer() + manufacturer = NestedManufacturerSerializer(read_only=True) class Meta: model = DeviceType fields = ['id', 'url', 'manufacturer', 'model', 'slug'] -class WritableDeviceTypeSerializer(CustomFieldModelSerializer): - subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, allow_null=True, required=False) - - class Meta: - model = DeviceType - fields = [ - 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', - ] - - # # Console port templates # -class ConsolePortTemplateSerializer(serializers.ModelSerializer): +class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -306,18 +257,11 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsolePortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = ConsolePortTemplate - fields = ['id', 'device_type', 'name'] - - # # Console server port templates # -class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): +class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -325,18 +269,11 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsoleServerPortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name'] - - # # Power port templates # -class PowerPortTemplateSerializer(serializers.ModelSerializer): +class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -344,18 +281,11 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerPortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = PowerPortTemplate - fields = ['id', 'device_type', 'name'] - - # # Power outlet templates # -class PowerOutletTemplateSerializer(serializers.ModelSerializer): +class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -363,27 +293,13 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerOutletTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = PowerOutletTemplate - fields = ['id', 'device_type', 'name'] - - # # Interface templates # -class InterfaceTemplateSerializer(serializers.ModelSerializer): +class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - - class Meta: - model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] - - -class WritableInterfaceTemplateSerializer(ValidatedModelSerializer): + form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) class Meta: model = InterfaceTemplate @@ -394,7 +310,7 @@ class WritableInterfaceTemplateSerializer(ValidatedModelSerializer): # Device bay templates # -class DeviceBayTemplateSerializer(serializers.ModelSerializer): +class DeviceBayTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -402,13 +318,6 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableDeviceBayTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = DeviceBayTemplate - fields = ['id', 'device_type', 'name'] - - # # Device roles # @@ -420,7 +329,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class NestedDeviceRoleSerializer(serializers.ModelSerializer): +class NestedDeviceRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') class Meta: @@ -432,15 +341,15 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer): # Platforms # -class PlatformSerializer(serializers.ModelSerializer): - manufacturer = NestedManufacturerSerializer() +class PlatformSerializer(ValidatedModelSerializer): + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) class Meta: model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] + fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] -class NestedPlatformSerializer(serializers.ModelSerializer): +class NestedPlatformSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') class Meta: @@ -448,13 +357,6 @@ class NestedPlatformSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritablePlatformSerializer(ValidatedModelSerializer): - - class Meta: - model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] - - # # Devices # @@ -487,51 +389,31 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'master'] -class DeviceSerializer(CustomFieldModelSerializer): +class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() - tenant = NestedTenantSerializer() - platform = NestedPlatformSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) + platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() - rack = NestedRackSerializer() - face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES) - status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES) - primary_ip = DeviceIPAddressSerializer() - primary_ip4 = DeviceIPAddressSerializer() - primary_ip6 = DeviceIPAddressSerializer() + rack = NestedRackSerializer(required=False, allow_null=True) + face = ChoiceField(choices=RACK_FACE_CHOICES, required=False) + status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False) + primary_ip = DeviceIPAddressSerializer(read_only=True) + primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() - cluster = NestedClusterSerializer() - virtual_chassis = DeviceVirtualChassisSerializer() + cluster = NestedClusterSerializer(required=False, allow_null=True) + virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) 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', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - - def get_parent_device(self, obj): - try: - device_bay = obj.parent_bay - except DeviceBay.DoesNotExist: - return None - context = {'request': self.context['request']} - data = NestedDeviceSerializer(instance=device_bay.device, context=context).data - data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data - return data - - -class WritableDeviceSerializer(CustomFieldModelSerializer): - - class Meta: - model = Device - fields = [ - 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', - 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated', - ] validators = [] def validate(self, data): @@ -543,101 +425,121 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): validator(data) # Enforce model validation - super(WritableDeviceSerializer, self).validate(data) + super(DeviceSerializer, self).validate(data) return data + def get_parent_device(self, obj): + try: + device_bay = obj.parent_bay + except DeviceBay.DoesNotExist: + return None + context = {'request': self.context['request']} + data = NestedDeviceSerializer(instance=device_bay.device, context=context).data + data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data + return data + + +class DeviceWithConfigContextSerializer(DeviceSerializer): + config_context = serializers.SerializerMethodField() + + class Meta(DeviceSerializer.Meta): + 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', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', + 'config_context', 'created', 'last_updated', + ] + + def get_config_context(self, obj): + return obj.get_config_context() + # # Console server ports # -class ConsoleServerPortSerializer(serializers.ModelSerializer): +class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() + tags = TagListSerializerField(required=False) class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name', 'connected_console'] + fields = ['id', 'device', 'name', 'connected_console', 'tags'] read_only_fields = ['connected_console'] -class WritableConsoleServerPortSerializer(ValidatedModelSerializer): +class NestedConsoleServerPortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') + device = NestedDeviceSerializer(read_only=True) class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name'] + fields = ['id', 'url', 'device', 'name'] # # Console ports # -class ConsolePortSerializer(serializers.ModelSerializer): +class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() - cs_port = ConsoleServerPortSerializer() + cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] - - -class WritableConsolePortSerializer(ValidatedModelSerializer): - - class Meta: - model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] + fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags'] # # Power outlets # -class PowerOutletSerializer(serializers.ModelSerializer): +class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() + tags = TagListSerializerField(required=False) class Meta: model = PowerOutlet - fields = ['id', 'device', 'name', 'connected_port'] + fields = ['id', 'device', 'name', 'connected_port', 'tags'] read_only_fields = ['connected_port'] -class WritablePowerOutletSerializer(ValidatedModelSerializer): +class NestedPowerOutletSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') + device = NestedDeviceSerializer(read_only=True) class Meta: model = PowerOutlet - fields = ['id', 'device', 'name'] + fields = ['id', 'url', 'device', 'name'] # # Power ports # -class PowerPortSerializer(serializers.ModelSerializer): +class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() - power_outlet = PowerOutletSerializer() + power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] - - -class WritablePowerPortSerializer(ValidatedModelSerializer): - - class Meta: - model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] + fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags'] # # Interfaces # -class NestedInterfaceSerializer(serializers.ModelSerializer): +class NestedInterfaceSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') class Meta: model = Interface - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'device', 'name'] class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): @@ -648,8 +550,8 @@ class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'cid'] -class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): - circuit = InterfaceNestedCircuitSerializer() +class InterfaceCircuitTerminationSerializer(WritableNestedSerializer): + circuit = InterfaceNestedCircuitSerializer(read_only=True) class Meta: model = CircuitTermination @@ -659,7 +561,7 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): # Cannot import ipam.api.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(serializers.ModelSerializer): +class InterfaceVLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') class Meta: @@ -667,67 +569,29 @@ class InterfaceVLANSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(serializers.ModelSerializer): +class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - lag = NestedInterfaceSerializer() + form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) + lag = NestedInterfaceSerializer(required=False, allow_null=True) is_connected = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True) - circuit_termination = InterfaceCircuitTerminationSerializer() - untagged_vlan = InterfaceVLANSerializer() - mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) - tagged_vlans = InterfaceVLANSerializer(many=True) + circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) + mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) + untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + tagged_vlans = SerializedPKRelatedField( + queryset=VLAN.objects.all(), + serializer=InterfaceVLANSerializer, + required=False, + many=True + ) + tags = TagListSerializerField(required=False) class Meta: model = Interface fields = [ 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans', - ] - - def get_is_connected(self, obj): - """ - Return True if the interface has a connected interface or circuit termination. - """ - if obj.connection: - return True - try: - circuit_termination = obj.circuit_termination - return True - except CircuitTermination.DoesNotExist: - pass - return False - - def get_interface_connection(self, obj): - if obj.connection: - return OrderedDict(( - ('interface', PeerInterfaceSerializer(obj.connected_interface, context=self.context).data), - ('status', obj.connection.connection_status), - )) - return None - - -class PeerInterfaceSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - device = NestedDeviceSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - lag = NestedInterfaceSerializer() - - class Meta: - model = Interface - fields = [ - 'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', - ] - - -class WritableInterfaceSerializer(ValidatedModelSerializer): - - class Meta: - model = Interface - fields = [ - 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', + 'tags', ] def validate(self, data): @@ -747,23 +611,46 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): "be global.".format(vlan) }) - return super(WritableInterfaceSerializer, self).validate(data) + return super(InterfaceSerializer, self).validate(data) + + def get_is_connected(self, obj): + """ + Return True if the interface has a connected interface or circuit termination. + """ + if obj.connection: + return True + try: + circuit_termination = obj.circuit_termination + return True + except CircuitTermination.DoesNotExist: + pass + return False + + def get_interface_connection(self, obj): + if obj.connection: + context = { + 'request': self.context['request'], + 'interface': obj.connected_interface, + } + return ContextualInterfaceConnectionSerializer(obj.connection, context=context).data + return None # # Device bays # -class DeviceBaySerializer(serializers.ModelSerializer): +class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() - installed_device = NestedDeviceSerializer() + installed_device = NestedDeviceSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device'] + fields = ['id', 'device', 'name', 'installed_device', 'tags'] -class NestedDeviceBaySerializer(serializers.ModelSerializer): +class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') class Meta: @@ -771,38 +658,22 @@ class NestedDeviceBaySerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name'] -class WritableDeviceBaySerializer(ValidatedModelSerializer): - - class Meta: - model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device'] - - # # Inventory items # -class InventoryItemSerializer(serializers.ModelSerializer): +class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() - manufacturer = NestedManufacturerSerializer() - - class Meta: - model = InventoryItem - fields = [ - 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', - ] - - -class WritableInventoryItemSerializer(ValidatedModelSerializer): # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) + manufacturer = NestedManufacturerSerializer() + tags = TagListSerializerField(required=False) class Meta: model = InventoryItem fields = [ 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', + 'description', 'tags', ] @@ -810,17 +681,17 @@ class WritableInventoryItemSerializer(ValidatedModelSerializer): # Interface connections # -class InterfaceConnectionSerializer(serializers.ModelSerializer): - interface_a = PeerInterfaceSerializer() - interface_b = PeerInterfaceSerializer() - connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES) +class InterfaceConnectionSerializer(ValidatedModelSerializer): + interface_a = NestedInterfaceSerializer() + interface_b = NestedInterfaceSerializer() + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] -class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): +class NestedInterfaceConnectionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') class Meta: @@ -828,35 +699,37 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'connection_status'] -class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): +class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer): + """ + A read-only representation of an InterfaceConnection from the perspective of either of its two connected Interfaces. + """ + interface = serializers.SerializerMethodField(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = InterfaceConnection - fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + fields = ['id', 'interface', 'connection_status'] + + def get_interface(self, obj): + return NestedInterfaceSerializer(self.context['interface'], context=self.context).data # # Virtual chassis # -class VirtualChassisSerializer(serializers.ModelSerializer): +class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): master = NestedDeviceSerializer() + tags = TagListSerializerField(required=False) class Meta: model = VirtualChassis - fields = ['id', 'master', 'domain'] + fields = ['id', 'master', 'domain', 'tags'] -class NestedVirtualChassisSerializer(serializers.ModelSerializer): +class NestedVirtualChassisSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') class Meta: model = VirtualChassis fields = ['id', 'url'] - - -class WritableVirtualChassisSerializer(ValidatedModelSerializer): - - class Meta: - model = VirtualChassis - fields = ['id', 'master', 'domain'] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index befde771f..901d9d2a5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -8,7 +8,7 @@ from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.openapi import Parameter from drf_yasg.utils import swagger_auto_schema -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ViewSet @@ -52,7 +52,6 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): class RegionViewSet(ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer - write_serializer_class = serializers.WritableRegionSerializer filter_class = filters.RegionFilter @@ -63,10 +62,9 @@ class RegionViewSet(ModelViewSet): class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant') serializer_class = serializers.SiteSerializer - write_serializer_class = serializers.WritableSiteSerializer filter_class = filters.SiteFilter - @detail_route() + @action(detail=True) def graphs(self, request, pk=None): """ A convenience method for rendering graphs for a particular site. @@ -84,7 +82,6 @@ class SiteViewSet(CustomFieldModelViewSet): class RackGroupViewSet(ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer - write_serializer_class = serializers.WritableRackGroupSerializer filter_class = filters.RackGroupFilter @@ -105,10 +102,9 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant') serializer_class = serializers.RackSerializer - write_serializer_class = serializers.WritableRackSerializer filter_class = filters.RackFilter - @detail_route() + @action(detail=True) def units(self, request, pk=None): """ List rack units (by rack) @@ -136,7 +132,6 @@ class RackViewSet(CustomFieldModelViewSet): class RackReservationViewSet(ModelViewSet): queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer - write_serializer_class = serializers.WritableRackReservationSerializer filter_class = filters.RackReservationFilter # Assign user from request @@ -161,7 +156,6 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer - write_serializer_class = serializers.WritableDeviceTypeSerializer filter_class = filters.DeviceTypeFilter @@ -172,42 +166,36 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet): queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer - write_serializer_class = serializers.WritableConsolePortTemplateSerializer filter_class = filters.ConsolePortTemplateFilter class ConsoleServerPortTemplateViewSet(ModelViewSet): queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer - write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer filter_class = filters.ConsoleServerPortTemplateFilter class PowerPortTemplateViewSet(ModelViewSet): queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer - write_serializer_class = serializers.WritablePowerPortTemplateSerializer filter_class = filters.PowerPortTemplateFilter class PowerOutletTemplateViewSet(ModelViewSet): queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer - write_serializer_class = serializers.WritablePowerOutletTemplateSerializer filter_class = filters.PowerOutletTemplateFilter class InterfaceTemplateViewSet(ModelViewSet): queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer - write_serializer_class = serializers.WritableInterfaceTemplateSerializer filter_class = filters.InterfaceTemplateFilter class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer - write_serializer_class = serializers.WritableDeviceBayTemplateSerializer filter_class = filters.DeviceBayTemplateFilter @@ -228,7 +216,6 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer - write_serializer_class = serializers.WritablePlatformSerializer filter_class = filters.PlatformFilter @@ -243,11 +230,17 @@ class DeviceViewSet(CustomFieldModelViewSet): ).prefetch_related( 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', ) - serializer_class = serializers.DeviceSerializer - write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter - @detail_route(url_path='napalm') + def get_serializer_class(self): + """ + Include rendered config context when retrieving a single Device. + """ + if self.action == 'retrieve': + return serializers.DeviceWithConfigContextSerializer + return serializers.DeviceSerializer + + @action(detail=True, url_path='napalm') def napalm(self, request, pk): """ Execute a NAPALM method on a Device @@ -285,12 +278,15 @@ class DeviceViewSet(CustomFieldModelViewSet): napalm_methods = request.GET.getlist('method') response = OrderedDict([(m, None) for m in napalm_methods]) ip_address = str(device.primary_ip.address.ip) + optional_args = settings.NAPALM_ARGS.copy() + if device.platform.napalm_args is not None: + optional_args.update(device.platform.napalm_args) d = driver( hostname=ip_address, username=settings.NAPALM_USERNAME, password=settings.NAPALM_PASSWORD, timeout=settings.NAPALM_TIMEOUT, - optional_args=settings.NAPALM_ARGS + optional_args=optional_args ) try: d.open() @@ -321,38 +317,33 @@ class DeviceViewSet(CustomFieldModelViewSet): class ConsolePortViewSet(ModelViewSet): queryset = ConsolePort.objects.select_related('device', 'cs_port__device') serializer_class = serializers.ConsolePortSerializer - write_serializer_class = serializers.WritableConsolePortSerializer filter_class = filters.ConsolePortFilter class ConsoleServerPortViewSet(ModelViewSet): queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') serializer_class = serializers.ConsoleServerPortSerializer - write_serializer_class = serializers.WritableConsoleServerPortSerializer filter_class = filters.ConsoleServerPortFilter class PowerPortViewSet(ModelViewSet): queryset = PowerPort.objects.select_related('device', 'power_outlet__device') serializer_class = serializers.PowerPortSerializer - write_serializer_class = serializers.WritablePowerPortSerializer filter_class = filters.PowerPortFilter class PowerOutletViewSet(ModelViewSet): queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') serializer_class = serializers.PowerOutletSerializer - write_serializer_class = serializers.WritablePowerOutletSerializer filter_class = filters.PowerOutletFilter class InterfaceViewSet(ModelViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceSerializer - write_serializer_class = serializers.WritableInterfaceSerializer filter_class = filters.InterfaceFilter - @detail_route() + @action(detail=True) def graphs(self, request, pk=None): """ A convenience method for rendering graphs for a particular interface. @@ -366,14 +357,12 @@ class InterfaceViewSet(ModelViewSet): class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device') serializer_class = serializers.DeviceBaySerializer - write_serializer_class = serializers.WritableDeviceBaySerializer filter_class = filters.DeviceBayFilter class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.select_related('device', 'manufacturer') serializer_class = serializers.InventoryItemSerializer - write_serializer_class = serializers.WritableInventoryItemSerializer filter_class = filters.InventoryItemFilter @@ -396,7 +385,6 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ModelViewSet): queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') serializer_class = serializers.InterfaceConnectionSerializer - write_serializer_class = serializers.WritableInterfaceConnectionSerializer filter_class = filters.InterfaceConnectionFilter @@ -407,7 +395,6 @@ class InterfaceConnectionViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet): queryset = VirtualChassis.objects.all() serializer_class = serializers.VirtualChassisSerializer - write_serializer_class = serializers.WritableVirtualChassisSerializer # diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index ef3158508..d61a46d98 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -8,4 +8,5 @@ class DCIMConfig(AppConfig): verbose_name = "DCIM" def ready(self): + import dcim.signals diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 701ea111f..18a0039e6 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -82,6 +82,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Site @@ -179,6 +182,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Rack @@ -286,6 +292,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = DeviceType @@ -497,6 +506,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=VirtualChassis.objects.all(), label='Virtual chassis (ID)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Device @@ -546,6 +558,9 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class ConsolePortFilter(DeviceComponentFilterSet): @@ -604,6 +619,9 @@ class InterfaceFilter(django_filters.FilterSet): method='_mac_address', label='MAC address', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Interface @@ -710,6 +728,9 @@ class VirtualChassisFilter(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = VirtualChassis diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 4a9eb15e4..761f1ba69 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -70,6 +70,8 @@ "model": "dcim.devicetype", "pk": 1, "fields": { + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", "manufacturer": 1, "model": "MX960", "slug": "mx960", @@ -84,6 +86,8 @@ "model": "dcim.devicetype", "pk": 2, "fields": { + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", "manufacturer": 1, "model": "EX9214", "slug": "ex9214", @@ -98,6 +102,8 @@ "model": "dcim.devicetype", "pk": 3, "fields": { + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", "manufacturer": 1, "model": "QFX5100-24Q", "slug": "qfx5100-24q", @@ -112,6 +118,8 @@ "model": "dcim.devicetype", "pk": 4, "fields": { + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", "manufacturer": 1, "model": "QFX5100-48S", "slug": "qfx5100-48s", @@ -126,6 +134,8 @@ "model": "dcim.devicetype", "pk": 5, "fields": { + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", "manufacturer": 2, "model": "CM4148", "slug": "cm4148", @@ -140,6 +150,8 @@ "model": "dcim.devicetype", "pk": 6, "fields": { + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", "manufacturer": 3, "model": "CWG-24VYM415C9", "slug": "cwg-24vym415c9", diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f8779dc06..521c3c858 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,9 +7,10 @@ from django.contrib.auth.models import User from django.contrib.postgres.forms.array import SimpleArrayField from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField +from taggit.forms import TagField from timezone_field import TimeZoneFormField -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -108,12 +109,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Site fields = [ 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', 'tags', ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), @@ -126,7 +129,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", 'physical_address': "Physical location of the building (e.g. for GPS)", - 'shipping_address': "If different from the physical address" + 'shipping_address': "If different from the physical address", + 'latitude': "Latitude in decimal format (xx.yyyyyy)", + 'longitude': "Longitude in decimal format (xx.yyyyyy)" } @@ -165,7 +170,7 @@ class SiteCSVForm(forms.ModelForm): } -class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Site.objects.all(), widget=forms.MultipleHiddenInput @@ -298,12 +303,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) ) comments = CommentField() + tags = TagField(required=False) class Meta: model = Rack fields = [ 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', + 'u_height', 'desc_units', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -374,6 +380,8 @@ class RackCSVForm(forms.ModelForm): site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') + name = self.cleaned_data.get('name') + facility_id = self.cleaned_data.get('facility_id') # Validate rack group if group_name: @@ -382,8 +390,20 @@ class RackCSVForm(forms.ModelForm): except RackGroup.DoesNotExist: raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) + # Validate uniqueness of rack name within group + if Rack.objects.filter(group=self.instance.group, name=name).exists(): + raise forms.ValidationError( + "A rack named {} already exists within group {}".format(name, group_name) + ) -class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + # Validate uniqueness of facility ID within group + if facility_id and Rack.objects.filter(group=self.instance.group, facility_id=facility_id).exists(): + raise forms.ValidationError( + "A rack with the facility ID {} already exists within group {}".format(facility_id, group_name) + ) + + +class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group') @@ -509,11 +529,14 @@ class ManufacturerCSVForm(forms.ModelForm): class DeviceTypeForm(BootstrapMixin, CustomFieldForm): slug = SlugField(slug_source='model') + tags = TagField(required=False) class Meta: model = DeviceType - fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags', + ] labels = { 'interface_ordering': 'Order interfaces by', } @@ -549,7 +572,7 @@ class DeviceTypeCSVForm(forms.ModelForm): } -class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) u_height = forms.IntegerField(min_value=1, required=False) @@ -723,7 +746,10 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] + fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] + widgets = { + 'napalm_args': SmallTextarea(), + } class PlatformCSVForm(forms.ModelForm): @@ -796,12 +822,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) ) comments = CommentField() + tags = TagField(required=False) class Meta: model = Device fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', - 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', + 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { 'device_role': "The function this device serves", @@ -1063,7 +1090,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name)) -class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') @@ -1153,10 +1180,11 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): # class ConsolePortForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = ConsolePort - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1322,10 +1350,11 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF # class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = ConsoleServerPort - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1418,10 +1447,11 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): # class PowerPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = PowerPort - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1587,10 +1617,11 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor # class PowerOutletForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = PowerOutlet - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -1683,12 +1714,13 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # class InterfaceForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = Interface fields = [ 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', + 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1849,7 +1881,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.none() -class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): +class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) @@ -2056,10 +2088,11 @@ class InterfaceConnectionCSVForm(forms.ModelForm): # class DeviceBayForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = DeviceBay - fields = ['device', 'name'] + fields = ['device', 'name', 'tags'] widgets = { 'device': forms.HiddenInput(), } @@ -2117,10 +2150,11 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = InventoryItem - fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags'] class InventoryItemCSVForm(forms.ModelForm): @@ -2176,10 +2210,11 @@ class DeviceSelectionForm(forms.Form): class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + tags = TagField(required=False) class Meta: model = VirtualChassis - fields = ['master', 'domain'] + fields = ['master', 'domain', 'tags'] widgets = { 'master': SelectWithPK, } diff --git a/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py b/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py new file mode 100644 index 000000000..a641c3a2f --- /dev/null +++ b/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:06 +from __future__ import unicode_literals + +import dcim.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import utilities.fields + + +class Migration(migrations.Migration): + + replaces = [('dcim', '0002_auto_20160622_1821'), ('dcim', '0003_auto_20160628_1721'), ('dcim', '0004_auto_20160701_2049'), ('dcim', '0005_auto_20160706_1722'), ('dcim', '0006_add_device_primary_ip4_ip6'), ('dcim', '0007_device_copy_primary_ip'), ('dcim', '0008_device_remove_primary_ip'), ('dcim', '0009_site_32bit_asn_support'), ('dcim', '0010_devicebay_installed_device_set_null'), ('dcim', '0011_devicetype_part_number'), ('dcim', '0012_site_rack_device_add_tenant'), ('dcim', '0013_add_interface_form_factors'), ('dcim', '0014_rack_add_type_width'), ('dcim', '0015_rack_add_u_height_validator'), ('dcim', '0016_module_add_manufacturer'), ('dcim', '0017_rack_add_role'), ('dcim', '0018_device_add_asset_tag'), ('dcim', '0019_new_iface_form_factors'), ('dcim', '0020_rack_desc_units'), ('dcim', '0021_add_ff_flexstack'), ('dcim', '0022_color_names_to_rgb')] + + dependencies = [ + ('dcim', '0001_initial'), + ('ipam', '0001_initial'), + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='rack', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_port_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='consoleserverport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_ports', to='dcim.Device'), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_port_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='consoleport', + name='cs_port', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name=b'Console server port'), + ), + migrations.AddField( + model_name='consoleport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_ports', to='dcim.Device'), + ), + migrations.AlterUniqueTogether( + name='rackgroup', + unique_together=set([('site', 'name'), ('site', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set([('site', 'facility_id'), ('site', 'name')]), + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='powerport', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='poweroutlet', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='module', + unique_together=set([('device', 'parent', 'name')]), + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AddField( + model_name='interface', + name='mac_address', + field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name=b'MAC Address'), + ), + migrations.AlterUniqueTogether( + name='interface', + unique_together=set([('device', 'name')]), + ), + migrations.AddField( + model_name='devicetype', + name='subdevice_role', + field=models.NullBooleanField(choices=[(None, b'None'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'), + ), + migrations.AlterUniqueTogether( + name='devicetype', + unique_together=set([('manufacturer', 'slug'), ('manufacturer', 'model')]), + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set([('rack', 'position', 'face')]), + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleserverport', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleport', + unique_together=set([('device', 'name')]), + ), + migrations.CreateModel( + name='DeviceBay', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name=b'Name')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bays', to='dcim.Device')), + ('installed_device', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_bay', to='dcim.Device')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='DeviceBayTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bay_templates', to='dcim.DeviceType')), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.AlterUniqueTogether( + name='devicebaytemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='devicebay', + unique_together=set([('device', 'name')]), + ), + migrations.AddField( + model_name='device', + name='primary_ip4', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name=b'Primary IPv4'), + ), + migrations.AddField( + model_name='device', + name='primary_ip6', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name=b'Primary IPv6'), + ), + migrations.AlterField( + model_name='site', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'), + ), + migrations.AlterField( + model_name='devicebay', + name='installed_device', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_bay', to='dcim.Device'), + ), + migrations.AddField( + model_name='devicetype', + name='part_number', + field=models.CharField(blank=True, help_text=b'Discrete part number (optional)', max_length=50), + ), + migrations.AddField( + model_name='device', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='rack', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='site', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='rack', + name='type', + field=models.PositiveSmallIntegerField(blank=True, choices=[(100, b'2-post frame'), (200, b'4-post frame'), (300, b'4-post cabinet'), (1000, b'Wall-mounted frame'), (1100, b'Wall-mounted cabinet')], null=True, verbose_name=b'Type'), + ), + migrations.AddField( + model_name='rack', + name='width', + field=models.PositiveSmallIntegerField(choices=[(19, b'19 inches'), (23, b'23 inches')], default=19, help_text=b'Rail-to-rail width', verbose_name=b'Width'), + ), + migrations.AlterField( + model_name='rack', + name='u_height', + field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Height (U)'), + ), + migrations.AddField( + model_name='module', + name='manufacturer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='modules', to='dcim.Manufacturer'), + ), + migrations.CreateModel( + name='RackRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('color', utilities.fields.ColorField(max_length=6)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='rack', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.RackRole'), + ), + migrations.AddField( + model_name='device', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text=b'A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name=b'Asset tag'), + ), + migrations.AddField( + model_name='rack', + name='desc_units', + field=models.BooleanField(default=False, help_text=b'Units are numbered top-to-bottom', verbose_name=b'Descending units'), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.PositiveSmallIntegerField(blank=True, help_text=b'The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)'), + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='devicerole', + name='color', + field=utilities.fields.ColorField(max_length=6), + ), + ] diff --git a/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py new file mode 100644 index 000000000..a613552ad --- /dev/null +++ b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py @@ -0,0 +1,436 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:13 +from __future__ import unicode_literals + +import dcim.fields +from django.conf import settings +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import utilities.fields + + +def copy_site_from_rack(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + for device in Device.objects.all(): + device.site = device.rack.site + device.save() + + +def rpc_client_to_napalm_driver(apps, schema_editor): + """ + Migrate legacy RPC clients to their respective NAPALM drivers + """ + Platform = apps.get_model('dcim', 'Platform') + + Platform.objects.filter(rpc_client='juniper-junos').update(napalm_driver='junos') + Platform.objects.filter(rpc_client='cisco-ios').update(napalm_driver='ios') + + +class Migration(migrations.Migration): + + replaces = [('dcim', '0023_devicetype_comments'), ('dcim', '0024_site_add_contact_fields'), ('dcim', '0025_devicetype_add_interface_ordering'), ('dcim', '0026_add_rack_reservations'), ('dcim', '0027_device_add_site'), ('dcim', '0028_device_copy_rack_to_site'), ('dcim', '0029_allow_rackless_devices'), ('dcim', '0030_interface_add_lag'), ('dcim', '0031_regions'), ('dcim', '0032_device_increase_name_length'), ('dcim', '0033_rackreservation_rack_editable'), ('dcim', '0034_rename_module_to_inventoryitem'), ('dcim', '0035_device_expand_status_choices'), ('dcim', '0036_add_ff_juniper_vcp'), ('dcim', '0037_unicode_literals'), ('dcim', '0038_wireless_interfaces'), ('dcim', '0039_interface_add_enabled_mtu'), ('dcim', '0040_inventoryitem_add_asset_tag_description'), ('dcim', '0041_napalm_integration'), ('dcim', '0042_interface_ff_10ge_cx4'), ('dcim', '0043_device_component_name_lengths')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0022_color_names_to_rgb'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='site', + name='contact_email', + field=models.EmailField(blank=True, max_length=254, verbose_name=b'Contact E-mail'), + ), + migrations.AddField( + model_name='site', + name='contact_name', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='site', + name='contact_phone', + field=models.CharField(blank=True, max_length=20), + ), + migrations.AddField( + model_name='devicetype', + name='interface_ordering', + field=models.PositiveSmallIntegerField(choices=[[1, b'Slot/position'], [2, b'Name (alphabetically)']], default=1), + ), + migrations.CreateModel( + name='RackReservation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), + ('created', models.DateTimeField(auto_now_add=True)), + ('description', models.CharField(max_length=100)), + ('rack', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack')), + ('user', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created'], + }, + ), + migrations.AddField( + model_name='device', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + migrations.RunPython( + code=copy_site_from_rack, + ), + migrations.AlterField( + model_name='device', + name='rack', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'), + ), + migrations.AlterField( + model_name='device', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + migrations.AddField( + model_name='interface', + name='lag', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name='Parent LAG'), + ), + migrations.CreateModel( + name='Region', + 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)), + ('lft', models.PositiveIntegerField(db_index=True, editable=False)), + ('rght', models.PositiveIntegerField(db_index=True, editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(db_index=True, editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='region', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Region'), + ), + migrations.AlterField( + model_name='device', + name='name', + field=utilities.fields.NullableCharField(blank=True, max_length=64, null=True, unique=True), + ), + migrations.AlterField( + model_name='rackreservation', + name='rack', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack'), + ), + migrations.RenameModel( + old_name='Module', + new_name='InventoryItem', + ), + migrations.AlterField( + model_name='inventoryitem', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='dcim.Device'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.InventoryItem'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='manufacturer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.Manufacturer'), + ), + migrations.AlterField( + model_name='device', + name='status', + field=models.PositiveIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'), + ), + migrations.AlterField( + model_name='device', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [2, 'Planned'], [3, 'Staged'], [4, 'Failed'], [5, 'Inventory']], default=1, verbose_name='Status'), + ), + migrations.AlterField( + model_name='consoleport', + name='connection_status', + field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True), + ), + migrations.AlterField( + model_name='consoleport', + name='cs_port', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name='Console server port'), + ), + migrations.AlterField( + model_name='device', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name='Asset tag'), + ), + migrations.AlterField( + model_name='device', + name='face', + field=models.PositiveSmallIntegerField(blank=True, choices=[[0, 'Front'], [1, 'Rear']], null=True, verbose_name='Rack face'), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.PositiveSmallIntegerField(blank=True, help_text='The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Position (U)'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip4', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name='Primary IPv4'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip6', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name='Primary IPv6'), + ), + migrations.AlterField( + model_name='device', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='devicebay', + name='name', + field=models.CharField(max_length=50, verbose_name='Name'), + ), + migrations.AlterField( + model_name='devicetype', + name='interface_ordering', + field=models.PositiveSmallIntegerField(choices=[[1, 'Slot/position'], [2, 'Name (alphabetically)']], default=1), + ), + migrations.AlterField( + model_name='devicetype', + name='is_console_server', + field=models.BooleanField(default=False, help_text='This type of device has console server ports', verbose_name='Is a console server'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_full_depth', + field=models.BooleanField(default=True, help_text='Device consumes both front and rear rack faces', verbose_name='Is full depth'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_network_device', + field=models.BooleanField(default=True, help_text='This type of device has network interfaces', verbose_name='Is a network device'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_pdu', + field=models.BooleanField(default=False, help_text='This type of device has power outlets', verbose_name='Is a PDU'), + ), + migrations.AlterField( + model_name='devicetype', + name='part_number', + field=models.CharField(blank=True, help_text='Discrete part number (optional)', max_length=50), + ), + migrations.AlterField( + model_name='devicetype', + name='subdevice_role', + field=models.NullBooleanField(choices=[(None, 'None'), (True, 'Parent'), (False, 'Child')], default=None, help_text='Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name='Parent/child status'), + ), + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.PositiveSmallIntegerField(default=1, verbose_name='Height (U)'), + ), + migrations.AlterField( + model_name='interface', + name='mac_address', + field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address'), + ), + migrations.AlterField( + model_name='interface', + name='mgmt_only', + field=models.BooleanField(default=False, help_text='This interface is used only for out-of-band management', verbose_name='OOB Management'), + ), + migrations.AlterField( + model_name='interfaceconnection', + name='connection_status', + field=models.BooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True, verbose_name='Status'), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='mgmt_only', + field=models.BooleanField(default=False, verbose_name='Management only'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='discovered', + field=models.BooleanField(default=False, verbose_name='Discovered'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='name', + field=models.CharField(max_length=50, verbose_name='Name'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='part_id', + field=models.CharField(blank=True, max_length=50, verbose_name='Part ID'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='platform', + name='rpc_client', + field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='RPC client'), + ), + migrations.AlterField( + model_name='powerport', + name='connection_status', + field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True), + ), + migrations.AlterField( + model_name='rack', + name='desc_units', + field=models.BooleanField(default=False, help_text='Units are numbered top-to-bottom', verbose_name='Descending units'), + ), + migrations.AlterField( + model_name='rack', + name='facility_id', + field=utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name='Facility ID'), + ), + migrations.AlterField( + model_name='rack', + name='type', + field=models.PositiveSmallIntegerField(blank=True, choices=[(100, '2-post frame'), (200, '4-post frame'), (300, '4-post cabinet'), (1000, 'Wall-mounted frame'), (1100, 'Wall-mounted cabinet')], null=True, verbose_name='Type'), + ), + migrations.AlterField( + model_name='rack', + name='u_height', + field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Height (U)'), + ), + migrations.AlterField( + model_name='rack', + name='width', + field=models.PositiveSmallIntegerField(choices=[(19, '19 inches'), (23, '23 inches')], default=19, help_text='Rail-to-rail width', verbose_name='Width'), + ), + migrations.AlterField( + model_name='site', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'), + ), + migrations.AlterField( + model_name='site', + name='contact_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail'), + ), + migrations.AddField( + model_name='interface', + name='enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='interface', + name='mtu', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU'), + ), + migrations.AddField( + model_name='inventoryitem', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this item', max_length=50, null=True, unique=True, verbose_name='Asset tag'), + ), + migrations.AddField( + model_name='inventoryitem', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterModelOptions( + name='device', + options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + ), + migrations.AddField( + model_name='platform', + name='napalm_driver', + field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices.', max_length=50, verbose_name='NAPALM driver'), + ), + migrations.AlterField( + model_name='platform', + name='rpc_client', + field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='Legacy RPC client'), + ), + migrations.RunPython( + code=rpc_client_to_napalm_driver, + ), + 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)'], [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)'], [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='consoleport', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='consoleserverport', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='interface', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name='poweroutlet', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='powerport', + name='name', + field=models.CharField(max_length=50), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='name', + field=models.CharField(max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py new file mode 100644 index 000000000..42fc5f317 --- /dev/null +++ b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:17 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import timezone_field.fields +import utilities.fields + + +class Migration(migrations.Migration): + + replaces = [('dcim', '0044_virtualization'), ('dcim', '0045_devicerole_vm_role'), ('dcim', '0046_rack_lengthen_facility_id'), ('dcim', '0047_more_100ge_form_factors'), ('dcim', '0048_rack_serial'), ('dcim', '0049_rackreservation_change_user'), ('dcim', '0050_interface_vlan_tagging'), ('dcim', '0051_rackreservation_tenant'), ('dcim', '0052_virtual_chassis'), ('dcim', '0053_platform_manufacturer'), ('dcim', '0054_site_status_timezone_description'), ('dcim', '0055_virtualchassis_ordering')] + + dependencies = [ + ('dcim', '0043_device_component_name_lengths'), + ('ipam', '0020_ipaddress_add_role_carp'), + ('virtualization', '0001_virtualization'), + ('tenancy', '0003_unicode_literals'), + ] + + 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'), + ), + 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'), + ), + migrations.AlterField( + model_name='rack', + name='facility_id', + field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'), + ), + 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), + ), + migrations.AddField( + model_name='rack', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='rackreservation', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='interface', + name='mode', + field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True), + ), + migrations.AddField( + model_name='interface', + name='tagged_vlans', + field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'), + ), + migrations.AddField( + model_name='interface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'), + ), + migrations.AddField( + model_name='rackreservation', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'), + ), + migrations.CreateModel( + name='VirtualChassis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(blank=True, max_length=30)), + ('master', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')), + ], + options={ + 'verbose_name_plural': 'virtual chassis', + 'ordering': ['master'], + }, + ), + migrations.AddField( + model_name='device', + name='virtual_chassis', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'), + ), + migrations.AddField( + model_name='device', + name='vc_position', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]), + ), + migrations.AddField( + model_name='device', + name='vc_priority', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]), + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set([('rack', 'position', 'face'), ('virtual_chassis', 'vc_position')]), + ), + migrations.AddField( + model_name='platform', + name='manufacturer', + field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'), + ), + migrations.AlterField( + model_name='platform', + name='napalm_driver', + field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'), + ), + migrations.AddField( + model_name='site', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='site', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1), + ), + migrations.AddField( + model_name='site', + name='time_zone', + field=timezone_field.fields.TimeZoneField(blank=True), + ), + ] diff --git a/netbox/dcim/migrations/0056_django2.py b/netbox/dcim/migrations/0056_django2.py new file mode 100644 index 000000000..bb7af920e --- /dev/null +++ b/netbox/dcim/migrations/0056_django2.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.3 on 2018-03-30 14:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0055_virtualchassis_ordering'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'), + ), + migrations.AlterField( + model_name='platform', + name='manufacturer', + field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='dcim.Manufacturer'), + ), + ] diff --git a/netbox/dcim/migrations/0057_tags.py b/netbox/dcim/migrations/0057_tags.py new file mode 100644 index 000000000..b0cccfdf3 --- /dev/null +++ b/netbox/dcim/migrations/0057_tags.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:04 +from __future__ import unicode_literals + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('dcim', '0056_django2'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='devicetype', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='rack', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='site', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='consoleport', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='consoleserverport', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='devicebay', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='interface', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='inventoryitem', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='poweroutlet', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='powerport', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='virtualchassis', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py new file mode 100644 index 000000000..e4974be2f --- /dev/null +++ b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:27 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0057_tags'), + ] + + operations = [ + migrations.AlterModelOptions( + name='rack', + options={'ordering': ['site', 'group', 'name']}, + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set([('group', 'name'), ('group', 'facility_id')]), + ), + ] diff --git a/netbox/dcim/migrations/0059_site_latitude_longitude.py b/netbox/dcim/migrations/0059_site_latitude_longitude.py new file mode 100644 index 000000000..15e666f35 --- /dev/null +++ b/netbox/dcim/migrations/0059_site_latitude_longitude.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-21 18:45 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0058_relax_rack_naming_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='site', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True), + ), + migrations.AddField( + model_name='site', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0060_change_logging.py b/netbox/dcim/migrations/0060_change_logging.py new file mode 100644 index 000000000..8a40f4e4e --- /dev/null +++ b/netbox/dcim/migrations/0060_change_logging.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0059_site_latitude_longitude'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicerole', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicetype', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='platform', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='platform', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackreservation', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackrole', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rackrole', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='region', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='region', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='virtualchassis', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='device', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='device', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='rack', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rack', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='rackreservation', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='site', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='site', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0061_platform_napalm_args.py b/netbox/dcim/migrations/0061_platform_napalm_args.py new file mode 100644 index 000000000..6da863aec --- /dev/null +++ b/netbox/dcim/migrations/0061_platform_napalm_args.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.6 on 2018-06-29 15:02 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0060_change_logging'), + ] + + operations = [ + migrations.AddField( + model_name='platform', + name='napalm_args', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)', null=True, verbose_name='NAPALM arguments'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 107dcba51..ca18cbb25 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -6,7 +6,7 @@ from itertools import count, groupby from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -14,34 +14,70 @@ from django.db.models import Count, Q, ObjectDoesNotExist from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from mptt.models import MPTTModel, TreeForeignKey +from taggit.managers import TaggableManager from timezone_field import TimeZoneField from circuits.models import Circuit -from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment +from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange from extras.rpc import RPC_CLIENTS -from tenancy.models import Tenant from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager -from utilities.models import CreatedUpdatedModel +from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object from .constants import * from .fields import ASNField, MACAddressField from .querysets import InterfaceQuerySet +class ComponentModel(models.Model): + + class Meta: + abstract = True + + def get_component_parent(self): + raise NotImplementedError( + "ComponentModel must implement get_component_parent()" + ) + + def log_change(self, user, request_id, action): + """ + Log an ObjectChange including the parent Device/VM. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.get_component_parent(), + action=action, + object_data=serialize_object(self) + ).save() + + # # Regions # @python_2_unicode_compatible -class Region(MPTTModel): +class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. """ parent = TreeForeignKey( - 'self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True ) - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) csv_headers = ['name', 'slug', 'parent'] @@ -67,40 +103,106 @@ class Region(MPTTModel): # class SiteManager(NaturalOrderByManager): - - def get_queryset(self): - return self.natural_order_by('name') + natural_order_field = 'name' @python_2_unicode_compatible -class Site(CreatedUpdatedModel, CustomFieldModel): +class Site(ChangeLoggedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE) - region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL) - tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT) - facility = models.CharField(max_length=50, blank=True) - asn = ASNField(blank=True, null=True, verbose_name='ASN') - time_zone = TimeZoneField(blank=True) - description = models.CharField(max_length=100, blank=True) - physical_address = models.CharField(max_length=200, blank=True) - shipping_address = models.CharField(max_length=200, blank=True) - contact_name = models.CharField(max_length=50, blank=True) - contact_phone = models.CharField(max_length=20, blank=True) - contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + status = models.PositiveSmallIntegerField( + choices=SITE_STATUS_CHOICES, + default=SITE_STATUS_ACTIVE + ) + region = models.ForeignKey( + to='dcim.Region', + on_delete=models.SET_NULL, + related_name='sites', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='sites', + blank=True, + null=True + ) + facility = models.CharField( + max_length=50, + blank=True + ) + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN' + ) + time_zone = TimeZoneField( + blank=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + physical_address = models.CharField( + max_length=200, + blank=True + ) + shipping_address = models.CharField( + max_length=200, + blank=True + ) + latitude = models.DecimalField( + max_digits=8, + decimal_places=6, + blank=True, + null=True + ) + longitude = models.DecimalField( + max_digits=9, + decimal_places=6, + blank=True, + null=True + ) + contact_name = models.CharField( + max_length=50, + blank=True + ) + contact_phone = models.CharField( + max_length=20, + blank=True + ) + contact_email = models.EmailField( + blank=True, + verbose_name='Contact E-mail' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = SiteManager() + tags = TaggableManager() csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] class Meta: @@ -125,6 +227,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): self.description, self.physical_address, self.shipping_address, + self.latitude, + self.longitude, self.contact_name, self.contact_phone, self.contact_email, @@ -165,15 +269,21 @@ class Site(CreatedUpdatedModel, CustomFieldModel): # @python_2_unicode_compatible -class RackGroup(models.Model): +class RackGroup(ChangeLoggedModel): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor. """ - name = models.CharField(max_length=50) + name = models.CharField( + max_length=50 + ) slug = models.SlugField() - site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='rack_groups' + ) csv_headers = ['site', 'name', 'slug'] @@ -199,12 +309,17 @@ class RackGroup(models.Model): @python_2_unicode_compatible -class RackRole(models.Model): +class RackRole(ChangeLoggedModel): """ Racks can be organized by functional role, similar to Devices. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) color = ColorField() csv_headers = ['name', 'slug', 'color'] @@ -227,36 +342,91 @@ class RackRole(models.Model): class RackManager(NaturalOrderByManager): - - def get_queryset(self): - return self.natural_order_by('site__name', 'name') + natural_order_field = 'name' @python_2_unicode_compatible -class Rack(CreatedUpdatedModel, CustomFieldModel): +class Rack(ChangeLoggedModel, CustomFieldModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. """ - name = models.CharField(max_length=50) - 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') - u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', - validators=[MinValueValidator(1), MaxValueValidator(100)]) - desc_units = models.BooleanField(default=False, verbose_name='Descending units', - help_text='Units are numbered top-to-bottom') - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + name = models.CharField( + max_length=50 + ) + facility_id = NullableCharField( + max_length=50, + blank=True, + null=True, + verbose_name='Facility ID' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='racks' + ) + group = models.ForeignKey( + to='dcim.RackGroup', + on_delete=models.SET_NULL, + related_name='racks', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='racks', + blank=True, + null=True + ) + role = models.ForeignKey( + to='dcim.RackRole', + on_delete=models.PROTECT, + related_name='racks', + blank=True, + null=True + ) + 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' + ) + u_height = models.PositiveSmallIntegerField( + default=42, + verbose_name='Height (U)', + validators=[MinValueValidator(1), MaxValueValidator(100)] + ) + desc_units = models.BooleanField( + default=False, + verbose_name='Descending units', + help_text='Units are numbered top-to-bottom' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = RackManager() + tags = TaggableManager() csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', @@ -264,10 +434,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): ] class Meta: - ordering = ['site', 'name'] + ordering = ['site', 'group', 'name'] unique_together = [ - ['site', 'name'], - ['site', 'facility_id'], + ['group', 'name'], + ['group', 'facility_id'], ] def __str__(self): @@ -434,16 +604,32 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class RackReservation(models.Model): +class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. """ - rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE) - units = ArrayField(models.PositiveSmallIntegerField()) - created = models.DateTimeField(auto_now_add=True) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT) - user = models.ForeignKey(User, on_delete=models.PROTECT) - description = models.CharField(max_length=100) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.CASCADE, + related_name='reservations' + ) + units = ArrayField( + base_field=models.PositiveSmallIntegerField() + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='rackreservations', + blank=True, + null=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.PROTECT + ) + description = models.CharField( + max_length=100 + ) class Meta: ordering = ['created'] @@ -492,12 +678,17 @@ class RackReservation(models.Model): # @python_2_unicode_compatible -class Manufacturer(models.Model): +class Manufacturer(ChangeLoggedModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) csv_headers = ['name', 'slug'] @@ -518,7 +709,7 @@ class Manufacturer(models.Model): @python_2_unicode_compatible -class DeviceType(models.Model, CustomFieldModel): +class DeviceType(ChangeLoggedModel, CustomFieldModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -533,27 +724,65 @@ class DeviceType(models.Model, CustomFieldModel): When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the DeviceType) are automatically created as well. """ - manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT) - model = models.CharField(max_length=50) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='device_types' + ) + model = models.CharField( + max_length=50 + ) slug = models.SlugField() - part_number = models.CharField(max_length=50, blank=True, help_text="Discrete part number (optional)") - u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1) - is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth", - help_text="Device consumes both front and rear rack faces") - interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES, - default=IFACE_ORDERING_POSITION) - is_console_server = models.BooleanField(default=False, verbose_name='Is a console server', - help_text="This type of device has console server ports") - is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU', - help_text="This type of device has power outlets") - is_network_device = models.BooleanField(default=True, verbose_name='Is a network device', - help_text="This type of device has network interfaces") - subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status', - choices=SUBDEVICE_ROLE_CHOICES, - help_text="Parent devices house child devices in device bays. Select " - "\"None\" if this device type is neither a parent nor a child.") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + part_number = models.CharField( + max_length=50, + blank=True, + help_text='Discrete part number (optional)' + ) + u_height = models.PositiveSmallIntegerField( + default=1, + verbose_name='Height (U)' + ) + is_full_depth = models.BooleanField( + default=True, + verbose_name='Is full depth', + help_text='Device consumes both front and rear rack faces' + ) + interface_ordering = models.PositiveSmallIntegerField( + choices=IFACE_ORDERING_CHOICES, + default=IFACE_ORDERING_POSITION + ) + is_console_server = models.BooleanField( + default=False, + verbose_name='Is a console server', + help_text='This type of device has console server ports' + ) + is_pdu = models.BooleanField( + default=False, + verbose_name='Is a PDU', + help_text='This type of device has power outlets' + ) + is_network_device = models.BooleanField( + default=True, + verbose_name='Is a network device', + help_text='This type of device has network interfaces' + ) + subdevice_role = models.NullBooleanField( + default=None, + verbose_name='Parent/child status', + choices=SUBDEVICE_ROLE_CHOICES, + help_text='Parent devices house child devices in device bays. Select ' + '"None" if this device type is neither a parent nor a child.' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', @@ -654,12 +883,18 @@ class DeviceType(models.Model, CustomFieldModel): @python_2_unicode_compatible -class ConsolePortTemplate(models.Model): +class ConsolePortTemplate(ComponentModel): """ A template for a ConsolePort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='console_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -668,14 +903,23 @@ class ConsolePortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class ConsoleServerPortTemplate(models.Model): +class ConsoleServerPortTemplate(ComponentModel): """ A template for a ConsoleServerPort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='cs_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -684,14 +928,23 @@ class ConsoleServerPortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class PowerPortTemplate(models.Model): +class PowerPortTemplate(ComponentModel): """ A template for a PowerPort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='power_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -700,14 +953,23 @@ class PowerPortTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class PowerOutletTemplate(models.Model): +class PowerOutletTemplate(ComponentModel): """ A template for a PowerOutlet to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='power_outlet_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -716,16 +978,31 @@ class PowerOutletTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class InterfaceTemplate(models.Model): +class InterfaceTemplate(ComponentModel): """ A template for a physical data interface on a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=64) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) - mgmt_only = models.BooleanField(default=False, verbose_name='Management only') + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='interface_templates' + ) + name = models.CharField( + max_length=64 + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + mgmt_only = models.BooleanField( + default=False, + verbose_name='Management only' + ) objects = InterfaceQuerySet.as_manager() @@ -736,14 +1013,23 @@ class InterfaceTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + @python_2_unicode_compatible -class DeviceBayTemplate(models.Model): +class DeviceBayTemplate(ComponentModel): """ A template for a DeviceBay to be created for a new parent Device. """ - device_type = models.ForeignKey('DeviceType', related_name='device_bay_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='device_bay_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -752,25 +1038,33 @@ class DeviceBayTemplate(models.Model): def __str__(self): return self.name + def get_component_parent(self): + return self.device_type + # # Devices # @python_2_unicode_compatible -class DeviceRole(models.Model): +class DeviceRole(ChangeLoggedModel): """ 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. 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) + 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" + verbose_name='VM Role', + help_text='Virtual machines may be assigned to this role' ) csv_headers = ['name', 'slug', 'color', 'vm_role'] @@ -791,35 +1085,47 @@ class DeviceRole(models.Model): @python_2_unicode_compatible -class Platform(models.Model): +class Platform(ChangeLoggedModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by specifying a NAPALM driver. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) manufacturer = models.ForeignKey( - to='Manufacturer', + to='dcim.Manufacturer', + on_delete=models.PROTECT, related_name='platforms', blank=True, null=True, - help_text="Optionally limit this platform to devices of a certain manufacturer" + help_text='Optionally limit this platform to devices of a certain manufacturer' ) napalm_driver = models.CharField( max_length=50, blank=True, verbose_name='NAPALM driver', - help_text="The name of the NAPALM driver to use when interacting with devices" + help_text='The name of the NAPALM driver to use when interacting with devices' + ) + napalm_args = JSONField( + blank=True, + null=True, + verbose_name='NAPALM arguments', + help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)' ) rpc_client = models.CharField( max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, - verbose_name="Legacy RPC client" + verbose_name='Legacy RPC client' ) - csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] + csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] class Meta: ordering = ['name'] @@ -836,17 +1142,16 @@ class Platform(models.Model): self.slug, self.manufacturer.name if self.manufacturer else None, self.napalm_driver, + self.napalm_args, ) class DeviceManager(NaturalOrderByManager): - - def get_queryset(self): - return self.natural_order_by('name') + natural_order_field = 'name' @python_2_unicode_compatible -class Device(CreatedUpdatedModel, CustomFieldModel): +class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -858,30 +1163,93 @@ class Device(CreatedUpdatedModel, CustomFieldModel): by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the creation of a Device. """ - device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT) - device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT) - platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL) - name = NullableCharField(max_length=64, blank=True, null=True, unique=True) - serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.PROTECT, + related_name='instances' + ) + device_role = models.ForeignKey( + to='dcim.DeviceRole', + on_delete=models.PROTECT, + related_name='devices' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) + platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.SET_NULL, + related_name='devices', + blank=True, + null=True + ) + name = NullableCharField( + max_length=64, + blank=True, + null=True, + unique=True + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) asset_tag = NullableCharField( - max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', help_text='A unique tag used to identify this device' ) - site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT) - rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='devices' + ) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) position = models.PositiveSmallIntegerField( - blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', + blank=True, + null=True, + validators=[MinValueValidator(1)], + verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) - face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face') - status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status') + face = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=RACK_FACE_CHOICES, + verbose_name='Rack face' + ) + status = models.PositiveSmallIntegerField( + choices=DEVICE_STATUS_CHOICES, + default=DEVICE_STATUS_ACTIVE, + verbose_name='Status' + ) primary_ip4 = models.OneToOneField( - 'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True, + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='primary_ip4_for', + blank=True, + null=True, verbose_name='Primary IPv4' ) primary_ip6 = models.OneToOneField( - 'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='primary_ip6_for', + blank=True, + null=True, verbose_name='Primary IPv6' ) cluster = models.ForeignKey( @@ -908,11 +1276,20 @@ class Device(CreatedUpdatedModel, CustomFieldModel): null=True, validators=[MaxValueValidator(255)] ) - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = DeviceManager() + tags = TaggableManager() csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', @@ -1167,15 +1544,32 @@ class Device(CreatedUpdatedModel, CustomFieldModel): # @python_2_unicode_compatible -class ConsolePort(models.Model): +class ConsolePort(ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ - device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) - cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL, - verbose_name='Console server port', blank=True, null=True) - connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='console_ports' + ) + name = models.CharField( + max_length=50 + ) + cs_port = models.OneToOneField( + to='dcim.ConsoleServerPort', + on_delete=models.SET_NULL, + related_name='connected_console', + verbose_name='Console server port', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) + + tags = TaggableManager() csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] @@ -1189,6 +1583,9 @@ class ConsolePort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.cs_port.device.identifier if self.cs_port else None, @@ -1214,14 +1611,21 @@ class ConsoleServerPortManager(models.Manager): @python_2_unicode_compatible -class ConsoleServerPort(models.Model): +class ConsoleServerPort(ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ - device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='cs_ports' + ) + name = models.CharField( + max_length=50 + ) objects = ConsoleServerPortManager() + tags = TaggableManager() class Meta: unique_together = ['device', 'name'] @@ -1232,6 +1636,9 @@ class ConsoleServerPort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Check that the parent device's DeviceType is a console server @@ -1249,15 +1656,31 @@ class ConsoleServerPort(models.Model): # @python_2_unicode_compatible -class PowerPort(models.Model): +class PowerPort(ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ - device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) - power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL, - blank=True, null=True) - connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='power_ports' + ) + name = models.CharField( + max_length=50 + ) + power_outlet = models.OneToOneField( + to='dcim.PowerOutlet', + on_delete=models.SET_NULL, + related_name='connected_port', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) + + tags = TaggableManager() csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] @@ -1271,6 +1694,9 @@ class PowerPort(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.power_outlet.device.identifier if self.power_outlet else None, @@ -1296,14 +1722,21 @@ class PowerOutletManager(models.Manager): @python_2_unicode_compatible -class PowerOutlet(models.Model): +class PowerOutlet(ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ - device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='power_outlets' + ) + name = models.CharField( + max_length=50 + ) objects = PowerOutletManager() + tags = TaggableManager() class Meta: unique_together = ['device', 'name'] @@ -1314,6 +1747,9 @@ class PowerOutlet(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Check that the parent device's DeviceType is a PDU @@ -1331,7 +1767,7 @@ class PowerOutlet(models.Model): # @python_2_unicode_compatible -class Interface(models.Model): +class Interface(ComponentModel): """ A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other Interface via the creation of an InterfaceConnection. @@ -1358,17 +1794,35 @@ class Interface(models.Model): blank=True, verbose_name='Parent LAG' ) - name = models.CharField(max_length=64) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) - enabled = models.BooleanField(default=True) - mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') - mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU') + name = models.CharField( + max_length=64 + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + enabled = models.BooleanField( + default=True + ) + mac_address = MACAddressField( + null=True, + blank=True, + verbose_name='MAC Address' + ) + mtu = models.PositiveSmallIntegerField( + blank=True, + null=True, + verbose_name='MTU' + ) mgmt_only = models.BooleanField( default=False, verbose_name='OOB Management', - help_text="This interface is used only for out-of-band management" + help_text='This interface is used only for out-of-band management' + ) + description = models.CharField( + max_length=100, + blank=True ) - description = models.CharField(max_length=100, blank=True) mode = models.PositiveSmallIntegerField( choices=IFACE_MODE_CHOICES, blank=True, @@ -1376,19 +1830,21 @@ class Interface(models.Model): ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', + on_delete=models.SET_NULL, + related_name='interfaces_as_untagged', null=True, blank=True, - verbose_name='Untagged VLAN', - related_name='interfaces_as_untagged' + verbose_name='Untagged VLAN' ) tagged_vlans = models.ManyToManyField( to='ipam.VLAN', + related_name='interfaces_as_tagged', blank=True, - verbose_name='Tagged VLANs', - related_name='interfaces_as_tagged' + verbose_name='Tagged VLANs' ) objects = InterfaceQuerySet.as_manager() + tags = TaggableManager() class Meta: ordering = ['device', 'name'] @@ -1398,7 +1854,10 @@ class Interface(models.Model): return self.name def get_absolute_url(self): - return self.parent.get_absolute_url() + return reverse('dcim:interface', kwargs={'pk': self.pk}) + + def get_component_parent(self): + return self.device or self.virtual_machine def clean(self): @@ -1470,10 +1929,31 @@ class Interface(models.Model): return super(Interface, self).save(*args, **kwargs) + def log_change(self, user, request_id, action): + """ + Include the connected Interface (if any). + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.get_component_parent(), + action=action, + object_data=serialize_object(self, extra={ + 'connected_interface': self.connected_interface.pk if self.connection else None, + 'connection_status': self.connection.connection_status if self.connection else None, + }) + ).save() + + # TODO: Replace `parent` with get_component_parent() (from ComponentModel) @property def parent(self): return self.device or self.virtual_machine + @property + def is_connectable(self): + return self.form_factor not in NONCONNECTABLE_IFACE_TYPES + @property def is_virtual(self): return self.form_factor in VIRTUAL_IFACE_TYPES @@ -1526,10 +2006,21 @@ class InterfaceConnection(models.Model): An InterfaceConnection represents a symmetrical, one-to-one connection between two Interfaces. There is no significant difference between the interface_a and interface_b fields. """ - interface_a = models.OneToOneField('Interface', related_name='connected_as_a', on_delete=models.CASCADE) - interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE) - connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, - verbose_name='Status') + interface_a = models.OneToOneField( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='connected_as_a' + ) + interface_b = models.OneToOneField( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='connected_as_b' + ) + connection_status = models.BooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED, + verbose_name='Status' + ) csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] @@ -1563,20 +2054,61 @@ class InterfaceConnection(models.Model): self.get_connection_status_display(), ) + def log_change(self, user, request_id, action): + """ + Create a new ObjectChange for each of the two affected Interfaces. + """ + interfaces = ( + (self.interface_a, self.interface_b), + (self.interface_b, self.interface_a), + ) + for interface, peer_interface in interfaces: + if action == OBJECTCHANGE_ACTION_DELETE: + connection_data = { + 'connected_interface': None, + } + else: + connection_data = { + 'connected_interface': peer_interface.pk, + 'connection_status': self.connection_status + } + ObjectChange( + user=user, + request_id=request_id, + changed_object=interface, + related_object=interface.parent, + action=OBJECTCHANGE_ACTION_UPDATE, + object_data=serialize_object(interface, extra=connection_data) + ).save() + # # Device bays # @python_2_unicode_compatible -class DeviceBay(models.Model): +class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device """ - device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE) - name = models.CharField(max_length=50, verbose_name='Name') - installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True, - null=True) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='device_bays' + ) + name = models.CharField( + max_length=50, + verbose_name='Name' + ) + installed_device = models.OneToOneField( + to='dcim.Device', + on_delete=models.SET_NULL, + related_name='parent_bay', + blank=True, + null=True + ) + + tags = TaggableManager() class Meta: ordering = ['device', 'name'] @@ -1588,6 +2120,9 @@ class DeviceBay(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def clean(self): # Validate that the parent Device can have DeviceBays @@ -1606,25 +2141,62 @@ class DeviceBay(models.Model): # @python_2_unicode_compatible -class InventoryItem(models.Model): +class InventoryItem(ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. InventoryItems are used only for inventory purposes. """ - device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE) - parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE) - name = models.CharField(max_length=50, verbose_name='Name') - manufacturer = models.ForeignKey( - 'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='inventory_items' + ) + parent = models.ForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='child_items', + blank=True, + null=True + ) + name = models.CharField( + max_length=50, + verbose_name='Name' + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='inventory_items', + blank=True, + null=True + ) + part_id = models.CharField( + max_length=50, + verbose_name='Part ID', + blank=True + ) + serial = models.CharField( + max_length=50, + verbose_name='Serial number', + blank=True ) - part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) - serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) asset_tag = NullableCharField( - max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + max_length=50, + unique=True, + blank=True, + null=True, + verbose_name='Asset tag', help_text='A unique tag used to identify this item' ) - discovered = models.BooleanField(default=False, verbose_name='Discovered') - description = models.CharField(max_length=100, blank=True) + discovered = models.BooleanField( + default=False, + verbose_name='Discovered' + ) + description = models.CharField( + max_length=100, + blank=True + ) + + tags = TaggableManager() csv_headers = [ 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', @@ -1640,6 +2212,9 @@ class InventoryItem(models.Model): def get_absolute_url(self): return self.device.get_absolute_url() + def get_component_parent(self): + return self.device + def to_csv(self): return ( self.device.name or '{' + self.device.pk + '}', @@ -1658,7 +2233,7 @@ class InventoryItem(models.Model): # @python_2_unicode_compatible -class VirtualChassis(models.Model): +class VirtualChassis(ChangeLoggedModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ @@ -1672,6 +2247,10 @@ class VirtualChassis(models.Model): blank=True ) + tags = TaggableManager() + + csv_headers = ['master', 'domain'] + class Meta: ordering = ['master'] verbose_name_plural = 'virtual chassis' @@ -1690,3 +2269,9 @@ class VirtualChassis(models.Model): raise ValidationError({ 'master': "The selected master is not assigned to this virtual chassis." }) + + def to_csv(self): + return ( + self.master, + self.domain, + ) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index eb4f74157..fc9105774 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -4,7 +4,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem, @@ -41,12 +41,18 @@ DEVICE_LINK = """ """ REGION_ACTIONS = """ + + + {% if perms.dcim.change_region %} {% endif %} """ RACKGROUP_ACTIONS = """ + + + @@ -58,6 +64,9 @@ RACKGROUP_ACTIONS = """ """ RACKROLE_ACTIONS = """ + + + {% if perms.dcim.change_rackrole %} {% endif %} @@ -76,20 +85,29 @@ RACK_DEVICE_COUNT = """ """ RACKRESERVATION_ACTIONS = """ + + + {% if perms.dcim.change_rackreservation %} {% endif %} """ -DEVICEROLE_ACTIONS = """ -{% if perms.dcim.change_devicerole %} - +MANUFACTURER_ACTIONS = """ + + + +{% if perms.dcim.change_manufacturer %} + {% endif %} """ -MANUFACTURER_ACTIONS = """ -{% if perms.dcim.change_manufacturer %} - +DEVICEROLE_ACTIONS = """ + + + +{% if perms.dcim.change_devicerole %} + {% endif %} """ @@ -110,6 +128,9 @@ PLATFORM_VM_COUNT = """ """ PLATFORM_ACTIONS = """ + + + {% if perms.dcim.change_platform %} {% endif %} @@ -143,6 +164,9 @@ UTILIZATION_GRAPH = """ """ VIRTUALCHASSIS_ACTIONS = """ + + + {% if perms.dcim.change_virtualchassis %} {% endif %} @@ -175,7 +199,7 @@ class RegionTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') region = tables.TemplateColumn(template_code=SITE_REGION_LINK) tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -236,7 +260,7 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -322,10 +346,10 @@ class DeviceTypeTable(BaseTable): args=[Accessor('pk')], verbose_name='Device Type' ) - is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') - is_console_server = tables.BooleanColumn(verbose_name='CS') - is_pdu = tables.BooleanColumn(verbose_name='PDU') - is_network_device = tables.BooleanColumn(verbose_name='Net') + is_full_depth = BooleanColumn(verbose_name='Full Depth') + is_console_server = BooleanColumn(verbose_name='CS') + is_pdu = BooleanColumn(verbose_name='PDU') + is_network_device = BooleanColumn(verbose_name='Net') subdevice_role = tables.TemplateColumn( template_code=SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role' @@ -468,7 +492,10 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn(template_code=DEVICE_LINK) + name = tables.TemplateColumn( + order_by=('_nat1', '_nat2', '_nat3'), + template_code=DEVICE_LINK + ) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') tenant = tables.TemplateColumn(template_code=COL_TENANT) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 37743b499..6498e7f39 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,12 +1,11 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from dcim.constants import ( - IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SITE_STATUS_ACTIVE, SUBDEVICE_ROLE_CHILD, + SUBDEVICE_ROLE_PARENT, ) from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -16,17 +15,14 @@ from dcim.models import ( ) from ipam.models import VLAN from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import APITestCase -class RegionTest(HttpStatusMixin, APITestCase): +class RegionTest(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)} + super(RegionTest, self).setUp() self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') @@ -113,13 +109,11 @@ class RegionTest(HttpStatusMixin, APITestCase): self.assertEqual(Region.objects.count(), 2) -class SiteTest(HttpStatusMixin, APITestCase): +class SiteTest(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)} + super(SiteTest, self).setUp() self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') @@ -168,6 +162,7 @@ class SiteTest(HttpStatusMixin, APITestCase): 'name': 'Test Site 4', 'slug': 'test-site-4', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, } url = reverse('dcim-api:site-list') @@ -187,16 +182,19 @@ class SiteTest(HttpStatusMixin, APITestCase): 'name': 'Test Site 4', 'slug': 'test-site-4', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, { 'name': 'Test Site 5', 'slug': 'test-site-5', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, { 'name': 'Test Site 6', 'slug': 'test-site-6', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, ] @@ -236,13 +234,11 @@ class SiteTest(HttpStatusMixin, APITestCase): self.assertEqual(Site.objects.count(), 2) -class RackGroupTest(HttpStatusMixin, APITestCase): +class RackGroupTest(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)} + super(RackGroupTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -338,13 +334,11 @@ class RackGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(RackGroup.objects.count(), 2) -class RackRoleTest(HttpStatusMixin, APITestCase): +class RackRoleTest(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)} + super(RackRoleTest, self).setUp() self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') @@ -438,13 +432,11 @@ class RackRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(RackRole.objects.count(), 2) -class RackTest(HttpStatusMixin, APITestCase): +class RackTest(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)} + super(RackTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -564,25 +556,22 @@ class RackTest(HttpStatusMixin, APITestCase): self.assertEqual(Rack.objects.count(), 2) -class RackReservationTest(HttpStatusMixin, APITestCase): +class RackReservationTest(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)} + super(RackReservationTest, self).setUp() - self.user1 = user self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1') self.rackreservation1 = RackReservation.objects.create( - rack=self.rack1, units=[1, 2, 3], user=user, description='Reservation #1', + rack=self.rack1, units=[1, 2, 3], user=self.user, description='Reservation #1', ) self.rackreservation2 = RackReservation.objects.create( - rack=self.rack1, units=[4, 5, 6], user=user, description='Reservation #2', + rack=self.rack1, units=[4, 5, 6], user=self.user, description='Reservation #2', ) self.rackreservation3 = RackReservation.objects.create( - rack=self.rack1, units=[7, 8, 9], user=user, description='Reservation #3', + rack=self.rack1, units=[7, 8, 9], user=self.user, description='Reservation #3', ) def test_get_rackreservation(self): @@ -604,7 +593,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): data = { 'rack': self.rack1.pk, 'units': [10, 11, 12], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Fourth reservation', } @@ -625,19 +614,19 @@ class RackReservationTest(HttpStatusMixin, APITestCase): { 'rack': self.rack1.pk, 'units': [10, 11, 12], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Reservation #4', }, { 'rack': self.rack1.pk, 'units': [13, 14, 15], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Reservation #5', }, { 'rack': self.rack1.pk, 'units': [16, 17, 18], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Reservation #6', }, ] @@ -656,7 +645,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): data = { 'rack': self.rack1.pk, 'units': [10, 11, 12], - 'user': self.user1.pk, + 'user': self.user.pk, 'description': 'Modified reservation', } @@ -678,13 +667,11 @@ class RackReservationTest(HttpStatusMixin, APITestCase): self.assertEqual(RackReservation.objects.count(), 2) -class ManufacturerTest(HttpStatusMixin, APITestCase): +class ManufacturerTest(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)} + super(ManufacturerTest, self).setUp() self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') @@ -771,13 +758,11 @@ class ManufacturerTest(HttpStatusMixin, APITestCase): self.assertEqual(Manufacturer.objects.count(), 2) -class DeviceTypeTest(HttpStatusMixin, APITestCase): +class DeviceTypeTest(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)} + super(DeviceTypeTest, self).setUp() self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') @@ -879,13 +864,11 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceType.objects.count(), 2) -class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): +class ConsolePortTemplateTest(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)} + super(ConsolePortTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -981,13 +964,11 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsolePortTemplate.objects.count(), 2) -class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): +class ConsoleServerPortTemplateTest(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)} + super(ConsoleServerPortTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1083,13 +1064,11 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsoleServerPortTemplate.objects.count(), 2) -class PowerPortTemplateTest(HttpStatusMixin, APITestCase): +class PowerPortTemplateTest(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)} + super(PowerPortTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1185,13 +1164,11 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerPortTemplate.objects.count(), 2) -class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): +class PowerOutletTemplateTest(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)} + super(PowerOutletTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1287,13 +1264,11 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerOutletTemplate.objects.count(), 2) -class InterfaceTemplateTest(HttpStatusMixin, APITestCase): +class InterfaceTemplateTest(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)} + super(InterfaceTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1389,13 +1364,11 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(InterfaceTemplate.objects.count(), 2) -class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): +class DeviceBayTemplateTest(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)} + super(DeviceBayTemplateTest, self).setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1491,13 +1464,11 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceBayTemplate.objects.count(), 2) -class DeviceRoleTest(HttpStatusMixin, APITestCase): +class DeviceRoleTest(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)} + super(DeviceRoleTest, self).setUp() self.devicerole1 = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -1597,13 +1568,11 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceRole.objects.count(), 2) -class PlatformTest(HttpStatusMixin, APITestCase): +class PlatformTest(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)} + super(PlatformTest, self).setUp() self.platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1') self.platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2') @@ -1690,13 +1659,11 @@ class PlatformTest(HttpStatusMixin, APITestCase): self.assertEqual(Platform.objects.count(), 2) -class DeviceTest(HttpStatusMixin, APITestCase): +class DeviceTest(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)} + super(DeviceTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -1818,13 +1785,11 @@ class DeviceTest(HttpStatusMixin, APITestCase): self.assertEqual(Device.objects.count(), 2) -class ConsolePortTest(HttpStatusMixin, APITestCase): +class ConsolePortTest(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)} + super(ConsolePortTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -1925,13 +1890,11 @@ class ConsolePortTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsolePort.objects.count(), 2) -class ConsoleServerPortTest(HttpStatusMixin, APITestCase): +class ConsoleServerPortTest(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)} + super(ConsoleServerPortTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2028,13 +1991,11 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): self.assertEqual(ConsoleServerPort.objects.count(), 2) -class PowerPortTest(HttpStatusMixin, APITestCase): +class PowerPortTest(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)} + super(PowerPortTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2135,13 +2096,11 @@ class PowerPortTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerPort.objects.count(), 2) -class PowerOutletTest(HttpStatusMixin, APITestCase): +class PowerOutletTest(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)} + super(PowerOutletTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2238,13 +2197,11 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): self.assertEqual(PowerOutlet.objects.count(), 2) -class InterfaceTest(HttpStatusMixin, APITestCase): +class InterfaceTest(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)} + super(InterfaceTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2322,8 +2279,8 @@ class InterfaceTest(HttpStatusMixin, APITestCase): 'device': self.device.pk, 'name': 'Test Interface 4', 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan3.id, 'tagged_vlans': [self.vlan1.id, self.vlan2.id], - 'untagged_vlan': self.vlan3.id } url = reverse('dcim-api:interface-list') @@ -2331,11 +2288,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 4) - interface5 = Interface.objects.get(pk=response.data['id']) - self.assertEqual(interface5.device_id, data['device']) - self.assertEqual(interface5.name, data['name']) - self.assertEqual(interface5.tagged_vlans.count(), 2) - self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan']) + self.assertEqual(response.data['device']['id'], data['device']) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) + self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) def test_create_interface_bulk(self): @@ -2370,22 +2326,22 @@ class InterfaceTest(HttpStatusMixin, APITestCase): 'device': self.device.pk, 'name': 'Test Interface 4', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, { 'device': self.device.pk, 'name': 'Test Interface 5', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, { 'device': self.device.pk, 'name': 'Test Interface 6', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, ] @@ -2394,15 +2350,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) - self.assertEqual(len(response.data[0]['tagged_vlans']), 1) - self.assertEqual(len(response.data[1]['tagged_vlans']), 1) - self.assertEqual(len(response.data[2]['tagged_vlans']), 1) - self.assertEqual(response.data[0]['untagged_vlan'], self.vlan2.id) - self.assertEqual(response.data[1]['untagged_vlan'], self.vlan2.id) - self.assertEqual(response.data[2]['untagged_vlan'], self.vlan2.id) + for i in range(0, 3): + self.assertEqual(response.data[i]['name'], data[i]['name']) + self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) + self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) def test_update_interface(self): @@ -2434,13 +2385,11 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(Interface.objects.count(), 2) -class DeviceBayTest(HttpStatusMixin, APITestCase): +class DeviceBayTest(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)} + super(DeviceBayTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2549,13 +2498,11 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): self.assertEqual(DeviceBay.objects.count(), 2) -class InventoryItemTest(HttpStatusMixin, APITestCase): +class InventoryItemTest(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)} + super(InventoryItemTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2667,13 +2614,11 @@ class InventoryItemTest(HttpStatusMixin, APITestCase): self.assertEqual(InventoryItem.objects.count(), 2) -class ConsoleConnectionTest(HttpStatusMixin, APITestCase): +class ConsoleConnectionTest(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)} + super(ConsoleConnectionTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2710,13 +2655,11 @@ class ConsoleConnectionTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['count'], 3) -class PowerConnectionTest(HttpStatusMixin, APITestCase): +class PowerConnectionTest(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)} + super(PowerConnectionTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2753,13 +2696,11 @@ class PowerConnectionTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['count'], 3) -class InterfaceConnectionTest(HttpStatusMixin, APITestCase): +class InterfaceConnectionTest(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)} + super(InterfaceConnectionTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2847,9 +2788,9 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InterfaceConnection.objects.count(), 6) - self.assertEqual(response.data[0]['interface_a'], data[0]['interface_a']) - self.assertEqual(response.data[1]['interface_a'], data[1]['interface_a']) - self.assertEqual(response.data[2]['interface_a'], data[2]['interface_a']) + for i in range(0, 3): + self.assertEqual(response.data[i]['interface_a']['id'], data[i]['interface_a']) + self.assertEqual(response.data[i]['interface_b']['id'], data[i]['interface_b']) def test_update_interfaceconnection(self): @@ -2880,13 +2821,11 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.assertEqual(InterfaceConnection.objects.count(), 2) -class ConnectedDeviceTest(HttpStatusMixin, APITestCase): +class ConnectedDeviceTest(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)} + super(ConnectedDeviceTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -2922,13 +2861,11 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase): self.assertEqual(response.data['name'], self.device1.name) -class VirtualChassisTest(HttpStatusMixin, APITestCase): +class VirtualChassisTest(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)} + super(VirtualChassisTest, self).setUp() site = Site.objects.create(name='Test Site', slug='test-site') manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer') @@ -3047,12 +2984,9 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VirtualChassis.objects.count(), 5) - self.assertEqual(response.data[0]['master'], data[0]['master']) - self.assertEqual(response.data[0]['domain'], data[0]['domain']) - self.assertEqual(response.data[1]['master'], data[1]['master']) - self.assertEqual(response.data[1]['domain'], data[1]['domain']) - self.assertEqual(response.data[2]['master'], data[2]['master']) - self.assertEqual(response.data[2]['domain'], data[2]['domain']) + for i in range(0, 3): + self.assertEqual(response.data[i]['master']['id'], data[i]['master']) + self.assertEqual(response.data[i]['domain'], data[i]['domain']) def test_update_virtualchassis(self): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 5682bd8e7..7345cdacd 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -2,11 +2,14 @@ from __future__ import unicode_literals from django.conf.urls import url -from extras.views import ImageAttachmentEditView +from extras.views import ObjectChangeLogView, ImageAttachmentEditView from ipam.views import ServiceCreateView from secrets.views import secret_add from . import views -from .models import Device, Rack, Site +from .models import ( + Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, + Region, Site, VirtualChassis, +) app_name = 'dcim' urlpatterns = [ @@ -17,6 +20,7 @@ urlpatterns = [ 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'), + url(r'^regions/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), @@ -26,6 +30,7 @@ urlpatterns = [ url(r'^sites/(?P[\w-]+)/$', views.SiteView.as_view(), name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), + url(r'^sites/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), url(r'^sites/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups @@ -34,6 +39,7 @@ urlpatterns = [ 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'), + url(r'^rack-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), @@ -41,6 +47,7 @@ urlpatterns = [ 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'), + url(r'^rack-roles/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'), @@ -48,6 +55,7 @@ urlpatterns = [ url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + url(r'^rack-reservations/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), # Racks url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), @@ -59,6 +67,7 @@ urlpatterns = [ url(r'^racks/(?P\d+)/$', views.RackView.as_view(), name='rack'), url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), + url(r'^racks/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), @@ -68,6 +77,7 @@ urlpatterns = [ 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'), + url(r'^manufacturers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -78,6 +88,7 @@ urlpatterns = [ url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), url(r'^device-types/(?P\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), url(r'^device-types/(?P\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), + url(r'^device-types/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), # Console port templates url(r'^device-types/(?P\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), @@ -110,6 +121,7 @@ urlpatterns = [ 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'), + url(r'^device-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), @@ -117,6 +129,7 @@ urlpatterns = [ 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'), + url(r'^platforms/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), @@ -128,6 +141,8 @@ urlpatterns = [ url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), + url(r'^devices/(?P\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'), + url(r'^devices/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), @@ -184,9 +199,11 @@ urlpatterns = [ url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), + url(r'^interfaces/(?P\d+)/$', views.InterfaceView.as_view(), name='interface'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), + url(r'^interfaces/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), # Device bays @@ -221,6 +238,7 @@ urlpatterns = [ url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + url(r'^virtual-chassis/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), url(r'^virtual-chassis-members/(?P\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bee775773..eb7f71a25 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,14 +12,16 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape -from django.utils.http import is_safe_url, urlencode +from django.utils.http import urlencode from django.utils.safestring import mark_safe from django.views.generic import View from natsort import natsorted from circuits.models import Circuit -from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE, UserAction +from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +from extras.views import ObjectConfigContextView from ipam.models import Prefix, Service, VLAN +from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( @@ -37,7 +39,7 @@ from .models import ( ) -class BulkRenameView(View): +class BulkRenameView(GetReturnURLMixin, View): """ An extendable view for renaming device components in bulk. """ @@ -49,10 +51,6 @@ class BulkRenameView(View): model = self.queryset.model - return_url = request.GET.get('return_url') - if not return_url or not is_safe_url(url=return_url, host=request.get_host()): - return_url = 'home' - if '_preview' in request.POST or '_apply' in request.POST: form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) selected_objects = self.queryset.filter(pk__in=form.initial['pk']) @@ -69,7 +67,7 @@ class BulkRenameView(View): len(selected_objects), model._meta.verbose_name_plural )) - return redirect(return_url) + return redirect(self.get_return_url(request)) else: form = self.form(initial={'pk': request.POST.getlist('pk')}) @@ -79,7 +77,7 @@ class BulkRenameView(View): 'form': form, 'obj_type_plural': model._meta.verbose_name_plural, 'selected_objects': selected_objects, - 'return_url': return_url, + 'return_url': self.get_return_url(request), }) @@ -137,9 +135,7 @@ class RegionCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_region' model = Region model_form = forms.RegionForm - - def get_return_url(self, request, obj): - return reverse('dcim:region_list') + default_return_url = 'dcim:region_list' class RegionEditView(RegionCreateView): @@ -155,7 +151,6 @@ class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' - cls = Region queryset = Region.objects.annotate(site_count=Count('sites')) filter = filters.RegionFilter table = tables.RegionTable @@ -227,7 +222,6 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_site' - cls = Site queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter table = tables.SiteTable @@ -251,9 +245,7 @@ class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackgroup' model = RackGroup model_form = forms.RackGroupForm - - def get_return_url(self, request, obj): - return reverse('dcim:rackgroup_list') + default_return_url = 'dcim:rackgroup_list' class RackGroupEditView(RackGroupCreateView): @@ -269,7 +261,6 @@ class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' - cls = RackGroup queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter table = tables.RackGroupTable @@ -290,9 +281,7 @@ class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_rackrole' model = RackRole model_form = forms.RackRoleForm - - def get_return_url(self, request, obj): - return reverse('dcim:rackrole_list') + default_return_url = 'dcim:rackrole_list' class RackRoleEditView(RackRoleCreateView): @@ -308,7 +297,6 @@ class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView): class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackrole' - cls = RackRole queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -423,7 +411,6 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView): class RackBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rack' - cls = Rack queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter table = tables.RackTable @@ -433,7 +420,6 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' - cls = Rack queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter table = tables.RackTable @@ -481,7 +467,6 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rackreservation' - cls = RackReservation queryset = RackReservation.objects.select_related('rack', 'user') filter = filters.RackReservationFilter table = tables.RackReservationTable @@ -491,7 +476,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackreservation' - cls = RackReservation + queryset = RackReservation.objects.select_related('rack', 'user') filter = filters.RackReservationFilter table = tables.RackReservationTable default_return_url = 'dcim:rackreservation_list' @@ -514,9 +499,7 @@ class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_manufacturer' model = Manufacturer model_form = forms.ManufacturerForm - - def get_return_url(self, request, obj): - return reverse('dcim:manufacturer_list') + default_return_url = 'dcim:manufacturer_list' class ManufacturerEditView(ManufacturerCreateView): @@ -532,7 +515,6 @@ class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView): class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_manufacturer' - cls = Manufacturer queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -629,7 +611,6 @@ class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' - cls = DeviceType queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter table = tables.DeviceTypeTable @@ -639,7 +620,6 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' - cls = DeviceType queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter table = tables.DeviceTypeTable @@ -662,10 +642,8 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleporttemplate' + queryset = ConsolePortTemplate.objects.all() parent_model = DeviceType - parent_field = 'device_type' - cls = ConsolePortTemplate - parent_cls = DeviceType table = tables.ConsolePortTemplateTable @@ -681,8 +659,8 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverporttemplate' - cls = ConsoleServerPortTemplate - parent_cls = DeviceType + queryset = ConsoleServerPortTemplate.objects.all() + parent_model = DeviceType table = tables.ConsoleServerPortTemplateTable @@ -698,8 +676,8 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerporttemplate' - cls = PowerPortTemplate - parent_cls = DeviceType + queryset = PowerPortTemplate.objects.all() + parent_model = DeviceType table = tables.PowerPortTemplateTable @@ -715,8 +693,8 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlettemplate' - cls = PowerOutletTemplate - parent_cls = DeviceType + queryset = PowerOutletTemplate.objects.all() + parent_model = DeviceType table = tables.PowerOutletTemplateTable @@ -732,16 +710,16 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' - cls = InterfaceTemplate - parent_cls = DeviceType + queryset = InterfaceTemplate.objects.all() + parent_model = DeviceType table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interfacetemplate' - cls = InterfaceTemplate - parent_cls = DeviceType + queryset = InterfaceTemplate.objects.all() + parent_model = DeviceType table = tables.InterfaceTemplateTable @@ -757,8 +735,8 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebaytemplate' - cls = DeviceBayTemplate - parent_cls = DeviceType + queryset = DeviceBayTemplate.objects.all() + parent_model = DeviceType table = tables.DeviceBayTemplateTable @@ -776,9 +754,7 @@ class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_devicerole' model = DeviceRole model_form = forms.DeviceRoleForm - - def get_return_url(self, request, obj): - return reverse('dcim:devicerole_list') + default_return_url = 'dcim:devicerole_list' class DeviceRoleEditView(DeviceRoleCreateView): @@ -794,8 +770,7 @@ class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicerole' - cls = DeviceRole - queryset = DeviceRole.objects.annotate(device_count=Count('devices')) + queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -814,9 +789,7 @@ class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_platform' model = Platform model_form = forms.PlatformForm - - def get_return_url(self, request, obj): - return reverse('dcim:platform_list') + default_return_url = 'dcim:platform_list' class PlatformEditView(PlatformCreateView): @@ -832,8 +805,7 @@ class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView): class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_platform' - cls = Platform - queryset = Platform.objects.annotate(device_count=Count('devices')) + queryset = Platform.objects.all() table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -945,6 +917,7 @@ class DeviceInventoryView(View): return render(request, 'dcim/device_inventory.html', { 'device': device, 'inventory_items': inventory_items, + 'active_tab': 'inventory', }) @@ -957,6 +930,7 @@ class DeviceStatusView(PermissionRequiredMixin, View): return render(request, 'dcim/device_status.html', { 'device': device, + 'active_tab': 'status', }) @@ -975,6 +949,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): return render(request, 'dcim/device_lldp_neighbors.html', { 'device': device, 'interfaces': interfaces, + 'active_tab': 'lldp-neighbors', }) @@ -987,9 +962,15 @@ class DeviceConfigView(PermissionRequiredMixin, View): return render(request, 'dcim/device_config.html', { 'device': device, + 'active_tab': 'config', }) +class DeviceConfigContextView(ObjectConfigContextView): + object_class = Device + base_template = 'dcim/device.html' + + class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_device' model = Device @@ -1037,7 +1018,6 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_device' - cls = Device queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter table = tables.DeviceTable @@ -1047,7 +1027,6 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' - cls = Device queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter table = tables.DeviceTable @@ -1104,7 +1083,6 @@ class ConsolePortConnectView(PermissionRequiredMixin, View): escape(consoleport.cs_port.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleport.device.pk) @@ -1155,7 +1133,6 @@ class ConsolePortDisconnectView(PermissionRequiredMixin, View): escape(cs_port.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleport.device.pk) @@ -1179,8 +1156,8 @@ class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' - cls = ConsolePort - parent_cls = Device + queryset = ConsolePort.objects.all() + parent_model = Device table = tables.ConsolePortTable @@ -1244,7 +1221,6 @@ class ConsoleServerPortConnectView(PermissionRequiredMixin, View): escape(consoleserverport.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleserverport.device.pk) @@ -1296,7 +1272,6 @@ class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View): escape(consoleserverport.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleserverport.device.pk) @@ -1335,8 +1310,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' - cls = ConsoleServerPort - parent_cls = Device + queryset = ConsoleServerPort.objects.all() + parent_model = Device table = tables.ConsoleServerPortTable @@ -1390,7 +1365,6 @@ class PowerPortConnectView(PermissionRequiredMixin, View): escape(powerport.power_outlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=powerport.device.pk) @@ -1441,7 +1415,6 @@ class PowerPortDisconnectView(PermissionRequiredMixin, View): escape(power_outlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=powerport.device.pk) @@ -1465,8 +1438,8 @@ class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' - cls = PowerPort - parent_cls = Device + queryset = PowerPort.objects.all() + parent_model = Device table = tables.PowerPortTable @@ -1529,7 +1502,6 @@ class PowerOutletConnectView(PermissionRequiredMixin, View): escape(poweroutlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=poweroutlet.device.pk) @@ -1580,7 +1552,6 @@ class PowerOutletDisconnectView(PermissionRequiredMixin, View): escape(poweroutlet.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=poweroutlet.device.pk) @@ -1621,8 +1592,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView) class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' - cls = PowerOutlet - parent_cls = Device + queryset = PowerOutlet.objects.all() + parent_model = Device table = tables.PowerOutletTable @@ -1630,6 +1601,47 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Interfaces # +class InterfaceView(View): + + def get(self, request, pk): + + interface = get_object_or_404(Interface, pk=pk) + + # Get connected interface + connected_interface = interface.connected_interface + if connected_interface is None and hasattr(interface, 'circuit_termination'): + peer_termination = interface.circuit_termination.get_peer_termination() + if peer_termination is not None: + connected_interface = peer_termination.interface + + # Get assigned IP addresses + ipaddress_table = InterfaceIPAddressTable( + data=interface.ip_addresses.select_related('vrf', 'tenant'), + orderable=False + ) + + # Get assigned VLANs and annotate whether each is tagged or untagged + vlans = [] + if interface.untagged_vlan is not None: + vlans.append(interface.untagged_vlan) + vlans[0].tagged = False + for vlan in interface.tagged_vlans.select_related('site', 'group', 'tenant', 'role'): + vlan.tagged = True + vlans.append(vlan) + vlan_table = InterfaceVLANTable( + interface=interface, + data=vlans, + orderable=False + ) + + return render(request, 'dcim/interface.html', { + 'interface': interface, + 'connected_interface': connected_interface, + 'ipaddress_table': ipaddress_table, + 'vlan_table': vlan_table, + }) + + class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interface' parent_model = Device @@ -1672,8 +1684,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' - cls = Interface - parent_cls = Device + queryset = Interface.objects.all() + parent_model = Device table = tables.InterfaceTable form = forms.InterfaceBulkEditForm @@ -1686,8 +1698,8 @@ class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' - cls = Interface - parent_cls = Device + queryset = Interface.objects.all() + parent_model = Device table = tables.InterfaceTable @@ -1793,8 +1805,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebay' - cls = DeviceBay - parent_cls = Device + queryset = DeviceBay.objects.all() + parent_model = Device table = tables.DeviceBayTable @@ -1910,7 +1922,6 @@ class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, Vie escape(interfaceconnection.interface_b.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, interfaceconnection, msg) if '_addanother' in request.POST: base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) @@ -1961,7 +1972,6 @@ class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin, escape(interfaceconnection.interface_b.name), ) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, interfaceconnection, msg) return redirect(self.get_return_url(request, interfaceconnection)) @@ -2053,7 +2063,6 @@ class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_inventoryitem' - cls = InventoryItem queryset = InventoryItem.objects.select_related('device', 'manufacturer') filter = filters.InventoryItemFilter table = tables.InventoryItemTable @@ -2063,7 +2072,6 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView): class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_inventoryitem' - cls = InventoryItem queryset = InventoryItem.objects.select_related('device', 'manufacturer') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' @@ -2241,7 +2249,6 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi membership_form.save() msg = 'Added member {}'.format(device.get_absolute_url(), escape(device)) messages.success(request, mark_safe(msg)) - UserAction.objects.log_edit(request.user, device, msg) if '_addanother' in request.POST: return redirect(request.get_full_path()) @@ -2296,7 +2303,6 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis) messages.success(request, msg) - UserAction.objects.log_edit(request.user, device, msg) return redirect(self.get_return_url(request, device)) diff --git a/netbox/extras/__init__.py b/netbox/extras/__init__.py index e69de29bb..c7e9c66ad 100644 --- a/netbox/extras/__init__.py +++ b/netbox/extras/__init__.py @@ -0,0 +1,15 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + + +default_app_config = 'extras.apps.ExtrasConfig' + +# check that django-rq is installed and we can connect to redis +if settings.WEBHOOKS_ENABLED: + try: + import django_rq + except ImportError: + raise ImproperlyConfigured( + "django-rq is not installed! You must install this package per " + "the documentation to use the webhook backend." + ) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 07b5a9ae7..2b140b444 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -4,7 +4,12 @@ from django import forms from django.contrib import admin from django.utils.safestring import mark_safe -from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction +from utilities.forms import LaxURLField +from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from .models import ( + ConfigContext, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, + Webhook, +) def order_content_types(field): @@ -15,6 +20,37 @@ def order_content_types(field): field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset] +# +# Webhooks +# + +class WebhookForm(forms.ModelForm): + payload_url = LaxURLField( + label='URL' + ) + + class Meta: + model = Webhook + exclude = [] + + def __init__(self, *args, **kwargs): + super(WebhookForm, self).__init__(*args, **kwargs) + + order_content_types(self.fields['obj_type']) + + +@admin.register(Webhook) +class WebhookAdmin(admin.ModelAdmin): + list_display = [ + 'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', + 'type_delete', 'ssl_verification', + ] + form = WebhookForm + + def models(self, obj): + return ', '.join([ct.name for ct in obj.obj_type.all()]) + + # # Custom fields # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6c3cdd409..d0d2c67b0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -2,28 +2,31 @@ from __future__ import unicode_literals from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers +from taggit.models import Tag -from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer -from dcim.models import Device, Rack, Site -from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES -from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from dcim.api.serializers import ( + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, + NestedRegionSerializer, NestedSiteSerializer, +) +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from extras.models import ( + ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, +) +from extras.constants import * +from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer +from tenancy.models import Tenant, TenantGroup from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer +from utilities.api import ( + ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer, +) # # Graphs # -class GraphSerializer(serializers.ModelSerializer): - type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) - - class Meta: - model = Graph - fields = ['id', 'type', 'weight', 'name', 'source', 'link'] - - -class WritableGraphSerializer(serializers.ModelSerializer): +class GraphSerializer(ValidatedModelSerializer): + type = ChoiceField(choices=GRAPH_TYPE_CHOICES) class Meta: model = Graph @@ -33,7 +36,7 @@ class WritableGraphSerializer(serializers.ModelSerializer): class RenderedGraphSerializer(serializers.ModelSerializer): embed_url = serializers.SerializerMethodField() embed_link = serializers.SerializerMethodField() - type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) + type = ChoiceField(choices=GRAPH_TYPE_CHOICES) class Meta: model = Graph @@ -50,7 +53,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer): # Export templates # -class ExportTemplateSerializer(serializers.ModelSerializer): +class ExportTemplateSerializer(ValidatedModelSerializer): class Meta: model = ExportTemplate @@ -61,7 +64,7 @@ class ExportTemplateSerializer(serializers.ModelSerializer): # Topology maps # -class TopologyMapSerializer(serializers.ModelSerializer): +class TopologyMapSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() class Meta: @@ -69,23 +72,46 @@ class TopologyMapSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] -class WritableTopologyMapSerializer(serializers.ModelSerializer): +# +# Tags +# + +class TagSerializer(ValidatedModelSerializer): + tagged_items = serializers.IntegerField(read_only=True) class Meta: - model = TopologyMap - fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] + model = Tag + fields = ['id', 'name', 'slug', 'tagged_items'] # # Image attachments # -class ImageAttachmentSerializer(serializers.ModelSerializer): - parent = serializers.SerializerMethodField() +class ImageAttachmentSerializer(ValidatedModelSerializer): + content_type = ContentTypeField() + parent = serializers.SerializerMethodField(read_only=True) class Meta: model = ImageAttachment - fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created'] + fields = [ + 'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created', + ] + + def validate(self, data): + + # Validate that the parent object exists + try: + data['content_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) + ) + + # Enforce model validation + super(ImageAttachmentSerializer, self).validate(data) + + return data def get_parent(self, obj): @@ -102,27 +128,54 @@ class ImageAttachmentSerializer(serializers.ModelSerializer): return serializer(obj.parent, context={'request': self.context['request']}).data -class WritableImageAttachmentSerializer(ValidatedModelSerializer): - content_type = ContentTypeFieldSerializer() +# +# Config contexts +# + +class ConfigContextSerializer(ValidatedModelSerializer): + regions = SerializedPKRelatedField( + queryset=Region.objects.all(), + serializer=NestedRegionSerializer, + required=False, + many=True + ) + sites = SerializedPKRelatedField( + queryset=Site.objects.all(), + serializer=NestedSiteSerializer, + required=False, + many=True + ) + roles = SerializedPKRelatedField( + queryset=DeviceRole.objects.all(), + serializer=NestedDeviceRoleSerializer, + required=False, + many=True + ) + platforms = SerializedPKRelatedField( + queryset=Platform.objects.all(), + serializer=NestedPlatformSerializer, + required=False, + many=True + ) + tenant_groups = SerializedPKRelatedField( + queryset=TenantGroup.objects.all(), + serializer=NestedTenantGroupSerializer, + required=False, + many=True + ) + tenants = SerializedPKRelatedField( + queryset=Tenant.objects.all(), + serializer=NestedTenantSerializer, + required=False, + many=True + ) class Meta: - model = ImageAttachment - fields = ['id', 'content_type', 'object_id', 'name', 'image'] - - def validate(self, data): - - # Validate that the parent object exists - try: - data['content_type'].get_object_for_this_type(id=data['object_id']) - except ObjectDoesNotExist: - raise serializers.ValidationError( - "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) - ) - - # Enforce model validation - super(WritableImageAttachmentSerializer, self).validate(data) - - return data + model = ConfigContext + fields = [ + 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', + 'tenant_groups', 'tenants', 'data', + ] # @@ -160,13 +213,42 @@ class ReportDetailSerializer(ReportSerializer): result = ReportResultSerializer() +# +# Change logging +# + +class ObjectChangeSerializer(serializers.ModelSerializer): + user = NestedUserSerializer(read_only=True) + content_type = ContentTypeField(read_only=True) + changed_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ObjectChange + fields = [ + 'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data', + ] + + def get_changed_object(self, obj): + """ + Serialize a nested representation of the changed object. + """ + if obj.changed_object is None: + return None + serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') + if serializer is None: + return obj.object_repr + context = {'request': self.context['request']} + data = serializer(obj.changed_object, context=context).data + return data + + # # User actions # class UserActionSerializer(serializers.ModelSerializer): user = NestedUserSerializer() - action = ChoiceFieldSerializer(choices=ACTION_CHOICES) + action = ChoiceField(choices=ACTION_CHOICES) class Meta: model = UserAction diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index cc278644d..cf61841dd 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -28,12 +28,21 @@ router.register(r'export-templates', views.ExportTemplateViewSet) # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) +# Tags +router.register(r'tags', views.TagViewSet) + # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) +# Config contexts +router.register(r'config-contexts', views.ConfigContextViewSet) + # Reports router.register(r'reports', views.ReportViewSet, base_name='report') +# Change logging +router.register(r'object-changes', views.ObjectChangeViewSet) + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index f39629fa0..b278f9a7c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,15 +1,20 @@ from __future__ import unicode_literals from django.contrib.contenttypes.models import ContentType +from django.db.models import Count from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 -from rest_framework.decorators import detail_route +from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet +from taggit.models import Tag from extras import filters -from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction +from extras.models import ( + ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, + UserAction, +) from extras.reports import get_report, get_reports from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet from . import serializers @@ -67,7 +72,6 @@ class CustomFieldModelViewSet(ModelViewSet): class GraphViewSet(ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer - write_serializer_class = serializers.WritableGraphSerializer filter_class = filters.GraphFilter @@ -88,10 +92,9 @@ class ExportTemplateViewSet(ModelViewSet): class TopologyMapViewSet(ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer - write_serializer_class = serializers.WritableTopologyMapSerializer filter_class = filters.TopologyMapFilter - @detail_route() + @action(detail=True) def render(self, request, pk): tmap = get_object_or_404(TopologyMap, pk=pk) @@ -111,6 +114,16 @@ class TopologyMapViewSet(ModelViewSet): return response +# +# Tags +# + +class TagViewSet(ModelViewSet): + queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items')) + serializer_class = serializers.TagSerializer + filter_class = filters.TagFilter + + # # Image attachments # @@ -118,7 +131,15 @@ class TopologyMapViewSet(ModelViewSet): class ImageAttachmentViewSet(ModelViewSet): queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer - write_serializer_class = serializers.WritableImageAttachmentSerializer + + +# +# Config contexts +# + +class ConfigContextViewSet(ModelViewSet): + queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants') + serializer_class = serializers.ConfigContextSerializer # @@ -178,7 +199,7 @@ class ReportViewSet(ViewSet): return Response(serializer.data) - @detail_route(methods=['post']) + @action(detail=True, methods=['post']) def run(self, request, pk): """ Run a Report and create a new ReportResult, overwriting any previous result for the Report. @@ -197,13 +218,26 @@ class ReportViewSet(ViewSet): return Response(serializer.data) +# +# Change logging +# + +class ObjectChangeViewSet(ReadOnlyModelViewSet): + """ + Retrieve a list of recent changes. + """ + queryset = ObjectChange.objects.select_related('user') + serializer_class = serializers.ObjectChangeSerializer + filter_class = filters.ObjectChangeFilter + + # # User activity # class RecentActivityViewSet(ReadOnlyModelViewSet): """ - List all UserActions to provide a log of recent activity. + DEPRECATED: List all UserActions to provide a log of recent activity. """ queryset = UserAction.objects.all() serializer_class = serializers.UserActionSerializer diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py new file mode 100644 index 000000000..4520b1923 --- /dev/null +++ b/netbox/extras/apps.py @@ -0,0 +1,33 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig +from django.core.exceptions import ImproperlyConfigured +from django.conf import settings + + +class ExtrasConfig(AppConfig): + name = "extras" + + def ready(self): + # Check that we can connect to the configured Redis database if webhooks are enabled. + if settings.WEBHOOKS_ENABLED: + try: + import redis + except ImportError: + raise ImproperlyConfigured( + "WEBHOOKS_ENABLED is True but the redis Python package is not installed. (Try 'pip install " + "redis'.)" + ) + try: + rs = redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DATABASE, + password=settings.REDIS_PASSWORD or None, + ) + rs.ping() + except redis.exceptions.ConnectionError: + raise ImproperlyConfigured( + "Unable to connect to the Redis database. Check that the Redis configuration has been defined in " + "configuration.py." + ) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 94f58c2d1..9707d9121 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -3,11 +3,12 @@ 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 - 'tenant', # Tenancy - 'cluster', 'virtualmachine', # Virtualization + 'provider', 'circuit', # Circuits + 'site', 'rack', 'devicetype', 'device', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM + 'secret', # Secrets + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization ) # Custom field types @@ -50,8 +51,9 @@ GRAPH_TYPE_CHOICES = ( EXPORTTEMPLATE_MODELS = [ 'provider', 'circuit', # Circuits 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM - 'consoleport', 'powerport', 'interfaceconnection', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM + 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM + 'secret', # Secrets 'tenant', # Tenancy 'cluster', 'virtualmachine', # Virtualization ] @@ -66,6 +68,16 @@ TOPOLOGYMAP_TYPE_CHOICES = ( (TOPOLOGYMAP_TYPE_POWER, 'Power'), ) +# Change log actions +OBJECTCHANGE_ACTION_CREATE = 1 +OBJECTCHANGE_ACTION_UPDATE = 2 +OBJECTCHANGE_ACTION_DELETE = 3 +OBJECTCHANGE_ACTION_CHOICES = ( + (OBJECTCHANGE_ACTION_CREATE, 'Created'), + (OBJECTCHANGE_ACTION_UPDATE, 'Updated'), + (OBJECTCHANGE_ACTION_DELETE, 'Deleted'), +) + # User action types ACTION_CREATE = 1 ACTION_IMPORT = 2 @@ -97,3 +109,23 @@ LOG_LEVEL_CODES = { LOG_WARNING: 'warning', LOG_FAILURE: 'failure', } + +# webhook content types +WEBHOOK_CT_JSON = 1 +WEBHOOK_CT_X_WWW_FORM_ENCODED = 2 +WEBHOOK_CT_CHOICES = ( + (WEBHOOK_CT_JSON, 'application/json'), + (WEBHOOK_CT_X_WWW_FORM_ENCODED, 'application/x-www-form-urlencoded'), +) + +# Models which support registered webhooks +WEBHOOK_MODELS = ( + 'provider', 'circuit', # Circuits + 'site', 'rack', 'devicetype', 'device', 'virtualchassis', # DCIM + 'consoleport', 'consoleserverport', 'powerport', 'poweroutlet', + 'interface', 'devicebay', 'inventoryitem', + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM + 'secret', # Secrets + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization +) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 4a991471b..71c9314cd 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -3,10 +3,12 @@ from __future__ import unicode_literals import django_filters from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from taggit.models import Tag from dcim.models import Site from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT -from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction +from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction class CustomFieldFilter(django_filters.Filter): @@ -85,6 +87,25 @@ class ExportTemplateFilter(django_filters.FilterSet): fields = ['content_type', 'name'] +class TagFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = Tag + fields = ['name', 'slug'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) + ) + + class TopologyMapFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( name='site', @@ -103,6 +124,26 @@ class TopologyMapFilter(django_filters.FilterSet): fields = ['name', 'slug'] +class ObjectChangeFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + time = django_filters.DateTimeFromToRangeFilter() + + class Meta: + model = ObjectChange + fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(user_name__icontains=value) | + Q(object_repr__icontains=value) + ) + + class UserActionFilter(django_filters.FilterSet): username = django_filters.ModelMultipleChoiceFilter( name='user__username', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 55f8435d6..0b8d27233 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,14 +3,26 @@ from __future__ import unicode_literals from collections import OrderedDict from django import forms +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from mptt.forms import TreeNodeMultipleChoiceField +from taggit.forms import TagField +from taggit.models import Tag -from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField -from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL -from .models import CustomField, CustomFieldValue, ImageAttachment +from dcim.models import Region +from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField +from .constants import ( + CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, + OBJECTCHANGE_ACTION_CHOICES, +) +from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange +# +# Custom fields +# + def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): """ Retrieve all CustomFields applicable to the given ContentType @@ -170,8 +182,88 @@ class CustomFieldFilterForm(forms.Form): self.fields[name] = field +# +# Tags +# + +class TagForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = Tag + fields = ['name', 'slug'] + + +class AddRemoveTagsForm(forms.Form): + + def __init__(self, *args, **kwargs): + super(AddRemoveTagsForm, self).__init__(*args, **kwargs) + + # Add add/remove tags fields + self.fields['add_tags'] = TagField(required=False) + self.fields['remove_tags'] = TagField(required=False) + + +# +# Config contexts +# + +class ConfigContextForm(BootstrapMixin, forms.ModelForm): + regions = TreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + required=False + ) + data = JSONField() + + class Meta: + model = ConfigContext + fields = [ + 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', + 'tenants', 'data', + ] + + +# +# Image attachments +# + class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class Meta: model = ImageAttachment fields = ['name', 'image'] + + +# +# Change logging +# + +class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = ObjectChange + q = forms.CharField( + required=False, + label='Search' + ) + # TODO: Change time_0 and time_1 to time_after and time_before for django-filter==2.0 + time_0 = forms.DateTimeField( + label='After', + required=False, + widget=forms.TextInput( + attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'} + ) + ) + time_1 = forms.DateTimeField( + label='Before', + required=False, + widget=forms.TextInput( + attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'} + ) + ) + action = forms.ChoiceField( + choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES), + required=False + ) + user = forms.ModelChoiceField( + queryset=User.objects.order_by('username'), + required=False + ) diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py new file mode 100644 index 000000000..7dfddbad6 --- /dev/null +++ b/netbox/extras/middleware.py @@ -0,0 +1,94 @@ +from __future__ import unicode_literals + +from datetime import timedelta +import random +import threading +import uuid + +from django.conf import settings +from django.db.models.signals import post_delete, post_save +from django.utils import timezone +from django.utils.functional import curry + +from extras.webhooks import enqueue_webhooks +from .constants import ( + OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE, +) +from .models import ObjectChange + + +_thread_locals = threading.local() + + +def cache_changed_object(instance, **kwargs): + + action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE + + # Cache the object for further processing was the response has completed. + _thread_locals.changed_objects.append( + (instance, action) + ) + + +def _record_object_deleted(request, instance, **kwargs): + + # Record that the object was deleted. + if hasattr(instance, 'log_change'): + instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) + + enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE) + + +class ObjectChangeMiddleware(object): + """ + This middleware performs two functions in response to an object being created, updated, or deleted: + + 1. Create an ObjectChange to reflect the modification to the object in the changelog. + 2. Enqueue any relevant webhooks. + + The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit + differently for each. Objects being saved are cached into thread-local storage for action *after* the response has + completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags) + have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the + object is recorded before it (and any related objects) are actually deleted from the database. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + + # Initialize an empty list to cache objects being saved. + _thread_locals.changed_objects = [] + + # Assign a random unique ID to the request. This will be used to associate multiple object changes made during + # the same request. + request.id = uuid.uuid4() + + # Signals don't include the request context, so we're currying it into the pre_delete function ahead of time. + record_object_deleted = curry(_record_object_deleted, request) + + # Connect our receivers to the post_save and pre_delete signals. + post_save.connect(cache_changed_object, dispatch_uid='record_object_saved') + post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted') + + # Process the request + response = self.get_response(request) + + # Create records for any cached objects that were created/updated. + for obj, action in _thread_locals.changed_objects: + + # Record the change + if hasattr(obj, 'log_change'): + obj.log_change(request.user, request.id, action) + + # Enqueue webhooks + enqueue_webhooks(obj, action) + + # Housekeeping: 1% chance of clearing out expired ObjectChanges + if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: + cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) + purged_count, _ = ObjectChange.objects.filter( + time__lt=cutoff + ).delete() + + return response diff --git a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py new file mode 100644 index 000000000..0ac826ba4 --- /dev/null +++ b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:19 +from __future__ import unicode_literals + +import re +from distutils.version import StrictVersion + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import connection, migrations, models +import django.db.models.deletion +import extras.models +from django.db.utils import OperationalError + +from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT + + +def verify_postgresql_version(apps, schema_editor): + """ + Verify that PostgreSQL is version 9.4 or higher. + """ + try: + with connection.cursor() as cursor: + cursor.execute("SELECT VERSION()") + row = cursor.fetchone() + pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1) + if StrictVersion(pg_version) < StrictVersion('9.4.0'): + raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version)) + + # Skip if the database is missing (e.g. for CI testing) or misconfigured. + except OperationalError: + pass + + +class Migration(migrations.Migration): + + replaces = [('extras', '0001_initial'), ('extras', '0002_custom_fields'), ('extras', '0003_exporttemplate_add_description'), ('extras', '0004_topologymap_change_comma_to_semicolon'), ('extras', '0005_useraction_add_bulk_create'), ('extras', '0006_add_imageattachments'), ('extras', '0007_unicode_literals'), ('extras', '0008_reports'), ('extras', '0009_topologymap_type'), ('extras', '0010_customfield_filter_logic')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0002_auto_20160622_1821'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='ExportTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('template_code', models.TextField()), + ('mime_type', models.CharField(blank=True, max_length=15)), + ('file_extension', models.CharField(blank=True, max_length=15)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('description', models.CharField(blank=True, max_length=200)), + ], + options={ + 'ordering': ['content_type', 'name'], + }, + ), + migrations.CreateModel( + name='Graph', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.PositiveSmallIntegerField(choices=[(100, 'Interface'), (200, 'Provider'), (300, 'Site')])), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('source', models.CharField(max_length=500, verbose_name='Source URL')), + ('link', models.URLField(blank=True, verbose_name='Link URL')), + ], + options={ + 'ordering': ['type', 'weight', 'name'], + }, + ), + migrations.CreateModel( + name='TopologyMap', + 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)), + ('device_patterns', models.TextField(help_text='Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.')), + ('description', models.CharField(blank=True, max_length=100)), + ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='topology_maps', to='dcim.Site')), + ('type', models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='UserAction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('object_id', models.PositiveIntegerField(blank=True, null=True)), + ('action', models.PositiveSmallIntegerField(choices=[(1, 'created'), (7, 'bulk created'), (2, 'imported'), (3, 'modified'), (4, 'bulk edited'), (5, 'deleted'), (6, 'bulk deleted')])), + ('message', models.TextField(blank=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-time'], + }, + ), + migrations.AlterUniqueTogether( + name='exporttemplate', + unique_together=set([('content_type', 'name')]), + ), + migrations.CreateModel( + name='CustomField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.PositiveSmallIntegerField(choices=[(100, 'Text'), (200, 'Integer'), (300, 'Boolean (true/false)'), (400, 'Date'), (500, 'URL'), (600, 'Selection')], default=100)), + ('name', models.CharField(max_length=50, unique=True)), + ('label', models.CharField(blank=True, help_text="Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50)), + ('description', models.CharField(blank=True, max_length=100)), + ('required', models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.')), + ('default', models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100)), + ('weight', models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.')), + ('obj_type', models.ManyToManyField(help_text='The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)')), + ('filter_logic', models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.')), + ], + options={ + 'ordering': ['weight', 'name'], + }, + ), + migrations.CreateModel( + name='CustomFieldChoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(max_length=100)), + ('weight', models.PositiveSmallIntegerField(default=100, help_text='Higher weights appear lower in the list')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField')), + ], + options={ + 'ordering': ['field', 'weight', 'value'], + }, + ), + migrations.CreateModel( + name='CustomFieldValue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('obj_id', models.PositiveIntegerField()), + ('serialized_value', models.CharField(max_length=255)), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='extras.CustomField')), + ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['obj_type', 'obj_id'], + }, + ), + migrations.AlterUniqueTogether( + name='customfieldvalue', + unique_together=set([('field', 'obj_type', 'obj_id')]), + ), + migrations.AlterUniqueTogether( + name='customfieldchoice', + unique_together=set([('field', 'value')]), + ), + migrations.CreateModel( + name='ImageAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('image', models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width')), + ('image_height', models.PositiveSmallIntegerField()), + ('image_width', models.PositiveSmallIntegerField()), + ('name', models.CharField(blank=True, max_length=50)), + ('created', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.RunPython( + code=verify_postgresql_version, + ), + 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/migrations/0011_django2.py b/netbox/extras/migrations/0011_django2.py new file mode 100644 index 000000000..f8e0954d6 --- /dev/null +++ b/netbox/extras/migrations/0011_django2.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.3 on 2018-03-30 14:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0010_customfield_filter_logic'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + ), + migrations.AlterField( + model_name='customfieldchoice', + name='field', + field=models.ForeignKey(limit_choices_to={'type': 600}, on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interfaceconnection', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/migrations/0012_webhooks.py b/netbox/extras/migrations/0012_webhooks.py new file mode 100644 index 000000000..70c8e9c14 --- /dev/null +++ b/netbox/extras/migrations/0012_webhooks.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-30 17:55 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0011_django2'), + ] + + operations = [ + migrations.CreateModel( + name='Webhook', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, unique=True)), + ('type_create', models.BooleanField(default=False, help_text='Call this webhook when a matching object is created.')), + ('type_update', models.BooleanField(default=False, help_text='Call this webhook when a matching object is updated.')), + ('type_delete', models.BooleanField(default=False, help_text='Call this webhook when a matching object is deleted.')), + ('payload_url', models.CharField(help_text='A POST will be sent to this URL when the webhook is called.', max_length=500, verbose_name='URL')), + ('http_content_type', models.PositiveSmallIntegerField(choices=[(1, 'application/json'), (2, 'application/x-www-form-urlencoded')], default=1, verbose_name='HTTP content type')), + ('secret', models.CharField(blank=True, help_text="When provided, the request will include a 'X-Hook-Signature' header containing a HMAC hex digest of the payload body using the secret as the key. The secret is not transmitted in the request.", max_length=255)), + ('enabled', models.BooleanField(default=True)), + ('ssl_verification', models.BooleanField(default=True, help_text='Enable SSL certificate verification. Disable with caution!', verbose_name='SSL verification')), + ('obj_type', models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types')), + ], + ), + migrations.AlterUniqueTogether( + name='webhook', + unique_together=set([('payload_url', 'type_create', 'type_update', 'type_delete')]), + ), + ] diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py new file mode 100644 index 000000000..de4762a46 --- /dev/null +++ b/netbox/extras/migrations/0013_objectchange.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-22 18:13 +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), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0012_webhooks'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectChange', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('time', models.DateTimeField(auto_now_add=True)), + ('user_name', models.CharField(editable=False, max_length=150)), + ('request_id', models.UUIDField(editable=False)), + ('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])), + ('changed_object_id', models.PositiveIntegerField()), + ('related_object_id', models.PositiveIntegerField(blank=True, null=True)), + ('object_repr', models.CharField(editable=False, max_length=200)), + ('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)), + ('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-time'], + }, + ), + ] diff --git a/netbox/extras/migrations/0014_configcontexts.py b/netbox/extras/migrations/0014_configcontexts.py new file mode 100644 index 000000000..789679e4f --- /dev/null +++ b/netbox/extras/migrations/0014_configcontexts.py @@ -0,0 +1,52 @@ +# Generated by Django 2.0.7 on 2018-07-27 19:44 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0005_change_logging'), + ('dcim', '0061_platform_napalm_args'), + ('extras', '0013_objectchange'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigContext', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ('description', models.CharField(blank=True, max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('platforms', models.ManyToManyField(blank=True, related_name='_configcontext_platforms_+', to='dcim.Platform')), + ('regions', models.ManyToManyField(blank=True, related_name='_configcontext_regions_+', to='dcim.Region')), + ('roles', models.ManyToManyField(blank=True, related_name='_configcontext_roles_+', to='dcim.DeviceRole')), + ('sites', models.ManyToManyField(blank=True, related_name='_configcontext_sites_+', to='dcim.Site')), + ('tenant_groups', models.ManyToManyField(blank=True, related_name='_configcontext_tenant_groups_+', to='tenancy.TenantGroup')), + ('tenants', models.ManyToManyField(blank=True, related_name='_configcontext_tenants_+', to='tenancy.Tenant')), + ], + options={ + 'ordering': ['weight', 'name'], + }, + ), + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='webhook', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'virtualchassis', 'consoleport', 'consoleserverport', 'powerport', 'poweroutlet', 'interface', 'devicebay', 'inventoryitem', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine')}, related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index cb68f0e0d..ad4fcdb18 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -13,19 +13,102 @@ from django.db import models from django.db.models import Q from django.http import HttpResponse from django.template import Template, Context +from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from django.utils.safestring import mark_safe from dcim.constants import CONNECTION_STATUS_CONNECTED from utilities.utils import foreground_color from .constants import * +from .querysets import ConfigContextQuerySet + + +# +# Webhooks +# + +@python_2_unicode_compatible +class Webhook(models.Model): + """ + A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or + delete in NetBox. The request will contain a representation of the object, which the remote application can act on. + Each Webhook can be limited to firing only on certain actions or certain object types. + """ + + obj_type = models.ManyToManyField( + to=ContentType, + related_name='webhooks', + verbose_name='Object types', + limit_choices_to={'model__in': WEBHOOK_MODELS}, + help_text="The object(s) to which this Webhook applies." + ) + name = models.CharField( + max_length=150, + unique=True + ) + type_create = models.BooleanField( + default=False, + help_text="Call this webhook when a matching object is created." + ) + type_update = models.BooleanField( + default=False, + help_text="Call this webhook when a matching object is updated." + ) + type_delete = models.BooleanField( + default=False, + help_text="Call this webhook when a matching object is deleted." + ) + payload_url = models.CharField( + max_length=500, + verbose_name='URL', + help_text="A POST will be sent to this URL when the webhook is called." + ) + http_content_type = models.PositiveSmallIntegerField( + choices=WEBHOOK_CT_CHOICES, + default=WEBHOOK_CT_JSON, + verbose_name='HTTP content type' + ) + secret = models.CharField( + max_length=255, + blank=True, + help_text="When provided, the request will include a 'X-Hook-Signature' " + "header containing a HMAC hex digest of the payload body using " + "the secret as the key. The secret is not transmitted in " + "the request." + ) + enabled = models.BooleanField( + default=True + ) + ssl_verification = models.BooleanField( + default=True, + verbose_name='SSL verification', + help_text="Enable SSL certificate verification. Disable with caution!" + ) + + class Meta: + unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) + + def __str__(self): + return self.name + + def clean(self): + """ + Validate model + """ + if not self.type_create and not self.type_delete and not self.type_update: + raise ValidationError( + "You must select at least one type: create, update, and/or delete." + ) # # Custom fields # -class CustomFieldModel(object): +class CustomFieldModel(models.Model): + + class Meta: + abstract = True def cf(self): """ @@ -73,7 +156,8 @@ class CustomField(models.Model): label = models.CharField( max_length=50, blank=True, - help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)' + help_text='Name of the field as displayed to users (if not provided, ' + 'the field\'s name will be used)' ) description = models.CharField( max_length=100, @@ -81,12 +165,14 @@ class CustomField(models.Model): ) required = models.BooleanField( default=False, - help_text='If true, this field is required when creating new objects or editing an existing object.' + help_text='If true, this field is required when creating new objects ' + 'or editing an existing object.' ) filter_logic = models.PositiveSmallIntegerField( choices=CF_FILTER_CHOICES, default=CF_FILTER_LOOSE, - help_text="Loose matches any instance of a given string; exact matches the entire field." + help_text='Loose matches any instance of a given string; exact ' + 'matches the entire field.' ) default = models.CharField( max_length=100, @@ -143,11 +229,24 @@ class CustomField(models.Model): @python_2_unicode_compatible class CustomFieldValue(models.Model): - field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE) - obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT) + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='values' + ) + obj_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + related_name='+' + ) obj_id = models.PositiveIntegerField() - obj = GenericForeignKey('obj_type', 'obj_id') - serialized_value = models.CharField(max_length=255) + obj = GenericForeignKey( + ct_field='obj_type', + fk_field='obj_id' + ) + serialized_value = models.CharField( + max_length=255 + ) class Meta: ordering = ['obj_type', 'obj_id'] @@ -174,10 +273,19 @@ class CustomFieldValue(models.Model): @python_2_unicode_compatible class CustomFieldChoice(models.Model): - field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT}, - on_delete=models.CASCADE) - value = models.CharField(max_length=100) - weight = models.PositiveSmallIntegerField(default=100, help_text="Higher weights appear lower in the list") + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='choices', + limit_choices_to={'type': CF_TYPE_SELECT} + ) + value = models.CharField( + max_length=100 + ) + weight = models.PositiveSmallIntegerField( + default=100, + help_text='Higher weights appear lower in the list' + ) class Meta: ordering = ['field', 'weight', 'value'] @@ -203,11 +311,24 @@ class CustomFieldChoice(models.Model): @python_2_unicode_compatible class Graph(models.Model): - type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) - weight = models.PositiveSmallIntegerField(default=1000) - name = models.CharField(max_length=100, verbose_name='Name') - source = models.CharField(max_length=500, verbose_name='Source URL') - link = models.URLField(verbose_name='Link URL', blank=True) + type = models.PositiveSmallIntegerField( + choices=GRAPH_TYPE_CHOICES + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) + name = models.CharField( + max_length=100, + verbose_name='Name' + ) + source = models.CharField( + max_length=500, + verbose_name='Source URL' + ) + link = models.URLField( + blank=True, + verbose_name='Link URL' + ) class Meta: ordering = ['type', 'weight', 'name'] @@ -233,13 +354,26 @@ class Graph(models.Model): @python_2_unicode_compatible class ExportTemplate(models.Model): content_type = models.ForeignKey( - ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE + to=ContentType, + on_delete=models.CASCADE, + limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS} + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True ) - name = models.CharField(max_length=100) - description = models.CharField(max_length=200, blank=True) template_code = models.TextField() - mime_type = models.CharField(max_length=15, blank=True) - file_extension = models.CharField(max_length=15, blank=True) + mime_type = models.CharField( + max_length=15, + blank=True + ) + file_extension = models.CharField( + max_length=15, + blank=True + ) class Meta: ordering = ['content_type', 'name'] @@ -278,25 +412,35 @@ class ExportTemplate(models.Model): @python_2_unicode_compatible class TopologyMap(models.Model): - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) type = models.PositiveSmallIntegerField( choices=TOPOLOGYMAP_TYPE_CHOICES, default=TOPOLOGYMAP_TYPE_NETWORK ) site = models.ForeignKey( to='dcim.Site', + on_delete=models.CASCADE, related_name='topology_maps', blank=True, - null=True, - on_delete=models.CASCADE + null=True ) device_patterns = models.TextField( - help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will " - "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. " - "Devices will be rendered in the order they are defined." + help_text='Identify devices to include in the diagram using regular ' + 'expressions, one per line. Each line will result in a new ' + 'tier of the drawing. Separate multiple regexes within a ' + 'line using semicolons. Devices will be rendered in the ' + 'order they are defined.' + ) + description = models.CharField( + max_length=100, + blank=True ) - description = models.CharField(max_length=100, blank=True) class Meta: ordering = ['name'] @@ -432,14 +576,29 @@ class ImageAttachment(models.Model): """ An uploaded image which is associated with an object. """ - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) object_id = models.PositiveIntegerField() - parent = GenericForeignKey('content_type', 'object_id') - image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') + parent = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + image = models.ImageField( + upload_to=image_upload, + height_field='image_height', + width_field='image_width' + ) image_height = models.PositiveSmallIntegerField() image_width = models.PositiveSmallIntegerField() - name = models.CharField(max_length=50, blank=True) - created = models.DateTimeField(auto_now_add=True) + name = models.CharField( + max_length=50, + blank=True + ) + created = models.DateTimeField( + auto_now_add=True + ) class Meta: ordering = ['name'] @@ -474,6 +633,92 @@ class ImageAttachment(models.Model): return None +# +# Config contexts +# + +class ConfigContext(models.Model): + """ + A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned + qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B + will be available to a Device in site A assigned to tenant B. Data is stored in JSON format. + """ + name = models.CharField( + max_length=100, + unique=True + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) + description = models.CharField( + max_length=100, + blank=True + ) + is_active = models.BooleanField( + default=True, + ) + regions = models.ManyToManyField( + to='dcim.Region', + related_name='+', + blank=True + ) + sites = models.ManyToManyField( + to='dcim.Site', + related_name='+', + blank=True + ) + roles = models.ManyToManyField( + to='dcim.DeviceRole', + related_name='+', + blank=True + ) + platforms = models.ManyToManyField( + to='dcim.Platform', + related_name='+', + blank=True + ) + tenant_groups = models.ManyToManyField( + to='tenancy.TenantGroup', + related_name='+', + blank=True + ) + tenants = models.ManyToManyField( + to='tenancy.Tenant', + related_name='+', + blank=True + ) + data = JSONField() + + objects = ConfigContextQuerySet.as_manager() + + class Meta: + ordering = ['weight', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:configcontext', kwargs={'pk': self.pk}) + + +class ConfigContextModel(models.Model): + + class Meta: + abstract = True + + def get_config_context(self): + """ + Return the rendered configuration context for a device or VM. + """ + + # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs + data = OrderedDict() + for context in ConfigContext.objects.get_for_object(self): + data.update(context.data) + + return data + + # # Report results # @@ -482,9 +727,20 @@ 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) + report = models.CharField( + max_length=255, + unique=True + ) + created = models.DateTimeField( + auto_now_add=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) failed = models.BooleanField() data = JSONField() @@ -492,6 +748,114 @@ class ReportResult(models.Model): ordering = ['report'] +# +# Change logging +# + +@python_2_unicode_compatible +class ObjectChange(models.Model): + """ + Record a change to an object and the user account associated with that change. A change record may optionally + indicate an object related to the one being changed. For example, a change to an interface may also indicate the + parent device. This will ensure changes made to component models appear in the parent model's changelog. + """ + time = models.DateTimeField( + auto_now_add=True, + editable=False + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + related_name='changes', + blank=True, + null=True + ) + user_name = models.CharField( + max_length=150, + editable=False + ) + request_id = models.UUIDField( + editable=False + ) + action = models.PositiveSmallIntegerField( + choices=OBJECTCHANGE_ACTION_CHOICES + ) + changed_object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + related_name='+' + ) + changed_object_id = models.PositiveIntegerField() + changed_object = GenericForeignKey( + ct_field='changed_object_type', + fk_field='changed_object_id' + ) + related_object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + related_object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + related_object = GenericForeignKey( + ct_field='related_object_type', + fk_field='related_object_id' + ) + object_repr = models.CharField( + max_length=200, + editable=False + ) + object_data = JSONField( + editable=False + ) + + csv_headers = [ + 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', + 'related_object_type', 'related_object_id', 'object_repr', 'object_data', + ] + + class Meta: + ordering = ['-time'] + + def __str__(self): + return '{} {} {} by {}'.format( + self.changed_object_type, + self.object_repr, + self.get_action_display().lower(), + self.user_name + ) + + def save(self, *args, **kwargs): + + # Record the user's name and the object's representation as static strings + self.user_name = self.user.username + self.object_repr = str(self.changed_object) + + return super(ObjectChange, self).save(*args, **kwargs) + + def get_absolute_url(self): + return reverse('extras:objectchange', args=[self.pk]) + + def to_csv(self): + return ( + self.time, + self.user, + self.user_name, + self.request_id, + self.get_action_display(), + self.changed_object_type, + self.changed_object_id, + self.related_object_type, + self.related_object_id, + self.object_repr, + self.object_data, + ) + + # # User actions # @@ -539,17 +903,35 @@ class UserActionManager(models.Manager): self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message) +# TODO: Remove UserAction, which has been replaced by ObjectChange. @python_2_unicode_compatible class UserAction(models.Model): """ - A record of an action (add, edit, or delete) performed on an object by a User. + DEPRECATED: A record of an action (add, edit, or delete) performed on an object by a User. """ - time = models.DateTimeField(auto_now_add=True, editable=False) - user = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE) - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField(blank=True, null=True) - action = models.PositiveSmallIntegerField(choices=ACTION_CHOICES) - message = models.TextField(blank=True) + time = models.DateTimeField( + auto_now_add=True, + editable=False + ) + user = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + related_name='actions' + ) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + action = models.PositiveSmallIntegerField( + choices=ACTION_CHOICES + ) + message = models.TextField( + blank=True + ) objects = UserActionManager() diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py new file mode 100644 index 000000000..bcc6f1e54 --- /dev/null +++ b/netbox/extras/querysets.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +from django.db.models import Q, QuerySet + + +class ConfigContextQuerySet(QuerySet): + + def get_for_object(self, obj): + """ + Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included. + """ + + # `device_role` for Device; `role` for VirtualMachine + role = getattr(obj, 'device_role', None) or obj.role + + # Get the group of the assigned tenant, if any + tenant_group = obj.tenant.group if obj.tenant else None + + # Match against the directly assigned region as well as any parent regions. + region = getattr(obj.site, 'region', None) + if region: + regions = region.get_ancestors(include_self=True) + else: + regions = [] + + return self.filter( + Q(regions__in=regions) | Q(regions=None), + Q(sites=obj.site) | Q(sites=None), + Q(roles=role) | Q(roles=None), + Q(platforms=obj.platform) | Q(platforms=None), + Q(tenant_groups=tenant_group) | Q(tenant_groups=None), + Q(tenants=obj.tenant) | Q(tenants=None), + is_active=True, + ).order_by('weight', 'name') diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py new file mode 100644 index 000000000..dd73bfe3e --- /dev/null +++ b/netbox/extras/tables.py @@ -0,0 +1,107 @@ +from __future__ import unicode_literals + +import django_tables2 as tables +from taggit.models import Tag + +from utilities.tables import BaseTable, BooleanColumn, ToggleColumn +from .models import ConfigContext, ObjectChange + +TAG_ACTIONS = """ +{% if perms.taggit.change_tag %} + +{% endif %} +{% if perms.taggit.delete_tag %} + +{% endif %} +""" + +CONFIGCONTEXT_ACTIONS = """ +{% if perms.extras.change_configcontext %} + +{% endif %} +{% if perms.extras.delete_configcontext %} + +{% endif %} +""" + +OBJECTCHANGE_TIME = """ +{{ value|date:"SHORT_DATETIME_FORMAT" }} +""" + +OBJECTCHANGE_ACTION = """ +{% if record.action == 1 %} + Created +{% elif record.action == 2 %} + Updated +{% elif record.action == 3 %} + Deleted +{% endif %} +""" + +OBJECTCHANGE_OBJECT = """ +{% if record.action != 3 and record.changed_object.get_absolute_url %} + {{ record.object_repr }} +{% elif record.action != 3 and record.related_object.get_absolute_url %} + {{ record.object_repr }} +{% else %} + {{ record.object_repr }} +{% endif %} +""" + +OBJECTCHANGE_REQUEST_ID = """ +{{ value }} +""" + + +class TagTable(BaseTable): + pk = ToggleColumn() + actions = tables.TemplateColumn( + template_code=TAG_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Tag + fields = ('pk', 'name', 'items', 'slug', 'actions') + + +class ConfigContextTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + is_active = BooleanColumn( + verbose_name='Active' + ) + actions = tables.TemplateColumn( + template_code=CONFIGCONTEXT_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = ConfigContext + fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions') + + +class ObjectChangeTable(BaseTable): + time = tables.TemplateColumn( + template_code=OBJECTCHANGE_TIME + ) + action = tables.TemplateColumn( + template_code=OBJECTCHANGE_ACTION + ) + changed_object_type = tables.Column( + verbose_name='Type' + ) + object_repr = tables.TemplateColumn( + template_code=OBJECTCHANGE_OBJECT, + verbose_name='Object' + ) + request_id = tables.TemplateColumn( + template_code=OBJECTCHANGE_REQUEST_ID, + verbose_name='Request ID' + ) + + class Meta(BaseTable.Meta): + model = ObjectChange + fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 036d8143c..3d0e5d1f7 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,25 +1,22 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase +from taggit.models import Tag -from dcim.models import Device +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site from extras.constants import GRAPH_TYPE_SITE -from extras.models import Graph, ExportTemplate -from users.models import Token -from utilities.tests import HttpStatusMixin +from extras.models import ConfigContext, Graph, ExportTemplate +from tenancy.models import Tenant, TenantGroup +from utilities.testing import APITestCase -class GraphTest(HttpStatusMixin, APITestCase): +class GraphTest(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)} + super(GraphTest, self).setUp() self.graph1 = Graph.objects.create( type=GRAPH_TYPE_SITE, name='Test Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1' @@ -119,13 +116,11 @@ class GraphTest(HttpStatusMixin, APITestCase): self.assertEqual(Graph.objects.count(), 2) -class ExportTemplateTest(HttpStatusMixin, APITestCase): +class ExportTemplateTest(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)} + super(ExportTemplateTest, self).setUp() self.content_type = ContentType.objects.get_for_model(Device) self.exporttemplate1 = ExportTemplate.objects.create( @@ -226,3 +221,305 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ExportTemplate.objects.count(), 2) + + +class TagTest(APITestCase): + + def setUp(self): + + super(TagTest, self).setUp() + + self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1') + self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2') + self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3') + + def test_get_tag(self): + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.tag1.name) + + def test_list_tags(self): + + url = reverse('extras-api:tag-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_tag(self): + + data = { + 'name': 'Test Tag 4', + 'slug': 'test-tag-4', + } + + url = reverse('extras-api:tag-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Tag.objects.count(), 4) + tag4 = Tag.objects.get(pk=response.data['id']) + self.assertEqual(tag4.name, data['name']) + self.assertEqual(tag4.slug, data['slug']) + + def test_create_tag_bulk(self): + + data = [ + { + 'name': 'Test Tag 4', + 'slug': 'test-tag-4', + }, + { + 'name': 'Test Tag 5', + 'slug': 'test-tag-5', + }, + { + 'name': 'Test Tag 6', + 'slug': 'test-tag-6', + }, + ] + + url = reverse('extras-api:tag-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Tag.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_update_tag(self): + + data = { + 'name': 'Test Tag X', + 'slug': 'test-tag-x', + } + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Tag.objects.count(), 3) + tag1 = Tag.objects.get(pk=response.data['id']) + self.assertEqual(tag1.name, data['name']) + self.assertEqual(tag1.slug, data['slug']) + + def test_delete_tag(self): + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Tag.objects.count(), 2) + + +class ConfigContextTest(APITestCase): + + def setUp(self): + + super(ConfigContextTest, self).setUp() + + self.configcontext1 = ConfigContext.objects.create( + name='Test Config Context 1', + weight=100, + data={'foo': 123} + ) + self.configcontext2 = ConfigContext.objects.create( + name='Test Config Context 2', + weight=200, + data={'bar': 456} + ) + self.configcontext3 = ConfigContext.objects.create( + name='Test Config Context 3', + weight=300, + data={'baz': 789} + ) + + def test_get_configcontext(self): + + url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.configcontext1.name) + self.assertEqual(response.data['data'], self.configcontext1.data) + + def test_list_configcontexts(self): + + url = reverse('extras-api:configcontext-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_configcontext(self): + + region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + role1 = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1') + role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2') + platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1') + platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2') + tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') + tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') + tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1') + tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2') + + data = { + 'name': 'Test Config Context 4', + 'weight': 1000, + 'regions': [region1.pk, region2.pk], + 'sites': [site1.pk, site2.pk], + 'roles': [role1.pk, role2.pk], + 'platforms': [platform1.pk, platform2.pk], + 'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk], + 'tenants': [tenant1.pk, tenant2.pk], + 'data': {'foo': 'XXX'} + } + + url = reverse('extras-api:configcontext-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConfigContext.objects.count(), 4) + configcontext4 = ConfigContext.objects.get(pk=response.data['id']) + self.assertEqual(configcontext4.name, data['name']) + self.assertEqual(region1.pk, data['regions'][0]) + self.assertEqual(region2.pk, data['regions'][1]) + self.assertEqual(site1.pk, data['sites'][0]) + self.assertEqual(site2.pk, data['sites'][1]) + self.assertEqual(role1.pk, data['roles'][0]) + self.assertEqual(role2.pk, data['roles'][1]) + self.assertEqual(platform1.pk, data['platforms'][0]) + self.assertEqual(platform2.pk, data['platforms'][1]) + self.assertEqual(tenantgroup1.pk, data['tenant_groups'][0]) + self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1]) + self.assertEqual(tenant1.pk, data['tenants'][0]) + self.assertEqual(tenant2.pk, data['tenants'][1]) + self.assertEqual(configcontext4.data, data['data']) + + def test_create_configcontext_bulk(self): + + data = [ + { + 'name': 'Test Config Context 4', + 'data': {'more_foo': True}, + }, + { + 'name': 'Test Config Context 5', + 'data': {'more_bar': False}, + }, + { + 'name': 'Test Config Context 6', + 'data': {'more_baz': None}, + }, + ] + + url = reverse('extras-api:configcontext-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConfigContext.objects.count(), 6) + for i in range(0, 3): + self.assertEqual(response.data[i]['name'], data[i]['name']) + self.assertEqual(response.data[i]['data'], data[i]['data']) + + def test_update_configcontext(self): + + region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + + data = { + 'name': 'Test Config Context X', + 'weight': 999, + 'regions': [region1.pk, region2.pk], + 'data': {'foo': 'XXX'} + } + + url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ConfigContext.objects.count(), 3) + configcontext1 = ConfigContext.objects.get(pk=response.data['id']) + self.assertEqual(configcontext1.name, data['name']) + self.assertEqual(configcontext1.weight, data['weight']) + self.assertEqual(sorted([r.pk for r in configcontext1.regions.all()]), sorted(data['regions'])) + self.assertEqual(configcontext1.data, data['data']) + + def test_delete_configcontext(self): + + url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConfigContext.objects.count(), 2) + + def test_render_configcontext_for_object(self): + + # Create a Device for which we'll render a config context + manufacturer = Manufacturer.objects.create( + name='Test Manufacturer', + slug='test-manufacturer' + ) + device_type = DeviceType.objects.create( + manufacturer=manufacturer, + model='Test Device Type' + ) + device_role = DeviceRole.objects.create( + name='Test Role', + slug='test-role' + ) + site = Site.objects.create( + name='Test Site', + slug='test-site' + ) + device = Device.objects.create( + name='Test Device', + device_type=device_type, + device_role=device_role, + site=site + ) + + # Test default config contexts (created at test setup) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['foo'], 123) + self.assertEqual(rendered_context['bar'], 456) + self.assertEqual(rendered_context['baz'], 789) + + # Add another context specific to the site + configcontext4 = ConfigContext( + name='Test Config Context 4', + data={'site_data': 'ABC'} + ) + configcontext4.save() + configcontext4.sites.add(site) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['site_data'], 'ABC') + + # Override one of the default contexts + configcontext5 = ConfigContext( + name='Test Config Context 5', + weight=2000, + data={'foo': 999} + ) + configcontext5.save() + configcontext5.sites.add(site) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['foo'], 999) + + # Add a context which does NOT match our device and ensure it does not apply + site2 = Site.objects.create( + name='Test Site 2', + slug='test-site-2' + ) + configcontext6 = ConfigContext( + name='Test Config Context 6', + weight=2000, + data={'bar': 999} + ) + configcontext6.save() + configcontext6.sites.add(site2) + rendered_context = device.get_config_context() + self.assertEqual(rendered_context['bar'], 456) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 84aaa76b2..97eb69cd9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -2,18 +2,15 @@ from __future__ import unicode_literals from datetime import date -from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from dcim.models import Site from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL from extras.models import CustomField, CustomFieldValue, CustomFieldChoice -from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import APITestCase class CustomFieldTest(TestCase): @@ -45,7 +42,7 @@ class CustomFieldTest(TestCase): # Create a custom field cf = CustomField(type=data['field_type'], name='my_field', required=False) cf.save() - cf.obj_type = [obj_type] + cf.obj_type.set([obj_type]) cf.save() # Assign a value to the first Site @@ -73,7 +70,7 @@ class CustomFieldTest(TestCase): # Create a custom field cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False) cf.save() - cf.obj_type = [obj_type] + cf.obj_type.set([obj_type]) cf.save() # Create some choices for the field @@ -102,50 +99,48 @@ class CustomFieldTest(TestCase): cf.delete() -class CustomFieldAPITest(HttpStatusMixin, APITestCase): +class CustomFieldAPITest(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)} + super(CustomFieldAPITest, self).setUp() content_type = ContentType.objects.get_for_model(Site) # Text custom field self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word') self.cf_text.save() - self.cf_text.obj_type = [content_type] + self.cf_text.obj_type.set([content_type]) self.cf_text.save() # Integer custom field self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number') self.cf_integer.save() - self.cf_integer.obj_type = [content_type] + self.cf_integer.obj_type.set([content_type]) self.cf_integer.save() # Boolean custom field self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic') self.cf_boolean.save() - self.cf_boolean.obj_type = [content_type] + self.cf_boolean.obj_type.set([content_type]) self.cf_boolean.save() # Date custom field self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date') self.cf_date.save() - self.cf_date.obj_type = [content_type] + self.cf_date.obj_type.set([content_type]) self.cf_date.save() # URL custom field self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url') self.cf_url.save() - self.cf_url.obj_type = [content_type] + self.cf_url.obj_type.set([content_type]) self.cf_url.save() # Select custom field self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice') self.cf_select.save() - self.cf_select.obj_type = [content_type] + self.cf_select.obj_type.set([content_type]) self.cf_select.save() self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo') self.cf_select_choice1.save() diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py new file mode 100644 index 000000000..d4c0a79c6 --- /dev/null +++ b/netbox/extras/tests/test_tags.py @@ -0,0 +1,55 @@ +from __future__ import unicode_literals + +from django.urls import reverse +from rest_framework import status + +from dcim.models import Site +from utilities.testing import APITestCase + + +class TaggedItemTest(APITestCase): + """ + Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH). + """ + + def setUp(self): + + super(TaggedItemTest, self).setUp() + + def test_create_tagged_item(self): + + data = { + 'name': 'Test Site', + 'slug': 'test-site', + 'tags': ['Foo', 'Bar', 'Baz'] + } + + url = reverse('dcim-api:site-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) + site = Site.objects.get(pk=response.data['id']) + tags = [tag.name for tag in site.tags.all()] + self.assertEqual(sorted(tags), sorted(data['tags'])) + + def test_update_tagged_item(self): + + site = Site.objects.create( + name='Test Site', + slug='test-site', + tags=['Foo', 'Bar', 'Baz'] + ) + + data = { + 'tags': ['Foo', 'Bar', 'New Tag'] + } + + url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + response = self.client.patch(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) + site = Site.objects.get(pk=response.data['id']) + tags = [tag.name for tag in site.tags.all()] + self.assertEqual(sorted(tags), sorted(data['tags'])) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 13e50a229..e56652280 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -7,6 +7,20 @@ from extras import views app_name = 'extras' urlpatterns = [ + # Tags + url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), + url(r'^tags/(?P[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'), + url(r'^tags/(?P[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'), + url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + + # Config contexts + url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'), + url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + url(r'^config-contexts/(?P\d+)/$', views.ConfigContextView.as_view(), name='configcontext'), + url(r'^config-contexts/(?P\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'), + url(r'^config-contexts/(?P\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), + url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), + # Image attachments 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'), @@ -16,4 +30,8 @@ urlpatterns = [ url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), + # Change logging + url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'), + url(r'^changelog/(?P\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'), + ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3f7c0435b..7c0ab67d3 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,17 +1,188 @@ from __future__ import unicode_literals +from django import template from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.contenttypes.models import ContentType +from django.db.models import Count, Q 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 taggit.models import Tag from utilities.forms import ConfirmationForm -from utilities.views import ObjectDeleteView, ObjectEditView -from .forms import ImageAttachmentForm -from .models import ImageAttachment, ReportResult, UserAction +from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView +from . import filters +from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm +from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult from .reports import get_report, get_reports +from .tables import ConfigContextTable, ObjectChangeTable, TagTable + + +# +# Tags +# + +class TagListView(ObjectListView): + queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + table = TagTable + template_name = 'extras/tag_list.html' + + +class TagEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'taggit.change_tag' + model = Tag + model_form = TagForm + default_return_url = 'extras:tag_list' + + +class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'taggit.delete_tag' + model = Tag + default_return_url = 'extras:tag_list' + + +class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'circuits.delete_circuittype' + queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + table = TagTable + default_return_url = 'extras:tag_list' + + +# +# Config contexts +# + +class ConfigContextListView(ObjectListView): + queryset = ConfigContext.objects.all() + table = ConfigContextTable + template_name = 'extras/configcontext_list.html' + + +class ConfigContextView(View): + + def get(self, request, pk): + + configcontext = get_object_or_404(ConfigContext, pk=pk) + + return render(request, 'extras/configcontext.html', { + 'configcontext': configcontext, + }) + + +class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'extras.add_configcontext' + model = ConfigContext + model_form = ConfigContextForm + default_return_url = 'extras:configcontext_list' + template_name = 'extras/configcontext_edit.html' + + +class ConfigContextEditView(ConfigContextCreateView): + permission_required = 'extras.change_configcontext' + + +class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'extras.delete_configcontext' + model = ConfigContext + default_return_url = 'extras:configcontext_list' + + +class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'extras.delete_cconfigcontext' + queryset = ConfigContext.objects.all() + table = ConfigContextTable + default_return_url = 'extras:configcontext_list' + + +class ObjectConfigContextView(View): + object_class = None + base_template = None + + def get(self, request, pk): + + obj = get_object_or_404(self.object_class, pk=pk) + source_contexts = ConfigContext.objects.get_for_object(obj) + + return render(request, 'extras/object_configcontext.html', { + self.object_class._meta.model_name: obj, + 'rendered_context': obj.get_config_context(), + 'source_contexts': source_contexts, + 'base_template': self.base_template, + 'active_tab': 'config-context', + }) + + +# +# Change logging +# + +class ObjectChangeListView(ObjectListView): + queryset = ObjectChange.objects.select_related('user', 'changed_object_type') + filter = filters.ObjectChangeFilter + filter_form = ObjectChangeFilterForm + table = ObjectChangeTable + template_name = 'extras/objectchange_list.html' + + +class ObjectChangeView(View): + + def get(self, request, pk): + + objectchange = get_object_or_404(ObjectChange, pk=pk) + + related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk) + related_changes_table = ObjectChangeTable( + data=related_changes[:50], + orderable=False + ) + + return render(request, 'extras/objectchange.html', { + 'objectchange': objectchange, + 'related_changes_table': related_changes_table, + 'related_changes_count': related_changes.count() + }) + + +class ObjectChangeLogView(View): + """ + Present a history of changes made to a particular object. + """ + + def get(self, request, model, **kwargs): + + # Get object my model and kwargs (e.g. slug='foo') + obj = get_object_or_404(model, **kwargs) + + # Gather all changes for this object (and its related objects) + content_type = ContentType.objects.get_for_model(model) + objectchanges = ObjectChange.objects.select_related( + 'user', 'changed_object_type' + ).filter( + Q(changed_object_type=content_type, changed_object_id=obj.pk) | + Q(related_object_type=content_type, related_object_id=obj.pk) + ) + objectchanges_table = ObjectChangeTable( + data=objectchanges, + orderable=False + ) + + # Check whether a header template exists for this model + base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name) + try: + template.loader.get_template(base_template) + object_var = model._meta.model_name + except template.TemplateDoesNotExist: + base_template = '_base.html' + object_var = 'obj' + + return render(request, 'extras/object_changelog.html', { + object_var: obj, + 'objectchanges_table': objectchanges_table, + 'base_template': base_template, + 'active_tab': 'changelog', + }) # @@ -113,6 +284,5 @@ class ReportRunView(PermissionRequiredMixin, View): 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/extras/webhooks.py b/netbox/extras/webhooks.py new file mode 100644 index 000000000..e1269a3a5 --- /dev/null +++ b/netbox/extras/webhooks.py @@ -0,0 +1,59 @@ +import datetime + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +from extras.models import Webhook +from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from utilities.api import get_serializer_for_model +from .constants import WEBHOOK_MODELS + + +def enqueue_webhooks(instance, action): + """ + Find Webhook(s) assigned to this instance + action and enqueue them + to be processed + """ + if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS: + return + + type_create = action == OBJECTCHANGE_ACTION_CREATE + type_update = action == OBJECTCHANGE_ACTION_UPDATE + type_delete = action == OBJECTCHANGE_ACTION_DELETE + + # Find assigned webhooks + obj_type = ContentType.objects.get_for_model(instance.__class__) + webhooks = Webhook.objects.filter( + Q(enabled=True) & + ( + Q(type_create=type_create) | + Q(type_update=type_update) | + Q(type_delete=type_delete) + ) & + Q(obj_type=obj_type) + ) + + if webhooks: + # Get the Model's API serializer class and serialize the object + serializer_class = get_serializer_for_model(instance.__class__) + serializer_context = { + 'request': None, + } + serializer = serializer_class(instance, context=serializer_context) + + # We must only import django_rq if the Webhooks feature is enabled. + # Only if we have gotten to ths point, is the feature enabled + from django_rq import get_queue + webhook_queue = get_queue('default') + + # enqueue the webhooks: + for webhook in webhooks: + webhook_queue.enqueue( + "extras.webhooks_worker.process_webhook", + webhook, + serializer.data, + instance.__class__, + action, + str(datetime.datetime.now()) + ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py new file mode 100644 index 000000000..91e95baa1 --- /dev/null +++ b/netbox/extras/webhooks_worker.py @@ -0,0 +1,53 @@ +import hashlib +import hmac +import requests +import json + +from django_rq import job +from rest_framework.utils.encoders import JSONEncoder + +from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJECTCHANGE_ACTION_CHOICES + + +@job('default') +def process_webhook(webhook, data, model_class, event, timestamp): + """ + Make a POST request to the defined Webhook + """ + payload = { + 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(), + 'timestamp': timestamp, + 'model': model_class._meta.model_name, + 'data': data + } + headers = { + 'Content-Type': webhook.get_http_content_type_display(), + } + params = { + 'method': 'POST', + 'url': webhook.payload_url, + 'headers': headers + } + + if webhook.http_content_type == WEBHOOK_CT_JSON: + params.update({'data': json.dumps(payload, cls=JSONEncoder)}) + elif webhook.http_content_type == WEBHOOK_CT_X_WWW_FORM_ENCODED: + params.update({'data': payload}) + + prepared_request = requests.Request(**params).prepare() + + if webhook.secret != '': + # sign the request with the secret + hmac_prep = hmac.new(bytearray(webhook.secret, 'utf8'), prepared_request.body, digestmod=hashlib.sha512) + prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest() + + with requests.Session() as session: + session.verify = webhook.ssl_verification + response = session.send(prepared_request) + + if response.status_code >= 200 and response.status_code <= 299: + return 'Status {} returned, webhook successfully processed.'.format(response.status_code) + else: + raise requests.exceptions.RequestException( + "Status {} returned, webhook FAILED to process.".format(response.status_code) + ) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 2eca51895..021495a2c 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -5,6 +5,7 @@ from collections import OrderedDict from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from dcim.models import Interface @@ -14,7 +15,9 @@ from ipam.constants import ( ) from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ( + ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, +) from virtualization.api.serializers import NestedVirtualMachineSerializer @@ -22,18 +25,19 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer # VRFs # -class VRFSerializer(CustomFieldModelSerializer): - tenant = NestedTenantSerializer() +class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): + tenant = NestedTenantSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = VRF fields = [ - 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created', - 'last_updated', + 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields', + 'created', 'last_updated', ] -class NestedVRFSerializer(serializers.ModelSerializer): +class NestedVRFSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') class Meta: @@ -41,15 +45,6 @@ class NestedVRFSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'rd'] -class WritableVRFSerializer(CustomFieldModelSerializer): - - class Meta: - model = VRF - fields = [ - 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated', - ] - - # # Roles # @@ -61,7 +56,7 @@ class RoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'weight'] -class NestedRoleSerializer(serializers.ModelSerializer): +class NestedRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') class Meta: @@ -80,7 +75,7 @@ class RIRSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'is_private'] -class NestedRIRSerializer(serializers.ModelSerializer): +class NestedRIRSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') class Meta: @@ -92,17 +87,20 @@ class NestedRIRSerializer(serializers.ModelSerializer): # Aggregates # -class AggregateSerializer(CustomFieldModelSerializer): +class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): rir = NestedRIRSerializer() + tags = TagListSerializerField(required=False) class Meta: model = Aggregate fields = [ - 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', ] + read_only_fields = ['family'] -class NestedAggregateSerializer(serializers.ModelSerializer): +class NestedAggregateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') class Meta(AggregateSerializer.Meta): @@ -110,34 +108,12 @@ class NestedAggregateSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'prefix'] -class WritableAggregateSerializer(CustomFieldModelSerializer): - - class Meta: - model = Aggregate - fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated'] - - # # VLAN groups # -class VLANGroupSerializer(serializers.ModelSerializer): - site = NestedSiteSerializer() - - class Meta: - model = VLANGroup - fields = ['id', 'name', 'slug', 'site'] - - -class NestedVLANGroupSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') - - class Meta: - model = VLANGroup - fields = ['id', 'url', 'name', 'slug'] - - -class WritableVLANGroupSerializer(serializers.ModelSerializer): +class VLANGroupSerializer(ValidatedModelSerializer): + site = NestedSiteSerializer(required=False, allow_null=True) class Meta: model = VLANGroup @@ -154,46 +130,37 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): validator(data) # Enforce model validation - super(WritableVLANGroupSerializer, self).validate(data) + super(VLANGroupSerializer, self).validate(data) return data +class NestedVLANGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') + + class Meta: + model = VLANGroup + fields = ['id', 'url', 'name', 'slug'] + + # # VLANs # -class VLANSerializer(CustomFieldModelSerializer): - site = NestedSiteSerializer() - group = NestedVLANGroupSerializer() - tenant = NestedTenantSerializer() - status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES) - role = NestedRoleSerializer() +class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): + site = NestedSiteSerializer(required=False, allow_null=True) + group = NestedVLANGroupSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False) + role = NestedRoleSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = VLAN fields = [ - 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', + 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', ] - - -class NestedVLANSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') - - class Meta: - model = VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] - - -class WritableVLANSerializer(CustomFieldModelSerializer): - - class Meta: - model = VLAN - fields = [ - 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created', - 'last_updated', - ] validators = [] def validate(self, data): @@ -206,32 +173,42 @@ class WritableVLANSerializer(CustomFieldModelSerializer): validator(data) # Enforce model validation - super(WritableVLANSerializer, self).validate(data) + super(VLANSerializer, self).validate(data) return data +class NestedVLANSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') + + class Meta: + model = VLAN + fields = ['id', 'url', 'vid', 'name', 'display_name'] + + # # Prefixes # -class PrefixSerializer(CustomFieldModelSerializer): - site = NestedSiteSerializer() - vrf = NestedVRFSerializer() - tenant = NestedTenantSerializer() - vlan = NestedVLANSerializer() - status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES) - role = NestedRoleSerializer() +class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): + site = NestedSiteSerializer(required=False, allow_null=True) + vrf = NestedVRFSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + vlan = NestedVLANSerializer(required=False, allow_null=True) + status = ChoiceField(choices=PREFIX_STATUS_CHOICES, required=False) + role = NestedRoleSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = Prefix fields = [ 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields', 'created', 'last_updated', + 'tags', 'custom_fields', 'created', 'last_updated', ] + read_only_fields = ['family'] -class NestedPrefixSerializer(serializers.ModelSerializer): +class NestedPrefixSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') class Meta: @@ -239,16 +216,6 @@ class NestedPrefixSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'prefix'] -class WritablePrefixSerializer(CustomFieldModelSerializer): - - class Meta: - model = Prefix - fields = [ - 'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields', 'created', 'last_updated', - ] - - class AvailablePrefixSerializer(serializers.Serializer): def to_representation(self, instance): @@ -267,10 +234,10 @@ class AvailablePrefixSerializer(serializers.Serializer): # IP addresses # -class IPAddressInterfaceSerializer(serializers.ModelSerializer): +class IPAddressInterfaceSerializer(WritableNestedSerializer): url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here - device = NestedDeviceSerializer() - virtual_machine = NestedVirtualMachineSerializer() + device = NestedDeviceSerializer(read_only=True) + virtual_machine = NestedVirtualMachineSerializer(read_only=True) class Meta(InterfaceSerializer.Meta): model = Interface @@ -287,22 +254,24 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer): return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request']) -class IPAddressSerializer(CustomFieldModelSerializer): - vrf = NestedVRFSerializer() - tenant = NestedTenantSerializer() - status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES) - role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES) - interface = IPAddressInterfaceSerializer() +class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): + vrf = NestedVRFSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) + role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False) + interface = IPAddressInterfaceSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = IPAddress fields = [ 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', - 'nat_outside', 'custom_fields', 'created', 'last_updated', + 'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated', ] + read_only_fields = ['family'] -class NestedIPAddressSerializer(serializers.ModelSerializer): +class NestedIPAddressSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') class Meta: @@ -310,18 +279,8 @@ class NestedIPAddressSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'address'] -IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer() -IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer() - - -class WritableIPAddressSerializer(CustomFieldModelSerializer): - - class Meta: - model = IPAddress - fields = [ - 'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', - 'custom_fields', 'created', 'last_updated', - ] +IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True) +IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True) class AvailableIPSerializer(serializers.Serializer): @@ -342,26 +301,20 @@ class AvailableIPSerializer(serializers.Serializer): # Services # -class ServiceSerializer(serializers.ModelSerializer): - device = NestedDeviceSerializer() - virtual_machine = NestedVirtualMachineSerializer() - protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) - ipaddresses = NestedIPAddressSerializer(many=True) +class ServiceSerializer(CustomFieldModelSerializer): + device = NestedDeviceSerializer(required=False, allow_null=True) + virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) + protocol = ChoiceField(choices=IP_PROTOCOL_CHOICES) + ipaddresses = SerializedPKRelatedField( + queryset=IPAddress.objects.all(), + serializer=NestedIPAddressSerializer, + required=False, + many=True + ) class Meta: model = Service fields = [ - 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created', - 'last_updated', - ] - - -# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError. -class WritableServiceSerializer(serializers.ModelSerializer): - - class Meta: - model = Service - fields = [ - 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created', - 'last_updated', + 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index c709062b0..30a987f74 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals from django.conf import settings from django.shortcuts import get_object_or_404 from rest_framework import status -from rest_framework.decorators import detail_route -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from extras.api.views import CustomFieldModelViewSet @@ -35,7 +35,6 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet): class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.select_related('tenant') serializer_class = serializers.VRFSerializer - write_serializer_class = serializers.WritableVRFSerializer filter_class = filters.VRFFilter @@ -56,7 +55,6 @@ class RIRViewSet(ModelViewSet): class AggregateViewSet(CustomFieldModelViewSet): queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer - write_serializer_class = serializers.WritableAggregateSerializer filter_class = filters.AggregateFilter @@ -77,10 +75,9 @@ class RoleViewSet(ModelViewSet): class PrefixViewSet(CustomFieldModelViewSet): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') serializer_class = serializers.PrefixSerializer - write_serializer_class = serializers.WritablePrefixSerializer filter_class = filters.PrefixFilter - @detail_route(url_path='available-prefixes', methods=['get', 'post']) + @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) def available_prefixes(self, request, pk=None): """ A convenience method for returning available child prefixes within a parent. @@ -144,9 +141,9 @@ class PrefixViewSet(CustomFieldModelViewSet): # Initialize the serializer with a list or a single object depending on what was requested if isinstance(request.data, list): - serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True) + serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True) else: - serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0]) + serializer = serializers.PrefixSerializer(data=requested_prefixes[0]) # Create the new Prefix(es) if serializer.is_valid(): @@ -164,7 +161,7 @@ class PrefixViewSet(CustomFieldModelViewSet): return Response(serializer.data) - @detail_route(url_path='available-ips', methods=['get', 'post']) + @action(detail=True, url_path='available-ips', methods=['get', 'post']) def available_ips(self, request, pk=None): """ A convenience method for returning available IP addresses within a prefix. By default, the number of IPs @@ -203,9 +200,9 @@ class PrefixViewSet(CustomFieldModelViewSet): # Initialize the serializer with a list or a single object depending on what was requested if isinstance(request.data, list): - serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True) + serializer = serializers.IPAddressSerializer(data=requested_ips, many=True) else: - serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0]) + serializer = serializers.IPAddressSerializer(data=requested_ips[0]) # Create the new IP address(es) if serializer.is_valid(): @@ -249,7 +246,6 @@ class IPAddressViewSet(CustomFieldModelViewSet): 'nat_outside' ) serializer_class = serializers.IPAddressSerializer - write_serializer_class = serializers.WritableIPAddressSerializer filter_class = filters.IPAddressFilter @@ -260,7 +256,6 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(ModelViewSet): queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer - write_serializer_class = serializers.WritableVLANGroupSerializer filter_class = filters.VLANGroupFilter @@ -271,7 +266,6 @@ class VLANGroupViewSet(ModelViewSet): class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') serializer_class = serializers.VLANSerializer - write_serializer_class = serializers.WritableVLANSerializer filter_class = filters.VLANFilter @@ -282,5 +276,4 @@ class VLANViewSet(CustomFieldModelViewSet): class ServiceViewSet(ModelViewSet): queryset = Service.objects.select_related('device') serializer_class = serializers.ServiceSerializer - write_serializer_class = serializers.WritableServiceSerializer filter_class = filters.ServiceFilter diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index f21cc299d..0a8606e52 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -31,6 +31,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) def search(self, queryset, name, value): if not value.strip(): @@ -70,6 +73,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='RIR (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Aggregate @@ -168,6 +174,9 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=PREFIX_STATUS_CHOICES, null_value=None ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Prefix @@ -294,6 +303,9 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): role = django_filters.MultipleChoiceFilter( choices=IPADDRESS_ROLE_CHOICES ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = IPAddress @@ -410,6 +422,9 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=VLAN_STATUS_CHOICES, null_value=None ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = VLAN @@ -427,6 +442,10 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class ServiceFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', @@ -447,7 +466,16 @@ class ServiceFilter(django_filters.FilterSet): to_field_name='name', label='Virtual machine (name)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Service fields = ['name', 'protocol', 'port'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(name__icontains=value) | Q(description__icontains=value) + return queryset.filter(qs_filter) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 3353d981f..8209b2ffa 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -2,10 +2,12 @@ from __future__ import unicode_literals from django import forms from django.core.exceptions import MultipleObjectsReturned +from django.core.validators import MaxValueValidator, MinValueValidator from django.db.models import Count +from taggit.forms import TagField from dcim.models import Site, Rack, Device, Interface -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -14,7 +16,9 @@ from utilities.forms import ( SlugField, add_blank_choice, ) from virtualization.models import VirtualMachine -from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES +from .constants import ( + IP_PROTOCOL_CHOICES, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES, +) from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF IP_FAMILY_CHOICES = [ @@ -32,10 +36,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)] # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): + tags = TagField(required=False) class Meta: model = VRF - fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant'] + fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags'] labels = { 'rd': "RD", } @@ -63,7 +68,7 @@ class VRFCSVForm(forms.ModelForm): } -class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) enforce_unique = forms.NullBooleanField( @@ -121,10 +126,11 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # class AggregateForm(BootstrapMixin, CustomFieldForm): + tags = TagField(required=False) class Meta: model = Aggregate - fields = ['prefix', 'rir', 'date_added', 'description'] + fields = ['prefix', 'rir', 'date_added', 'description', 'tags'] help_texts = { 'prefix': "IPv4 or IPv6 network", 'rir': "Regional Internet Registry responsible for this prefix", @@ -147,7 +153,7 @@ class AggregateCSVForm(forms.ModelForm): fields = Aggregate.csv_headers -class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') date_added = forms.DateField(required=False) @@ -228,10 +234,14 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name' ) ) + tags = TagField(required=False) class Meta: model = Prefix - fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant'] + fields = [ + 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant', + 'tags', + ] def __init__(self, *args, **kwargs): @@ -336,7 +346,7 @@ class PrefixCSVForm(forms.ModelForm): raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid)) -class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') @@ -455,12 +465,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) ) ) primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM') + tags = TagField(required=False) class Meta: model = IPAddress fields = [ 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site', - 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', + 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', ] def __init__(self, *args, **kwargs): @@ -667,7 +678,7 @@ class IPAddressCSVForm(forms.ModelForm): return ipaddress -class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) @@ -780,10 +791,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): api_url='/api/ipam/vlan-groups/?site_id={{site}}', ) ) + tags = TagField(required=False) class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant'] + fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags'] help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", @@ -857,7 +869,7 @@ class VLANCSVForm(forms.ModelForm): raise forms.ValidationError("Global VLAN group {} not found".format(group_name)) -class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) @@ -905,11 +917,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): # Services # -class ServiceForm(BootstrapMixin, forms.ModelForm): +class ServiceForm(BootstrapMixin, CustomFieldForm): + tags = TagField(required=False) class Meta: model = Service - fields = ['name', 'protocol', 'port', 'ipaddresses', 'description'] + fields = ['name', 'protocol', 'port', 'ipaddresses', 'description', 'tags'] help_texts = { 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " "reachable via all IPs assigned to the device.", @@ -931,3 +944,28 @@ class ServiceForm(BootstrapMixin, forms.ModelForm): ) else: self.fields['ipaddresses'].choices = [] + + +class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Service + q = forms.CharField( + required=False, + label='Search' + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(IP_PROTOCOL_CHOICES), + required=False + ) + port = forms.IntegerField( + required=False + ) + + +class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Service.objects.all(), widget=forms.MultipleHiddenInput) + protocol = forms.ChoiceField(choices=add_blank_choice(IP_PROTOCOL_CHOICES), required=False) + port = forms.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], required=False) + description = forms.CharField(max_length=100, required=False) + + class Meta: + nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] diff --git a/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py b/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py new file mode 100644 index 000000000..c4271ea51 --- /dev/null +++ b/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:12 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields + + +class Migration(migrations.Migration): + + replaces = [('ipam', '0002_vrf_add_enforce_unique'), ('ipam', '0003_ipam_add_vlangroups'), ('ipam', '0004_ipam_vlangroup_uniqueness'), ('ipam', '0005_auto_20160725_1842'), ('ipam', '0006_vrf_vlan_add_tenant'), ('ipam', '0007_prefix_ipaddress_add_tenant'), ('ipam', '0008_prefix_change_order'), ('ipam', '0009_ipaddress_add_status'), ('ipam', '0010_ipaddress_help_texts'), ('ipam', '0011_rir_add_is_private'), ('ipam', '0012_services'), ('ipam', '0013_prefix_add_is_pool'), ('ipam', '0014_ipaddress_status_add_deprecated'), ('ipam', '0015_global_vlans'), ('ipam', '0016_unicode_literals'), ('ipam', '0017_ipaddress_roles'), ('ipam', '0018_remove_service_uniqueness_constraint')] + + dependencies = [ + ('dcim', '0010_devicebay_installed_device_set_null'), + ('dcim', '0022_color_names_to_rgb'), + ('tenancy', '0001_initial'), + ('ipam', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vrf', + name='enforce_unique', + field=models.BooleanField(default=True, help_text='Prevent duplicate prefixes/IP addresses within this VRF', verbose_name='Enforce unique space'), + ), + migrations.CreateModel( + name='VLANGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('slug', models.SlugField()), + ('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_groups', to='dcim.Site')), + ], + options={ + 'ordering': ['site', 'name'], + }, + ), + migrations.AddField( + model_name='vlan', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.VLANGroup'), + ), + migrations.AlterUniqueTogether( + name='vlangroup', + unique_together=set([('site', 'slug'), ('site', 'name')]), + ), + migrations.AlterModelOptions( + name='vlan', + options={'ordering': ['site', 'group', 'vid'], 'verbose_name': 'VLAN', 'verbose_name_plural': 'VLANs'}, + ), + migrations.AlterModelOptions( + name='vlangroup', + options={'ordering': ['site', 'name'], 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'}, + ), + migrations.AddField( + model_name='vlan', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterUniqueTogether( + name='vlan', + unique_together=set([('group', 'vid'), ('group', 'name')]), + ), + migrations.AlterField( + model_name='vlan', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AddField( + model_name='vlan', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='vrf', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vrfs', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='ipaddress', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='tenancy.Tenant'), + ), + migrations.AddField( + model_name='prefix', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='tenancy.Tenant'), + ), + migrations.AlterModelOptions( + name='prefix', + options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'}, + ), + migrations.AddField( + model_name='ipaddress', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, help_text='The operational status of this IP', verbose_name='Status'), + ), + migrations.AlterField( + model_name='ipaddress', + name='address', + field=ipam.fields.IPAddressField(help_text=b'IPv4 or IPv6 address (with mask)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.OneToOneField(blank=True, help_text=b'The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT (Inside)'), + ), + migrations.AddField( + model_name='rir', + name='is_private', + field=models.BooleanField(default=False, help_text='IP space managed by this RIR is considered private', verbose_name='Private'), + ), + migrations.CreateModel( + name='Service', + 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=30)), + ('protocol', models.PositiveSmallIntegerField(choices=[(6, 'TCP'), (17, 'UDP')])), + ('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name='Port number')), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device')), + ('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name='IP addresses')), + ], + options={ + 'ordering': ['device', 'protocol', 'port'], + }, + ), + migrations.AlterUniqueTogether( + name='service', + unique_together=set([('device', 'protocol', 'port')]), + ), + migrations.AddField( + model_name='prefix', + name='is_pool', + field=models.BooleanField(default=False, help_text='All IP addresses within this prefix are considered usable', verbose_name='Is a pool'), + ), + migrations.AlterField( + model_name='prefix', + name='prefix', + field=ipam.fields.IPNetworkField(help_text=b'IPv4 or IPv6 network with mask'), + ), + migrations.AlterField( + model_name='prefix', + name='role', + field=models.ForeignKey(blank=True, help_text=b'The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'), + ), + migrations.AlterField( + model_name='vlan', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site'), + ), + migrations.AlterField( + model_name='aggregate', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')]), + ), + migrations.AlterField( + model_name='aggregate', + name='rir', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name='RIR'), + ), + migrations.AlterField( + model_name='ipaddress', + name='address', + field=ipam.fields.IPAddressField(help_text='IPv4 or IPv6 address (with mask)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.OneToOneField(blank=True, help_text='The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name='NAT (Inside)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name='VRF'), + ), + migrations.AlterField( + model_name='prefix', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False), + ), + migrations.AlterField( + model_name='prefix', + name='prefix', + field=ipam.fields.IPNetworkField(help_text='IPv4 or IPv6 network with mask'), + ), + migrations.AlterField( + model_name='prefix', + name='role', + field=models.ForeignKey(blank=True, help_text='The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'), + ), + migrations.AlterField( + model_name='prefix', + name='status', + field=models.PositiveSmallIntegerField(choices=[(0, 'Container'), (1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, help_text='Operational status of this prefix', verbose_name='Status'), + ), + migrations.AlterField( + model_name='prefix', + name='vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name='VLAN'), + ), + migrations.AlterField( + model_name='prefix', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name='VRF'), + ), + migrations.AlterField( + model_name='vlan', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, verbose_name='Status'), + ), + migrations.AlterField( + model_name='vlan', + name='vid', + field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name='ID'), + ), + migrations.AlterField( + model_name='vrf', + name='rd', + field=models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher'), + ), + migrations.AddField( + 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')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + migrations.AlterUniqueTogether( + name='service', + unique_together=set([]), + ), + ] diff --git a/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py b/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py new file mode 100644 index 000000000..c8292bbc0 --- /dev/null +++ b/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:14 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('ipam', '0019_virtualization'), ('ipam', '0020_ipaddress_add_role_carp')] + + dependencies = [ + ('ipam', '0018_remove_service_uniqueness_constraint'), + ('virtualization', '0001_virtualization'), + ] + + 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'), + ), + 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/migrations/0022_tags.py b/netbox/ipam/migrations/0022_tags.py new file mode 100644 index 000000000..14a508317 --- /dev/null +++ b/netbox/ipam/migrations/0022_tags.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:04 +from __future__ import unicode_literals + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('ipam', '0021_vrf_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='aggregate', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='ipaddress', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='prefix', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='service', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='vlan', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='vrf', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/netbox/ipam/migrations/0023_change_logging.py b/netbox/ipam/migrations/0023_change_logging.py new file mode 100644 index 000000000..d548fdf15 --- /dev/null +++ b/netbox/ipam/migrations/0023_change_logging.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0022_tags'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rir', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='role', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='role', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='vlangroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='vlangroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='aggregate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='aggregate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='ipaddress', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='ipaddress', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='prefix', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='prefix', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='service', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='service', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='vlan', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='vlan', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='vrf', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='vrf', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 29702ff5c..1b109f939 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -10,30 +10,54 @@ from django.db.models import Q from django.db.models.expressions import RawSQL from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from taggit.managers import TaggableManager from dcim.models import Interface -from extras.models import CustomFieldModel, CustomFieldValue -from tenancy.models import Tenant -from utilities.models import CreatedUpdatedModel +from extras.models import CustomFieldModel +from utilities.models import ChangeLoggedModel from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet @python_2_unicode_compatible -class VRF(CreatedUpdatedModel, CustomFieldModel): +class VRF(ChangeLoggedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF are said to exist in the "global" table.) """ - name = models.CharField(max_length=50) - rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher') - tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT) - enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space', - help_text="Prevent duplicate prefixes/IP addresses within this VRF") - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + name = models.CharField( + max_length=50 + ) + rd = models.CharField( + max_length=21, + unique=True, + verbose_name='Route distinguisher' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vrfs', + blank=True, + null=True + ) + enforce_unique = models.BooleanField( + default=True, + verbose_name='Enforce unique space', + help_text='Prevent duplicate prefixes/IP addresses within this VRF' + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] @@ -65,15 +89,23 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class RIR(models.Model): +class RIR(ChangeLoggedModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - is_private = models.BooleanField(default=False, verbose_name='Private', - help_text='IP space managed by this RIR is considered private') + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + is_private = models.BooleanField( + default=False, + verbose_name='Private', + help_text='IP space managed by this RIR is considered private' + ) csv_headers = ['name', 'slug', 'is_private'] @@ -97,17 +129,36 @@ class RIR(models.Model): @python_2_unicode_compatible -class Aggregate(CreatedUpdatedModel, CustomFieldModel): +class Aggregate(ChangeLoggedModel, CustomFieldModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. """ - family = models.PositiveSmallIntegerField(choices=AF_CHOICES) + family = models.PositiveSmallIntegerField( + choices=AF_CHOICES + ) prefix = IPNetworkField() - rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR') - date_added = models.DateField(blank=True, null=True) - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='aggregates', + verbose_name='RIR' + ) + date_added = models.DateField( + blank=True, + null=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() csv_headers = ['prefix', 'rir', 'date_added', 'description'] @@ -173,14 +224,21 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class Role(models.Model): +class Role(ChangeLoggedModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - weight = models.PositiveSmallIntegerField(default=1000) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) csv_headers = ['name', 'slug', 'weight'] @@ -199,30 +257,80 @@ class Role(models.Model): @python_2_unicode_compatible -class Prefix(CreatedUpdatedModel, CustomFieldModel): +class Prefix(ChangeLoggedModel, CustomFieldModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be assigned to a VLAN where appropriate. """ - family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) - prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask") - site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True) - vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, - verbose_name='VRF') - tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT) - vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, - verbose_name='VLAN') - status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE, - help_text="Operational status of this prefix") - role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True, - help_text="The primary function of this prefix") - is_pool = models.BooleanField(verbose_name='Is a pool', default=False, - help_text="All IP addresses within this prefix are considered usable") - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + family = models.PositiveSmallIntegerField( + choices=AF_CHOICES, + editable=False + ) + prefix = IPNetworkField( + help_text='IPv4 or IPv6 network with mask' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True + ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True, + verbose_name='VRF' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True + ) + vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.PROTECT, + related_name='prefixes', + blank=True, + null=True, + verbose_name='VLAN' + ) + status = models.PositiveSmallIntegerField( + choices=PREFIX_STATUS_CHOICES, + default=PREFIX_STATUS_ACTIVE, + verbose_name='Status', + help_text='Operational status of this prefix' + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='prefixes', + blank=True, + null=True, + help_text='The primary function of this prefix' + ) + is_pool = models.BooleanField( + verbose_name='Is a pool', + default=False, + help_text='All IP addresses within this prefix are considered usable' + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) objects = PrefixQuerySet.as_manager() + tags = TaggableManager() csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', @@ -389,7 +497,7 @@ class IPAddressManager(models.Manager): @python_2_unicode_compatible -class IPAddress(CreatedUpdatedModel, CustomFieldModel): +class IPAddress(ChangeLoggedModel, CustomFieldModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -400,27 +508,69 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP. """ - family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) - address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)") - vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, - verbose_name='VRF') - tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) + family = models.PositiveSmallIntegerField( + choices=AF_CHOICES, + editable=False + ) + address = IPAddressField( + help_text='IPv4 or IPv6 address (with mask)' + ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.PROTECT, + related_name='ip_addresses', + blank=True, + null=True, + verbose_name='VRF' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='ip_addresses', + blank=True, + null=True + ) status = models.PositiveSmallIntegerField( - 'Status', choices=IPADDRESS_STATUS_CHOICES, default=IPADDRESS_STATUS_ACTIVE, + choices=IPADDRESS_STATUS_CHOICES, + default=IPADDRESS_STATUS_ACTIVE, + verbose_name='Status', help_text='The operational status of this IP' ) role = models.PositiveSmallIntegerField( - 'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP' + verbose_name='Role', + choices=IPADDRESS_ROLE_CHOICES, + blank=True, + null=True, + help_text='The functional role of this IP' + ) + interface = models.ForeignKey( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='ip_addresses', + blank=True, + null=True + ) + nat_inside = models.OneToOneField( + to='self', + on_delete=models.SET_NULL, + related_name='nat_outside', + blank=True, + null=True, + verbose_name='NAT (Inside)', + help_text='The IP for which this address is the "outside" IP' + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' ) - interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, - null=True) - nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, - null=True, verbose_name='NAT (Inside)', - help_text="The IP for which this address is the \"outside\" IP") - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') objects = IPAddressManager() + tags = TaggableManager() csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', @@ -505,13 +655,21 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class VLANGroup(models.Model): +class VLANGroup(ChangeLoggedModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. """ - name = models.CharField(max_length=50) + name = models.CharField( + max_length=50 + ) slug = models.SlugField() - site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlan_groups', + blank=True, + null=True + ) csv_headers = ['name', 'slug', 'site'] @@ -549,7 +707,7 @@ class VLANGroup(models.Model): @python_2_unicode_compatible -class VLAN(CreatedUpdatedModel, CustomFieldModel): +class VLAN(ChangeLoggedModel, CustomFieldModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, @@ -558,18 +716,57 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it. """ - site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True) - group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) - vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ - MinValueValidator(1), - MaxValueValidator(4094) - ]) - name = models.CharField(max_length=64) - tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) - status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1) - role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True) - description = models.CharField(max_length=100, blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + group = models.ForeignKey( + to='ipam.VLANGroup', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + vid = models.PositiveSmallIntegerField( + verbose_name='ID', + validators=[MinValueValidator(1), MaxValueValidator(4094)] + ) + name = models.CharField( + max_length=64 + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + status = models.PositiveSmallIntegerField( + choices=VLAN_STATUS_CHOICES, + default=1, + verbose_name='Status' + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='vlans', + blank=True, + null=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] @@ -626,7 +823,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible -class Service(CreatedUpdatedModel): +class Service(ChangeLoggedModel, CustomFieldModel): """ 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. @@ -666,6 +863,15 @@ class Service(CreatedUpdatedModel): max_length=100, blank=True ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() + + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description'] class Meta: ordering = ['protocol', 'port'] @@ -673,6 +879,9 @@ class Service(CreatedUpdatedModel): def __str__(self): return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + def get_absolute_url(self): + return reverse('ipam:service', args=[self.pk]) + @property def parent(self): return self.device or self.virtual_machine @@ -684,3 +893,13 @@ class Service(CreatedUpdatedModel): 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.") + + def to_csv(self): + return ( + self.device.name if self.device else None, + self.virtual_machine.name if self.virtual_machine else None, + self.name, + self.get_protocol_display(), + self.port, + self.description, + ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index b2610eef1..261c047df 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -5,8 +5,8 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ToggleColumn -from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF +from utilities.tables import BaseTable, BooleanColumn, ToggleColumn +from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """
@@ -28,6 +28,9 @@ RIR_UTILIZATION = """ """ RIR_ACTIONS = """ + + + {% if perms.ipam.change_rir %} {% endif %} @@ -47,6 +50,9 @@ ROLE_VLAN_COUNT = """ """ ROLE_ACTIONS = """ + + + {% if perms.ipam.change_role %} {% endif %} @@ -137,6 +143,9 @@ VLAN_ROLE_LINK = """ """ VLANGROUP_ACTIONS = """ + + + {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -194,7 +203,7 @@ class VRFTable(BaseTable): class RIRTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') - is_private = tables.BooleanColumn(verbose_name='Private') + is_private = BooleanColumn(verbose_name='Private') aggregate_count = tables.Column(verbose_name='Aggregates') actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') @@ -361,6 +370,20 @@ class IPAddressAssignTable(BaseTable): orderable = False +class InterfaceIPAddressTable(BaseTable): + """ + List IP addresses assigned to a specific Interface. + """ + address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address') + vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') + status = tables.TemplateColumn(STATUS_LABEL) + tenant = tables.TemplateColumn(template_code=TENANT_LINK) + + class Meta(BaseTable.Meta): + model = IPAddress + fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description') + + # # VLAN groups # @@ -423,3 +446,40 @@ class VLANMemberTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('parent', 'name', 'untagged', 'actions') + + +class InterfaceVLANTable(BaseTable): + """ + List VLANs assigned to a specific Interface. + """ + vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') + tagged = BooleanColumn() + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') + tenant = tables.TemplateColumn(template_code=COL_TENANT) + status = tables.TemplateColumn(STATUS_LABEL) + role = tables.TemplateColumn(VLAN_ROLE_LINK) + + class Meta(BaseTable.Meta): + model = VLAN + fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') + + def __init__(self, interface, *args, **kwargs): + self.interface = interface + super(InterfaceVLANTable, self).__init__(*args, **kwargs) + + +# +# Services +# + +class ServiceTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn( + viewname='ipam:service', + args=[Accessor('pk')] + ) + + class Meta(BaseTable.Meta): + model = Service + fields = ('pk', 'name', 'parent', 'protocol', 'port', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 505d914a0..f295bee29 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1,25 +1,20 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from rest_framework.test import APITestCase from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.constants import IP_PROTOCOL_TCP, IP_PROTOCOL_UDP from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import APITestCase -class VRFTest(HttpStatusMixin, APITestCase): +class VRFTest(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)} + super(VRFTest, self).setUp() self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2') @@ -106,13 +101,11 @@ class VRFTest(HttpStatusMixin, APITestCase): self.assertEqual(VRF.objects.count(), 2) -class RIRTest(HttpStatusMixin, APITestCase): +class RIRTest(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)} + super(RIRTest, self).setUp() self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') @@ -199,13 +192,11 @@ class RIRTest(HttpStatusMixin, APITestCase): self.assertEqual(RIR.objects.count(), 2) -class AggregateTest(HttpStatusMixin, APITestCase): +class AggregateTest(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)} + super(AggregateTest, self).setUp() self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') @@ -294,13 +285,11 @@ class AggregateTest(HttpStatusMixin, APITestCase): self.assertEqual(Aggregate.objects.count(), 2) -class RoleTest(HttpStatusMixin, APITestCase): +class RoleTest(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)} + super(RoleTest, self).setUp() self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1') self.role2 = Role.objects.create(name='Test Role 2', slug='test-role-2') @@ -387,13 +376,11 @@ class RoleTest(HttpStatusMixin, APITestCase): self.assertEqual(Role.objects.count(), 2) -class PrefixTest(HttpStatusMixin, APITestCase): +class PrefixTest(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)} + super(PrefixTest, self).setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') @@ -614,13 +601,11 @@ class PrefixTest(HttpStatusMixin, APITestCase): self.assertEqual(len(response.data), 8) -class IPAddressTest(HttpStatusMixin, APITestCase): +class IPAddressTest(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)} + super(IPAddressTest, self).setUp() self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') self.ipaddress1 = IPAddress.objects.create(address=IPNetwork('192.168.0.1/24')) @@ -705,13 +690,11 @@ class IPAddressTest(HttpStatusMixin, APITestCase): self.assertEqual(IPAddress.objects.count(), 2) -class VLANGroupTest(HttpStatusMixin, APITestCase): +class VLANGroupTest(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)} + super(VLANGroupTest, self).setUp() self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1') self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2') @@ -798,13 +781,11 @@ class VLANGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(VLANGroup.objects.count(), 2) -class VLANTest(HttpStatusMixin, APITestCase): +class VLANTest(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)} + super(VLANTest, self).setUp() self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1') self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2') @@ -891,13 +872,11 @@ class VLANTest(HttpStatusMixin, APITestCase): self.assertEqual(VLAN.objects.count(), 2) -class ServiceTest(HttpStatusMixin, APITestCase): +class ServiceTest(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)} + super(ServiceTest, self).setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 20bdf8e31..700d78ae4 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from . import views +from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF app_name = 'ipam' @@ -17,6 +19,7 @@ urlpatterns = [ url(r'^vrfs/(?P\d+)/$', views.VRFView.as_view(), name='vrf'), url(r'^vrfs/(?P\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), url(r'^vrfs/(?P\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), + url(r'^vrfs/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), # RIRs url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), @@ -24,6 +27,7 @@ urlpatterns = [ 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'), + url(r'^vrfs/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), @@ -34,6 +38,7 @@ urlpatterns = [ url(r'^aggregates/(?P\d+)/$', views.AggregateView.as_view(), name='aggregate'), url(r'^aggregates/(?P\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), url(r'^aggregates/(?P\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), + url(r'^aggregates/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), # Roles url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), @@ -41,6 +46,7 @@ urlpatterns = [ 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'), + url(r'^roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), @@ -51,6 +57,7 @@ urlpatterns = [ url(r'^prefixes/(?P\d+)/$', views.PrefixView.as_view(), name='prefix'), url(r'^prefixes/(?P\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), url(r'^prefixes/(?P\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), + url(r'^prefixes/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), url(r'^prefixes/(?P\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), url(r'^prefixes/(?P\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), @@ -61,6 +68,7 @@ urlpatterns = [ url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + url(r'^ip-addresses/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), url(r'^ip-addresses/(?P\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), @@ -73,6 +81,7 @@ urlpatterns = [ 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'), url(r'^vlan-groups/(?P\d+)/vlans/$', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), + url(r'^vlan-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), @@ -84,9 +93,15 @@ urlpatterns = [ url(r'^vlans/(?P\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'), url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), + url(r'^vlans/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services + url(r'^services/$', views.ServiceListView.as_view(), name='service_list'), + url(r'^services/edit/$', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), + url(r'^services/delete/$', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), + url(r'^services/(?P\d+)/$', views.ServiceView.as_view(), name='service'), url(r'^services/(?P\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), url(r'^services/(?P\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), + url(r'^services/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index e5c6670c0..91c741789 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -5,7 +5,6 @@ from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse from django.views.generic import View from django_tables2 import RequestConfig @@ -167,7 +166,6 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vrf' - cls = VRF queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter table = tables.VRFTable @@ -177,7 +175,6 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vrf' - cls = VRF queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter table = tables.VRFTable @@ -268,9 +265,7 @@ class RIRCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_rir' model = RIR model_form = forms.RIRForm - - def get_return_url(self, request, obj): - return reverse('ipam:rir_list') + default_return_url = 'ipam:rir_list' class RIREditView(RIRCreateView): @@ -286,7 +281,6 @@ class RIRBulkImportView(PermissionRequiredMixin, BulkImportView): class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_rir' - cls = RIR queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter table = tables.RIRTable @@ -390,7 +384,6 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_aggregate' - cls = Aggregate queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter table = tables.AggregateTable @@ -400,7 +393,6 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_aggregate' - cls = Aggregate queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter table = tables.AggregateTable @@ -421,9 +413,7 @@ class RoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_role' model = Role model_form = forms.RoleForm - - def get_return_url(self, request, obj): - return reverse('ipam:role_list') + default_return_url = 'ipam:role_list' class RoleEditView(RoleCreateView): @@ -439,7 +429,7 @@ class RoleBulkImportView(PermissionRequiredMixin, BulkImportView): class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_role' - cls = Role + queryset = Role.objects.all() table = tables.RoleTable default_return_url = 'ipam:role_list' @@ -542,6 +532,7 @@ class PrefixPrefixesView(View): 'prefix_table': prefix_table, 'permissions': permissions, 'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), + 'active_tab': 'prefixes', }) @@ -580,6 +571,7 @@ class PrefixIPAddressesView(View): 'ip_table': ip_table, 'permissions': permissions, 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), + 'active_tab': 'ip-addresses', }) @@ -611,7 +603,6 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_prefix' - cls = Prefix queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter table = tables.PrefixTable @@ -621,7 +612,6 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_prefix' - cls = Prefix queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter table = tables.PrefixTable @@ -784,7 +774,6 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_ipaddress' - cls = IPAddress queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable @@ -794,7 +783,6 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' - cls = IPAddress queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device') filter = filters.IPAddressFilter table = tables.IPAddressTable @@ -817,9 +805,7 @@ class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlangroup' model = VLANGroup model_form = forms.VLANGroupForm - - def get_return_url(self, request, obj): - return reverse('ipam:vlangroup_list') + default_return_url = 'ipam:vlangroup_list' class VLANGroupEditView(VLANGroupCreateView): @@ -835,7 +821,6 @@ class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlangroup' - cls = VLANGroup queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) filter = filters.VLANGroupFilter table = tables.VLANGroupTable @@ -914,8 +899,6 @@ class VLANMembersView(View): members = vlan.get_members().select_related('device', 'virtual_machine') members_table = tables.VLANMemberTable(members) - # if request.user.has_perm('dcim.change_interface'): - # members_table.columns.show('pk') paginate = { 'klass': EnhancedPaginator, @@ -923,18 +906,10 @@ class VLANMembersView(View): } RequestConfig(request, paginate).configure(members_table) - # Compile permissions list for rendering the object table - # permissions = { - # 'add': request.user.has_perm('ipam.add_ipaddress'), - # 'change': request.user.has_perm('ipam.change_ipaddress'), - # 'delete': request.user.has_perm('ipam.delete_ipaddress'), - # } - return render(request, 'ipam/vlan_members.html', { 'vlan': vlan, 'members_table': members_table, - # 'permissions': permissions, - # 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), + 'active_tab': 'members', }) @@ -965,7 +940,6 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vlan' - cls = VLAN queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter table = tables.VLANTable @@ -975,7 +949,6 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' - cls = VLAN queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter table = tables.VLANTable @@ -986,6 +959,25 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Services # +class ServiceListView(ObjectListView): + queryset = Service.objects.select_related('device', 'virtual_machine') + filter = filters.ServiceFilter + filter_form = forms.ServiceFilterForm + table = tables.ServiceTable + template_name = 'ipam/service_list.html' + + +class ServiceView(View): + + def get(self, request, pk): + + service = get_object_or_404(Service, pk=pk) + + return render(request, 'ipam/service.html', { + 'service': service, + }) + + class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_service' model = Service @@ -999,9 +991,6 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine']) return obj - def get_return_url(self, request, obj): - return obj.parent.get_absolute_url() - class ServiceEditView(ServiceCreateView): permission_required = 'ipam.change_service' @@ -1010,3 +999,20 @@ class ServiceEditView(ServiceCreateView): class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_service' model = Service + + +class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipam.change_service' + queryset = Service.objects.select_related('device', 'virtual_machine') + filter = filters.ServiceFilter + table = tables.ServiceTable + form = forms.ServiceBulkEditForm + default_return_url = 'ipam:service_list' + + +class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipam.delete_service' + queryset = Service.objects.select_related('device', 'virtual_machine') + filter = filters.ServiceFilter + table = tables.ServiceTable + default_return_url = 'ipam:service_list' diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 4c4cf4277..23d6ba221 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -50,6 +50,9 @@ BANNER_LOGIN = '' # BASE_PATH = 'netbox/' BASE_PATH = '' +# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) +CHANGELOG_RETENTION = 90 + # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers @@ -118,6 +121,19 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. PREFER_IPV4 = False +# The Webhook event backend is disabled by default. Set this to True to enable it. Note that this requires a Redis +# database be configured and accessible by NetBox (see `REDIS` below). +WEBHOOKS_ENABLED = False + +# Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled. +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, +} + # 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' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 143de78bb..6f1c240aa 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.3.8-dev' +VERSION = '2.4.0' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -44,6 +44,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only +CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) @@ -64,11 +65,13 @@ NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) 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('/') +REDIS = getattr(configuration, 'REDIS', {}) 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') TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') +WEBHOOKS_ENABLED = getattr(configuration, 'WEBHOOKS_ENABLED', False) CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS @@ -109,6 +112,13 @@ DATABASES = { 'default': configuration.DATABASE, } +# Redis +REDIS_HOST = REDIS.get('HOST', 'localhost') +REDIS_PORT = REDIS.get('PORT', 6379) +REDIS_PASSWORD = REDIS.get('PASSWORD', '') +REDIS_DATABASE = REDIS.get('DATABASE', 0) +REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) + # Email EMAIL_HOST = EMAIL.get('SERVER') EMAIL_PORT = EMAIL.get('PORT', 25) @@ -119,7 +129,7 @@ SERVER_EMAIL = EMAIL.get('FROM_EMAIL') EMAIL_SUBJECT_PREFIX = '[NetBox] ' # Installed applications -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -133,6 +143,8 @@ INSTALLED_APPS = ( 'django_tables2', 'mptt', 'rest_framework', + 'taggit', + 'taggit_serializer', 'timezone_field', 'circuits', 'dcim', @@ -144,7 +156,11 @@ INSTALLED_APPS = ( 'utilities', 'virtualization', 'drf_yasg', -) +] + +# Only load django-rq if the webhook backend is enabled +if WEBHOOKS_ENABLED: + INSTALLED_APPS.append('django_rq') # Middleware MIDDLEWARE = ( @@ -154,13 +170,13 @@ MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', 'utilities.middleware.ExceptionHandlingMiddleware', 'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.APIVersionMiddleware', + 'extras.middleware.ObjectChangeMiddleware', ) ROOT_URLCONF = 'netbox.urls' @@ -246,6 +262,18 @@ REST_FRAMEWORK = { 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } +# Django RQ (Webhooks backend) +RQ_QUEUES = { + 'default': { + 'HOST': REDIS_HOST, + 'PORT': REDIS_PORT, + 'DB': REDIS_DATABASE, + 'PASSWORD': REDIS_PASSWORD, + 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, + } +} +RQ_SHOW_ADMIN_LINK = True + # drf_yasg settings for Swagger SWAGGER_SETTINGS = { 'DEFAULT_FIELD_INSPECTORS': [ diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 3a2a7205f..d23e2d64e 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -64,6 +64,12 @@ _patterns = [ ] +if settings.WEBHOOKS_ENABLED: + _patterns += [ + url(r'^admin/webhook-backend-status/', include('django_rq.urls')), + ] + + if settings.DEBUG: import debug_toolbar _patterns += [ diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index f2aa30b9e..1e3433016 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -15,7 +15,7 @@ from circuits.tables import CircuitTable, ProviderTable from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable -from extras.models import ReportResult, TopologyMap, UserAction +from extras.models import ObjectChange, ReportResult, TopologyMap from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable @@ -184,7 +184,7 @@ class HomeView(View): 'stats': stats, 'topology_maps': TopologyMap.objects.filter(site__isnull=True), 'report_results': ReportResult.objects.order_by('-created')[:10], - 'recent_activity': UserAction.objects.select_related('user')[:50] + 'changelog': ObjectChange.objects.select_related('user')[:50] }) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 32bb8aea5..6222a477d 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -366,6 +366,10 @@ table.component-list td.subtable td { padding-bottom: 6px; padding-top: 6px; } +table.interface-ips th { + font-size: 80%; + font-weight: normal; +} /* Reports */ table.reports td.method { diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index f0208df7b..91b83bf2a 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -127,4 +127,54 @@ $(document).ready(function() { }); }); + + // Auto-complete tags + function split_tags(val) { + return val.split(/,\s*/); + } + $("#id_tags") + .on("keydown", function(event) { + if (event.keyCode === $.ui.keyCode.TAB && + $(this).autocomplete("instance").menu.active) { + event.preventDefault(); + } + }) + .autocomplete({ + source: function(request, response) { + $.ajax({ + type: 'GET', + url: netbox_api_path + 'extras/tags/', + data: 'q=' + split_tags(request.term).pop(), + success: function(data) { + var choices = []; + $.each(data.results, function (index, choice) { + choices.push(choice.name); + }); + response(choices); + } + }); + }, + search: function() { + // Need 3 or more characters to begin searching + var term = split_tags(this.value).pop(); + if (term.length < 3) { + return false; + } + }, + focus: function() { + // prevent value inserted on focus + return false; + }, + select: function(event, ui) { + var terms = split_tags(this.value); + // remove the current input + terms.pop(); + // add the selected item + terms.push(ui.item.value); + // add placeholder to get the comma-and-space at the end + terms.push(""); + this.value = terms.join(", "); + return false; + } + }); }); diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index a4e61a018..ee7217b63 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -2,10 +2,12 @@ from __future__ import unicode_literals from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.serializers import NestedDeviceSerializer +from extras.api.customfields import CustomFieldModelSerializer from secrets.models import Secret, SecretRole -from utilities.api import ValidatedModelSerializer +from utilities.api import ValidatedModelSerializer, WritableNestedSerializer # @@ -19,7 +21,7 @@ class SecretRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedSecretRoleSerializer(serializers.ModelSerializer): +class NestedSecretRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') class Meta: @@ -31,21 +33,17 @@ class NestedSecretRoleSerializer(serializers.ModelSerializer): # Secrets # -class SecretSerializer(serializers.ModelSerializer): +class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() - - class Meta: - model = Secret - fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] - - -class WritableSecretSerializer(serializers.ModelSerializer): plaintext = serializers.CharField() + tags = TagListSerializerField(required=False) class Meta: model = Secret - fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] + fields = [ + 'id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', + ] validators = [] def validate(self, data): @@ -64,6 +62,6 @@ class WritableSecretSerializer(serializers.ModelSerializer): validator(data) # Enforce model validation - super(WritableSecretSerializer, self).validate(data) + super(SecretSerializer, self).validate(data) return data diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index d2fb2ef00..9bc52f9f0 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -51,7 +51,6 @@ class SecretViewSet(ModelViewSet): 'role__users', 'role__groups', ) serializer_class = serializers.SecretSerializer - write_serializer_class = serializers.WritableSecretSerializer filter_class = filters.SecretFilter master_key = None @@ -68,7 +67,7 @@ class SecretViewSet(ModelViewSet): super(SecretViewSet, self).initial(request, *args, **kwargs) - if request.user.is_authenticated(): + if request.user.is_authenticated: # Read session key from HTTP cookie or header if it has been provided. The session key must be provided in # order to encrypt/decrypt secrets. diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 6578eb4b8..f43a82b22 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -4,6 +4,7 @@ import django_filters from django.db.models import Q from dcim.models import Device +from extras.filters import CustomFieldFilterSet from utilities.filters import NumericInFilter from .models import Secret, SecretRole @@ -15,7 +16,7 @@ class SecretRoleFilter(django_filters.FilterSet): fields = ['name', 'slug'] -class SecretFilter(django_filters.FilterSet): +class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', @@ -41,6 +42,9 @@ class SecretFilter(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Secret diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 7a7e81610..59e637a18 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -4,9 +4,11 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms from django.db.models import Count +from taggit.forms import TagField from dcim.models import Device -from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField +from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm +from utilities.forms import BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField from .models import Secret, SecretRole, UserKey @@ -57,7 +59,7 @@ class SecretRoleCSVForm(forms.ModelForm): # Secrets # -class SecretForm(BootstrapMixin, forms.ModelForm): +class SecretForm(BootstrapMixin, CustomFieldForm): plaintext = forms.CharField( max_length=65535, required=False, @@ -70,10 +72,11 @@ class SecretForm(BootstrapMixin, forms.ModelForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) + tags = TagField(required=False) class Meta: model = Secret - fields = ['role', 'name', 'plaintext', 'plaintext2'] + fields = ['role', 'name', 'plaintext', 'plaintext2', 'tags'] def __init__(self, *args, **kwargs): @@ -126,7 +129,7 @@ class SecretCSVForm(forms.ModelForm): return s -class SecretBulkEditForm(BootstrapMixin, BulkEditForm): +class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) name = forms.CharField(max_length=100, required=False) @@ -135,7 +138,8 @@ class SecretBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = ['name'] -class SecretFilterForm(BootstrapMixin, forms.Form): +class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Secret q = forms.CharField(required=False, label='Search') role = FilterChoiceField( queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), diff --git a/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py new file mode 100644 index 000000000..fb7d37431 --- /dev/null +++ b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-08-01 17:45 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('secrets', '0001_initial'), ('secrets', '0002_userkey_add_session_key'), ('secrets', '0003_unicode_literals')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0002_auto_20160622_1821'), + ('auth', '0007_alter_validators_add_error_messages'), + ] + + operations = [ + migrations.CreateModel( + name='SecretRole', + 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)), + ('groups', models.ManyToManyField(blank=True, related_name='secretroles', to='auth.Group')), + ('users', models.ManyToManyField(blank=True, related_name='secretroles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Secret', + 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(blank=True, max_length=100)), + ('ciphertext', models.BinaryField(max_length=65568)), + ('hash', models.CharField(editable=False, max_length=128)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='secrets', to='dcim.Device')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='secrets.SecretRole')), + ], + options={ + 'ordering': ['device', 'role', 'name'], + }, + ), + migrations.CreateModel( + name='UserKey', + 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)), + ('public_key', models.TextField(verbose_name='RSA public key')), + ('master_key_cipher', models.BinaryField(blank=True, max_length=512, null=True)), + ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),), + 'ordering': ['user__username'], + }, + ), + migrations.AlterUniqueTogether( + name='secret', + unique_together=set([('device', 'role', 'name')]), + ), + migrations.CreateModel( + name='SessionKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cipher', models.BinaryField(max_length=512)), + ('hash', models.CharField(editable=False, max_length=128)), + ('created', models.DateTimeField(auto_now_add=True)), + ('userkey', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to='secrets.UserKey')), + ], + options={ + 'ordering': ['userkey__user__username'], + }, + ), + ] diff --git a/netbox/secrets/migrations/0004_tags.py b/netbox/secrets/migrations/0004_tags.py new file mode 100644 index 000000000..ac952dc92 --- /dev/null +++ b/netbox/secrets/migrations/0004_tags.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:04 +from __future__ import unicode_literals + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('secrets', '0003_unicode_literals'), + ] + + operations = [ + migrations.AddField( + model_name='secret', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/netbox/secrets/migrations/0005_change_logging.py b/netbox/secrets/migrations/0005_change_logging.py new file mode 100644 index 000000000..947087934 --- /dev/null +++ b/netbox/secrets/migrations/0005_change_logging.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:29 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0004_tags'), + ] + + operations = [ + migrations.AddField( + model_name='secretrole', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='secretrole', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='secret', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='secret', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 4bd644564..8bbf3d14d 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -8,13 +8,15 @@ from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import Group, User +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 force_bytes, python_2_unicode_compatible +from taggit.managers import TaggableManager -from dcim.models import Device -from utilities.models import CreatedUpdatedModel +from extras.models import CustomFieldModel +from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher from .querysets import UserKeyQuerySet @@ -48,15 +50,33 @@ def decrypt_master_key(master_key_cipher, private_key): @python_2_unicode_compatible -class UserKey(CreatedUpdatedModel): +class UserKey(models.Model): """ A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's matching (private) decryption key. """ - user = models.OneToOneField(User, related_name='user_key', editable=False, on_delete=models.CASCADE) - public_key = models.TextField(verbose_name='RSA public key') - master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False) + created = models.DateField( + auto_now_add=True + ) + last_updated = models.DateTimeField( + auto_now=True + ) + user = models.OneToOneField( + to=User, + on_delete=models.CASCADE, + related_name='user_key', + editable=False + ) + public_key = models.TextField( + verbose_name='RSA public key' + ) + master_key_cipher = models.BinaryField( + max_length=512, + blank=True, + null=True, + editable=False + ) objects = UserKeyQuerySet.as_manager() @@ -172,10 +192,23 @@ class SessionKey(models.Model): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. """ - userkey = models.OneToOneField(UserKey, related_name='session_key', on_delete=models.CASCADE, editable=False) - cipher = models.BinaryField(max_length=512, editable=False) - hash = models.CharField(max_length=128, editable=False) - created = models.DateTimeField(auto_now_add=True) + userkey = models.OneToOneField( + to='secrets.UserKey', + on_delete=models.CASCADE, + related_name='session_key', + editable=False + ) + cipher = models.BinaryField( + max_length=512, + editable=False + ) + hash = models.CharField( + max_length=128, + editable=False + ) + created = models.DateTimeField( + auto_now_add=True + ) key = None @@ -226,7 +259,7 @@ class SessionKey(models.Model): @python_2_unicode_compatible -class SecretRole(models.Model): +class SecretRole(ChangeLoggedModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles such as "Login Credentials" or "SNMP Communities." @@ -234,10 +267,23 @@ class SecretRole(models.Model): By default, only superusers will have access to decrypt Secrets. To allow other users to decrypt Secrets, grant them access to the appropriate SecretRoles either individually or by group. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - users = models.ManyToManyField(User, related_name='secretroles', blank=True) - groups = models.ManyToManyField(Group, related_name='secretroles', blank=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + users = models.ManyToManyField( + to=User, + related_name='secretroles', + blank=True + ) + groups = models.ManyToManyField( + to=Group, + related_name='secretroles', + blank=True + ) csv_headers = ['name', 'slug'] @@ -266,7 +312,7 @@ class SecretRole(models.Model): @python_2_unicode_compatible -class Secret(CreatedUpdatedModel): +class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a @@ -276,11 +322,35 @@ class Secret(CreatedUpdatedModel): A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis. """ - device = models.ForeignKey(Device, related_name='secrets', on_delete=models.CASCADE) - role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT) - name = models.CharField(max_length=100, blank=True) - ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded - hash = models.CharField(max_length=128, editable=False) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='secrets' + ) + role = models.ForeignKey( + to='secrets.SecretRole', + on_delete=models.PROTECT, + related_name='secrets' + ) + name = models.CharField( + max_length=100, + blank=True + ) + ciphertext = models.BinaryField( + max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded + editable=False + ) + hash = models.CharField( + max_length=128, + editable=False + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] @@ -304,6 +374,14 @@ class Secret(CreatedUpdatedModel): def get_absolute_url(self): return reverse('secrets:secret', args=[self.pk]) + def to_csv(self): + return ( + self.device, + self.role, + self.name, + self.plaintext or '', + ) + def _pad(self, s): """ Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B). diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index d68ac37fe..4cfb1a6ea 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn from .models import SecretRole, Secret SECRETROLE_ACTIONS = """ + + + {% if perms.secrets.change_secretrole %} {% endif %} diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 0bf93eafd..985e0ea7f 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -2,15 +2,12 @@ from __future__ import unicode_literals import base64 -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import APITestCase # Dummy RSA key pair for testing use only PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- @@ -52,13 +49,11 @@ qQIDAQAB -----END PUBLIC KEY-----""" -class SecretRoleTest(HttpStatusMixin, APITestCase): +class SecretRoleTest(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)} + super(SecretRoleTest, self).setUp() self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') @@ -145,21 +140,20 @@ class SecretRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(SecretRole.objects.count(), 2) -class SecretTest(HttpStatusMixin, APITestCase): +class SecretTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) + super(SecretTest, self).setUp() - userkey = UserKey(user=user, public_key=PUBLIC_KEY) + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() self.master_key = userkey.get_master_key(PRIVATE_KEY) session_key = SessionKey(userkey=userkey) session_key.save(self.master_key) self.header = { - 'HTTP_AUTHORIZATION': 'Token {}'.format(token.key), + 'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key), 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), } @@ -288,21 +282,20 @@ class SecretTest(HttpStatusMixin, APITestCase): self.assertEqual(Secret.objects.count(), 2) -class GetSessionKeyTest(HttpStatusMixin, APITestCase): +class GetSessionKeyTest(APITestCase): def setUp(self): - user = User.objects.create(username='testuser', is_superuser=True) - token = Token.objects.create(user=user) + super(GetSessionKeyTest, self).setUp() - userkey = UserKey(user=user, public_key=PUBLIC_KEY) + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() master_key = userkey.get_master_key(PRIVATE_KEY) self.session_key = SessionKey(userkey=userkey) self.session_key.save(master_key) self.header = { - 'HTTP_AUTHORIZATION': 'Token {}'.format(token.key), + 'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key), } def test_get_session_key(self): diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index cd6415719..952725b54 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from . import views +from .models import Secret, SecretRole app_name = 'secrets' urlpatterns = [ @@ -13,6 +15,7 @@ urlpatterns = [ 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'), + url(r'^secret-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), @@ -22,5 +25,6 @@ urlpatterns = [ url(r'^secrets/(?P\d+)/$', views.SecretView.as_view(), name='secret'), url(r'^secrets/(?P\d+)/edit/$', views.secret_edit, name='secret_edit'), url(r'^secrets/(?P\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'), + url(r'^secrets/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), ] diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index dfde0a662..d15c9cbc2 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -44,9 +44,7 @@ class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'secrets.add_secretrole' model = SecretRole model_form = forms.SecretRoleForm - - def get_return_url(self, request, obj): - return reverse('secrets:secretrole_list') + default_return_url = 'secrets:secretrole_list' class SecretRoleEditView(SecretRoleCreateView): @@ -62,7 +60,6 @@ class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secretrole' - cls = SecretRole queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable default_return_url = 'secrets:secretrole_list' @@ -244,13 +241,12 @@ class SecretBulkImportView(BulkImportView): 'form': self._import_form(request.POST), 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'secrets.change_secret' - cls = Secret queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter table = tables.SecretTable @@ -260,7 +256,6 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secret' - cls = Secret queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter table = tables.SecretTable diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index f34c0fbde..27ebb052d 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -34,6 +34,7 @@ {{ message }}
{% endfor %} + {% block header %}{% endblock %} {% block content %}{% endblock %}
{% if settings.BANNER_BOTTOM %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 1133f41f3..5c86cb24e 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -1,44 +1,57 @@ {% extends '_base.html' %} {% load helpers %} +{% block title %}{{ circuit }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.circuits.change_circuit %} + + + Edit this circuit + + {% endif %} + {% if perms.circuits.delete_circuit %} + + + Delete this circuit + + {% endif %} +
+

{{ circuit }}

+ {% include 'inc/created_updated.html' with obj=circuit %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.circuits.change_circuit %} - - - Edit this circuit - - {% endif %} - {% if perms.circuits.delete_circuit %} - - - Delete this circuit - - {% endif %} -
-

{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=circuit %}
@@ -112,9 +125,8 @@
- {% with circuit.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=circuit %} + {% include 'extras/inc/tags_panel.html' with tags=circuit.tags.all url='circuits:circuit_list' %}
Comments diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 8503e68f6..325d68dea 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -38,6 +38,12 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html index f05552f7d..81e09c32b 100644 --- a/netbox/templates/circuits/circuit_list.html +++ b/netbox/templates/circuits/circuit_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 6dcccfd8d..4ec9adee1 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -2,49 +2,62 @@ {% load static from staticfiles %} {% load helpers %} +{% block title %}{{ provider }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if show_graphs %} + + {% endif %} + {% if perms.circuits.change_provider %} + + + Edit this provider + + {% endif %} + {% if perms.circuits.delete_provider %} + + + Delete this provider + + {% endif %} +
+

{{ provider }}

+ {% include 'inc/created_updated.html' with obj=provider %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if show_graphs %} - - {% endif %} - {% if perms.circuits.change_provider %} - - - Edit this provider - - {% endif %} - {% if perms.circuits.delete_provider %} - - - Delete this provider - - {% endif %} -
-

{% block title %}{{ provider }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=provider %}
@@ -110,9 +123,8 @@
- {% with provider.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=provider %} + {% include 'extras/inc/tags_panel.html' with tags=provider.tags.all url='circuits:provider_list' %}
Comments diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html index 4fb3889b1..63b7f11b9 100644 --- a/netbox/templates/circuits/provider_edit.html +++ b/netbox/templates/circuits/provider_edit.html @@ -27,6 +27,12 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html index cb7aab406..a0036f46c 100644 --- a/netbox/templates/circuits/provider_list.html +++ b/netbox/templates/circuits/provider_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index cf272449f..a915d0c7f 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -4,620 +4,691 @@ {% block title %}{{ device }}{% endblock %} -{% block content %} -{% include 'dcim/inc/device_header.html' with active_tab='info' %} -
-
-
-
- Device -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Site - {% if device.site.region %} - {{ device.site.region }} - - {% endif %} - {{ device.site }} -
Rack - {% if device.rack %} - {% if device.rack.group %} - {{ device.rack.group }} - - {% endif %} - {{ device.rack }} - {% else %} - None - {% endif %} -
Position - {% if device.parent_bay %} - {% with device.parent_bay.device as parent %} - {{ parent }} {{ device.parent_bay }} - {% if parent.position %} - (U{{ parent.position }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif device.rack and device.position %} - U{{ device.position }} / {{ device.get_face_display }} - {% elif device.rack and device.device_type.u_height %} - Not racked - {% else %} - N/A - {% endif %} -
Tenant - {% if device.tenant %} - {% if device.tenant.group %} - {{ device.tenant.group }} - - {% endif %} - {{ device.tenant }} - {% else %} - None - {% endif %} -
Device Type - {{ device.device_type.full_name }} ({{ device.device_type.u_height }}U) -
Serial Number - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} -
Asset Tag - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} -
+{% block header %} +
+
+
- {% if vc_members %} +
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_device %} + + + Edit this device + + {% endif %} + {% if perms.dcim.delete_device %} + + + Delete this device + + {% endif %} +
+

{{ device }}

+ {% include 'inc/created_updated.html' with obj=device %} + +{% endblock %} + +{% block content %} +
+
- Virtual Chassis + Device
- - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - {% for vc_member in vc_members %} - - - - - - - {% endfor %}
DevicePositionMasterPrioritySite + {% if device.site.region %} + {{ device.site.region }} + + {% endif %} + {{ device.site }} +
Rack + {% if device.rack %} + {% if device.rack.group %} + {{ device.rack.group }} + + {% endif %} + {{ device.rack }} + {% else %} + None + {% endif %} +
Position + {% if device.parent_bay %} + {% with device.parent_bay.device as parent %} + {{ parent }} {{ device.parent_bay }} + {% if parent.position %} + (U{{ parent.position }} / {{ parent.get_face_display }}) + {% endif %} + {% endwith %} + {% elif device.rack and device.position %} + U{{ device.position }} / {{ device.get_face_display }} + {% elif device.rack and device.device_type.u_height %} + Not racked + {% else %} + N/A + {% endif %} +
Tenant + {% if device.tenant %} + {% if device.tenant.group %} + {{ device.tenant.group }} + + {% endif %} + {{ device.tenant }} + {% else %} + None + {% endif %} +
Device Type + {{ device.device_type.full_name }} ({{ device.device_type.u_height }}U) +
Serial Number + {% if device.serial %} + {{ device.serial }} + {% else %} + N/A + {% endif %} +
Asset Tag + {% if device.asset_tag %} + {{ device.asset_tag }} + {% else %} + N/A + {% endif %} +
- {{ vc_member }} - {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
- + {% if vc_members %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vc_member }} + {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
+ +
+ {% endif %} +
+
+ Management +
+ + + + + + + + + + + + + + + + + + + + + + {% if device.cluster %} + + + + {% endif %} - {% if perms.dcim.delete_virtualchassis %} - - Delete Virtual Chassis - +
Role + {{ device.device_role }} +
Platform + {% if device.platform %} + {{ device.platform }} + {% else %} + None + {% endif %} +
Status + {{ device.get_status_display }} +
Primary IPv4 + {% if device.primary_ip4 %} + {{ device.primary_ip4.address.ip }} + {% if device.primary_ip4.nat_inside %} + (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) + {% elif device.primary_ip4.nat_outside %} + (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
Primary IPv6 + {% if device.primary_ip6 %} + {{ device.primary_ip6.address.ip }} + {% if device.primary_ip6.nat_inside %} + (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) + {% elif device.primary_ip6.nat_outside %} + (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) + {% endif %} + {% else %} + N/A + {% endif %} +
Cluster + {% if device.cluster.group %} + {{ device.cluster.group }} + + {% endif %} + {{ device.cluster }} +
+
+ {% include 'inc/custom_fields_panel.html' with obj=device %} + {% include 'extras/inc/tags_panel.html' with tags=device.tags.all url='dcim:device_list' %} +
+
+ Comments +
+
+ {% if device.comments %} + {{ device.comments|gfm }} + {% else %} + None {% endif %}
- {% endif %} -
-
- Management -
- - - - - - - - - - - - - - - - - - - - - - {% if device.cluster %} - - - - - {% endif %} -
Role - {{ device.device_role }} -
Platform - {% if device.platform %} - {{ device.platform }} - {% else %} - None - {% endif %} -
Status - {{ device.get_status_display }} -
Primary IPv4 - {% if device.primary_ip4 %} - {{ device.primary_ip4.address.ip }} - {% if device.primary_ip4.nat_inside %} - (NAT for {{ device.primary_ip4.nat_inside.address.ip }}) - {% elif device.primary_ip4.nat_outside %} - (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) - {% endif %} - {% else %} - N/A - {% endif %} -
Primary IPv6 - {% if device.primary_ip6 %} - {{ device.primary_ip6.address.ip }} - {% if device.primary_ip6.nat_inside %} - (NAT for {{ device.primary_ip6.nat_inside.address.ip }}) - {% elif device.primary_ip6.nat_outside %} - (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) - {% endif %} - {% else %} - N/A - {% endif %} -
Cluster - {% if device.cluster.group %} - {{ device.cluster.group }} - - {% endif %} - {{ device.cluster }} -
- {% with device.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} -
-
- Comments -
-
- {% if device.comments %} - {{ device.comments|gfm }} - {% else %} - None +
+
+
+ Console / Power +
+ + {% for cp in console_ports %} + {% include 'dcim/inc/consoleport.html' %} + {% empty %} + {% if device.device_type.console_port_templates.exists %} + + + + {% endif %} + {% endfor %} + {% for pp in power_ports %} + {% include 'dcim/inc/powerport.html' %} + {% empty %} + {% if device.device_type.power_port_templates.exists %} + + + + {% endif %} + {% endfor %} +
+ No console ports defined + {% if perms.dcim.add_consoleport %} + + {% endif %} +
+ No power ports defined + {% if perms.dcim.add_powerport %} + + {% endif %} +
+ {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} + {% endif %}
-
-
-
-
-
- Console / Power -
- - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% empty %} - {% if device.device_type.console_port_templates.exists %} - - - + {% if request.user.is_authenticated %} +
+
+ Secrets +
+ {% if secrets %} +
- No console ports defined - {% if perms.dcim.add_consoleport %} - - {% endif %} -
+ {% for secret in secrets %} + {% include 'secrets/inc/secret_tr.html' %} + {% endfor %} +
+ {% else %} +
+ None found +
{% endif %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% empty %} - {% if device.device_type.power_port_templates.exists %} - - - No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} - - - {% endif %} - {% endfor %} - - {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} - {% endif %} -
- {% if request.user.is_authenticated %}
- Secrets + Services
- {% if secrets %} + {% if services %} - {% for secret in secrets %} - {% include 'secrets/inc/secret_tr.html' %} + {% for service in services %} + {% include 'ipam/inc/service.html' %} {% endfor %}
{% else %}
- None found + None
{% endif %} - {% if perms.secrets.add_secret %} -
- {% csrf_token %} -
+ {% if perms.ipam.add_service %} {% endif %}
- {% endif %} -
-
- Services -
- {% if services %} - - {% for service in services %} - {% include 'ipam/inc/service.html' %} - {% endfor %} -
- {% else %} -
- None -
- {% endif %} - {% if perms.ipam.add_service %} - - {% endif %} -
-
-
- Images -
- {% include 'inc/image_attachments.html' with images=device.images.all %} - {% if perms.extras.add_imageattachment %} - - {% endif %} -
-
-
- Related Devices -
- {% if related_devices %} - - {% for rd in related_devices %} - - - - - - {% endfor %} -
- {{ rd }} - - {% if rd.rack %} - Rack {{ rd.rack }} - {% else %} - - {% endif %} - {{ rd.device_type.full_name }}
- {% else %} -
None found
- {% endif %} -
-
-
-
-
- {% if device_bays or device.device_type.is_parent_device %} - {% if perms.dcim.delete_devicebay %} -
- {% csrf_token %} - {% endif %}
- Device Bays + Images
- - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} + {% include 'inc/image_attachments.html' with images=device.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} + +
+
+ Related Devices +
+ {% if related_devices %} +
NameStatusInstalled Device
+ {% for rd in related_devices %} - + + + {% endfor %} - -
— No device bays defined — + {{ rd }} + + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} + {{ rd.device_type.full_name }}
- + + {% else %} +
None found
+ {% endif %}
- {% if perms.dcim.delete_devicebay %} -
+
+
+
+
+ {% if device_bays or device.device_type.is_parent_device %} + {% if perms.dcim.delete_devicebay %} +
+ {% csrf_token %} + {% endif %} +
+
+ Device Bays +
+ + + + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} + + {% endif %} + + + + + + + + {% for devicebay in device_bays %} + {% include 'dcim/inc/devicebay.html' %} + {% empty %} + + + + {% endfor %} + +
NameStatusInstalled Device
— No device bays defined —
+ +
+ {% if perms.dcim.delete_devicebay %} +
+ {% endif %} {% endif %} - {% endif %} - {% if interfaces or device.device_type.is_network_device %} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
- {% csrf_token %} - + {% if interfaces or device.device_type.is_network_device %} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% csrf_token %} + + {% endif %} +
+
+ Interfaces +
+ +
+
+ + + + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + + + + + + + + + {% for iface in interfaces %} + {% include 'dcim/inc/interface.html' %} + {% empty %} + + + + {% endfor %} + +
NameLAGDescriptionModeConnection
— No interfaces defined —
+ +
+ {% if perms.dcim.delete_interface %} +
+ {% endif %} {% endif %} -
-
- Interfaces -
- + {% if cs_ports or device.device_type.is_console_server %} + {% if perms.dcim.delete_consoleserverport %} +
+ {% csrf_token %} + {% endif %} +
+
+ Console Server Ports +
+ + + + {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} + + {% endif %} + + + + + + + {% for csp in cs_ports %} + {% include 'dcim/inc/consoleserverport.html' %} + {% empty %} + + + + {% endfor %} + +
NameConnection
— No console server ports defined —
+
- - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% empty %} + {% if perms.dcim.delete_consoleserverport %} + + {% endif %} + {% endif %} + {% if power_outlets or device.device_type.is_pdu %} + {% if perms.dcim.delete_poweroutlet %} + + {% csrf_token %} + {% endif %} +
+
+ Power Outlets +
+
NameLAGDescriptionMTUMAC AddressConnection
+ - + {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} + + {% endif %} + + + - {% endfor %} - -
— No interfaces defined —NameConnection
- -
- {% if perms.dcim.delete_interface %} - - {% endif %} - {% endif %} - {% if cs_ports or device.device_type.is_console_server %} - {% if perms.dcim.delete_consoleserverport %} -
- {% csrf_token %} - {% endif %} -
-
- Console Server Ports + + + {% for po in power_outlets %} + {% include 'dcim/inc/poweroutlet.html' %} + {% empty %} + + — No power outlets defined — + + {% endfor %} + + +
- - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - {% for csp in cs_ports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% empty %} - - - - {% endfor %} - -
NameConnection
— No console server ports defined —
- -
- {% if perms.dcim.delete_consoleserverport %} -
+ {% if perms.dcim.delete_poweroutlet %} + + {% endif %} {% endif %} - {% endif %} - {% if power_outlets or device.device_type.is_pdu %} - {% if perms.dcim.delete_poweroutlet %} -
- {% csrf_token %} - {% endif %} -
-
- Power Outlets -
- - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - {% for po in power_outlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% empty %} - - - - {% endfor %} - -
NameConnection
— No power outlets defined —
- -
- {% if perms.dcim.delete_poweroutlet %} -
- {% endif %} - {% endif %} -
-
+
+
{% include 'inc/graphs_modal.html' %} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} @@ -680,9 +751,9 @@ $(".interface-toggle").click(function() { $('button.toggle-ips').click(function() { var selected = $(this).attr('selected'); if (selected) { - $('#interfaces_table tr.ipaddress').hide(); + $('#interfaces_table tr.ipaddresses').hide(); } else { - $('#interfaces_table tr.ipaddress').show(); + $('#interfaces_table tr.ipaddresses').show(); } $(this).attr('selected', !selected); $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); diff --git a/netbox/templates/dcim/device_config.html b/netbox/templates/dcim/device_config.html index b62ff0211..210a9379a 100644 --- a/netbox/templates/dcim/device_config.html +++ b/netbox/templates/dcim/device_config.html @@ -1,11 +1,10 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% load staticfiles %} {% block title %}{{ device }} - Config{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='config' %}
diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 07206ca27..23e023c5c 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -77,6 +77,12 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 1db2dcefa..1efbd0fbc 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -1,77 +1,76 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% block title %}{{ device }} - Inventory{% endblock %} {% block content %} -{% include 'dcim/inc/device_header.html' with active_tab='inventory' %} -
-
-
-
- Chassis -
- - - - - - - - - - - - - -
Model{{ device.device_type.full_name }}
Serial Number - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} -
Asset Tag - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} -
-
-
-
-
-
- Hardware -
- - - - - - - - - - - - - - - {% for item in inventory_items %} - {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} - {% include template_name %} - {% endwith %} - {% endfor %} - -
NameManufacturerPart NumberSerial NumberAsset TagDescription
- {% if perms.dcim.add_inventoryitem %} - {% endblock %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index f96b27309..4bae11781 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index 0e423ad56..c0c82f459 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -1,10 +1,9 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% block title %}{{ device }} - LLDP Neighbors{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
LLDP Neighbors diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index 7c62b3971..7743cc635 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device_status.html @@ -1,11 +1,10 @@ -{% extends '_base.html' %} +{% extends 'dcim/device.html' %} {% load staticfiles %} {% block title %}{{ device }} - Status{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} - {% include 'dcim/inc/device_header.html' with active_tab='status' %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 12281734b..652c291e6 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,35 +1,47 @@ {% extends '_base.html' %} {% load helpers %} +{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %} + +{% block header %} +
+
+ +
+
+ {% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %} +
+ {% if perms.dcim.change_devicetype %} + + + Edit this device type + + {% endif %} + {% if perms.dcim.delete_devicetype %} + + + Delete this device type + + {% endif %} +
+ {% endif %} +

{{ devicetype.manufacturer }} {{ devicetype.model }}

+ {% include 'inc/created_updated.html' with obj=devicetype %} + +{% endblock %} + {% block content %} -
-
- -
-
- -{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %} -
- {% if perms.dcim.change_devicetype %} - - - Edit this device type - - {% endif %} - {% if perms.dcim.delete_devicetype %} - - - Delete this device type - - {% endif %} -
-{% endif %} - -

{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}

@@ -146,9 +158,8 @@
- {% with devicetype.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=devicetype %} + {% include 'extras/inc/tags_panel.html' with tags=devicetype.tags.all url='dcim:devicetype_list' %}
Comments diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index d2a107607..d0ed2c204 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -31,6 +31,12 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index 91745082a..eb901f5a0 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html deleted file mode 100644 index 92acd297d..000000000 --- a/netbox/templates/dcim/inc/device_header.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.dcim.change_device %} - - - Edit this device - - {% endif %} - {% if perms.dcim.delete_device %} - - - Delete this device - -{% endif %} -
-

{{ device }}

-{% include 'inc/created_updated.html' with obj=device %} - diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 33e30b126..6fd0bd52e 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -11,7 +11,7 @@ - {{ iface }} + {{ iface }} @@ -23,13 +23,10 @@ {# Description #} - {{ iface.description }} + {{ iface.description|default:"—" }} - {# MTU #} - {{ iface.mtu|default:"" }} - - {# MAC address #} - {{ iface.mac_address|default:"" }} + {# 802.1Q mode #} + {{ iface.get_mode_display }} {# Connection or type #} {% if iface.is_lag %} @@ -131,56 +128,82 @@ {% endif %} -{% for ip in iface.ip_addresses.all %} - - {# Placeholder #} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - {# IP address #} - - {{ ip }} - - - {# Primary, status, role #} - - {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} - Primary +{% with ipaddresses=iface.ip_addresses.all %} + {% if ipaddresses %} + + {# Placeholder #} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + {% endif %} - {{ ip.get_status_display }} - {% if ip.role %} - {{ ip.get_role_display }} - {% endif %} - - {# VRF #} - - {% if ip.vrf %} - {{ ip.vrf.name }} - {% else %} - Global - {% endif %} - + {# IP addresses table #} + + + + + + + + + + + + {% for ip in iface.ip_addresses.all %} + - {# Description #} - + {# IP address #} + - {# Buttons #} - - -{% endfor %} + {# Primary/status/role #} + + + {# VRF #} + + + {# Description #} + + + {# Buttons #} + + + + {% endfor %} +
IP AddressStatus/RoleVRFDescription
- {{ ip.description }} - + {{ ip }} + - {% if perms.ipam.change_ipaddress %} - - - - {% endif %} - {% if perms.ipam.delete_ipaddress %} - - - - {% endif %} -
+ {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} + Primary + {% endif %} + {{ ip.get_status_display }} + {% if ip.role %} + {{ ip.get_role_display }} + {% endif %} + + {% if ip.vrf %} + {{ ip.vrf.name }} + {% else %} + Global + {% endif %} + + {% if ip.description %} + {{ ip.description }} + {% else %} + + {% endif %} + + {% if perms.ipam.change_ipaddress %} + + + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} +
+ + + {% endif %} +{% endwith %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html new file mode 100644 index 000000000..2004af1b1 --- /dev/null +++ b/netbox/templates/dcim/interface.html @@ -0,0 +1,283 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+ {% if perms.dcim.change_interface %} + + Edit this interface + + {% endif %} + {% if perms.dcim.delete_interface %} + + Delete this interface + + {% endif %} +
+

{% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}

+ +{% endblock %} + +{% block content %} +
+
+
+
+ Interface +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% if interface.device %}Device{% else %}Virtual Machine{% endif %} + {{ interface.parent }} +
Name{{ interface.name }}
Type{{ interface.get_form_factor_display }}
Enabled + {% if interface.enabled %} + + {% else %} + + {% endif %} +
LAG + {% if interface.lag%} + {{ interface.lag }} + {% else %} + None + {% endif %} +
Description + {% if interface.description %} + {{ interface.description }} + {% else %} + N/A + {% endif %} +
MTU + {% if interface.mtu %} + {{ interface.mtu }} + {% else %} + N/A + {% endif %} +
MAC Address + {% if interface.mac_address %} + {{ interface.mac_address }} + {% else %} + N/A + {% endif %} +
802.1Q Mode{{ interface.get_mode_display }}
+
+ {% include 'extras/inc/tags_panel.html' with tags=interface.tags.all %} +
+
+ {% if interface.is_connectable %} +
+
+ Connected Interface +
+ {% if connected_interface %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if interface.connection %} + + + + + {% endif %} +
{% if connected_interface.device %}Device{% else %}Virtual Machine{% endif %} + {{ connected_interface.parent }} +
Name{{ connected_interface.name }}
Type{{ connected_interface.get_form_factor_display }}
Enabled + {% if connected_interface.enabled %} + + {% else %} + + {% endif %} +
LAG + {% if connected_interface.lag%} + {{ connected_interface.lag }} + {% else %} + None + {% endif %} +
Description + {% if connected_interface.description %} + {{ connected_interface.description }} + {% else %} + N/A + {% endif %} +
MTU + {% if connected_interface.mtu %} + {{ connected_interface.mtu }} + {% else %} + N/A + {% endif %} +
MAC Address + {% if connected_interface.mac_address %} + {{ connected_interface.mac_address }} + {% else %} + N/A + {% endif %} +
802.1Q Mode{{ connected_interface.get_mode_display }}
Connection Status + {% if interface.connection.connection_status %} + {{ interface.connection.get_connection_status_display }} + {% else %} + {{ interface.connection.get_connection_status_display }} + {% endif %} +
+ {% else %} +
+ No connected interface +
+ {% endif %} +
+
+
+ Circuit Termination +
+ + {% if interface.circuit_termination %} + + + + + + + + + {% else %} + + + + {% endif %} +
Circuit{{ interface.circuit_termination.circuit }}
Side{{ interface.circuit_termination.term_side }}
None
+
+ {% endif %} + {% if interface.is_lag %} +
+
LAG Members
+ + + + + + + + + + {% for member in interface.member_interfaces.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
ParentInterfaceType
+ {{ member.parent }} + + {{ member }} + + {{ member.get_form_factor_display }} +
No member interfaces
+
+ {% endif %} +
+
+
+
+ {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
+
+
+
+ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 0e212cf3e..6423c61c2 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -16,6 +16,12 @@ {% render_field form.mode %}
+
+
Tags
+
+ {% render_field form.tags %} +
+
{% if obj.mode %}
802.1Q VLANs
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 28a9dfb6f..aaebe02da 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,48 +1,59 @@ {% extends '_base.html' %} {% load helpers %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ + Previous Rack + + + Next Rack + + {% if perms.dcim.change_rack %} + + Edit this rack + + {% endif %} + {% if perms.dcim.delete_rack %} + + Delete this rack + + {% endif %} +
+

{% block title %}Rack {{ rack }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=rack %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- - Previous Rack - - - Next Rack - - {% if perms.dcim.change_rack %} - - Edit this rack - - {% endif %} - {% if perms.dcim.delete_rack %} - - Delete this rack - - {% endif %} -
-

{% block title %}Rack {{ rack }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=rack %}
@@ -147,9 +158,20 @@
- {% with rack.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=rack %} + {% include 'extras/inc/tags_panel.html' with tags=rack.tags.all url='dcim:rack_list' %} +
+
+ Comments +
+
+ {% if rack.comments %} + {{ rack.comments|gfm }} + {% else %} + None + {% endif %} +
+
Non-Racked Devices @@ -191,18 +213,6 @@
{% endif %}
-
-
- Comments -
-
- {% if rack.comments %} - {{ rack.comments|gfm }} - {% else %} - None - {% endif %} -
-
Images diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 4ab129a1d..d500a1954 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -37,6 +37,12 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html index d5734ee2b..e61f4eadf 100644 --- a/netbox/templates/dcim/rack_list.html +++ b/netbox/templates/dcim/rack_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 7797ebdf9..f4623b57b 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -3,54 +3,66 @@ {% load tz %} {% load helpers %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if show_graphs %} + + {% endif %} + {% if perms.dcim.change_site %} + + + Edit this site + + {% endif %} + {% if perms.dcim.delete_site %} + + + Delete this site + + {% endif %} +
+

{% block title %}{{ site }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=site %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if show_graphs %} - - {% endif %} - {% if perms.dcim.change_site %} - - - Edit this site - - {% endif %} - {% if perms.dcim.delete_site %} - - - Delete this site - - {% endif %} -
-

{% block title %}{{ site }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=site %}
@@ -165,6 +177,21 @@ {% endif %} + + GPS Coordinates + + {% if site.latitude and site.longitude %} + + {{ site.latitude }}, {{ site.longitude }} + {% else %} + N/A + {% endif %} + + Contact Name @@ -197,9 +224,8 @@
- {% with site.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=site %} + {% include 'extras/inc/tags_panel.html' with tags=site.tags.all url='dcim:site_list' %}
Comments diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index 399551434..5819b964f 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -27,6 +27,8 @@
{% render_field form.physical_address %} {% render_field form.shipping_address %} + {% render_field form.latitude %} + {% render_field form.longitude %} {% render_field form.contact_name %} {% render_field form.contact_phone %} {% render_field form.contact_email %} @@ -40,6 +42,12 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html index 7baa76dad..50066186d 100644 --- a/netbox/templates/dcim/site_list.html +++ b/netbox/templates/dcim/site_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html index e8d4f3366..3f1781d95 100644 --- a/netbox/templates/dcim/virtualchassis_list.html +++ b/netbox/templates/dcim/virtualchassis_list.html @@ -1,7 +1,11 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %} +
+ {% export_button content_type %} +

{% block title %}Virtual Chassis{% endblock %}

@@ -9,6 +13,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html new file mode 100644 index 000000000..c87ff9039 --- /dev/null +++ b/netbox/templates/extras/configcontext.html @@ -0,0 +1,171 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.extras.change_configcontext %} + + + Edit this config context + + {% endif %} +
+

{% block title %}{{ configcontext }}{% endblock %}

+{% endblock %} + +{% block content %} +
+
+
+
+ Config Context +
+ + + + + + + + + + + + + + + + + +
Name + {{ configcontext.name }} +
Weight + {{ configcontext.weight }} +
Description + {% if configcontext.description %} + {{ configcontext.description }} + {% else %} + N/A + {% endif %} +
Active + {% if configcontext.is_active %} + + + + {% else %} + + + + {% endif %} +
+
+
+
+ Assignment +
+ + + + + + + + + + + + + + + + + + + + + +
Regions + {% if configcontext.regions.all %} +
    + {% for region in configcontext.regions.all %} +
  • {{ region }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
Sites + {% if configcontext.sites.all %} +
    + {% for site in configcontext.sites.all %} +
  • {{ site }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
Roles + {% if configcontext.roles.all %} +
    + {% for role in configcontext.roles.all %} +
  • {{ role }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
Platforms + {% if configcontext.platforms.all %} +
    + {% for platform in configcontext.platforms.all %} +
  • {{ platform }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
Tenants + {% if configcontext.tenants.all %} +
    + {% for tenant in configcontext.tenants.all %} +
  • {{ tenant }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} +
+
+
+
+
+
+ Data +
+
+
{{ configcontext.data|render_json }}
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html new file mode 100644 index 000000000..7a3566a00 --- /dev/null +++ b/netbox/templates/extras/configcontext_edit.html @@ -0,0 +1,31 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Config Context
+
+ {% render_field form.name %} + {% render_field form.weight %} + {% render_field form.description %} + {% render_field form.is_active %} +
+
+
+
Assignment
+
+ {% render_field form.regions %} + {% render_field form.sites %} + {% render_field form.roles %} + {% render_field form.platforms %} + {% render_field form.tenant_groups %} + {% render_field form.tenants %} +
+
+
+
Data
+
+ {% render_field form.data %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html new file mode 100644 index 000000000..98913d987 --- /dev/null +++ b/netbox/templates/extras/configcontext_list.html @@ -0,0 +1,16 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% if perms.extras.add_configcontext %} + {% add_button 'extras:configcontext_add' %} + {% endif %} +
+

{% block title %}Config Contexts{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/inc/tags_panel.html b/netbox/templates/extras/inc/tags_panel.html new file mode 100644 index 000000000..257a1fc22 --- /dev/null +++ b/netbox/templates/extras/inc/tags_panel.html @@ -0,0 +1,13 @@ +{% load helpers %} +
+
+ Tags +
+
+ {% for tag in tags %} + {% tag tag url %} + {% empty %} + No tags assigned + {% endfor %} +
+
diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html new file mode 100644 index 000000000..ac79be2a6 --- /dev/null +++ b/netbox/templates/extras/object_changelog.html @@ -0,0 +1,8 @@ +{% extends base_template %} + +{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %} + +{% block content %} + {% if obj %}

{{ obj }}

{% endif %} + {% include 'panel_table.html' with table=objectchanges_table %} +{% endblock %} diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html new file mode 100644 index 000000000..81f8e1780 --- /dev/null +++ b/netbox/templates/extras/object_configcontext.html @@ -0,0 +1,42 @@ +{% extends base_template %} +{% load helpers %} + +{% block title %}{{ block.super }} - Config Context{% endblock %} + +{% block content %} +
+
+
+
+ Rendered Context +
+
+
{{ rendered_context|render_json }}
+
+
+
+
+
+
+ Source Contexts +
+ {% for context in source_contexts %} +
+
+ {{ context.weight }} +
+ {{ context.name }} + {% if context.description %} +
{{ context.description }} + {% endif %} +
{{ context.data|render_json }}
+
+ {% empty %} +
+ None found +
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html new file mode 100644 index 000000000..306aeeee0 --- /dev/null +++ b/netbox/templates/extras/objectchange.html @@ -0,0 +1,108 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ objectchange }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+{% endblock %} + +{% block content %} +
+
+
+
+ Change +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Time + {{ objectchange.time }} +
User + {{ objectchange.user|default:objectchange.user_name }} +
Action + {{ objectchange.get_action_display }} +
Object Type + {{ objectchange.changed_object_type }} +
Object + {% if objectchange.changed_object.get_absolute_url %} + {{ objectchange.changed_object }} + {% else %} + {{ objectchange.object_repr }} + {% endif %} +
Request ID + {{ objectchange.request_id }} +
+
+
+
+
+
+ Object Data +
+
+
{{ objectchange.object_data|render_json }}
+
+
+
+
+
+
+ {% include 'panel_table.html' with table=related_changes_table heading='Related Changes' %} + {% if related_changes_count > related_changes_table.rows|length %} + + {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/extras/objectchange_list.html new file mode 100644 index 000000000..46ddc1d94 --- /dev/null +++ b/netbox/templates/extras/objectchange_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Changelog{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/tag_list.html b/netbox/templates/extras/tag_list.html new file mode 100644 index 000000000..3136991a0 --- /dev/null +++ b/netbox/templates/extras/tag_list.html @@ -0,0 +1,11 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +

{% block title %}Tags{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %} +
+
+{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 46bfdbbd5..090be1350 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% block content %} {% include 'search_form.html' %} @@ -157,7 +158,7 @@
{% for result in report_results %} - + @@ -167,14 +168,32 @@ {% endif %}
- Recent Activity + Changelog
- {% for a in recent_activity %} -
- {{ a.icon }} {{ a.message|safe }}
- {{ a.user }} - {{ a.time|date:'SHORT_DATETIME_FORMAT' }} -
+ {% for change in changelog %} + {% with action=change.get_action_display|lower %} +
+ {% if action == 'created' %} + + {% elif action == 'updated' %} + + {% elif action == 'deleted' %} + + {% endif %} + {{ change.changed_object_type.name|bettertitle }} + {% if change.changed_object.get_absolute_url %} + {{ change.changed_object }} + {% else %} + {{ change.changed_object|default:change.object_repr }} + {% endif %} +
+ + {{ change.user|default:change.user_name }} - + {{ change.time|date:'SHORT_DATETIME_FORMAT' }} + +
+ {% endwith %} {% empty %}
Welcome to NetBox! {% if perms.add_site %} Add a site to get started.{% endif %} diff --git a/netbox/templates/import_success.html b/netbox/templates/import_success.html index 00aadfea7..dba525af5 100644 --- a/netbox/templates/import_success.html +++ b/netbox/templates/import_success.html @@ -8,6 +8,6 @@ Import more {% if return_url %} - View All + View All {% endif %} {% endblock %} diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 2ed10ee4d..3b82b8ee6 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -1,29 +1,31 @@ -{% if custom_fields %} -
-
- Custom Fields +{% with custom_fields=obj.custom_fields %} + {% if custom_fields %} +
+
+ Custom Fields +
+
{{ result.report }} {% include 'extras/inc/report_label.html' %}
+ {% for field, value in custom_fields.items %} + + + + + {% endfor %} +
{{ field }} + {% if field.type == 300 and value == True %} + + {% elif field.type == 300 and value == False %} + + {% elif field.type == 500 and value %} + {{ value|truncatechars:70 }} + {% elif field.type == 200 or value %} + {{ value }} + {% elif field.required %} + Not defined + {% else %} + N/A + {% endif %} +
- - {% for field, value in custom_fields.items %} - - - - - {% endfor %} -
{{ field }} - {% if field.type == 300 and value == True %} - - {% elif field.type == 300 and value == False %} - - {% elif field.type == 500 and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 200 or value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - N/A - {% endif %} -
-
-{% endif %} + {% endif %} +{% endwith %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index a85647993..aeddf1969 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -16,7 +16,7 @@ {% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index 73da9695d..33db74e5c 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -17,6 +17,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
Statistics diff --git a/netbox/templates/ipam/inc/prefix_header.html b/netbox/templates/ipam/inc/prefix_header.html deleted file mode 100644 index f3c694c64..000000000 --- a/netbox/templates/ipam/inc/prefix_header.html +++ /dev/null @@ -1,55 +0,0 @@ -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} - - Add Child Prefix - - {% endif %} - {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} - - - Add an IP Address - - {% endif %} - {% if perms.ipam.change_prefix %} - - - Edit this prefix - - {% endif %} - {% if perms.ipam.delete_prefix %} - - - Delete this prefix - - {% endif %} -
-

{{ prefix }}

-{% include 'inc/created_updated.html' with obj=prefix %} - diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html index a88aff4ce..c2999be68 100644 --- a/netbox/templates/ipam/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -1,5 +1,7 @@ - {{ service.name }} + + {{ service.name }} + {{ service.get_protocol_display }}/{{ service.port }} @@ -12,6 +14,9 @@ {{ service.description }} + + + {% if perms.ipam.change_service %} diff --git a/netbox/templates/ipam/inc/vlan_header.html b/netbox/templates/ipam/inc/vlan_header.html deleted file mode 100644 index bf5d4ccdd..000000000 --- a/netbox/templates/ipam/inc/vlan_header.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.ipam.change_vlan %} - - - Edit this VLAN - - {% endif %} - {% if perms.ipam.delete_vlan %} - - - Delete this VLAN - - {% endif %} -
-

{% block title %}VLAN {{ vlan.display_name }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=vlan %} - diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 24889972b..a8501c8a0 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -1,45 +1,57 @@ {% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.change_ipaddress %} + + + Edit this IP + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + Delete this IP + + {% endif %} +
+

{% block title %}{{ ipaddress }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=ipaddress %} + +{% endblock %} {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.ipam.change_ipaddress %} - - - Edit this IP - - {% endif %} - {% if perms.ipam.delete_ipaddress %} - - - Delete this IP - - {% endif %} -
-

{% block title %}{{ ipaddress }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=ipaddress %}
@@ -136,9 +148,8 @@
- {% with ipaddress.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=ipaddress %} + {% include 'extras/inc/tags_panel.html' with tags=ipaddress.tags.all url='ipam:ipaddress_list' %}
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d0dad69ee..72fc02a1e 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -66,6 +66,12 @@ {% render_field form.nat_inside %}
+
+
Tags
+
+ {% render_field form.tags %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html index 5f8fdeb88..418b807bd 100644 --- a/netbox/templates/ipam/ipaddress_list.html +++ b/netbox/templates/ipam/ipaddress_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 1b23284f4..f621a0130 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -1,135 +1,197 @@ {% extends '_base.html' %} {% load helpers %} -{% block title %}{{ prefix }}{% endblock %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} + + Add Child Prefix + + {% endif %} + {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} + + + Add an IP Address + + {% endif %} + {% if perms.ipam.change_prefix %} + + + Edit this prefix + + {% endif %} + {% if perms.ipam.delete_prefix %} + + + Delete this prefix + + {% endif %} +
+

{% block title %}{{ prefix }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=prefix %} + +{% endblock %} {% block content %} -{% include 'ipam/inc/prefix_header.html' with active_tab='prefix' %} -
-
-
-
- Prefix +
+
+
+
+ Prefix +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Family{{ prefix.get_family_display }}
VRF + {% if prefix.vrf %} + {{ prefix.vrf }} ({{ prefix.vrf.rd }}) + {% else %} + Global + {% endif %} +
Tenant + {% if prefix.tenant %} + {% if prefix.tenant.group %} + {{ prefix.tenant.group }} + + {% endif %} + {{ prefix.tenant }} + {% else %} + None + {% endif %} +
Aggregate + {% if aggregate %} + {{ aggregate.prefix }} ({{ aggregate.rir }}) + {% else %} + None + {% endif %} +
Site + {% if prefix.site %} + {% if prefix.site.region %} + {{ prefix.site.region }} + + {% endif %} + {{ prefix.site }} + {% else %} + None + {% endif %} +
VLAN + {% if prefix.vlan %} + {% if prefix.vlan.group %} + {{ prefix.vlan.group }} + + {% endif %} + {{ prefix.vlan.display_name }} + {% else %} + None + {% endif %} +
Status + {{ prefix.get_status_display }} +
Role + {% if prefix.role %} + {{ prefix.role }} + {% else %} + None + {% endif %} +
Description + {% if prefix.description %} + {{ prefix.description }} + {% else %} + N/A + {% endif %} +
Is a pool + {% if prefix.is_pool %} + + {% else %} + + {% endif %} +
Utilization{% utilization_graph prefix.get_utilization %}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Family{{ prefix.get_family_display }}
VRF - {% if prefix.vrf %} - {{ prefix.vrf }} ({{ prefix.vrf.rd }}) - {% else %} - Global - {% endif %} -
Tenant - {% if prefix.tenant %} - {% if prefix.tenant.group %} - {{ prefix.tenant.group }} - - {% endif %} - {{ prefix.tenant }} - {% else %} - None - {% endif %} -
Aggregate - {% if aggregate %} - {{ aggregate.prefix }} ({{ aggregate.rir }}) - {% else %} - None - {% endif %} -
Site - {% if prefix.site %} - {% if prefix.site.region %} - {{ prefix.site.region }} - - {% endif %} - {{ prefix.site }} - {% else %} - None - {% endif %} -
VLAN - {% if prefix.vlan %} - {% if prefix.vlan.group %} - {{ prefix.vlan.group }} - - {% endif %} - {{ prefix.vlan.display_name }} - {% else %} - None - {% endif %} -
Status - {{ prefix.get_status_display }} -
Role - {% if prefix.role %} - {{ prefix.role }} - {% else %} - None - {% endif %} -
Description - {% if prefix.description %} - {{ prefix.description }} - {% else %} - N/A - {% endif %} -
Is a pool - {% if prefix.is_pool %} - - {% else %} - - {% endif %} -
Utilization{% utilization_graph prefix.get_utilization %}
+ {% include 'inc/custom_fields_panel.html' with obj=prefix %} + {% include 'extras/inc/tags_panel.html' with tags=prefix.tags.all url='ipam:prefix_list' %}
- {% with prefix.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} -
-
-
- {% if duplicate_prefix_table.rows %} - {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} - {% endif %} - {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %} -
-
+
+ {% if duplicate_prefix_table.rows %} + {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %} + {% endif %} + {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html index 938a75da3..401a53e38 100644 --- a/netbox/templates/ipam/prefix_edit.html +++ b/netbox/templates/ipam/prefix_edit.html @@ -36,4 +36,10 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/prefix_ipaddresses.html b/netbox/templates/ipam/prefix_ipaddresses.html index 02e90569d..1da5b7518 100644 --- a/netbox/templates/ipam/prefix_ipaddresses.html +++ b/netbox/templates/ipam/prefix_ipaddresses.html @@ -1,9 +1,8 @@ -{% extends '_base.html' %} +{% extends 'ipam/prefix.html' %} -{% block title %}{{ prefix }} - IP Addresses{% endblock %} +{% block title %}{{ block.super }} - IP Addresses{% endblock %} {% block content %} - {% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %}
{% include 'utilities/obj_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index d65904595..3ce9d4a9c 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -21,6 +21,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/ipam/prefix_prefixes.html b/netbox/templates/ipam/prefix_prefixes.html index 2535b672d..9cf50a640 100644 --- a/netbox/templates/ipam/prefix_prefixes.html +++ b/netbox/templates/ipam/prefix_prefixes.html @@ -1,9 +1,8 @@ -{% extends '_base.html' %} +{% extends 'ipam/prefix.html' %} -{% block title %}{{ prefix }} - Prefixes{% endblock %} +{% block title %}{{ block.super }} - Prefixes{% endblock %} {% block content %} - {% include 'ipam/inc/prefix_header.html' with active_tab='prefixes' %}
{% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html new file mode 100644 index 000000000..a0f1e57a5 --- /dev/null +++ b/netbox/templates/ipam/service.html @@ -0,0 +1,87 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+{% if perms.dcim.change_service %} + +{% endif %} +

{% block title %}{{ service }}{% endblock %}

+{% include 'inc/created_updated.html' with obj=service %} +
+
+
+
+ Service +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ service.name }}
Parent + {{ service.parent }} +
Protocol{{ service.get_protocol_display }}
Port{{ service.port }}
IP Addresses + {% for ipaddress in service.ipaddresses.all %} + {{ ipaddress }}
+ {% empty %} + None + {% endfor %} +
Description + {% if service.description %} + {{ service.description }} + {% else %} + N/A + {% endif %} +
+
+ {% include 'inc/custom_fields_panel.html' with obj=service %} + {% include 'extras/inc/tags_panel.html' with tags=service.tags.all url='ipam:service_list' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index e1db968dd..521aec137 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -32,4 +32,18 @@ {% render_field form.description %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html new file mode 100644 index 000000000..d2e67a000 --- /dev/null +++ b/netbox/templates/ipam/service_list.html @@ -0,0 +1,18 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Services{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:service_bulk_edit' bulk_delete_url='ipam:service_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 971c3359f..73e87149b 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -1,107 +1,162 @@ {% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.change_vlan %} + + + Edit this VLAN + + {% endif %} + {% if perms.ipam.delete_vlan %} + + + Delete this VLAN + + {% endif %} +
+

{% block title %}VLAN {{ vlan.display_name }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=vlan %} + +{% endblock %} {% block content %} -{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %} -
-
-
-
- VLAN -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Site - {% if vlan.site %} - {% if vlan.site.region %} - {{ vlan.site.region }} - - {% endif %} - {{ vlan.site }} - {% else %} - None - {% endif %} -
Group - {% if vlan.group %} - {{ vlan.group }} - {% else %} - None - {% endif %} -
VLAN ID{{ vlan.vid }}
Name{{ vlan.name }}
Tenant - {% if vlan.tenant %} - {% if vlan.tenant.group %} - {{ vlan.tenant.group }} - - {% endif %} - {{ vlan.tenant }} - {% else %} - None - {% endif %} -
Status - {{ vlan.get_status_display }} -
Role - {% if vlan.role %} - {{ vlan.role }} - {% else %} - None - {% endif %} -
Description - {% if vlan.description %} - {{ vlan.description }} - {% else %} - N/A - {% endif %} -
-
- {% with vlan.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} -
-
-
-
- Prefixes -
- {% include 'responsive_table.html' with table=prefix_table %} - {% if perms.ipam.add_prefix %} - +
+
+
+ Prefixes +
+ {% include 'responsive_table.html' with table=prefix_table %} + {% if perms.ipam.add_prefix %} + + {% endif %} +
+
+
{% endblock %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html index 3bfb7783e..1c191343d 100644 --- a/netbox/templates/ipam/vlan_edit.html +++ b/netbox/templates/ipam/vlan_edit.html @@ -29,4 +29,10 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html index 24e12595b..d734db8d2 100644 --- a/netbox/templates/ipam/vlan_list.html +++ b/netbox/templates/ipam/vlan_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/ipam/vlan_members.html b/netbox/templates/ipam/vlan_members.html index 27d5d50f7..9fc803e09 100644 --- a/netbox/templates/ipam/vlan_members.html +++ b/netbox/templates/ipam/vlan_members.html @@ -1,9 +1,8 @@ -{% extends '_base.html' %} +{% extends 'ipam/vlan.html' %} -{% block title %}{{ vlan }} - Members{% endblock %} +{% block title %}{{ block.super }} - Members{% endblock %} {% block content %} - {% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index e041ce73a..f75b8c7d1 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -1,42 +1,54 @@ {% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.change_vrf %} + + + Edit this VRF + + {% endif %} + {% if perms.ipam.delete_vrf %} + + + Delete this VRF + + {% endif %} +
+

{% block title %}VRF {{ vrf }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=vrf %} + +{% endblock %} {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.ipam.change_vrf %} - - - Edit this VRF - - {% endif %} - {% if perms.ipam.delete_vrf %} - - - Delete this VRF - - {% endif %} -
-

{% block title %}VRF {{ vrf }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=vrf %}
@@ -80,9 +92,8 @@
- {% with vrf.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=vrf %} + {% include 'extras/inc/tags_panel.html' with tags=vrf.tags.all url='ipam:vrf_list' %}
diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html index 63052129c..a2ff51d9b 100644 --- a/netbox/templates/ipam/vrf_edit.html +++ b/netbox/templates/ipam/vrf_edit.html @@ -26,4 +26,10 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
{% endblock %} diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html index 23bd16495..670f0ee5d 100644 --- a/netbox/templates/ipam/vrf_list.html +++ b/netbox/templates/ipam/vrf_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 66c844ebf..940a87157 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -1,33 +1,45 @@ {% extends '_base.html' %} {% load static from staticfiles %} +{% load helpers %} {% load secret_helpers %} -{% block content %} -
-
- +{% block header %} +
+
+ +
-
-
- {% if perms.secrets.change_secret %} - - - Edit this secret - - {% endif %} - {% if perms.secrets.delete_secret %} - - - Delete this secret - - {% endif %} -
-

{% block title %}{{ secret }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=secret %} +
+ {% if perms.secrets.change_secret %} + + + Edit this secret + + {% endif %} + {% if perms.secrets.delete_secret %} + + + Delete this secret + + {% endif %} +
+

{% block title %}{{ secret }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=secret %} + +{% endblock %} + +{% block content %}
@@ -57,6 +69,7 @@
+ {% include 'inc/custom_fields_panel.html' with obj=secret %}
{% if secret|decryptable_by:request.user %} @@ -88,6 +101,7 @@ You do not have permission to decrypt this secret.
{% endif %} + {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %}
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 920409177..2d2fc4644 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -54,6 +54,20 @@ {% render_field form.plaintext2 %}
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html index 6dd92cd89..a41c0b4ee 100644 --- a/netbox/templates/secrets/secret_list.html +++ b/netbox/templates/secrets/secret_list.html @@ -6,6 +6,7 @@ {% if perms.secrets.add_secret %} {% import_button 'secrets:secret_import' %} {% endif %} + {% export_button content_type %}

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

@@ -14,6 +15,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index d5eb7df98..6f2131a51 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -1,46 +1,57 @@ {% extends '_base.html' %} {% load helpers %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.tenancy.change_tenant %} + + + Edit this tenant + + {% endif %} + {% if perms.tenancy.delete_tenant %} + + + Delete this tenant + + {% endif %} +
+

{% block title %}{{ tenant }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=tenant %} + +{% endblock %} + {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.tenancy.change_tenant %} - - - Edit this tenant - - {% endif %} - {% if perms.tenancy.delete_tenant %} - - - Delete this tenant - - {% endif %} -
-

{% block title %}{{ tenant }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=tenant %}
@@ -70,9 +81,8 @@
- {% with tenant.get_custom_fields as custom_fields %} - {% include 'inc/custom_fields_panel.html' %} - {% endwith %} + {% include 'inc/custom_fields_panel.html' with obj=tenant %} + {% include 'extras/inc/tags_panel.html' with tags=tenant.tags.all url='tenancy:tenant_list' %}
Comments diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html index b2c472a1c..31bc73f3e 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -20,6 +20,12 @@
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html index e6fd61c37..176231507 100644 --- a/netbox/templates/tenancy/tenant_list.html +++ b/netbox/templates/tenancy/tenant_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index ced3ee733..89621a3c3 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -22,7 +22,7 @@
{% if return_url %} - Cancel + Cancel {% endif %}
diff --git a/netbox/templates/utilities/templatetags/tag.html b/netbox/templates/utilities/templatetags/tag.html new file mode 100644 index 000000000..09b885d42 --- /dev/null +++ b/netbox/templates/utilities/templatetags/tag.html @@ -0,0 +1,5 @@ +{% if url_name %} + {{ tag }} +{% else %} + {{ tag }} +{% endif %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 08251e2fa..69ed4e212 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -1,46 +1,57 @@ {% extends '_base.html' %} {% load helpers %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% 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 %} + +{% endblock %} + {% 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 %}
@@ -82,7 +93,8 @@
- {% include 'inc/custom_fields_panel.html' with custom_fields=cluster.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=cluster %} + {% include 'extras/inc/tags_panel.html' with tags=cluster.tags.all url='virtualization:cluster_list' %}
Comments diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html new file mode 100644 index 000000000..629c779ec --- /dev/null +++ b/netbox/templates/virtualization/cluster_edit.html @@ -0,0 +1,34 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Cluster
+
+ {% render_field form.name %} + {% render_field form.type %} + {% render_field form.group %} + {% render_field form.site %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+{% endblock %} diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html index 08f62e6ba..84513dbb1 100644 --- a/netbox/templates/virtualization/cluster_list.html +++ b/netbox/templates/virtualization/cluster_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 944792705..97502af5c 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -1,45 +1,59 @@ {% extends '_base.html' %} {% load helpers %} +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.virtualization.change_virtualmachine %} + + + Edit this VM + + {% endif %} + {% if perms.virtualization.delete_virtualmachine %} + + + Delete this VM + + {% endif %} +
+

{% block title %}{{ virtualmachine }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=virtualmachine %} + +{% endblock %} + {% 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 %}
@@ -49,19 +63,19 @@ - +
Name{{ vm }}{{ virtualmachine }}
Status - {{ vm.get_status_display }} + {{ virtualmachine.get_status_display }}
Role - {% if vm.role %} - {{ vm.role }} + {% if virtualmachine.role %} + {{ virtualmachine.role }} {% else %} None {% endif %} @@ -70,8 +84,8 @@
Platform - {% if vm.platform %} - {{ vm.platform }} + {% if virtualmachine.platform %} + {{ virtualmachine.platform }} {% else %} None {% endif %} @@ -80,12 +94,12 @@
Tenant - {% if vm.tenant %} - {% if vm.tenant.group %} - {{ vm.tenant.group }} + {% if virtualmachine.tenant %} + {% if virtualmachine.tenant.group %} + {{ virtualmachine.tenant.group }} {% endif %} - {{ vm.tenant }} + {{ virtualmachine.tenant }} {% else %} None {% endif %} @@ -94,12 +108,12 @@
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 }}) + {% if virtualmachine.primary_ip4 %} + {{ virtualmachine.primary_ip4.address.ip }} + {% if virtualmachine.primary_ip4.nat_inside %} + (NAT for {{ virtualmachine.primary_ip4.nat_inside.address.ip }}) + {% elif virtualmachine.primary_ip4.nat_outside %} + (NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} N/A @@ -109,12 +123,12 @@
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 }}) + {% if virtualmachine.primary_ip6 %} + {{ virtualmachine.primary_ip6.address.ip }} + {% if virtualmachine.primary_ip6.nat_inside %} + (NAT for {{ virtualmachine.primary_ip6.nat_inside.address.ip }}) + {% elif virtualmachine.primary_ip6.nat_outside %} + (NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} N/A @@ -123,14 +137,15 @@
- {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %} + {% include 'inc/custom_fields_panel.html' with obj=virtualmachine %} + {% include 'extras/inc/tags_panel.html' with tags=virtualmachine.tags.all url='virtualization:virtualmachine_list' %}
Comments
- {% if vm.comments %} - {{ vm.comments|gfm }} + {% if virtualmachine.comments %} + {{ virtualmachine.comments|gfm }} {% else %} None {% endif %} @@ -146,16 +161,16 @@ Cluster - {% if vm.cluster.group %} - {{ vm.cluster.group }} + {% if virtualmachine.cluster.group %} + {{ virtualmachine.cluster.group }} {% endif %} - {{ vm.cluster }} + {{ virtualmachine.cluster }} Cluster Type - {{ vm.cluster.type }} + {{ virtualmachine.cluster.type }}
@@ -167,8 +182,8 @@ Virtual CPUs - {% if vm.vcpus %} - {{ vm.vcpus }} + {% if virtualmachine.vcpus %} + {{ virtualmachine.vcpus }} {% else %} N/A {% endif %} @@ -177,8 +192,8 @@ Memory - {% if vm.memory %} - {{ vm.memory }} MB + {% if virtualmachine.memory %} + {{ virtualmachine.memory }} MB {% else %} N/A {% endif %} @@ -187,8 +202,8 @@ Disk Space - {% if vm.disk %} - {{ vm.disk }} GB + {% if virtualmachine.disk %} + {{ virtualmachine.disk }} GB {% else %} N/A {% endif %} @@ -213,7 +228,7 @@ {% endif %} {% if perms.ipam.add_service %} @@ -226,7 +241,7 @@ {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
{% csrf_token %} - + {% endif %}
@@ -246,18 +261,17 @@ Name LAG Description - MTU - MAC Address + Mode Connection {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' with device=vm %} + {% include 'dcim/inc/interface.html' with device=virtualmachine %} {% empty %} - — No interfaces defined — + — No interfaces defined — {% endfor %} @@ -265,21 +279,21 @@ {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
{% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
Comments
diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 30ed76dae..bf2961fd8 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %}
{% endblock %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 454e41c52..592e35a6e 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,10 +1,11 @@ from __future__ import unicode_literals from rest_framework import serializers +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import ValidatedModelSerializer +from utilities.api import ValidatedModelSerializer, WritableNestedSerializer # @@ -18,7 +19,7 @@ class TenantGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedTenantGroupSerializer(serializers.ModelSerializer): +class NestedTenantGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') class Meta: @@ -30,24 +31,21 @@ class NestedTenantGroupSerializer(serializers.ModelSerializer): # Tenants # -class TenantSerializer(CustomFieldModelSerializer): - group = NestedTenantGroupSerializer() +class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): + group = NestedTenantGroupSerializer(required=False) + tags = TagListSerializerField(required=False) class Meta: model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] + fields = [ + 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', + ] -class NestedTenantSerializer(serializers.ModelSerializer): +class NestedTenantSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') class Meta: model = Tenant fields = ['id', 'url', 'name', 'slug'] - - -class WritableTenantSerializer(CustomFieldModelSerializer): - - class Meta: - model = Tenant - fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 26f9bc71e..1ebd95500 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -32,5 +32,4 @@ class TenantGroupViewSet(ModelViewSet): class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer - write_serializer_class = serializers.WritableTenantSerializer filter_class = filters.TenantFilter diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 330ab7f56..7eccff5d3 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -31,6 +31,9 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Group (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Tenant diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 4ea6c57ba..b90934923 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,8 +2,9 @@ from __future__ import unicode_literals from django import forms from django.db.models import Count +from taggit.forms import TagField -from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import ( APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField, ) @@ -40,10 +41,11 @@ class TenantGroupCSVForm(forms.ModelForm): class TenantForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments'] + fields = ['name', 'slug', 'group', 'description', 'comments', 'tags'] class TenantCSVForm(forms.ModelForm): @@ -67,7 +69,7 @@ class TenantCSVForm(forms.ModelForm): } -class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False) diff --git a/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py new file mode 100644 index 000000000..d4258f4dc --- /dev/null +++ b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('tenancy', '0002_tenant_group_optional'), ('tenancy', '0003_unicode_literals')] + + dependencies = [ + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenants', to='tenancy.TenantGroup'), + ), + migrations.AlterField( + model_name='tenant', + name='description', + field=models.CharField(blank=True, help_text='Long-form name (optional)', max_length=100), + ), + ] diff --git a/netbox/tenancy/migrations/0004_tags.py b/netbox/tenancy/migrations/0004_tags.py new file mode 100644 index 000000000..5cb9398b5 --- /dev/null +++ b/netbox/tenancy/migrations/0004_tags.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:04 +from __future__ import unicode_literals + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('tenancy', '0003_unicode_literals'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/netbox/tenancy/migrations/0005_change_logging.py b/netbox/tenancy/migrations/0005_change_logging.py new file mode 100644 index 000000000..7712e9d02 --- /dev/null +++ b/netbox/tenancy/migrations/0005_change_logging.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_tags'), + ] + + operations = [ + migrations.AddField( + model_name='tenantgroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='tenantgroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='tenant', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='tenant', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 1fea2ceaf..5a22143d3 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -4,18 +4,24 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible +from taggit.managers import TaggableManager -from extras.models import CustomFieldModel, CustomFieldValue -from utilities.models import CreatedUpdatedModel +from extras.models import CustomFieldModel +from utilities.models import ChangeLoggedModel @python_2_unicode_compatible -class TenantGroup(models.Model): +class TenantGroup(ChangeLoggedModel): """ An arbitrary collection of Tenants. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) csv_headers = ['name', 'slug'] @@ -36,17 +42,40 @@ class TenantGroup(models.Model): @python_2_unicode_compatible -class Tenant(CreatedUpdatedModel, CustomFieldModel): +class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. """ - name = models.CharField(max_length=30, unique=True) - slug = models.SlugField(unique=True) - group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL) - description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + name = models.CharField( + max_length=30, + unique=True + ) + slug = models.SlugField( + unique=True + ) + group = models.ForeignKey( + to='tenancy.TenantGroup', + on_delete=models.SET_NULL, + related_name='tenants', + blank=True, + null=True + ) + description = models.CharField( + max_length=100, + blank=True, + help_text='Long-form name (optional)' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager() csv_headers = ['name', 'slug', 'group', 'description', 'comments'] diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index b3c67e9e2..2e763591a 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn from .models import Tenant, TenantGroup TENANTGROUP_ACTIONS = """ + + + {% if perms.tenancy.change_tenantgroup %} {% endif %} diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index f1238eddb..95e1a6de3 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,22 +1,17 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase from tenancy.models import Tenant, TenantGroup -from users.models import Token -from utilities.tests import HttpStatusMixin +from utilities.testing import APITestCase -class TenantGroupTest(HttpStatusMixin, APITestCase): +class TenantGroupTest(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)} + super(TenantGroupTest, self).setUp() self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') @@ -103,13 +98,11 @@ class TenantGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(TenantGroup.objects.count(), 2) -class TenantTest(HttpStatusMixin, APITestCase): +class TenantTest(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)} + super(TenantTest, self).setUp() self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 668b194f0..2da03b7f5 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -2,7 +2,9 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from . import views +from .models import Tenant, TenantGroup app_name = 'tenancy' urlpatterns = [ @@ -13,6 +15,7 @@ urlpatterns = [ 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'), + url(r'^tenant-groups/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'), @@ -23,5 +26,6 @@ urlpatterns = [ url(r'^tenants/(?P[\w-]+)/$', views.TenantView.as_view(), name='tenant'), url(r'^tenants/(?P[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'), url(r'^tenants/(?P[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'), + url(r'^tenants/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 9020a8c19..fdb453665 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count, Q from django.shortcuts import get_object_or_404, render -from django.urls import reverse from django.views.generic import View from circuits.models import Circuit @@ -31,9 +30,7 @@ class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'tenancy.add_tenantgroup' model = TenantGroup model_form = forms.TenantGroupForm - - def get_return_url(self, request, obj): - return reverse('tenancy:tenantgroup_list') + default_return_url = 'tenancy:tenantgroup_list' class TenantGroupEditView(TenantGroupCreateView): @@ -49,7 +46,6 @@ class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenantgroup' - cls = TenantGroup queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable default_return_url = 'tenancy:tenantgroup_list' @@ -118,7 +114,6 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'tenancy.change_tenant' - cls = Tenant queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter table = tables.TenantTable @@ -128,7 +123,6 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenant' - cls = Tenant queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter table = tables.TenantTable diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 80f79516c..861bdade9 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,10 +1,11 @@ from __future__ import unicode_literals from django.contrib.auth.models import User -from rest_framework import serializers + +from utilities.api import WritableNestedSerializer -class NestedUserSerializer(serializers.ModelSerializer): +class NestedUserSerializer(WritableNestedSerializer): class Meta: model = User diff --git a/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py new file mode 100644 index 000000000..54a6078a0 --- /dev/null +++ b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-08-01 17:43 +from __future__ import unicode_literals + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('users', '0001_api_tokens'), ('users', '0002_unicode_literals')] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('expires', models.DateTimeField(blank=True, null=True)), + ('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])), + ('write_enabled', models.BooleanField(default=True, help_text='Permit create/update/delete operations using this key')), + ('description', models.CharField(blank=True, max_length=100)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_permissions': [], + }, + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 02f5bc0a0..b3698d925 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -16,12 +16,31 @@ class Token(models.Model): An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. """ - user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE) - created = models.DateTimeField(auto_now_add=True) - expires = models.DateTimeField(blank=True, null=True) - key = models.CharField(max_length=40, unique=True, validators=[MinLengthValidator(40)]) - write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") - description = models.CharField(max_length=100, blank=True) + user = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + related_name='tokens' + ) + created = models.DateTimeField( + auto_now_add=True + ) + expires = models.DateTimeField( + blank=True, + null=True + ) + key = models.CharField( + max_length=40, + unique=True, + validators=[MinLengthValidator(40)] + ) + write_enabled = models.BooleanField( + default=True, + help_text='Permit create/update/delete operations using this key' + ) + description = models.CharField( + max_length=100, + blank=True + ) class Meta: default_permissions = [] diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 5c78dacc4..0ce207d6e 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -5,16 +5,17 @@ import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.db.models import ManyToManyField from django.http import Http404 -from rest_framework import mixins from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission +from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError -from rest_framework.viewsets import GenericViewSet, ViewSet +from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet -WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] +from .utils import dynamic_import class ServiceUnavailable(APIException): @@ -22,6 +23,20 @@ class ServiceUnavailable(APIException): default_detail = "Service temporarily unavailable, please try again later." +def get_serializer_for_model(model, prefix=''): + """ + Dynamically resolve and return the appropriate serializer for a model. + """ + app_name, model_name = model._meta.label.split('.') + serializer_name = '{}.api.serializers.{}{}Serializer'.format( + app_name, prefix, model_name + ) + try: + return dynamic_import(serializer_name) + except AttributeError: + return None + + # # Authentication # @@ -33,41 +48,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): def has_permission(self, request, view): if not settings.LOGIN_REQUIRED: return True - return request.user.is_authenticated() + return request.user.is_authenticated # -# Serializers +# Fields # -class ValidatedModelSerializer(ModelSerializer): - """ - Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. - """ - def validate(self, data): - - # Remove custom field data (if any) prior to model validation - attrs = data.copy() - attrs.pop('custom_fields', None) - - # Run clean() on an instance of the model - if self.instance is None: - model = self.Meta.model - # Ignore ManyToManyFields for new instances (a PK is needed for validation) - for field in model._meta.get_fields(): - if isinstance(field, ManyToManyField) and field.name in attrs: - attrs.pop(field.name) - instance = self.Meta.model(**attrs) - else: - instance = self.instance - for k, v in attrs.items(): - setattr(instance, k, v) - instance.clean() - - return data - - -class ChoiceFieldSerializer(Field): +class ChoiceField(Field): """ Represent a ChoiceField as {'value': , 'label': }. """ @@ -80,16 +68,16 @@ class ChoiceFieldSerializer(Field): self._choices[k2] = v2 else: self._choices[k] = v - super(ChoiceFieldSerializer, self).__init__(**kwargs) + super(ChoiceField, self).__init__(**kwargs) def to_representation(self, obj): return {'value': obj, 'label': self._choices[obj]} def to_internal_value(self, data): - return self._choices.get(data) + return data -class ContentTypeFieldSerializer(Field): +class ContentTypeField(Field): """ Represent a ContentType as '.' """ @@ -108,7 +96,6 @@ class TimeZoneField(Field): """ Represent a pytz time zone. """ - def to_representation(self, obj): return obj.zone if obj else None @@ -121,31 +108,81 @@ class TimeZoneField(Field): raise ValidationError('Invalid time zone "{}"'.format(data)) +class SerializedPKRelatedField(PrimaryKeyRelatedField): + """ + Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related + objects in a ManyToManyField while still allowing a set of primary keys to be written. + """ + def __init__(self, serializer, **kwargs): + self.serializer = serializer + self.pk_field = kwargs.pop('pk_field', None) + super(SerializedPKRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + return self.serializer(value, context={'request': self.context['request']}).data + + +# +# Serializers +# + +# TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant +# way to enforce model validation on the serializer. +class ValidatedModelSerializer(ModelSerializer): + """ + Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. + """ + def validate(self, data): + + # Remove custom fields data and tags (if any) prior to model validation + attrs = data.copy() + attrs.pop('custom_fields', None) + attrs.pop('tags', None) + + # Skip ManyToManyFields + for field in self.Meta.model._meta.get_fields(): + if isinstance(field, ManyToManyField): + attrs.pop(field.name, None) + + # Run clean() on an instance of the model + if self.instance is None: + instance = self.Meta.model(**attrs) + else: + instance = self.instance + for k, v in attrs.items(): + setattr(instance, k, v) + instance.clean() + + return data + + +class WritableNestedSerializer(ModelSerializer): + """ + Returns a nested representation of an object on read, but accepts only a primary key on write. + """ + def to_internal_value(self, data): + if data is None: + return None + try: + return self.Meta.model.objects.get(pk=data) + except ObjectDoesNotExist: + raise ValidationError("Invalid ID") + + # # Viewsets # -class ModelViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - GenericViewSet): +class ModelViewSet(_ModelViewSet): """ - Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality: - 1. Use an alternate serializer (if provided) for write operations - 2. Accept either a single object or a list of objects to create + Accept either a single object or a list of objects to create. """ - def get_serializer_class(self): - # Check for a different serializer to use for write operations - if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): - return self.write_serializer_class - return self.serializer_class - def get_serializer(self, *args, **kwargs): + # If a list of objects has been provided, initialize the serializer with many=True if isinstance(kwargs.get('data', {}), list): kwargs['many'] = True + return super(ModelViewSet, self).get_serializer(*args, **kwargs) diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index 58c8641ec..dab35e982 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -4,6 +4,9 @@ from django.conf import settings as django_settings def settings(request): + """ + Expose Django settings in the template context. Example: {{ settings.DEBUG }} + """ return { 'settings': django_settings, } diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index b97506b85..ca6e08fc1 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -3,7 +3,7 @@ from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, from rest_framework.fields import ChoiceField from extras.api.customfields import CustomFieldsSerializer -from utilities.api import ChoiceFieldSerializer +from utilities.api import ChoiceField class CustomChoiceFieldInspector(FieldInspector): @@ -12,7 +12,7 @@ class CustomChoiceFieldInspector(FieldInspector): # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - if isinstance(field, ChoiceFieldSerializer): + if isinstance(field, ChoiceField): value_schema = openapi.Schema(type=openapi.TYPE_INTEGER) choices = list(field._choices.keys()) diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index d0a0c2180..34f59fe16 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -5,7 +5,12 @@ from django.db import models from .forms import ColorSelect -validate_color = RegexValidator('^[0-9a-f]{6}$', 'Enter a valid hexadecimal RGB color code.', 'invalid') + +ColorValidator = RegexValidator( + regex='^[0-9a-f]{6}$', + message='Enter a valid hexadecimal RGB color code.', + code='invalid' +) class NullableCharField(models.CharField): @@ -21,7 +26,7 @@ class NullableCharField(models.CharField): class ColorField(models.CharField): - default_validators = [validate_color] + default_validators = [ColorValidator] description = "A hexadecimal RGB color code" def __init__(self, *args, **kwargs): diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 3e403e676..90cdcd9fc 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -19,6 +19,9 @@ class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): class NullableCharFieldFilter(django_filters.CharFilter): + """ + Allow matching on null field values by passing a special string used to signify NULL. + """ null_value = 'NULL' def filter(self, qs, value): diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1cea0b0da..1e6e3c0c4 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -6,6 +6,7 @@ import re from django import forms from django.conf import settings +from django.contrib.postgres.forms import JSONField as _JSONField from django.db.models import Count from django.urls import reverse_lazy from mptt.forms import TreeNodeMultipleChoiceField @@ -153,6 +154,9 @@ def add_blank_choice(choices): # class SmallTextarea(forms.Textarea): + """ + Subclass used for rendering a smaller textarea element. + """ pass @@ -168,6 +172,9 @@ class ColorSelect(forms.Select): class BulkEditNullBooleanSelect(forms.NullBooleanSelect): + """ + A Select widget for NullBooleanFields + """ def __init__(self, *args, **kwargs): super(BulkEditNullBooleanSelect, self).__init__(*args, **kwargs) @@ -326,7 +333,7 @@ class CSVChoiceField(forms.ChoiceField): """ def __init__(self, choices, *args, **kwargs): - super(CSVChoiceField, self).__init__(choices, *args, **kwargs) + super(CSVChoiceField, self).__init__(choices=choices, *args, **kwargs) self.choices = [(label, label) for value, label in choices] self.choice_values = {label: value for value, label in choices} @@ -447,7 +454,9 @@ class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): class SlugField(forms.SlugField): - + """ + Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + """ def __init__(self, slug_source='name', *args, **kwargs): label = kwargs.pop('label', "Slug") help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") @@ -536,16 +545,36 @@ class LaxURLField(forms.URLField): default_validators = [EnhancedURLValidator()] +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super(JSONField, self).__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if value is None: + return '' + return super(JSONField, self).prepare_value(value) + + # # Forms # class BootstrapMixin(forms.BaseForm): - + """ + Add the base Bootstrap CSS classes to form elements. + """ def __init__(self, *args, **kwargs): super(BootstrapMixin, self).__init__(*args, **kwargs) - exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect] + exempt_widgets = [ + forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect + ] for field_name, field in self.fields.items(): if field.widget.__class__ not in exempt_widgets: @@ -615,14 +644,15 @@ class ComponentForm(BootstrapMixin, forms.Form): class BulkEditForm(forms.Form): - + """ + Base form for editing multiple objects in bulk + """ def __init__(self, model, parent_obj=None, *args, **kwargs): super(BulkEditForm, self).__init__(*args, **kwargs) self.model = model self.parent_obj = parent_obj + self.nullable_fields = [] # Copy any nullable fields defined in Meta if hasattr(self.Meta, 'nullable_fields'): - self.nullable_fields = [field for field in self.Meta.nullable_fields] - else: - self.nullable_fields = [] + self.nullable_fields = self.Meta.nullable_fields diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py index 33b4356d4..b112f4fae 100644 --- a/netbox/utilities/managers.py +++ b/netbox/utilities/managers.py @@ -4,29 +4,35 @@ from django.db.models import Manager class NaturalOrderByManager(Manager): + """ + Order objects naturally by a designated field. Leading and/or trailing digits of values within this field will be + cast as independent integers and sorted accordingly. For example, "Foo2" will be ordered before "Foo10", even though + the digit 1 is normally ordered before the digit 2. + """ + natural_order_field = None - def natural_order_by(self, *fields): - """ - Attempt to order records naturally by segmenting a field into three parts: + def get_queryset(self): - 1. Leading integer (if any) - 2. Middle portion - 3. Trailing integer (if any) + queryset = super(NaturalOrderByManager, self).get_queryset() - :param fields: The fields on which to order the queryset. The last field in the list will be ordered naturally. - """ db_table = self.model._meta.db_table - primary_field = fields[-1] + db_field = self.natural_order_field - id1 = '_{}_{}1'.format(db_table, primary_field) - id2 = '_{}_{}2'.format(db_table, primary_field) - id3 = '_{}_{}3'.format(db_table, primary_field) - - queryset = super(NaturalOrderByManager, self).get_queryset().extra(select={ - id1: r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, primary_field), - id2: r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, primary_field), - id3: r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, primary_field), + # Append the three subfields derived from the designated natural ordering field + queryset = queryset.extra(select={ + '_nat1': r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, db_field), + '_nat2': r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, db_field), + '_nat3': r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, db_field), }) - ordering = fields[0:-1] + (id1, id2, id3) + + # Replace any instance of the designated natural ordering field with its three subfields + ordering = [] + for field in self.model._meta.ordering: + if field == self.natural_order_field: + ordering.append('_nat1') + ordering.append('_nat2') + ordering.append('_nat3') + else: + ordering.append(field) return queryset.order_by(*ordering) diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index 70d018023..dafafde24 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -21,7 +21,7 @@ class LoginRequiredMiddleware(object): self.get_response = get_response def __call__(self, request): - if LOGIN_REQUIRED and not request.user.is_authenticated(): + if LOGIN_REQUIRED and not request.user.is_authenticated: # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API # performs its own authentication. api_path = reverse('api-root') diff --git a/netbox/utilities/models.py b/netbox/utilities/models.py index c6768c4c1..4b04c03e1 100644 --- a/netbox/utilities/models.py +++ b/netbox/utilities/models.py @@ -2,10 +2,38 @@ from __future__ import unicode_literals from django.db import models +from extras.models import ObjectChange +from utilities.utils import serialize_object -class CreatedUpdatedModel(models.Model): - created = models.DateField(auto_now_add=True) - last_updated = models.DateTimeField(auto_now=True) + +class ChangeLoggedModel(models.Model): + """ + An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be + null to facilitate adding these fields to existing instances via a database migration. + """ + created = models.DateField( + auto_now_add=True, + blank=True, + null=True + ) + last_updated = models.DateTimeField( + auto_now=True, + blank=True, + null=True + ) class Meta: abstract = True + + def log_change(self, user, request_id, action): + """ + Create a new ObjectChange representing a change made to this object. This will typically be called automatically + by extras.middleware.ChangeLoggingMiddleware. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + action=action, + object_data=serialize_object(self) + ).save() diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 8694d986b..e531b5e32 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -22,7 +22,9 @@ class BaseTable(tables.Table): class ToggleColumn(tables.CheckBoxColumn): - + """ + Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. + """ def __init__(self, *args, **kwargs): default = kwargs.pop('default', '') visible = kwargs.pop('visible', False) @@ -31,3 +33,18 @@ class ToggleColumn(tables.CheckBoxColumn): @property def header(self): return mark_safe('') + + +class BooleanColumn(tables.Column): + """ + Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode + character. + """ + def render(self, value): + if value is True: + rendered = '' + elif value is False: + rendered = '' + else: + rendered = '' + return mark_safe(rendered) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 7d79a5f2a..39959a668 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import datetime -import pytz +import json from django import template from django.utils.safestring import mark_safe @@ -47,6 +47,14 @@ def gfm(value): return mark_safe(html) +@register.filter() +def render_json(value): + """ + Render a dictionary as formatted JSON. + """ + return json.dumps(value, indent=4, sort_keys=True) + + @register.filter() def model_name(obj): """ @@ -160,3 +168,14 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90): 'warning_threshold': warning_threshold, 'danger_threshold': danger_threshold, } + + +@register.inclusion_tag('utilities/templatetags/tag.html') +def tag(tag, url_name=None): + """ + Display a tag, optionally linked to a filtered list of objects. + """ + return { + 'tag': tag, + 'url_name': url_name, + } diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py new file mode 100644 index 000000000..dcc564dfa --- /dev/null +++ b/netbox/utilities/testing.py @@ -0,0 +1,26 @@ +from __future__ import unicode_literals + +from django.contrib.auth.models import User +from rest_framework.test import APITestCase as _APITestCase + +from users.models import Token + + +class APITestCase(_APITestCase): + + def setUp(self): + """ + Create a superuser and token for API calls. + """ + self.user = User.objects.create(username='testuser', is_superuser=True) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + + def assertHttpStatus(self, response, expected_status): + """ + Provide more detail in the event of an unexpected HTTP response. + """ + err_message = "Expected HTTP status {}; received {}: {}" + self.assertEqual(response.status_code, expected_status, err_message.format( + expected_status, response.status_code, response.data + )) diff --git a/netbox/utilities/tests.py b/netbox/utilities/tests.py deleted file mode 100644 index d40202842..000000000 --- a/netbox/utilities/tests.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import unicode_literals - - -class HttpStatusMixin(object): - """ - Custom mixin to provide more detail in the event of an unexpected HTTP response. - """ - - def assertHttpStatus(self, response, expected_status): - err_message = "Expected HTTP status {}; received {}: {}" - self.assertEqual(response.status_code, expected_status, err_message.format( - expected_status, response.status_code, response.data - )) diff --git a/netbox/utilities/tests/__init__.py b/netbox/utilities/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/tests/test_managers.py b/netbox/utilities/tests/test_managers.py new file mode 100644 index 000000000..0bafaefde --- /dev/null +++ b/netbox/utilities/tests/test_managers.py @@ -0,0 +1,192 @@ +from __future__ import unicode_literals + +from django.test import TestCase + +from dcim.models import Site + + +class NaturalOrderByManagerTest(TestCase): + """ + Ensure consistent natural ordering given myriad sample data. We use dcim.Site as our guinea pig because it's simple. + """ + + def setUp(self): + return + + def evaluate_ordering(self, names): + + # Create the Sites + Site.objects.bulk_create( + Site(name=name, slug=name.lower()) for name in names + ) + + # Validate forward ordering + self.assertEqual( + names, + list(Site.objects.values_list('name', flat=True)) + ) + + # Validate reverse ordering + self.assertEqual( + list(reversed(names)), + list(Site.objects.reverse().values_list('name', flat=True)) + ) + + def test_leading_digits(self): + + self.evaluate_ordering([ + '1Alpha', + '1Bravo', + '1Charlie', + '9Alpha', + '9Bravo', + '9Charlie', + '10Alpha', + '10Bravo', + '10Charlie', + '99Alpha', + '99Bravo', + '99Charlie', + '100Alpha', + '100Bravo', + '100Charlie', + '999Alpha', + '999Bravo', + '999Charlie', + ]) + + def test_trailing_digits(self): + + self.evaluate_ordering([ + 'Alpha1', + 'Alpha9', + 'Alpha10', + 'Alpha99', + 'Alpha100', + 'Alpha999', + 'Bravo1', + 'Bravo9', + 'Bravo10', + 'Bravo99', + 'Bravo100', + 'Bravo999', + 'Charlie1', + 'Charlie9', + 'Charlie10', + 'Charlie99', + 'Charlie100', + 'Charlie999', + ]) + + def test_leading_and_trailing_digits(self): + + self.evaluate_ordering([ + '1Alpha1', + '1Alpha9', + '1Alpha10', + '1Alpha99', + '1Alpha100', + '1Alpha999', + '1Bravo1', + '1Bravo9', + '1Bravo10', + '1Bravo99', + '1Bravo100', + '1Bravo999', + '1Charlie1', + '1Charlie9', + '1Charlie10', + '1Charlie99', + '1Charlie100', + '1Charlie999', + '9Alpha1', + '9Alpha9', + '9Alpha10', + '9Alpha99', + '9Alpha100', + '9Alpha999', + '9Bravo1', + '9Bravo9', + '9Bravo10', + '9Bravo99', + '9Bravo100', + '9Bravo999', + '9Charlie1', + '9Charlie9', + '9Charlie10', + '9Charlie99', + '9Charlie100', + '9Charlie999', + '10Alpha1', + '10Alpha9', + '10Alpha10', + '10Alpha99', + '10Alpha100', + '10Alpha999', + '10Bravo1', + '10Bravo9', + '10Bravo10', + '10Bravo99', + '10Bravo100', + '10Bravo999', + '10Charlie1', + '10Charlie9', + '10Charlie10', + '10Charlie99', + '10Charlie100', + '10Charlie999', + '99Alpha1', + '99Alpha9', + '99Alpha10', + '99Alpha99', + '99Alpha100', + '99Alpha999', + '99Bravo1', + '99Bravo9', + '99Bravo10', + '99Bravo99', + '99Bravo100', + '99Bravo999', + '99Charlie1', + '99Charlie9', + '99Charlie10', + '99Charlie99', + '99Charlie100', + '99Charlie999', + '100Alpha1', + '100Alpha9', + '100Alpha10', + '100Alpha99', + '100Alpha100', + '100Alpha999', + '100Bravo1', + '100Bravo9', + '100Bravo10', + '100Bravo99', + '100Bravo100', + '100Bravo999', + '100Charlie1', + '100Charlie9', + '100Charlie10', + '100Charlie99', + '100Charlie100', + '100Charlie999', + '999Alpha1', + '999Alpha9', + '999Alpha10', + '999Alpha99', + '999Alpha100', + '999Alpha999', + '999Bravo1', + '999Bravo9', + '999Bravo10', + '999Bravo99', + '999Bravo100', + '999Bravo999', + '999Charlie1', + '999Charlie9', + '999Charlie10', + '999Charlie99', + '999Charlie100', + '999Charlie999', + ]) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 9e96a66fd..2ba5fa4ba 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,8 +1,10 @@ from __future__ import unicode_literals import datetime +import json import six +from django.core.serializers import serialize from django.http import HttpResponse @@ -71,3 +73,39 @@ def foreground_color(bg_color): return '000000' else: return 'ffffff' + + +def dynamic_import(name): + """ + Dynamically import a class from an absolute path string + """ + components = name.split('.') + mod = __import__(components[0]) + for comp in components[1:]: + mod = getattr(mod, comp) + return mod + + +def serialize_object(obj, extra=None): + """ + Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like + change logging, not the REST API.) Optionally include a dictionary to supplement the object data. + """ + json_str = serialize('json', [obj]) + data = json.loads(json_str)[0]['fields'] + + # Include any custom fields + if hasattr(obj, 'get_custom_fields'): + data['custom_fields'] = { + field.name: str(value) for field, value in obj.get_custom_fields().items() + } + + # Include any tags + # if hasattr(obj, 'tags'): + # data['tags'] = [tag.name for tag in obj.tags.all()] + + # Append any extra data + if extra is not None: + data.update(extra) + + return data diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index dcdb9bc6d..102e368a5 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -9,7 +9,6 @@ class EnhancedURLValidator(URLValidator): """ Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension. """ - class AnyURLScheme(object): """ A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1 diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index dcb4529b1..e11d681ef 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -9,7 +9,7 @@ from django.contrib import messages 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.db.models import Count, ProtectedError from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render @@ -24,7 +24,7 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME from django.views.generic import View from django_tables2 import RequestConfig -from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction +from extras.models import CustomField, CustomFieldValue, ExportTemplate from utilities.utils import queryset_to_csv from utilities.forms import BootstrapMixin, CSVDataField from .constants import M2M_FIELD_TYPES @@ -56,14 +56,22 @@ class GetReturnURLMixin(object): """ default_return_url = None - def get_return_url(self, request, obj): + def get_return_url(self, request, obj=None): + + # First, see if `return_url` was specified as a query parameter. Use it only if it's considered safe. query_param = request.GET.get('return_url') if query_param and is_safe_url(url=query_param, host=request.get_host()): return query_param - elif obj.pk and hasattr(obj, 'get_absolute_url'): + + # Next, check if the object being modified (if any) has an absolute URL. + elif obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): return obj.get_absolute_url() + + # Fall back to the default URL (if specified) for the view. elif self.default_return_url is not None: return reverse(self.default_return_url) + + # If all else fails, return home. Ideally this should never happen. return reverse('home') @@ -124,6 +132,12 @@ class ObjectListView(View): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') + # Construct queryset for tags list + if hasattr(model, 'tags'): + tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('name') + else: + tags = None + # Apply the request context paginate = { 'klass': EnhancedPaginator, @@ -136,6 +150,7 @@ class ObjectListView(View): 'table': table, 'permissions': permissions, 'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None, + 'tags': tags, } context.update(self.extra_context()) @@ -156,7 +171,6 @@ class ObjectEditView(GetReturnURLMixin, View): model: The model of the object being edited 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 model_form = None @@ -200,17 +214,15 @@ class ObjectEditView(GetReturnURLMixin, View): obj_created = not form.instance.pk obj = form.save() - msg = 'Created ' if obj_created else 'Modified ' - msg += self.model._meta.verbose_name + msg = '{} {}'.format( + 'Created' if obj_created else 'Modified', + self.model._meta.verbose_name + ) if hasattr(obj, 'get_absolute_url'): msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj)) else: msg = '{} {}'.format(msg, escape(obj)) messages.success(request, mark_safe(msg)) - if obj_created: - UserAction.objects.log_create(request.user, obj, msg) - else: - UserAction.objects.log_edit(request.user, obj, msg) if '_addanother' in request.POST: return redirect(request.get_full_path()) @@ -235,7 +247,6 @@ class ObjectDeleteView(GetReturnURLMixin, View): model: The model of the object being deleted template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after deleting the object """ model = None template_name = 'utilities/obj_delete.html' @@ -273,7 +284,6 @@ class ObjectDeleteView(GetReturnURLMixin, View): msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj) messages.success(request, msg) - UserAction.objects.log_delete(request.user, obj, msg) 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()): @@ -289,20 +299,19 @@ class ObjectDeleteView(GetReturnURLMixin, View): }) -class BulkCreateView(View): +class BulkCreateView(GetReturnURLMixin, View): """ Create new objects in bulk. form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects + pattern_target: Name of the field to be evaluated as a pattern (if any) template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after creating the objects """ form = None model_form = None pattern_target = '' template_name = None - default_return_url = 'home' def get(self, request): @@ -319,7 +328,7 @@ class BulkCreateView(View): 'obj_type': self.model_form._meta.model._meta.verbose_name, 'form': form, 'model_form': model_form, - 'return_url': reverse(self.default_return_url), + 'return_url': self.get_return_url(request), }) def post(self, request): @@ -359,11 +368,10 @@ class BulkCreateView(View): # If we make it to this point, validation has succeeded on all new objects. msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) messages.success(request, msg) - UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(model), msg) if '_addanother' in request.POST: return redirect(request.path) - return redirect(self.default_return_url) + return redirect(self.get_return_url(request)) except IntegrityError: pass @@ -372,23 +380,21 @@ class BulkCreateView(View): 'form': form, 'model_form': model_form, 'obj_type': model._meta.verbose_name, - 'return_url': reverse(self.default_return_url), + 'return_url': self.get_return_url(request), }) -class BulkImportView(View): +class BulkImportView(GetReturnURLMixin, View): """ Import objects in bulk (CSV format). model_form: The form used to create each imported object 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 = {} @@ -414,7 +420,7 @@ class BulkImportView(View): 'form': self._import_form(), 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) def post(self, request): @@ -444,11 +450,10 @@ class BulkImportView(View): if new_objs: msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural) messages.success(request, msg) - UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg) return render(request, "import_success.html", { 'table': obj_table, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) except ValidationError: @@ -458,61 +463,49 @@ class BulkImportView(View): 'form': form, 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, - 'return_url': self.default_return_url, + 'return_url': self.get_return_url(request), }) -class BulkEditView(View): +class BulkEditView(GetReturnURLMixin, View): """ Edit objects in bulk. - cls: The model of the objects being edited - parent_cls: The model of the parent object (if any) queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + parent_model: The model of the parent object (if any) filter: FilterSet to apply when deleting by QuerySet table: The table used to display devices being edited form: The form class used to edit objects in bulk template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after editing the objects (can be overridden by - POSTing return_url) """ - cls = None - parent_cls = None queryset = None + parent_model = None filter = None table = None form = None template_name = 'utilities/obj_bulk_edit.html' - default_return_url = 'home' def get(self, request): - return redirect(self.default_return_url) + return redirect(self.get_return_url(request)) def post(self, request, **kwargs): + model = self.queryset.model + # Attempt to derive parent object if a parent class has been given - if self.parent_cls: - parent_obj = get_object_or_404(self.parent_cls, **kwargs) + if self.parent_model: + parent_obj = get_object_or_404(self.parent_model, **kwargs) else: parent_obj = None - # 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 - elif parent_obj: - return_url = parent_obj.get_absolute_url() - else: - return_url = reverse(self.default_return_url) - # 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.cls.objects.only('pk')).qs] + pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs] else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] if '_apply' in request.POST: - form = self.form(self.cls, parent_obj, request.POST) + form = self.form(model, parent_obj, request.POST) if form.is_valid(): custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] @@ -524,7 +517,7 @@ class BulkEditView(View): with transaction.atomic(): updated_count = 0 - for obj in self.cls.objects.filter(pk__in=pk_list): + for obj in model.objects.filter(pk__in=pk_list): # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: @@ -536,7 +529,7 @@ class BulkEditView(View): obj.save() # Update custom fields - obj_type = ContentType.objects.get_for_model(self.cls) + obj_type = ContentType.objects.get_for_model(model) for name in custom_fields: field = form.fields[name].model if name in form.nullable_fields and name in nullified_fields: @@ -555,14 +548,19 @@ class BulkEditView(View): cfv.value = form.cleaned_data[name] cfv.save() + # Add/remove tags + if form.cleaned_data.get('add_tags', None): + obj.tags.add(*form.cleaned_data['add_tags']) + if form.cleaned_data.get('remove_tags', None): + obj.tags.remove(*form.cleaned_data['remove_tags']) + updated_count += 1 if updated_count: - msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural) + msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural) messages.success(self.request, msg) - UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg) - return redirect(return_url) + return redirect(self.get_return_url(request)) except ValidationError as e: messages.error(self.request, "{} failed validation: {}".format(obj, e)) @@ -570,72 +568,59 @@ class BulkEditView(View): else: initial_data = request.POST.copy() initial_data['pk'] = pk_list - form = self.form(self.cls, parent_obj, initial=initial_data) + form = self.form(model, parent_obj, initial=initial_data) # Retrieve objects being edited - queryset = self.queryset or self.cls.objects.all() - table = self.table(queryset.filter(pk__in=pk_list), orderable=False) + table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) if not table.rows: - messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural)) - return redirect(return_url) + messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural)) + return redirect(self.get_return_url(request)) return render(request, self.template_name, { 'form': form, 'table': table, - 'obj_type_plural': self.cls._meta.verbose_name_plural, - 'return_url': return_url, + 'obj_type_plural': model._meta.verbose_name_plural, + 'return_url': self.get_return_url(request), }) -class BulkDeleteView(View): +class BulkDeleteView(GetReturnURLMixin, View): """ Delete objects in bulk. - cls: The model of the objects being deleted - parent_cls: The model of the parent object (if any) queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) + parent_model: The model of the parent object (if any) filter: FilterSet to apply when deleting by QuerySet table: The table used to display devices being deleted form: The form class used to delete objects in bulk template_name: The name of the template - default_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by - POSTing return_url) """ - cls = None - parent_cls = None queryset = None + parent_model = None filter = None table = None form = None template_name = 'utilities/obj_bulk_delete.html' - default_return_url = 'home' def get(self, request): - return redirect(self.default_return_url) + return redirect(self.get_return_url(request)) def post(self, request, **kwargs): + model = self.queryset.model + # Attempt to derive parent object if a parent class has been given - if self.parent_cls: - parent_obj = get_object_or_404(self.parent_cls, **kwargs) + if self.parent_model: + parent_obj = get_object_or_404(self.parent_model, **kwargs) else: parent_obj = None - # Determine URL to redirect users upon deletion 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 - elif parent_obj: - return_url = parent_obj.get_absolute_url() - else: - return_url = reverse(self.default_return_url) - # Are we deleting *all* objects in the queryset or just a selected subset? if request.POST.get('_all'): if self.filter is not None: - pk_list = [obj.pk for obj in self.filter(request.GET, self.cls.objects.only('pk')).qs] + pk_list = [obj.pk for obj in self.filter(request.GET, model.objects.only('pk')).qs] else: - pk_list = self.cls.objects.values_list('pk', flat=True) + pk_list = model.objects.values_list('pk', flat=True) else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] @@ -646,34 +631,35 @@ class BulkDeleteView(View): if form.is_valid(): # Delete objects - queryset = self.cls.objects.filter(pk__in=pk_list) + queryset = model.objects.filter(pk__in=pk_list) try: - deleted_count = queryset.delete()[1][self.cls._meta.label] + deleted_count = queryset.delete()[1][model._meta.label] except ProtectedError as e: handle_protectederror(list(queryset), request, e) - return redirect(return_url) + return redirect(self.get_return_url(request)) - msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural) + msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural) messages.success(request, msg) - UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg) - return redirect(return_url) + return redirect(self.get_return_url(request)) else: - form = form_cls(initial={'pk': pk_list, 'return_url': return_url}) + form = form_cls(initial={ + 'pk': pk_list, + 'return_url': self.get_return_url(request), + }) # Retrieve objects being deleted - queryset = self.queryset or self.cls.objects.all() - table = self.table(queryset.filter(pk__in=pk_list), orderable=False) + table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) if not table.rows: - messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) - return redirect(return_url) + messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural)) + return redirect(self.get_return_url(request)) return render(request, self.template_name, { 'form': form, 'parent_obj': parent_obj, - 'obj_type_plural': self.cls._meta.verbose_name_plural, + 'obj_type_plural': model._meta.verbose_name_plural, 'table': table, - 'return_url': return_url, + 'return_url': self.get_return_url(request), }) def get_form(self): @@ -682,7 +668,7 @@ class BulkDeleteView(View): """ class BulkDeleteForm(ConfirmationForm): - pk = ModelMultipleChoiceField(queryset=self.cls.objects.all(), widget=MultipleHiddenInput) + pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) if self.form: return self.form @@ -786,7 +772,7 @@ class ComponentCreateView(View): }) -class BulkComponentCreateView(View): +class BulkComponentCreateView(GetReturnURLMixin, View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ @@ -798,7 +784,6 @@ class BulkComponentCreateView(View): filter = None table = None template_name = 'utilities/obj_bulk_add_component.html' - default_return_url = 'home' def post(self, request): @@ -808,17 +793,10 @@ class BulkComponentCreateView(View): 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) + return redirect(self.get_return_url(request)) table = self.table(selected_objects) if '_create' in request.POST: @@ -846,13 +824,14 @@ class BulkComponentCreateView(View): 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) + return redirect(self.get_return_url(request)) else: form = self.form(initial={'pk': pk_list}) @@ -861,7 +840,7 @@ class BulkComponentCreateView(View): 'form': form, 'component_name': self.model._meta.verbose_name_plural, 'table': table, - 'return_url': reverse(self.default_return_url), + 'return_url': self.get_return_url(request), }) diff --git a/netbox/virtualization/__init__.py b/netbox/virtualization/__init__.py index e69de29bb..3f12ae450 100644 --- a/netbox/virtualization/__init__.py +++ b/netbox/virtualization/__init__.py @@ -0,0 +1 @@ +default_app_config = 'virtualization.apps.VirtualizationConfig' diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index c03cdc166..9664e9218 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,14 +1,15 @@ from __future__ import unicode_literals from rest_framework import serializers +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES +from dcim.constants import IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -24,7 +25,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedClusterTypeSerializer(serializers.ModelSerializer): +class NestedClusterTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') class Meta: @@ -43,7 +44,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedClusterGroupSerializer(serializers.ModelSerializer): +class NestedClusterGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') class Meta: @@ -55,17 +56,20 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer): # Clusters # -class ClusterSerializer(CustomFieldModelSerializer): +class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): type = NestedClusterTypeSerializer() - group = NestedClusterGroupSerializer() - site = NestedSiteSerializer() + group = NestedClusterGroupSerializer(required=False, allow_null=True) + site = NestedSiteSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = Cluster - fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] + fields = [ + 'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] -class NestedClusterSerializer(serializers.ModelSerializer): +class NestedClusterSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') class Meta: @@ -73,13 +77,6 @@ class NestedClusterSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name'] -class WritableClusterSerializer(CustomFieldModelSerializer): - - class Meta: - model = Cluster - fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated'] - - # # Virtual machines # @@ -93,25 +90,39 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'family', 'address'] -class VirtualMachineSerializer(CustomFieldModelSerializer): - status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES) - cluster = NestedClusterSerializer() - role = NestedDeviceRoleSerializer() - tenant = NestedTenantSerializer() - platform = NestedPlatformSerializer() - primary_ip = VirtualMachineIPAddressSerializer() - primary_ip4 = VirtualMachineIPAddressSerializer() - primary_ip6 = VirtualMachineIPAddressSerializer() +class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): + status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) + cluster = NestedClusterSerializer(required=False, allow_null=True) + role = NestedDeviceRoleSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + platform = NestedPlatformSerializer(required=False, allow_null=True) + primary_ip = VirtualMachineIPAddressSerializer(read_only=True) + primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = VirtualMachine fields = [ 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated', + 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] -class NestedVirtualMachineSerializer(serializers.ModelSerializer): +class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): + config_context = serializers.SerializerMethodField() + + class Meta(VirtualMachineSerializer.Meta): + fields = [ + 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + ] + + def get_config_context(self, obj): + return obj.get_config_context() + + +class NestedVirtualMachineSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') class Meta: @@ -119,22 +130,12 @@ class NestedVirtualMachineSerializer(serializers.ModelSerializer): 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', 'created', 'last_updated', - ] - - # # VM interfaces # # Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(serializers.ModelSerializer): +class InterfaceVLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') class Meta: @@ -142,34 +143,30 @@ class InterfaceVLANSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(serializers.ModelSerializer): +class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) - untagged_vlan = InterfaceVLANSerializer() - tagged_vlans = InterfaceVLANSerializer(many=True) + form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) + mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) + untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + tagged_vlans = SerializedPKRelatedField( + queryset=VLAN.objects.all(), + serializer=InterfaceVLANSerializer, + required=False, + many=True + ) + tags = TagListSerializerField(required=False) class Meta: model = Interface fields = [ - 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans', - 'description', + 'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags', ] -class NestedInterfaceSerializer(serializers.ModelSerializer): +class NestedInterfaceSerializer(WritableNestedSerializer): 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', 'mode', 'untagged_vlan', - 'tagged_vlans', 'description', - ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 149bb3145..01b8792c8 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -37,7 +37,6 @@ class ClusterGroupViewSet(ModelViewSet): class ClusterViewSet(CustomFieldModelViewSet): queryset = Cluster.objects.select_related('type', 'group') serializer_class = serializers.ClusterSerializer - write_serializer_class = serializers.WritableClusterSerializer filter_class = filters.ClusterFilter @@ -47,13 +46,18 @@ class ClusterViewSet(CustomFieldModelViewSet): class VirtualMachineViewSet(CustomFieldModelViewSet): queryset = VirtualMachine.objects.all() - serializer_class = serializers.VirtualMachineSerializer - write_serializer_class = serializers.WritableVirtualMachineSerializer filter_class = filters.VirtualMachineFilter + def get_serializer_class(self): + """ + Include rendered config context when retrieving a single VirtualMachine. + """ + if self.action == 'retrieve': + return serializers.VirtualMachineWithConfigContextSerializer + return serializers.VirtualMachineSerializer + class InterfaceViewSet(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/filters.py b/netbox/virtualization/filters.py index 53c3f18d9..6af4e4a22 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -63,6 +63,9 @@ class ClusterFilter(CustomFieldFilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Cluster @@ -154,6 +157,9 @@ class VirtualMachineFilter(CustomFieldFilterSet): to_field_name='slug', label='Platform (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = VirtualMachine diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 4dfea1b42..10833234b 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -4,12 +4,13 @@ from django import forms from django.core.exceptions import ValidationError from django.db.models import Count from mptt.forms import TreeNodeChoiceField +from taggit.forms import TagField from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.formfields import MACAddressFormField from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site -from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from ipam.models import IPAddress from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -78,10 +79,11 @@ class ClusterGroupCSVForm(forms.ModelForm): class ClusterForm(BootstrapMixin, CustomFieldForm): comments = CommentField(widget=SmallTextarea) + tags = TagField(required=False) class Meta: model = Cluster - fields = ['name', 'type', 'group', 'site', 'comments'] + fields = ['name', 'type', 'group', 'site', 'comments', 'tags'] class ClusterCSVForm(forms.ModelForm): @@ -117,7 +119,7 @@ class ClusterCSVForm(forms.ModelForm): fields = Cluster.csv_headers -class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, 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) @@ -244,12 +246,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): api_url='/api/virtualization/clusters/?group_id={{cluster_group}}' ) ) + tags = TagField(required=False) class Meta: model = VirtualMachine fields = [ 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', + 'vcpus', 'memory', 'disk', 'comments', 'tags', ] def __init__(self, *args, **kwargs): @@ -346,7 +349,7 @@ class VirtualMachineCSVForm(forms.ModelForm): fields = VirtualMachine.csv_headers -class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): +class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='') cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) diff --git a/netbox/virtualization/migrations/0001_virtualization.py b/netbox/virtualization/migrations/0001_virtualization.py index cb553cf95..a5c7535cf 100644 --- a/netbox/virtualization/migrations/0001_virtualization.py +++ b/netbox/virtualization/migrations/0001_virtualization.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals from django.db import migrations, models import django.db.models.deletion -import extras.models class Migration(migrations.Migration): @@ -30,7 +29,6 @@ class Migration(migrations.Migration): options={ 'ordering': ['name'], }, - bases=(models.Model, extras.models.CustomFieldModel), ), migrations.CreateModel( name='ClusterGroup', @@ -74,7 +72,6 @@ class Migration(migrations.Migration): options={ 'ordering': ['name'], }, - bases=(models.Model, extras.models.CustomFieldModel), ), migrations.AddField( model_name='cluster', diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py new file mode 100644 index 000000000..295ec7d17 --- /dev/null +++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.14 on 2018-07-31 02:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('virtualization', '0002_virtualmachine_add_status'), ('virtualization', '0003_cluster_add_site'), ('virtualization', '0004_virtualmachine_add_role')] + + dependencies = [ + ('dcim', '0044_virtualization'), + ('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'), + ), + 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'), + ), + 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/0005_django2.py b/netbox/virtualization/migrations/0005_django2.py new file mode 100644 index 000000000..e79a55350 --- /dev/null +++ b/netbox/virtualization/migrations/0005_django2.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.3 on 2018-03-30 14:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0004_virtualmachine_add_role'), + ] + + operations = [ + migrations.AlterField( + model_name='virtualmachine', + name='role', + field=models.ForeignKey(blank=True, limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'), + ), + ] diff --git a/netbox/virtualization/migrations/0006_tags.py b/netbox/virtualization/migrations/0006_tags.py new file mode 100644 index 000000000..eed800852 --- /dev/null +++ b/netbox/virtualization/migrations/0006_tags.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:04 +from __future__ import unicode_literals + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('virtualization', '0005_django2'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + migrations.AddField( + model_name='virtualmachine', + name='tags', + field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/netbox/virtualization/migrations/0007_change_logging.py b/netbox/virtualization/migrations/0007_change_logging.py new file mode 100644 index 000000000..954f9f2a9 --- /dev/null +++ b/netbox/virtualization/migrations/0007_change_logging.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-13 17:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0006_tags'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='clustergroup', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='clustertype', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='clustertype', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='cluster', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='cluster', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AlterField( + model_name='virtualmachine', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='virtualmachine', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 0a6abc400..3d8a51fff 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -6,10 +6,11 @@ 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 taggit.managers import TaggableManager from dcim.models import Device -from extras.models import CustomFieldModel, CustomFieldValue -from utilities.models import CreatedUpdatedModel +from extras.models import ConfigContextModel, CustomFieldModel +from utilities.models import ChangeLoggedModel from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES @@ -18,7 +19,7 @@ from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSE # @python_2_unicode_compatible -class ClusterType(models.Model): +class ClusterType(ChangeLoggedModel): """ A type of Cluster. """ @@ -53,7 +54,7 @@ class ClusterType(models.Model): # @python_2_unicode_compatible -class ClusterGroup(models.Model): +class ClusterGroup(ChangeLoggedModel): """ An organizational group of Clusters. """ @@ -88,7 +89,7 @@ class ClusterGroup(models.Model): # @python_2_unicode_compatible -class Cluster(CreatedUpdatedModel, CustomFieldModel): +class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -119,11 +120,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): blank=True ) custom_field_values = GenericRelation( - to=CustomFieldValue, + to='extras.CustomFieldValue', content_type_field='obj_type', object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = ['name', 'type', 'group', 'site', 'comments'] class Meta: @@ -162,12 +165,12 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): # @python_2_unicode_compatible -class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): +class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. """ cluster = models.ForeignKey( - to=Cluster, + to='virtualization.Cluster', on_delete=models.PROTECT, related_name='virtual_machines' ) @@ -196,9 +199,9 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): ) role = models.ForeignKey( to='dcim.DeviceRole', - limit_choices_to={'vm_role': True}, on_delete=models.PROTECT, related_name='virtual_machines', + limit_choices_to={'vm_role': True}, blank=True, null=True ) @@ -237,11 +240,13 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): blank=True ) custom_field_values = GenericRelation( - to=CustomFieldValue, + to='extras.CustomFieldValue', content_type_field='obj_type', object_id_field='obj_id' ) + tags = TaggableManager() + csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 4d38a3fe5..84579af49 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -9,12 +9,18 @@ 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 %} diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 1f9e72ee5..b397e9dff 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -1,22 +1,20 @@ from __future__ import unicode_literals -from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase -from users.models import Token -from utilities.tests import HttpStatusMixin +from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED +from dcim.models import Interface +from ipam.models import VLAN +from utilities.testing import APITestCase from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine -class ClusterTypeTest(HttpStatusMixin, APITestCase): +class ClusterTypeTest(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)} + super(ClusterTypeTest, self).setUp() 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') @@ -103,13 +101,11 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase): self.assertEqual(ClusterType.objects.count(), 2) -class ClusterGroupTest(HttpStatusMixin, APITestCase): +class ClusterGroupTest(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)} + super(ClusterGroupTest, self).setUp() 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') @@ -196,13 +192,11 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(ClusterGroup.objects.count(), 2) -class ClusterTest(HttpStatusMixin, APITestCase): +class ClusterTest(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)} + super(ClusterTest, self).setUp() 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') @@ -301,13 +295,11 @@ class ClusterTest(HttpStatusMixin, APITestCase): self.assertEqual(Cluster.objects.count(), 2) -class VirtualMachineTest(HttpStatusMixin, APITestCase): +class VirtualMachineTest(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)} + super(VirtualMachineTest, self).setUp() 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') @@ -401,3 +393,168 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(VirtualMachine.objects.count(), 2) + + +class InterfaceTest(APITestCase): + + def setUp(self): + + super(InterfaceTest, self).setUp() + + clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') + cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype) + self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') + self.interface1 = Interface.objects.create( + virtual_machine=self.virtualmachine, + name='Test Interface 1', + form_factor=IFACE_FF_VIRTUAL + ) + self.interface2 = Interface.objects.create( + virtual_machine=self.virtualmachine, + name='Test Interface 2', + form_factor=IFACE_FF_VIRTUAL + ) + self.interface3 = Interface.objects.create( + virtual_machine=self.virtualmachine, + name='Test Interface 3', + form_factor=IFACE_FF_VIRTUAL + ) + + self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) + self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2) + self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3) + + def test_get_interface(self): + + url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.interface1.name) + + def test_list_interfaces(self): + + url = reverse('virtualization-api:interface-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interface(self): + + data = { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + } + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 4) + interface4 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface4.virtual_machine_id, data['virtual_machine']) + self.assertEqual(interface4.name, data['name']) + + def test_create_interface_with_802_1q(self): + + data = { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan3.id, + 'tagged_vlans': [self.vlan1.id, self.vlan2.id], + } + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 4) + self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine']) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) + self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) + + def test_create_interface_bulk(self): + + data = [ + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 5', + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 6', + }, + ] + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_create_interface_802_1q_bulk(self): + + data = [ + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 5', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], + }, + { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface 6', + 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], + }, + ] + + url = reverse('virtualization-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 6) + for i in range(0, 3): + self.assertEqual(response.data[i]['name'], data[i]['name']) + self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) + self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) + + def test_update_interface(self): + + data = { + 'virtual_machine': self.virtualmachine.pk, + 'name': 'Test Interface X', + } + + url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Interface.objects.count(), 3) + interface1 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface1.name, data['name']) + + def test_delete_interface(self): + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Interface.objects.count(), 2) diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 2ba0daff7..b03b3bc0a 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -2,8 +2,10 @@ from __future__ import unicode_literals from django.conf.urls import url +from extras.views import ObjectChangeLogView from ipam.views import ServiceCreateView from . import views +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine app_name = 'virtualization' urlpatterns = [ @@ -14,6 +16,7 @@ urlpatterns = [ 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'), + url(r'^cluster-types/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), # Cluster groups url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'), @@ -21,6 +24,7 @@ urlpatterns = [ 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'), + url(r'^cluster-groups/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), # Clusters url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'), @@ -31,6 +35,7 @@ urlpatterns = [ 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+)/changelog/$', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), 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'), @@ -43,6 +48,8 @@ urlpatterns = [ 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+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), + url(r'^virtual-machines/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), # VM interfaces diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 5aef710c1..4ddacce40 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -9,6 +9,7 @@ from django.views.generic import View from dcim.models import Device, Interface from dcim.tables import DeviceTable +from extras.views import ObjectConfigContextView from ipam.models import Service from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, @@ -32,9 +33,7 @@ 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') + default_return_url = 'virtualization:clustertype_list' class ClusterTypeEditView(ClusterTypeCreateView): @@ -50,7 +49,6 @@ class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView): 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' @@ -70,9 +68,7 @@ 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') + default_return_url = 'virtualization:clustergroup_list' class ClusterGroupEditView(ClusterGroupCreateView): @@ -88,7 +84,6 @@ class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView): 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' @@ -99,7 +94,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ClusterListView(ObjectListView): - queryset = Cluster.objects.select_related('type', 'group') + queryset = Cluster.objects.select_related('type', 'group', 'site') table = tables.ClusterTable filter = filters.ClusterFilter filter_form = forms.ClusterFilterForm @@ -126,6 +121,7 @@ class ClusterView(View): class ClusterCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_cluster' + template_name = 'virtualization/cluster_edit.html' model = Cluster model_form = forms.ClusterForm @@ -149,7 +145,7 @@ class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView): class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'virtualization.change_cluster' - cls = Cluster + queryset = Cluster.objects.select_related('type', 'group', 'site') filter = filters.ClusterFilter table = tables.ClusterTable form = forms.ClusterBulkEditForm @@ -158,8 +154,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_cluster' - cls = Cluster - queryset = Cluster.objects.all() + queryset = Cluster.objects.select_related('type', 'group', 'site') filter = filters.ClusterFilter table = tables.ClusterTable default_return_url = 'virtualization:cluster_list' @@ -246,7 +241,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # class VirtualMachineListView(ObjectListView): - queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'primary_ip4', 'primary_ip6') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6') filter = filters.VirtualMachineFilter filter_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineDetailTable @@ -257,17 +252,22 @@ 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) + virtualmachine = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk) + interfaces = Interface.objects.filter(virtual_machine=virtualmachine) + services = Service.objects.filter(virtual_machine=virtualmachine) return render(request, 'virtualization/virtualmachine.html', { - 'vm': vm, + 'virtualmachine': virtualmachine, 'interfaces': interfaces, 'services': services, }) +class VirtualMachineConfigContextView(ObjectConfigContextView): + object_class = VirtualMachine + base_template = 'virtualization/virtualmachine.html' + + class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'virtualization.add_virtualmachine' model = VirtualMachine @@ -295,8 +295,7 @@ class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView): class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'virtualization.change_virtualmachine' - cls = VirtualMachine - queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role') filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm @@ -305,8 +304,7 @@ class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView): class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_virtualmachine' - cls = VirtualMachine - queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role') filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable default_return_url = 'virtualization:virtualmachine_list' @@ -340,16 +338,16 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' - cls = Interface - parent_cls = VirtualMachine + queryset = Interface.objects.all() + parent_model = VirtualMachine table = tables.InterfaceTable form = forms.InterfaceBulkEditForm class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' - cls = Interface - parent_cls = VirtualMachine + queryset = Interface.objects.all() + parent_model = VirtualMachine table = tables.InterfaceTable diff --git a/requirements.txt b/requirements.txt index 8d7e251af..b3bee6b6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,23 @@ -Django>=1.11,<2.0 -django-cors-headers>=2.1.0 -django-debug-toolbar>=1.9.0 +Django>=1.11,<2.1 +django-cors-headers==2.4.0 +django-debug-toolbar==1.9.1 django-filter==1.1.0 -django-mptt>=0.9.0 -django-tables2>=1.19.0 -django-timezone-field>=2.0 -djangorestframework>=3.7.7 -drf-yasg[validation]>=1.4.4 -graphviz>=0.8.2 -Markdown>=2.6.11 -natsort>=5.2.0 -ncclient==0.5.3 -netaddr==0.7.18 -paramiko>=2.4.0 -Pillow>=5.0.0 -psycopg2-binary>=2.7.4 -py-gfm>=0.1.3 -pycryptodome>=3.4.11 -xmltodict>=0.11.0 +django-mptt==0.9.1 +django-tables2==1.21.2 +django-taggit==0.22.2 +django-taggit-serializer==0.1.7 +django-timezone-field==2.1 +djangorestframework==3.8.1 +drf-yasg[validation]==1.9.2 +graphviz==0.8.4 +Markdown==2.6.11 +natsort==5.3.3 +ncclient==0.6.0 +netaddr==0.7.19 +paramiko==2.4.1 +Pillow==5.2.0 +psycopg2-binary==2.7.5 +py-gfm==0.1.3 +pycryptodome==3.6.4 +xmltodict==0.11.0 +