diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4bf6fb9c6df..b01cb6dc89f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -17,15 +17,20 @@ about: Report a reproducible bug in the current release of NetBox --> ### Environment * Python version: -* NetBox version: +* NetBox version: ### Steps to Reproduce - +1. +2. +3. ### Expected Behavior diff --git a/.gitignore b/.gitignore index b33d46a4047..d859bad2899 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ fabfile.py *.swp gunicorn_config.py +.DS_Store +.vscode diff --git a/.travis.yml b/.travis.yml index 33abc8425b0..13c6d406bb0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ addons: postgresql: "9.4" language: python python: - - "2.7" - "3.5" install: - pip install -r requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f17a367e9..77a8bdcbd61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1693 +1,1920 @@ -v2.4.6 (2018-10-05) - -## Enhancements - -* [#2479](https://github.com/digitalocean/netbox/issues/2479) - Add user permissions for creating/modifying API tokens -* [#2487](https://github.com/digitalocean/netbox/issues/2487) - Return abbreviated API output when passed `?brief=1` - -## Bug Fixes - -* [#2393](https://github.com/digitalocean/netbox/issues/2393) - Fix Unicode support for CSV import under Python 2 -* [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE -* [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form -* [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM -* [#2491](https://github.com/digitalocean/netbox/issues/2491) - Fix exception when importing devices with invalid device type -* [#2492](https://github.com/digitalocean/netbox/issues/2492) - Sanitize hostname and port values returned through LLDP - ---- - -v2.4.5 (2018-10-02) - -## Enhancements - -* [#2392](https://github.com/digitalocean/netbox/issues/2392) - Implemented local context data for devices and virtual machines -* [#2402](https://github.com/digitalocean/netbox/issues/2402) - Order and format JSON data in form fields -* [#2432](https://github.com/digitalocean/netbox/issues/2432) - Link remote interface connections to the Interface view -* [#2438](https://github.com/digitalocean/netbox/issues/2438) - API optimizations for tagged objects - -## Bug Fixes - -* [#2406](https://github.com/digitalocean/netbox/issues/2406) - Remove hard-coded limit of 1000 objects from API-populated form fields -* [#2414](https://github.com/digitalocean/netbox/issues/2414) - Tags field missing from device/VM component creation forms -* [#2442](https://github.com/digitalocean/netbox/issues/2442) - Nullify "next" link in API when limit=0 is passed -* [#2443](https://github.com/digitalocean/netbox/issues/2443) - Enforce JSON object format when creating config contexts -* [#2444](https://github.com/digitalocean/netbox/issues/2444) - Improve validation of interface MAC addresses -* [#2455](https://github.com/digitalocean/netbox/issues/2455) - Ignore unique address enforcement for IPs with a shared/virtual role -* [#2470](https://github.com/digitalocean/netbox/issues/2470) - Log the creation of device/VM components as object changes - ---- - -v2.4.4 (2018-08-22) - -## Enhancements - -* [#2168](https://github.com/digitalocean/netbox/issues/2168) - Added Extreme SummitStack interface form factors -* [#2356](https://github.com/digitalocean/netbox/issues/2356) - Include cluster site as read-only field in VirtualMachine serializer -* [#2362](https://github.com/digitalocean/netbox/issues/2362) - Implemented custom admin site to properly handle BASE_PATH -* [#2254](https://github.com/digitalocean/netbox/issues/2254) - Implemented searchability for Rack Groups - -## Bug Fixes - -* [#2353](https://github.com/digitalocean/netbox/issues/2353) - Handle `DoesNotExist` exception when deleting a device with connected interfaces -* [#2354](https://github.com/digitalocean/netbox/issues/2354) - Increased maximum MTU for interfaces to 65536 bytes -* [#2355](https://github.com/digitalocean/netbox/issues/2355) - Added item count to inventory tab on device view -* [#2368](https://github.com/digitalocean/netbox/issues/2368) - Record change in device changelog when altering cluster assignment -* [#2369](https://github.com/digitalocean/netbox/issues/2369) - Corrected time zone validation on site API serializer -* [#2370](https://github.com/digitalocean/netbox/issues/2370) - Redirect to parent device after deleting device bays -* [#2374](https://github.com/digitalocean/netbox/issues/2374) - Fix toggling display of IP addresses in virtual machine interfaces list -* [#2378](https://github.com/digitalocean/netbox/issues/2378) - Corrected "edit" link for virtual machine interfaces - ---- - -v2.4.3 (2018-08-09) - -## Enhancements - -* [#2333](https://github.com/digitalocean/netbox/issues/2333) - Added search filters for ConfigContexts - -## Bug Fixes - -* [#2334](https://github.com/digitalocean/netbox/issues/2334) - TypeError raised when WritableNestedSerializer receives a non-integer value -* [#2335](https://github.com/digitalocean/netbox/issues/2335) - API requires group field when creating/updating a rack -* [#2336](https://github.com/digitalocean/netbox/issues/2336) - Bulk deleting power outlets and console server ports from a device redirects to home page -* [#2337](https://github.com/digitalocean/netbox/issues/2337) - Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError -* [#2340](https://github.com/digitalocean/netbox/issues/2340) - API requires manufacturer field when creating/updating an inventory item -* [#2342](https://github.com/digitalocean/netbox/issues/2342) - IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM -* [#2344](https://github.com/digitalocean/netbox/issues/2344) - AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site - ---- - -v2.4.2 (2018-08-08) - -## Bug Fixes - -* [#2318](https://github.com/digitalocean/netbox/issues/2318) - ImportError when viewing a report -* [#2319](https://github.com/digitalocean/netbox/issues/2319) - Extend ChoiceField to properly handle true/false choice keys -* [#2320](https://github.com/digitalocean/netbox/issues/2320) - TypeError when dispatching a webhook with a secret key configured -* [#2321](https://github.com/digitalocean/netbox/issues/2321) - Allow explicitly setting a null value on nullable ChoiceFields -* [#2322](https://github.com/digitalocean/netbox/issues/2322) - Webhooks firing on non-enabled event types -* [#2323](https://github.com/digitalocean/netbox/issues/2323) - DoesNotExist raised when deleting devices or virtual machines -* [#2330](https://github.com/digitalocean/netbox/issues/2330) - Incorrect tab link in VRF changelog view - ---- - -v2.4.1 (2018-08-07) - -## Bug Fixes - -* [#2303](https://github.com/digitalocean/netbox/issues/2303) - Always redirect to parent object when bulk editing/deleting components -* [#2308](https://github.com/digitalocean/netbox/issues/2308) - Custom fields panel absent from object view in UI -* [#2310](https://github.com/digitalocean/netbox/issues/2310) - False validation error on certain nested serializers -* [#2311](https://github.com/digitalocean/netbox/issues/2311) - Redirect to parent after editing interface from device/VM view -* [#2312](https://github.com/digitalocean/netbox/issues/2312) - Running a report yields a ValueError exception -* [#2314](https://github.com/digitalocean/netbox/issues/2314) - Serialized representation of object in change log does not include assigned tags - ---- - -v2.4.0 (2018-08-06) - -## New Features - -### Webhooks ([#81](https://github.com/digitalocean/netbox/issues/81)) - -Webhooks enable NetBox to send a representation of an object every time one is created, updated, or deleted. Webhooks are sent from NetBox to external services via HTTP, and can be limited by object type. Services which receive a webhook can act on the data provided by NetBox to automate other tasks. - -Special thanks to [John Anderson](https://github.com/lampwins) for doing the heavy lifting for this feature! - -### Tagging ([#132](https://github.com/digitalocean/netbox/issues/132)) - -Tags are free-form labels which can be assigned to a variety of objects in NetBox. Tags can be used to categorize and filter objects in addition to built-in and custom fields. Objects to which tags apply now include a `tags` field in the API. - -### Contextual Configuration Data ([#1349](https://github.com/digitalocean/netbox/issues/1349)) - -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 (expressed in JSON format) to devices and virtual machines grouped by region, site, role, platform, and/or tenancy. 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. - -### Change Logging ([#1898](https://github.com/digitalocean/netbox/issues/1898)) - -When an object is created, updated, or deleted, NetBox now automatically records a serialized representation of that object (similar to how it appears in the REST API) as well the event time and user account associated with the change. - -## Enhancements - -* [#238](https://github.com/digitalocean/netbox/issues/238) - Allow racks with the same name within a site (but in different groups) -* [#971](https://github.com/digitalocean/netbox/issues/971) - Add a view to show all VLAN IDs available within a group -* [#1673](https://github.com/digitalocean/netbox/issues/1673) - Added object/list views for services -* [#1687](https://github.com/digitalocean/netbox/issues/1687) - Enabled custom fields for services -* [#1739](https://github.com/digitalocean/netbox/issues/1739) - Enabled custom fields for secrets -* [#1794](https://github.com/digitalocean/netbox/issues/1794) - Improved POST/PATCH representation of nested objects -* [#2029](https://github.com/digitalocean/netbox/issues/2029) - Added optional NAPALM arguments to Platform model -* [#2034](https://github.com/digitalocean/netbox/issues/2034) - Include the ID when showing nested interface connections (API change) -* [#2118](https://github.com/digitalocean/netbox/issues/2118) - Added `latitude` and `longitude` fields to Site for GPS coordinates -* [#2131](https://github.com/digitalocean/netbox/issues/2131) - Added `created` and `last_updated` fields to DeviceType -* [#2157](https://github.com/digitalocean/netbox/issues/2157) - Fixed natural ordering of objects when sorted by name -* [#2225](https://github.com/digitalocean/netbox/issues/2225) - Add "view elevations" button for site rack groups - -## Bug Fixes - -* [#2272](https://github.com/digitalocean/netbox/issues/2272) - Allow subdevice_role to be null on DeviceTypeSerializer" -* [#2286](https://github.com/digitalocean/netbox/issues/2286) - Fixed "mark connected" button for PDU outlet connections - -## API Changes - -* Introduced the `/extras/config-contexts/`, `/extras/object-changes/`, and `/extras/tags/` API endpoints -* API writes now return a nested representation of related objects (rather than only a numeric ID) -* The dcim.DeviceType serializer now includes `created` and `last_updated` fields -* The dcim.Site serializer now includes `latitude` and `longitude` fields -* The ipam.Service and secrets.Secret serializers now include custom fields -* The dcim.Platform serializer now includes a free-form (JSON) `napalm_args` field - -## Changes Since v2.4-beta1 - -### Enhancements - -* [#2229](https://github.com/digitalocean/netbox/issues/2229) - Allow mapping of ConfigContexts to tenant groups -* [#2259](https://github.com/digitalocean/netbox/issues/2259) - Add changelog tab to interface view -* [#2264](https://github.com/digitalocean/netbox/issues/2264) - Added "map it" link for site GPS coordinates - -### Bug Fixes - -* [#2137](https://github.com/digitalocean/netbox/issues/2137) - Fixed JSON serialization of dates -* [#2258](https://github.com/digitalocean/netbox/issues/2258) - Include changed object type on home page changelog -* [#2265](https://github.com/digitalocean/netbox/issues/2265) - Include parent regions when filtering applicable ConfigContexts -* [#2288](https://github.com/digitalocean/netbox/issues/2288) - Fix exception when assigning objects to a ConfigContext via the API -* [#2296](https://github.com/digitalocean/netbox/issues/2296) - Fix AttributeError when creating a new object with tags assigned -* [#2300](https://github.com/digitalocean/netbox/issues/2300) - Fix assignment of an interface to an IP address via API PATCH -* [#2301](https://github.com/digitalocean/netbox/issues/2301) - Fix model validation on assignment of ManyToMany fields via API PATCH -* [#2305](https://github.com/digitalocean/netbox/issues/2305) - Make VLAN fields optional when creating a VM interface via the API - ---- - -v2.3.7 (2018-07-26) - -## Enhancements - -* [#2166](https://github.com/digitalocean/netbox/issues/2166) - Enable partial matching on device asset_tag during search - -## Bug Fixes - -* [#1977](https://github.com/digitalocean/netbox/issues/1977) - Fixed exception when creating a virtual chassis with a non-master device in position 1 -* [#1992](https://github.com/digitalocean/netbox/issues/1992) - Isolate errors when one of multiple NAPALM methods fails -* [#2202](https://github.com/digitalocean/netbox/issues/2202) - Ditched half-baked concept of tenancy inheritance via VRF -* [#2222](https://github.com/digitalocean/netbox/issues/2222) - IP addresses created via the `available-ips` API endpoint should have the same mask as their parent prefix (not /32) -* [#2231](https://github.com/digitalocean/netbox/issues/2231) - Remove `get_absolute_url()` from DeviceRole (can apply to devices or VMs) -* [#2250](https://github.com/digitalocean/netbox/issues/2250) - Include stat counters on report result navigation -* [#2255](https://github.com/digitalocean/netbox/issues/2255) - Corrected display of results in reports list -* [#2256](https://github.com/digitalocean/netbox/issues/2256) - Prevent navigation menu overlap when jumping to test results on report page -* [#2257](https://github.com/digitalocean/netbox/issues/2257) - Corrected casting of RIR utilization stats as floats -* [#2266](https://github.com/digitalocean/netbox/issues/2266) - Permit additional logging of exceptions beyond custom middleware - ---- - -v2.3.6 (2018-07-16) - -## Enhancements - -* [#2107](https://github.com/digitalocean/netbox/issues/2107) - Added virtual chassis to global search -* [#2125](https://github.com/digitalocean/netbox/issues/2125) - Show child status in device bay list - -## Bug Fixes - -* [#2214](https://github.com/digitalocean/netbox/issues/2214) - Error when assigning a VLAN to an interface on a VM in a cluster with no assigned site -* [#2239](https://github.com/digitalocean/netbox/issues/2239) - Pin django-filter to version 1.1.0 - ---- - -v2.3.5 (2018-07-02) - -## Enhancements - -* [#2159](https://github.com/digitalocean/netbox/issues/2159) - Allow custom choice field to specify a default choice -* [#2177](https://github.com/digitalocean/netbox/issues/2177) - Include device serial number in rack elevation pop-up -* [#2194](https://github.com/digitalocean/netbox/issues/2194) - Added `address` filter to IPAddress model - -## Bug Fixes - -* [#1826](https://github.com/digitalocean/netbox/issues/1826) - Corrected description of security parameters under API definition -* [#2021](https://github.com/digitalocean/netbox/issues/2021) - Fix recursion error when viewing API docs under Python 3.4 -* [#2064](https://github.com/digitalocean/netbox/issues/2064) - Disable calls to online swagger validator -* [#2173](https://github.com/digitalocean/netbox/issues/2173) - Fixed IndexError when automatically allocating IP addresses from large IPv6 prefixes -* [#2181](https://github.com/digitalocean/netbox/issues/2181) - Raise validation error on invalid `prefix_length` when allocating next-available prefix -* [#2182](https://github.com/digitalocean/netbox/issues/2182) - ValueError can be raised when viewing the interface connections table -* [#2191](https://github.com/digitalocean/netbox/issues/2191) - Added missing static choices to circuits and DCIM API endpoints -* [#2192](https://github.com/digitalocean/netbox/issues/2192) - Prevent a 0U device from being assigned to a rack position - ---- - -v2.3.4 (2018-06-07) - -## Bug Fixes - -* [#2066](https://github.com/digitalocean/netbox/issues/2066) - Catch `AddrFormatError` exception on invalid IP addresses -* [#2075](https://github.com/digitalocean/netbox/issues/2075) - Enable tenant assignment when creating a rack reservation via the API -* [#2083](https://github.com/digitalocean/netbox/issues/2083) - Add missing export button to rack roles list view -* [#2087](https://github.com/digitalocean/netbox/issues/2087) - Don't overwrite existing vc_position of master device when creating a virtual chassis -* [#2093](https://github.com/digitalocean/netbox/issues/2093) - Fix link to circuit termination in device interfaces table -* [#2097](https://github.com/digitalocean/netbox/issues/2097) - Fixed queryset-based bulk deletion of clusters and regions -* [#2098](https://github.com/digitalocean/netbox/issues/2098) - Fixed missing checkboxes for host devices in cluster view -* [#2127](https://github.com/digitalocean/netbox/issues/2127) - Prevent non-conntectable interfaces from being connected -* [#2143](https://github.com/digitalocean/netbox/issues/2143) - Accept null value for empty time zone field -* [#2148](https://github.com/digitalocean/netbox/issues/2148) - Do not force timezone selection when editing sites in bulk -* [#2150](https://github.com/digitalocean/netbox/issues/2150) - Fix display of LLDP neighbors when interface name contains a colon - ---- - -v2.3.3 (2018-04-19) - -## Enhancements - -* [#1990](https://github.com/digitalocean/netbox/issues/1990) - Improved search function when assigning an IP address to an interface - -## Bug Fixes - -* [#1975](https://github.com/digitalocean/netbox/issues/1975) - Correct filtering logic for custom boolean fields -* [#1988](https://github.com/digitalocean/netbox/issues/1988) - Order interfaces naturally when bulk renaming -* [#1993](https://github.com/digitalocean/netbox/issues/1993) - Corrected status choices in site CSV import form -* [#1999](https://github.com/digitalocean/netbox/issues/1999) - Added missing description field to site edit form -* [#2012](https://github.com/digitalocean/netbox/issues/2012) - Fixed deselection of an IP address as the primary IP for its parent device/VM -* [#2014](https://github.com/digitalocean/netbox/issues/2014) - Allow assignment of VLANs to VM interfaces via the API -* [#2019](https://github.com/digitalocean/netbox/issues/2019) - Avoid casting oversized numbers as integers -* [#2022](https://github.com/digitalocean/netbox/issues/2022) - Show 0 for zero-value fields on CSV export -* [#2023](https://github.com/digitalocean/netbox/issues/2023) - Manufacturer should not be a required field when importing platforms -* [#2037](https://github.com/digitalocean/netbox/issues/2037) - Fixed IndexError exception when attempting to create a new rack reservation - ---- - -v2.3.2 (2018-03-22) - -## Enhancements - -* [#1586](https://github.com/digitalocean/netbox/issues/1586) - Extend bulk interface creation to support alphanumeric characters -* [#1866](https://github.com/digitalocean/netbox/issues/1866) - Introduced AnnotatedMultipleChoiceField for filter forms -* [#1930](https://github.com/digitalocean/netbox/issues/1930) - Switched to drf-yasg for Swagger API documentation -* [#1944](https://github.com/digitalocean/netbox/issues/1944) - Enable assigning VLANs to virtual machine interfaces -* [#1945](https://github.com/digitalocean/netbox/issues/1945) - Implemented a VLAN members view -* [#1949](https://github.com/digitalocean/netbox/issues/1949) - Added a button to view elevations on rack groups list -* [#1952](https://github.com/digitalocean/netbox/issues/1952) - Implemented a more robust mechanism for assigning VLANs to interfaces - -## Bug Fixes - -* [#1948](https://github.com/digitalocean/netbox/issues/1948) - Fix TypeError when attempting to add a member to an existing virtual chassis -* [#1951](https://github.com/digitalocean/netbox/issues/1951) - Fix TypeError exception when importing platforms -* [#1953](https://github.com/digitalocean/netbox/issues/1953) - Ignore duplicate IPs when calculating prefix utilization -* [#1955](https://github.com/digitalocean/netbox/issues/1955) - Require a plaintext value when creating a new secret -* [#1978](https://github.com/digitalocean/netbox/issues/1978) - Include all virtual chassis member interfaces in LLDP neighbors view -* [#1980](https://github.com/digitalocean/netbox/issues/1980) - Fixed bug when trying to nullify a selection custom field under Python 2 - ---- - -v2.3.1 (2018-03-01) - -## Enhancements - -* [#1910](https://github.com/digitalocean/netbox/issues/1910) - Added filters for cluster group and cluster type - -## Bug Fixes - -* [#1915](https://github.com/digitalocean/netbox/issues/1915) - Redirect to device view after deleting a component -* [#1919](https://github.com/digitalocean/netbox/issues/1919) - Prevent exception when attempting to create a virtual machine without selecting devices -* [#1921](https://github.com/digitalocean/netbox/issues/1921) - Ignore ManyToManyFields when validating a new object created via the API -* [#1924](https://github.com/digitalocean/netbox/issues/1924) - Include VID in VLAN lists when editing an interface -* [#1926](https://github.com/digitalocean/netbox/issues/1926) - Prevent reassignment of parent device when bulk editing VC member interfaces -* [#1927](https://github.com/digitalocean/netbox/issues/1927) - Include all VC member interfaces on A side when creating a new interface connection -* [#1928](https://github.com/digitalocean/netbox/issues/1928) - Fixed form validation when modifying VLANs assigned to an interface -* [#1934](https://github.com/digitalocean/netbox/issues/1934) - Fixed exception when rendering export template on an object type with custom fields assigned -* [#1935](https://github.com/digitalocean/netbox/issues/1935) - Correct API validation of VLANs assigned to interfaces -* [#1936](https://github.com/digitalocean/netbox/issues/1936) - Trigger validation error when attempting to create a virtual chassis without specifying member positions - ---- - -v2.3.0 (2018-02-26) - -## New Features - -### Virtual Chassis ([#99](https://github.com/digitalocean/netbox/issues/99)) - -A virtual chassis represents a set of physical devices with a shared control plane; for example, a stack of switches managed as a single device. Viewing the master device of a virtual chassis will show all member interfaces and IP addresses. - -### Interface VLAN Assignments ([#150](https://github.com/digitalocean/netbox/issues/150)) - -Interfaces can now be assigned an 802.1Q mode (access or trunked) and associated with particular VLANs. Thanks to [John Anderson](https://github.com/lampwins) for his work on this! - -### Bulk Object Creation via the API ([#1553](https://github.com/digitalocean/netbox/issues/1553)) - -The REST API now supports the creation of multiple objects of the same type using a single POST request. For example, to create multiple devices: - -``` -curl -X POST -H "Authorization: Token " -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[ -{"name": "device1", "device_type": 24, "device_role": 17, "site": 6}, -{"name": "device2", "device_type": 24, "device_role": 17, "site": 6}, -{"name": "device3", "device_type": 24, "device_role": 17, "site": 6}, -]' -``` - -Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. - -### Automatic Provisioning of Next Available Prefixes ([#1694](https://github.com/digitalocean/netbox/issues/1694)) - -Similar to IP addresses, NetBox now supports automated provisioning of available prefixes from within a parent prefix. For example, to retrieve the next three available /28s within a parent /24: - -``` -curl -X POST -H "Authorization: Token " -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/ipam/prefixes/10153/available-prefixes/ --data '[ -{"prefix_length": 28}, -{"prefix_length": 28}, -{"prefix_length": 28} -]' -``` - -If the parent prefix cannot accommodate all requested prefixes, the operation is cancelled and no new prefixes are created. - -### Bulk Renaming of Device/VM Components ([#1781](https://github.com/digitalocean/netbox/issues/1781)) - -Device components (interfaces, console ports, etc.) can now be renamed in bulk via the web interface. This was implemented primarily to support the bulk renumbering of interfaces whose parent is part of a virtual chassis. - -## Enhancements - -* [#1283](https://github.com/digitalocean/netbox/issues/1283) - Added a `time_zone` field to the site model -* [#1321](https://github.com/digitalocean/netbox/issues/1321) - Added `created` and `last_updated` fields for relevant models to their API serializers -* [#1553](https://github.com/digitalocean/netbox/issues/1553) - Introduced support for bulk object creation via the API -* [#1592](https://github.com/digitalocean/netbox/issues/1592) - Added tenancy assignment for rack reservations -* [#1744](https://github.com/digitalocean/netbox/issues/1744) - Allow associating a platform with a specific manufacturer -* [#1758](https://github.com/digitalocean/netbox/issues/1758) - Added a `status` field to the site model -* [#1821](https://github.com/digitalocean/netbox/issues/1821) - Added a `description` field to the site model -* [#1864](https://github.com/digitalocean/netbox/issues/1864) - Added a `status` field to the circuit model - -## Bug Fixes - -* [#1136](https://github.com/digitalocean/netbox/issues/1136) - Enforce model validation during bulk update -* [#1645](https://github.com/digitalocean/netbox/issues/1645) - Simplified interface serialzier for IP addresses and optimized API view queryset -* [#1838](https://github.com/digitalocean/netbox/issues/1838) - Fix KeyError when attempting to create a VirtualChassis with no devices selected -* [#1847](https://github.com/digitalocean/netbox/issues/1847) - RecursionError when a virtual chasis master device has no name -* [#1848](https://github.com/digitalocean/netbox/issues/1848) - Allow null value for interface encapsulation mode -* [#1867](https://github.com/digitalocean/netbox/issues/1867) - Allow filtering on device status with multiple values -* [#1881](https://github.com/digitalocean/netbox/issues/1881)* - Fixed bulk editing of interface 802.1Q settings -* [#1884](https://github.com/digitalocean/netbox/issues/1884)* - Provide additional context to identify devices when creating/editing a virtual chassis -* [#1907](https://github.com/digitalocean/netbox/issues/1907) - Allow removing an IP as the primary for a device when editing the IP directly - -\* New since v2.3-beta2 - -## Breaking Changes - -* Constants representing device status have been renamed for clarity (for example, `STATUS_ACTIVE` is now `DEVICE_STATUS_ACTIVE`). Custom validation reports will need to be updated if they reference any of these constants. - -## API Changes - -* API creation calls now accept either a single JSON object or a list of JSON objects. If multiple objects are passed and one or more them fail validation, no objects will be created. -* Added `created` and `last_updated` fields for objects inheriting from CreatedUpdatedModel. -* Removed the `parent` filter for prefixes (use `within` or `within_include` instead). -* The IP address serializer now includes only a minimal nested representation of the assigned interface (if any) and its parent device or virtual machine. -* The rack reservation serializer now includes a nested representation of its owning user (as well as the assigned tenant, if any). -* Added endpoints for virtual chassis and VC memberships. -* Added `status`, `time_zone` (pytz format), and `description` fields to dcim.Site. -* Added a `manufacturer` foreign key field on dcim.Platform. -* Added a `status` field on circuits.Circuit. - ---- - -v2.2.10 (2018-02-21) - -## Enhancements - -* [#78](https://github.com/digitalocean/netbox/issues/78) - Extended topology maps to support console and power connections -* [#1693](https://github.com/digitalocean/netbox/issues/1693) - Allow specifying loose or exact matching for custom field filters -* [#1714](https://github.com/digitalocean/netbox/issues/1714) - Standardized CSV export functionality for all object lists -* [#1876](https://github.com/digitalocean/netbox/issues/1876) - Added explanatory title text to disabled NAPALM buttons on device view -* [#1885](https://github.com/digitalocean/netbox/issues/1885) - Added a device filter field for primary IP - -## Bug Fixes - -* [#1858](https://github.com/digitalocean/netbox/issues/1858) - Include device/VM count for cluster list in global search results -* [#1859](https://github.com/digitalocean/netbox/issues/1859) - Implemented support for line breaks within CSV fields -* [#1860](https://github.com/digitalocean/netbox/issues/1860) - Do not populate initial values for custom fields when editing objects in bulk -* [#1869](https://github.com/digitalocean/netbox/issues/1869) - Corrected ordering of VRFs with duplicate names -* [#1886](https://github.com/digitalocean/netbox/issues/1886) - Allow setting the primary IPv4/v6 address for a virtual machine via the web UI - ---- - -v2.2.9 (2018-01-31) - -## Enhancements - -* [#144](https://github.com/digitalocean/netbox/issues/144) - Implemented bulk import/edit/delete views for InventoryItems -* [#1073](https://github.com/digitalocean/netbox/issues/1073) - Include prefixes/IPs from all VRFs when viewing the children of a container prefix in the global table -* [#1366](https://github.com/digitalocean/netbox/issues/1366) - Enable searching for regions by name/slug -* [#1406](https://github.com/digitalocean/netbox/issues/1406) - Display tenant description as title text in object tables -* [#1824](https://github.com/digitalocean/netbox/issues/1824) - Add virtual machine count to platforms list -* [#1835](https://github.com/digitalocean/netbox/issues/1835) - Consistent positioning of previous/next rack buttons - -## Bug Fixes - -* [#1621](https://github.com/digitalocean/netbox/issues/1621) - Tweaked LLDP interface name evaluation logic -* [#1765](https://github.com/digitalocean/netbox/issues/1765) - Improved rendering of null options for model choice fields in filter forms -* [#1807](https://github.com/digitalocean/netbox/issues/1807) - Populate VRF from parent when creating a new prefix -* [#1809](https://github.com/digitalocean/netbox/issues/1809) - Populate tenant assignment from parent when creating a new prefix -* [#1818](https://github.com/digitalocean/netbox/issues/1818) - InventoryItem API serializer no longer requires specifying a null value for items with no parent -* [#1845](https://github.com/digitalocean/netbox/issues/1845) - Correct display of VMs in list with no role assigned -* [#1850](https://github.com/digitalocean/netbox/issues/1850) - Fix TypeError when attempting IP address import if only unnamed devices exist - ---- - -v2.2.8 (2017-12-20) - -## Enhancements - -* [#1771](https://github.com/digitalocean/netbox/issues/1771) - Added name filter for racks -* [#1772](https://github.com/digitalocean/netbox/issues/1772) - Added position filter for devices -* [#1773](https://github.com/digitalocean/netbox/issues/1773) - Moved child prefixes table to its own view -* [#1774](https://github.com/digitalocean/netbox/issues/1774) - Include a button to refine search results for all object types under global search -* [#1784](https://github.com/digitalocean/netbox/issues/1784) - Added `cluster_type` filters for virtual machines - -## Bug Fixes - -* [#1766](https://github.com/digitalocean/netbox/issues/1766) - Fixed display of "select all" button on device power outlets list -* [#1767](https://github.com/digitalocean/netbox/issues/1767) - Use proper template for 404 responses -* [#1778](https://github.com/digitalocean/netbox/issues/1778) - Preserve initial VRF assignment when adding IP addresses in bulk from a prefix -* [#1783](https://github.com/digitalocean/netbox/issues/1783) - Added `vm_role` filter for device roles -* [#1785](https://github.com/digitalocean/netbox/issues/1785) - Omit filter forms from browsable API -* [#1787](https://github.com/digitalocean/netbox/issues/1787) - Added missing site field to virtualization cluster CSV export - ---- - -v2.2.7 (2017-12-07) - -## Enhancements - -* [#1722](https://github.com/digitalocean/netbox/issues/1722) - Added virtual machine count to site view -* [#1737](https://github.com/digitalocean/netbox/issues/1737) - Added a `contains` API filter to find all prefixes containing a given IP or prefix - -## Bug Fixes - -* [#1712](https://github.com/digitalocean/netbox/issues/1712) - Corrected tenant inheritance for new IP addresses created from a parent prefix -* [#1721](https://github.com/digitalocean/netbox/issues/1721) - Differentiated child IP count from utilization percentage for prefixes -* [#1740](https://github.com/digitalocean/netbox/issues/1740) - Delete session_key cookie on logout -* [#1741](https://github.com/digitalocean/netbox/issues/1741) - Fixed Unicode support for secret plaintexts -* [#1743](https://github.com/digitalocean/netbox/issues/1743) - Include number of instances for device types in global search -* [#1751](https://github.com/digitalocean/netbox/issues/1751) - Corrected filtering for IPv6 addresses containing letters -* [#1756](https://github.com/digitalocean/netbox/issues/1756) - Improved natural ordering of console server ports and power outlets - ---- - -v2.2.6 (2017-11-16) - -## Enhancements - -* [#1669](https://github.com/digitalocean/netbox/issues/1669) - Clicking "add an IP" from the prefix view will default to the first available IP within the prefix - -## Bug Fixes - -* [#1397](https://github.com/digitalocean/netbox/issues/1397) - Display global search in navigation menu unless display is less than 1200px wide -* [#1599](https://github.com/digitalocean/netbox/issues/1599) - Reduce mobile cut-off for navigation menu to 960px -* [#1715](https://github.com/digitalocean/netbox/issues/1715) - Added missing import buttons on object lists -* [#1717](https://github.com/digitalocean/netbox/issues/1717) - Fixed interface validation for virtual machines -* [#1718](https://github.com/digitalocean/netbox/issues/1718) - Set empty label to "Global" or VRF field in IP assignment form - ---- - -v2.2.5 (2017-11-14) - -## Enhancements - -* [#1512](https://github.com/digitalocean/netbox/issues/1512) - Added a view to search for an IP address being assigned to an interface -* [#1679](https://github.com/digitalocean/netbox/issues/1679) - Added IP address roles to device/VM interface lists -* [#1683](https://github.com/digitalocean/netbox/issues/1683) - Replaced default 500 handler with custom middleware to provide preliminary troubleshooting assistance -* [#1684](https://github.com/digitalocean/netbox/issues/1684) - Replaced prefix `parent` filter with `within` and `within_include` - -## Bug Fixes - -* [#1471](https://github.com/digitalocean/netbox/issues/1471) - Correct bulk selection of IP addresses within a prefix assigned to a VRF -* [#1642](https://github.com/digitalocean/netbox/issues/1642) - Validate device type classification when creating console server ports and power outlets -* [#1650](https://github.com/digitalocean/netbox/issues/1650) - Correct numeric ordering for interfaces with no alphabetic type -* [#1676](https://github.com/digitalocean/netbox/issues/1676) - Correct filtering of child prefixes upon bulk edit/delete from the parent prefix view -* [#1689](https://github.com/digitalocean/netbox/issues/1689) - Disregard IP address mask when filtering for child IPs of a prefix -* [#1696](https://github.com/digitalocean/netbox/issues/1696) - Fix for NAPALM v2.0+ -* [#1699](https://github.com/digitalocean/netbox/issues/1699) - Correct nested representation in the API of primary IPs for virtual machines and add missing primary_ip property -* [#1701](https://github.com/digitalocean/netbox/issues/1701) - Fixed validation in `extras/0008_reports.py` migration for certain versions of PostgreSQL -* [#1703](https://github.com/digitalocean/netbox/issues/1703) - Added API serializer validation for custom integer fields -* [#1705](https://github.com/digitalocean/netbox/issues/1705) - Fixed filtering of devices with a status of offline - ---- - -v2.2.4 (2017-10-31) - -## Bug Fixes - -* [#1670](https://github.com/digitalocean/netbox/issues/1670) - Fixed server error when calling certain filters (regression from #1649) - ---- - -v2.2.3 (2017-10-31) - -## Enhancements - -* [#999](https://github.com/digitalocean/netbox/issues/999) - Display devices on which circuits are terminated in circuits list -* [#1491](https://github.com/digitalocean/netbox/issues/1491) - Added initial data for the virtualization app -* [#1620](https://github.com/digitalocean/netbox/issues/1620) - Loosen IP address search filter to match all IPs that start with the given string -* [#1631](https://github.com/digitalocean/netbox/issues/1631) - Added a `post_run` method to the Report class -* [#1666](https://github.com/digitalocean/netbox/issues/1666) - Allow modifying the owner of a rack reservation - -## Bug Fixes - -* [#1513](https://github.com/digitalocean/netbox/issues/1513) - Correct filtering of custom field choices -* [#1603](https://github.com/digitalocean/netbox/issues/1603) - Hide selection checkboxes for tables with no available actions -* [#1618](https://github.com/digitalocean/netbox/issues/1618) - Allow bulk deletion of all virtual machines -* [#1619](https://github.com/digitalocean/netbox/issues/1619) - Correct text-based filtering of IP network and address fields -* [#1624](https://github.com/digitalocean/netbox/issues/1624) - Add VM count to device roles table -* [#1634](https://github.com/digitalocean/netbox/issues/1634) - Cluster should not be a required field when importing child devices -* [#1649](https://github.com/digitalocean/netbox/issues/1649) - Correct filtering on null values (e.g. ?tenant_id=0) for django-filters v1.1.0+ -* [#1653](https://github.com/digitalocean/netbox/issues/1653) - Remove outdated description for DeviceType's `is_network_device` flag -* [#1664](https://github.com/digitalocean/netbox/issues/1664) - Added missing `serial` field in default rack CSV export - ---- - -v2.2.2 (2017-10-17) - -## Enhancements - -* [#1580](https://github.com/digitalocean/netbox/issues/1580) - Allow cluster assignment when bulk importing devices -* [#1587](https://github.com/digitalocean/netbox/issues/1587) - Add primary IP column for virtual machines in global search results - -## Bug Fixes - -* [#1498](https://github.com/digitalocean/netbox/issues/1498) - Avoid duplicating nodes when generating topology maps -* [#1579](https://github.com/digitalocean/netbox/issues/1579) - Devices already assigned to a cluster cannot be added to a different cluster -* [#1582](https://github.com/digitalocean/netbox/issues/1582) - Add `virtual_machine` attribute to IPAddress -* [#1584](https://github.com/digitalocean/netbox/issues/1584) - Colorized virtual machine role column -* [#1585](https://github.com/digitalocean/netbox/issues/1585) - Fixed slug-based filtering of virtual machines -* [#1605](https://github.com/digitalocean/netbox/issues/1605) - Added clusters and virtual machines to object list for global search -* [#1609](https://github.com/digitalocean/netbox/issues/1609) - Added missing `virtual_machine` field to IP address interface serializer - ---- - -v2.2.1 (2017-10-12) - -## Bug Fixes - -* [#1576](https://github.com/digitalocean/netbox/issues/1576) - Moved PostgreSQL validation logic into the relevant migration (fixed ImproperlyConfigured exception on init) - ---- - -v2.2.0 (2017-10-12) - -**Note:** This release requires PostgreSQL 9.4 or higher. Do not attempt to upgrade unless you are running at least PostgreSQL 9.4. - -**Note:** The release replaces the deprecated pycrypto library with [pycryptodome](https://github.com/Legrandin/pycryptodome). The upgrade script has been extended to automatically uninstall the old library, but please verify your installed packages with `pip freeze | grep pycrypto` if you run into problems. - -## New Features - -### Virtual Machines and Clusters ([#142](https://github.com/digitalocean/netbox/issues/142)) - -Our second-most popular feature request has arrived! NetBox now supports the creation of virtual machines, which can be assigned virtual interfaces and IP addresses. VMs are arranged into clusters, each of which has a type and (optionally) a group. - -### Custom Validation Reports ([#1511](https://github.com/digitalocean/netbox/issues/1511)) - -Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. - -## Enhancements - -* [#494](https://github.com/digitalocean/netbox/issues/494) - Include asset tag in device info pop-up on rack elevation -* [#1444](https://github.com/digitalocean/netbox/issues/1444) - Added a `serial` field to the rack model -* [#1479](https://github.com/digitalocean/netbox/issues/1479) - Added an IP address role for CARP -* [#1506](https://github.com/digitalocean/netbox/issues/1506) - Extended rack facility ID field from 30 to 50 characters -* [#1510](https://github.com/digitalocean/netbox/issues/1510) - Added ability to search by name when adding devices to a cluster -* [#1527](https://github.com/digitalocean/netbox/issues/1527) - Replace deprecated pycrypto library with pycryptodome -* [#1551](https://github.com/digitalocean/netbox/issues/1551) - Added API endpoints listing static field choices for each app -* [#1556](https://github.com/digitalocean/netbox/issues/1556) - Added CPAK, CFP2, and CFP4 100GE interface form factors -* Added CSV import views for all object types - -## Bug Fixes - -* [#1550](https://github.com/digitalocean/netbox/issues/1550) - Corrected interface connections link in navigation menu -* [#1554](https://github.com/digitalocean/netbox/issues/1554) - Don't require form_factor when creating an interface assigned to a virtual machine -* [#1557](https://github.com/digitalocean/netbox/issues/1557) - Added filtering for virtual machine interfaces -* [#1567](https://github.com/digitalocean/netbox/issues/1567) - Prompt user for session key when importing secrets - -## API Changes - -* Introduced the virtualization app and its associated endpoints at `/api/virtualization` -* Added the `/api/extras/reports` endpoint for fetching and running reports -* The `ipam.Service` and `dcim.Interface` models now have a `virtual_machine` field in addition to the `device` field. Only one of the two fields may be defined for each object -* Added a `vm_role` field to `dcim.DeviceRole`, which indicates whether a role is suitable for assigned to a virtual machine -* Added a `serial` field to 'dcim.Rack` for serial numbers -* Each app now has a `_choices` endpoint, which lists the available options for all model field with static choices (e.g. interface form factors) - ---- - -v2.1.6 (2017-10-11) - -## Enhancements - -* [#1548](https://github.com/digitalocean/netbox/issues/1548) - Automatically populate tenant assignment when adding an IP address from the prefix view -* [#1561](https://github.com/digitalocean/netbox/issues/1561) - Added primary IP to the devices table in global search -* [#1563](https://github.com/digitalocean/netbox/issues/1563) - Made necessary updates for Django REST Framework v3.7.0 - ---- - -v2.1.5 (2017-09-25) - -## Enhancements - -* [#1484](https://github.com/digitalocean/netbox/issues/1484) - Added individual "add VLAN" buttons on the VLAN groups list -* [#1485](https://github.com/digitalocean/netbox/issues/1485) - Added `BANNER_LOGIN` configuration setting to display a banner on the login page -* [#1499](https://github.com/digitalocean/netbox/issues/1499) - Added utilization graph to child prefixes table -* [#1523](https://github.com/digitalocean/netbox/issues/1523) - Improved the natural ordering of interfaces (thanks to [@tarkatronic](https://github.com/tarkatronic)) -* [#1536](https://github.com/digitalocean/netbox/issues/1536) - Improved formatting of aggregate prefix statistics - -## Bug Fixes - -* [#1469](https://github.com/digitalocean/netbox/issues/1469) - Allow a NAT IP to be assigned as the primary IP for a device -* [#1472](https://github.com/digitalocean/netbox/issues/1472) - Prevented truncation when displaying secret strings containing HTML characters -* [#1486](https://github.com/digitalocean/netbox/issues/1486) - Ignore subinterface IDs when validating LLDP neighbor connections -* [#1489](https://github.com/digitalocean/netbox/issues/1489) - Corrected server error on validation of empty required custom field -* [#1507](https://github.com/digitalocean/netbox/issues/1507) - Fixed error when creating the next available IP from a prefix within a VRF -* [#1520](https://github.com/digitalocean/netbox/issues/1520) - Redirect on GET request to bulk edit/delete views -* [#1522](https://github.com/digitalocean/netbox/issues/1522) - Removed object create/edit forms from the browsable API - ---- - -v2.1.4 (2017-08-30) - -## Enhancements - -* [#1326](https://github.com/digitalocean/netbox/issues/1326) - Added dropdown widget with common values for circuit speed fields -* [#1341](https://github.com/digitalocean/netbox/issues/1341) - Added a `MEDIA_ROOT` configuration setting to specify where uploaded files are stored on disk -* [#1376](https://github.com/digitalocean/netbox/issues/1376) - Ignore anycast addresses when detecting duplicate IPs -* [#1402](https://github.com/digitalocean/netbox/issues/1402) - Increased max length of name field for device components -* [#1431](https://github.com/digitalocean/netbox/issues/1431) - Added interface form factor for 10GBASE-CX4 -* [#1432](https://github.com/digitalocean/netbox/issues/1432) - Added a `commit_rate` field to the circuits list search form -* [#1460](https://github.com/digitalocean/netbox/issues/1460) - Hostnames with no domain are now acceptable in custom URL fields - -## Bug Fixes - -* [#1429](https://github.com/digitalocean/netbox/issues/1429) - Fixed uptime formatting on device status page -* [#1433](https://github.com/digitalocean/netbox/issues/1433) - Fixed `devicetype_id` filter for DeviceType components -* [#1443](https://github.com/digitalocean/netbox/issues/1443) - Fixed API validation error involving custom field data -* [#1458](https://github.com/digitalocean/netbox/issues/1458) - Corrected permission name on prefix/VLAN roles list - ---- - -v2.1.3 (2017-08-15) - -## Bug Fixes - -* [#1330](https://github.com/digitalocean/netbox/issues/1330) - Raise validation error when assigning an unrelated IP as the primary IP for a device -* [#1389](https://github.com/digitalocean/netbox/issues/1389) - Avoid splitting carat/prefix on prefix list -* [#1400](https://github.com/digitalocean/netbox/issues/1400) - Removed redundant display of assigned device interface from IP address list -* [#1414](https://github.com/digitalocean/netbox/issues/1414) - Selecting a site from the rack filters automatically updates the available rack groups -* [#1419](https://github.com/digitalocean/netbox/issues/1419) - Allow editing image attachments without re-uploading an image -* [#1420](https://github.com/digitalocean/netbox/issues/1420) - Exclude virtual interfaces from device LLDP neighbors view -* [#1421](https://github.com/digitalocean/netbox/issues/1421) - Improved model validation logic for API serializers -* Fixed page title capitalization in the browsable API - ---- - -v2.1.2 (2017-08-04) - -## Enhancements - -* [#992](https://github.com/digitalocean/netbox/issues/992) - Allow the creation of multiple services per device with the same protocol and port -* Tweaked navigation menu styling - -## Bug Fixes - -* [#1388](https://github.com/digitalocean/netbox/issues/1388) - Fixed server error when searching globally for IPs/prefixes (rolled back #1379) -* [#1390](https://github.com/digitalocean/netbox/issues/1390) - Fixed IndexError when viewing available IPs within large IPv6 prefixes - ---- - -v2.1.1 (2017-08-02) - -## Enhancements - -* [#893](https://github.com/digitalocean/netbox/issues/893) - Allow filtering by null values for NullCharacterFields (e.g. return only unnamed devices) -* [#1368](https://github.com/digitalocean/netbox/issues/1368) - Render reservations in rack elevations view -* [#1374](https://github.com/digitalocean/netbox/issues/1374) - Added NAPALM_ARGS and NAPALM_TIMEOUT configiuration parameters -* [#1375](https://github.com/digitalocean/netbox/issues/1375) - Renamed `NETBOX_USERNAME` and `NETBOX_PASSWORD` configuration parameters to `NAPALM_USERNAME` and `NAPALM_PASSWORD` -* [#1379](https://github.com/digitalocean/netbox/issues/1379) - Allow searching devices by interface MAC address in global search - -## Bug Fixes - -* [#461](https://github.com/digitalocean/netbox/issues/461) - Display a validation error when attempting to assigning a new child device to a rack face/position -* [#1385](https://github.com/digitalocean/netbox/issues/1385) - Connected device API endpoint no longer requires authentication if `LOGIN_REQUIRED` is False - ---- - -v2.1.0 (2017-07-25) - -## New Features - -### IP Address Roles ([#819](https://github.com/digitalocean/netbox/issues/819)) - -The IP address model now supports the assignment of a functional role to help identify special-purpose IPs. These include: - -* Loopback -* Secondary -* Anycast -* VIP -* VRRP -* HSRP -* GLBP - -### Automatic Provisioning of Next Available IP ([#1246](https://github.com/digitalocean/netbox/issues/1246)) - -A new API endpoint has been added at `/api/ipam/prefixes//available-ips/`. A GET request to this endpoint will return a list of available IP addresses within the prefix (up to the pagination limit). A POST request will automatically create and return the next available IP address. - -### NAPALM Integration ([#1348](https://github.com/digitalocean/netbox/issues/1348)) - -The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. - -## Enhancements - -* [#838](https://github.com/digitalocean/netbox/issues/838) - Display details of all objects being edited/deleted in bulk -* [#1041](https://github.com/digitalocean/netbox/issues/1041) - Added enabled and MTU fields to the interface model -* [#1121](https://github.com/digitalocean/netbox/issues/1121) - Added asset_tag and description fields to the InventoryItem model -* [#1141](https://github.com/digitalocean/netbox/issues/1141) - Include RD when listing VRFs in a form selection field -* [#1203](https://github.com/digitalocean/netbox/issues/1203) - Implemented query filters for all models -* [#1218](https://github.com/digitalocean/netbox/issues/1218) - Added IEEE 802.11 wireless interface types -* [#1269](https://github.com/digitalocean/netbox/issues/1269) - Added circuit termination to interface serializer -* [#1320](https://github.com/digitalocean/netbox/issues/1320) - Removed checkbox from confirmation dialog - -## Bug Fixes - -* [#1079](https://github.com/digitalocean/netbox/issues/1079) - Order interfaces naturally via API -* [#1285](https://github.com/digitalocean/netbox/issues/1285) - Enforce model validation when creating/editing objects via the API -* [#1358](https://github.com/digitalocean/netbox/issues/1358) - Correct VRF example values in IP/prefix import forms -* [#1362](https://github.com/digitalocean/netbox/issues/1362) - Raise validation error when attempting to create an API key that's too short -* [#1371](https://github.com/digitalocean/netbox/issues/1371) - Extend DeviceSerializer.parent_device to include standard fields - -## API changes - -* Added a new API endpoint which makes [NAPALM](https://github.com/napalm-automation/napalm) accessible via NetBox -* Device components (console ports, power ports, interfaces, etc.) can only be filtered by a single device name or ID. This limitation was necessary to allow the natural ordering of interfaces according to the device's parent device type. -* Added two new fields to the interface serializer: `enabled` (boolean) and `mtu` (unsigned integer) -* Modified the interface serializer to include three discrete fields relating to connections: `is_connected` (boolean), `interface_connection`, and `circuit_termination` -* Added two new fields to the inventory item serializer: `asset_tag` and `description` -* Added "wireless" to interface type filter (in addition to physical, virtual, and LAG) -* Added a new endpoint at /api/ipam/prefixes//available-ips/ to retrieve or create available IPs within a prefix -* Extended `parent_device` on DeviceSerializer to include the `url` and `display_name` of the parent Device, and the `url` of the DeviceBay - ---- - -v2.0.10 (2017-07-14) - -## Bug Fixes - -* [#1312](https://github.com/digitalocean/netbox/issues/1312) - Catch error when attempting to activate a user key with an invalid private key -* [#1333](https://github.com/digitalocean/netbox/issues/1333) - Corrected label on is_console_server field of DeviceType bulk edit form -* [#1338](https://github.com/digitalocean/netbox/issues/1338) - Allow importing prefixes with "container" status -* [#1339](https://github.com/digitalocean/netbox/issues/1339) - Fixed disappearing checkbox column under django-tables2 v1.7+ -* [#1342](https://github.com/digitalocean/netbox/issues/1342) - Allow designation of users and groups when creating/editing a secret role - ---- - -v2.0.9 (2017-07-10) - -## Bug Fixes - -* [#1319](https://github.com/digitalocean/netbox/issues/1319) - Fixed server error when attempting to create console/power connections -* [#1325](https://github.com/digitalocean/netbox/issues/1325) - Retain interface attachment when editing a circuit termination - ---- - -v2.0.8 (2017-07-05) - -## Enhancements - -* [#1298](https://github.com/digitalocean/netbox/issues/1298) - Calculate prefix utilization based on its status (container or non-container) -* [#1303](https://github.com/digitalocean/netbox/issues/1303) - Highlight installed interface connections in green on device view -* [#1315](https://github.com/digitalocean/netbox/issues/1315) - Enforce lowercase file extensions for image attachments - -## Bug Fixes - -* [#1279](https://github.com/digitalocean/netbox/issues/1279) - Fix primary_ip assignment during IP address import -* [#1281](https://github.com/digitalocean/netbox/issues/1281) - Show LLDP neighbors tab on device view only if necessary conditions are met -* [#1282](https://github.com/digitalocean/netbox/issues/1282) - Fixed tooltips on "mark connected/planned" toggle buttons for device connections -* [#1288](https://github.com/digitalocean/netbox/issues/1288) - Corrected permission name for deleting image attachments -* [#1289](https://github.com/digitalocean/netbox/issues/1289) - Retain inside NAT assignment when editing an IP address -* [#1297](https://github.com/digitalocean/netbox/issues/1297) - Allow passing custom field choice selection PKs to API as string-quoted integers -* [#1299](https://github.com/digitalocean/netbox/issues/1299) - Corrected permission name for adding services to devices - ---- - -v2.0.7 (2017-06-15) - -## Enhancements - -* [#626](https://github.com/digitalocean/netbox/issues/626) - Added bulk disconnect function for console/power/interface connections on device view - -## Bug Fixes - -* [#1238](https://github.com/digitalocean/netbox/issues/1238) - Fix error when editing an IP with a NAT assignment which has no assigned device -* [#1263](https://github.com/digitalocean/netbox/issues/1263) - Differentiate add and edit permissions for objects -* [#1265](https://github.com/digitalocean/netbox/issues/1265) - Fix console/power/interface connection validation when selecting a device via live search -* [#1266](https://github.com/digitalocean/netbox/issues/1266) - Prevent terminating a circuit to an already-connected interface -* [#1268](https://github.com/digitalocean/netbox/issues/1268) - Fix CSV import error under Python 3 -* [#1273](https://github.com/digitalocean/netbox/issues/1273) - Corrected status choices in IP address import form -* [#1274](https://github.com/digitalocean/netbox/issues/1274) - Exclude unterminated circuits from topology maps -* [#1275](https://github.com/digitalocean/netbox/issues/1275) - Raise validation error on prefix import when multiple VLANs are found - ---- - -v2.0.6 (2017-06-12) - -## Enhancements - -* [#40](https://github.com/digitalocean/netbox/issues/40) - Added IP utilization graph to prefix list -* [#704](https://github.com/digitalocean/netbox/issues/704) - Allow filtering VLANs by group when editing prefixes -* [#913](https://github.com/digitalocean/netbox/issues/913) - Added headers to object CSV exports -* [#990](https://github.com/digitalocean/netbox/issues/990) - Enable logging configuration in configuration.py -* [#1180](https://github.com/digitalocean/netbox/issues/1180) - Simplified the process of finding related devices when viewing a device - -## Bug Fixes - -* [#1253](https://github.com/digitalocean/netbox/issues/1253) - Improved `upgrade.sh` to allow forcing Python2 - ---- - -v2.0.5 (2017-06-08) - -## Notes - -The maximum number of objects an API consumer can request has been set to 1000 (e.g. `?limit=1000`). This limit can be modified by defining `MAX_PAGE_SIZE` in confgiuration.py. (To remove this limit, set `MAX_PAGE_SIZE=0`.) - -## Enhancements - -* [#655](https://github.com/digitalocean/netbox/issues/655) - Implemented header-based CSV import of objects -* [#1190](https://github.com/digitalocean/netbox/issues/1190) - Allow partial string matching when searching on custom fields -* [#1237](https://github.com/digitalocean/netbox/issues/1237) - Enabled setting limit=0 to disable pagination in API requests; added `MAX_PAGE_SIZE` configuration setting - -## Bug Fixes - -* [#837](https://github.com/digitalocean/netbox/issues/837) - Enforce uniqueness where applicable during bulk import of IP addresses -* [#1226](https://github.com/digitalocean/netbox/issues/1226) - Improved validation for custom field values submitted via the API -* [#1232](https://github.com/digitalocean/netbox/issues/1232) - Improved rack space validation on bulk import of devices (see #655) -* [#1235](https://github.com/digitalocean/netbox/issues/1235) - Fix permission name for adding/editing inventory items -* [#1236](https://github.com/digitalocean/netbox/issues/1236) - Truncate rack names in elevations list; add facility ID -* [#1239](https://github.com/digitalocean/netbox/issues/1239) - Fix server error when creating VLANGroup via API -* [#1243](https://github.com/digitalocean/netbox/issues/1243) - Catch ValueError in IP-based object filters -* [#1244](https://github.com/digitalocean/netbox/issues/1244) - Corrected "device" secrets filter to accept a device name - ---- - -v2.0.4 (2017-05-25) - -## Bug Fixes - -* [#1206](https://github.com/digitalocean/netbox/issues/1206) - Fix redirection in admin UI after activating secret keys when BASE_PATH is set -* [#1207](https://github.com/digitalocean/netbox/issues/1207) - Include nested LAG serializer when showing interface connections (API) -* [#1210](https://github.com/digitalocean/netbox/issues/1210) - Fix TemplateDoesNotExist errors on browsable API views -* [#1212](https://github.com/digitalocean/netbox/issues/1212) - Allow assigning new VLANs to global VLAN groups -* [#1213](https://github.com/digitalocean/netbox/issues/1213) - Corrected table header ordering links on object list views -* [#1214](https://github.com/digitalocean/netbox/issues/1214) - Add status to list of required fields on child device import form -* [#1219](https://github.com/digitalocean/netbox/issues/1219) - Fix image attachment URLs when BASE_PATH is set -* [#1220](https://github.com/digitalocean/netbox/issues/1220) - Suppressed innocuous warning about untracked migrations under Python 3 -* [#1229](https://github.com/digitalocean/netbox/issues/1229) - Fix validation error on forms where API search is used - ---- - -v2.0.3 (2017-05-18) - -## Enhancements - -* [#1196](https://github.com/digitalocean/netbox/issues/1196) - Added a lag_id filter to the API interfaces view -* [#1198](https://github.com/digitalocean/netbox/issues/1198) - Allow filtering unracked devices on device list - -## Bug Fixes - -* [#1157](https://github.com/digitalocean/netbox/issues/1157) - Hide nav menu search bar on small displays -* [#1186](https://github.com/digitalocean/netbox/issues/1186) - Corrected VLAN edit form so that site assignment is not required -* [#1187](https://github.com/digitalocean/netbox/issues/1187) - Fixed table pagination by introducing a custom table template -* [#1188](https://github.com/digitalocean/netbox/issues/1188) - Serialize interface LAG as nested objected (API) -* [#1189](https://github.com/digitalocean/netbox/issues/1189) - Enforce consistent ordering of objects returned by a global search -* [#1191](https://github.com/digitalocean/netbox/issues/1191) - Bulk selection of IPs under a prefix incorrect when "select all" is used -* [#1195](https://github.com/digitalocean/netbox/issues/1195) - Unable to create an interface connection when searching for peer device -* [#1197](https://github.com/digitalocean/netbox/issues/1197) - Fixed status assignment during bulk import of devices, prefixes, IPs, and VLANs -* [#1199](https://github.com/digitalocean/netbox/issues/1199) - Bulk import of secrets does not prompt user to generate a session key -* [#1200](https://github.com/digitalocean/netbox/issues/1200) - Form validation error when connecting power ports to power outlets - ---- - -v2.0.2 (2017-05-15) - -## Enhancements - -* [#1122](https://github.com/digitalocean/netbox/issues/1122) - Include NAT inside IPs in IP address list -* [#1137](https://github.com/digitalocean/netbox/issues/1137) - Allow filtering devices list by rack -* [#1170](https://github.com/digitalocean/netbox/issues/1170) - Include A and Z sites for circuits in global search results -* [#1172](https://github.com/digitalocean/netbox/issues/1172) - Linkify racks in side-by-side elevations view -* [#1177](https://github.com/digitalocean/netbox/issues/1177) - Render planned connections as dashed lines on topology maps -* [#1179](https://github.com/digitalocean/netbox/issues/1179) - Adjust topology map text color based on node background -* On all object edit forms, allow filtering the tenant list by tenant group - -## Bug Fixes - -* [#1158](https://github.com/digitalocean/netbox/issues/1158) - Exception thrown when creating a device component with an invalid name -* [#1159](https://github.com/digitalocean/netbox/issues/1159) - Only superusers can see "edit IP" buttons on the device interfaces list -* [#1160](https://github.com/digitalocean/netbox/issues/1160) - Linkify secrets and tenants in global search results -* [#1161](https://github.com/digitalocean/netbox/issues/1161) - Fix "add another" behavior when creating an API token -* [#1166](https://github.com/digitalocean/netbox/issues/1166) - Fixed bulk IP address creation when assigning tenants -* [#1168](https://github.com/digitalocean/netbox/issues/1168) - Total count of objects missing from list view paginator -* [#1171](https://github.com/digitalocean/netbox/issues/1171) - Allow removing site assignment when bulk editing VLANs -* [#1173](https://github.com/digitalocean/netbox/issues/1173) - Tweak interface manager to fall back to naive ordering - ---- - -v2.0.1 (2017-05-10) - -## Bug Fixes - -* [#1149](https://github.com/digitalocean/netbox/issues/1149) - Port list does not populate when creating a console or power connection -* [#1150](https://github.com/digitalocean/netbox/issues/1150) - Error when uploading image attachments with Unicode names under Python 2 -* [#1151](https://github.com/digitalocean/netbox/issues/1151) - Server error: name 'escape' is not defined -* [#1152](https://github.com/digitalocean/netbox/issues/1152) - Unable to edit user keys -* [#1153](https://github.com/digitalocean/netbox/issues/1153) - UnicodeEncodeError when searching for non-ASCII characters on Python 2 - ---- - -v2.0.0 (2017-05-09) - -## New Features - -### API 2.0 ([#113](https://github.com/digitalocean/netbox/issues/113)) - -The NetBox API has been completely rewritten and now features full read/write ability. - -### Image Attachments ([#152](https://github.com/digitalocean/netbox/issues/152)) - -Users are now able to attach photos and other images to sites, racks, and devices. (Please ensure that the new `media` directory is writable by the system account NetBox runs as.) - -### Global Search ([#159](https://github.com/digitalocean/netbox/issues/159)) - -NetBox now supports searching across all primary object types at once. - -### Rack Elevations View ([#951](https://github.com/digitalocean/netbox/issues/951)) - -A new view has been introduced to display the elevations of multiple racks side-by-side. - -## Enhancements - -* [#154](https://github.com/digitalocean/netbox/issues/154) - Expanded device status field to include options other than active/offline -* [#430](https://github.com/digitalocean/netbox/issues/430) - Include circuits when rendering topology maps -* [#578](https://github.com/digitalocean/netbox/issues/578) - Show topology maps not assigned to a site on the home view -* [#1100](https://github.com/digitalocean/netbox/issues/1100) - Add a "view all" link to completed bulk import views is_pool for prefixes) -* [#1110](https://github.com/digitalocean/netbox/issues/1110) - Expand bulk edit forms to include boolean fields (e.g. toggle is_pool for prefixes) - -## Bug Fixes - -From v1.9.6: - -* [#403](https://github.com/digitalocean/netbox/issues/403) - Record console/power/interface connects and disconnects as user actions -* [#853](https://github.com/digitalocean/netbox/issues/853) - Added "status" field to device bulk import form -* [#1101](https://github.com/digitalocean/netbox/issues/1101) - Fix AJAX scripting for device component selection forms -* [#1103](https://github.com/digitalocean/netbox/issues/1103) - Correct handling of validation errors when creating IP addresses in bulk -* [#1104](https://github.com/digitalocean/netbox/issues/1104) - Fix VLAN assignment on prefix import -* [#1115](https://github.com/digitalocean/netbox/issues/1115) - Enabled responsive (side-scrolling) tables for small screens -* [#1116](https://github.com/digitalocean/netbox/issues/1116) - Correct object links on recursive deletion error -* [#1125](https://github.com/digitalocean/netbox/issues/1125) - Include MAC addresses on a device's interface list -* [#1144](https://github.com/digitalocean/netbox/issues/1144) - Allow multiple status selections for Prefix, IP address, and VLAN filters - -From beta3: - -* [#1113](https://github.com/digitalocean/netbox/issues/1113) - Fixed server error when attempting to delete an image attachment -* [#1114](https://github.com/digitalocean/netbox/issues/1114) - Suppress OSError when attempting to access a deleted image attachment -* [#1126](https://github.com/digitalocean/netbox/issues/1126) - Fixed server error when editing a user key via admin UI attachment -* [#1132](https://github.com/digitalocean/netbox/issues/1132) - Prompt user to unlock session key when importing secrets - -## Additional Changes - -* The Module DCIM model has been renamed to InventoryItem to better reflect its intended function, and to make room for work on [#824](https://github.com/digitalocean/netbox/issues/824). -* Redundant portions of the admin UI have been removed ([#973](https://github.com/digitalocean/netbox/issues/973)). -* The Docker build components have been moved into [their own repository](https://github.com/digitalocean/netbox-docker). - ---- - -v1.9.6 (2017-04-21) - -## Improvements - -* [#878](https://github.com/digitalocean/netbox/issues/878) - Merged IP addresses with interfaces list on device view -* [#1001](https://github.com/digitalocean/netbox/issues/1001) - Interface assignment can be modified when editing an IP address -* [#1084](https://github.com/digitalocean/netbox/issues/1084) - Include custom fields when creating IP addresses in bulk - -## Bug Fixes - -* [#1057](https://github.com/digitalocean/netbox/issues/1057) - Corrected VLAN validation during prefix import -* [#1061](https://github.com/digitalocean/netbox/issues/1061) - Fixed potential for script injection via create/edit/delete messages -* [#1070](https://github.com/digitalocean/netbox/issues/1070) - Corrected installation instructions for Python3 on CentOS/RHEL -* [#1071](https://github.com/digitalocean/netbox/issues/1071) - Protect assigned circuit termination when an interface is deleted -* [#1072](https://github.com/digitalocean/netbox/issues/1072) - Order LAG interfaces naturally on bulk interface edit form -* [#1074](https://github.com/digitalocean/netbox/issues/1074) - Require ncclient 0.5.3 (Python 3 fix) -* [#1090](https://github.com/digitalocean/netbox/issues/1090) - Improved installation documentation for Python 3 -* [#1092](https://github.com/digitalocean/netbox/issues/1092) - Increase randomness in SECRET_KEY generation tool - ---- - -v1.9.5 (2017-04-06) - -## Improvements - -* [#1052](https://github.com/digitalocean/netbox/issues/1052) - Added rack reservation list and bulk delete views - -## Bug Fixes - -* [#1038](https://github.com/digitalocean/netbox/issues/1038) - Suppress upgrading to Django 1.11 (will be supported in v2.0) -* [#1037](https://github.com/digitalocean/netbox/issues/1037) - Fixed error on VLAN import with duplicate VLAN group names -* [#1047](https://github.com/digitalocean/netbox/issues/1047) - Correct ordering of numbered subinterfaces -* [#1051](https://github.com/digitalocean/netbox/issues/1051) - Upgraded django-rest-swagger - ---- - -v1.9.4-r1 (2017-04-04) - -## Improvements - -* [#362](https://github.com/digitalocean/netbox/issues/362) - Added per_page query parameter to control pagination page length - -## Bug Fixes - -* [#991](https://github.com/digitalocean/netbox/issues/991) - Correct server error on "create and connect another" interface connection -* [#1022](https://github.com/digitalocean/netbox/issues/1022) - Record user actions when creating IP addresses in bulk -* [#1027](https://github.com/digitalocean/netbox/issues/1027) - Fixed nav menu highlighting when BASE_PATH is set -* [#1034](https://github.com/digitalocean/netbox/issues/1034) - Added migration missing from v1.9.4 release - ---- - -v1.9.3 (2017-03-23) - -## Improvements - -* [#972](https://github.com/digitalocean/netbox/issues/972) - Add ability to filter connections list by device name -* [#974](https://github.com/digitalocean/netbox/issues/974) - Added MAC address filter to API interfaces list -* [#978](https://github.com/digitalocean/netbox/issues/978) - Allow filtering device types by function and subdevice role -* [#981](https://github.com/digitalocean/netbox/issues/981) - Allow filtering primary objects by a given set of IDs -* [#983](https://github.com/digitalocean/netbox/issues/983) - Include peer device names when listing circuits in device view - -## Bug Fixes - -* [#967](https://github.com/digitalocean/netbox/issues/967) - Fix error when assigning a new interface to a LAG - ---- - -v1.9.2 (2017-03-14) - -## Bug Fixes - -* [#950](https://github.com/digitalocean/netbox/issues/950) - Fix site_id error on child device import -* [#956](https://github.com/digitalocean/netbox/issues/956) - Correct bug affecting unnamed rackless devices -* [#957](https://github.com/digitalocean/netbox/issues/957) - Correct device site filter count to include unracked devices -* [#963](https://github.com/digitalocean/netbox/issues/963) - Fix bug in IPv6 address range expansion -* [#964](https://github.com/digitalocean/netbox/issues/964) - Fix bug when bulk editing/deleting filtered set of objects - ---- - -v1.9.1 (2017-03-08) - -## Improvements - -* [#945](https://github.com/digitalocean/netbox/issues/945) - Display the current user in the navigation menu -* [#946](https://github.com/digitalocean/netbox/issues/946) - Disregard mask length when filtering IP addresses by a parent prefix - -## Bug Fixes - -* [#941](https://github.com/digitalocean/netbox/issues/941) - Corrected old references to rack.site on Device -* [#943](https://github.com/digitalocean/netbox/issues/943) - Child prefixes missing on Python 3 -* [#944](https://github.com/digitalocean/netbox/issues/944) - Corrected console and power connection form behavior -* [#948](https://github.com/digitalocean/netbox/issues/948) - Region name should be hyperlinked to site list - ---- - -v1.9.0-r1 (2017-03-03) - -## New Features - -### Rack Reservations ([#36](https://github.com/digitalocean/netbox/issues/36)) - -Users can now reserve an arbitrary number of units within a rack, adding a comment noting their intentions. Reservations do not interfere with installed devices: It is possible to reserve a unit for future use even if it is currently occupied by a device. - -### Interface Groups ([#105](https://github.com/digitalocean/netbox/issues/105)) - -A new Link Aggregation Group (LAG) virtual form factor has been added. Physical interfaces can be assigned to a parent LAG interface to represent a port-channel or similar logical bundling of links. - -### Regions ([#164](https://github.com/digitalocean/netbox/issues/164)) - -A new region model has been introduced to allow for the geographic organization of sites. Regions can be nested recursively to form a hierarchy. - -### Rackless Devices ([#198](https://github.com/digitalocean/netbox/issues/198)) - -Previous releases required each device to be assigned to a particular rack within a site. This requirement has been relaxed so that devices must only be assigned to a site, and may optionally be assigned to a rack. - -### Global VLANs ([#235](https://github.com/digitalocean/netbox/issues/235)) - -Assignment of VLANs and VLAN groups to sites is now optional, allowing for the representation of a VLAN spanning multiple sites. - -## Improvements - -* [#862](https://github.com/digitalocean/netbox/issues/862) - Show both IPv6 and IPv4 primary IPs in device list -* [#894](https://github.com/digitalocean/netbox/issues/894) - Expand device name max length to 64 characters -* [#898](https://github.com/digitalocean/netbox/issues/898) - Expanded circuits list in provider view rack face -* [#901](https://github.com/digitalocean/netbox/issues/901) - Support for filtering prefixes and IP addresses by mask length - -## Bug Fixes - -* [#872](https://github.com/digitalocean/netbox/issues/872) - Fixed TypeError on bulk IP address creation (Python 3) -* [#884](https://github.com/digitalocean/netbox/issues/884) - Preserve selected rack unit when changing a device's rack face -* [#892](https://github.com/digitalocean/netbox/issues/892) - Restored missing edit/delete buttons when viewing child prefixes and IP addresses from a parent object -* [#897](https://github.com/digitalocean/netbox/issues/897) - Fixed power connections CSV export -* [#903](https://github.com/digitalocean/netbox/issues/903) - Only alert on missing critical connections if present in the parent device type -* [#935](https://github.com/digitalocean/netbox/issues/935) - Fix form validation error when connecting an interface using live search -* [#937](https://github.com/digitalocean/netbox/issues/937) - Region assignment should be optional when creating a site -* [#938](https://github.com/digitalocean/netbox/issues/938) - Provider view yields an error if one or more circuits is assigned to a tenant - ---- - -v1.8.4 (2017-02-03) - -## Improvements - -* [#856](https://github.com/digitalocean/netbox/issues/856) - Strip whitespace from fields during CSV import - -## Bug Fixes - -* [#851](https://github.com/digitalocean/netbox/issues/851) - Resolve encoding issues during import/export (Python 3) -* [#854](https://github.com/digitalocean/netbox/issues/854) - Correct processing of get_return_url() in ObjectDeleteView -* [#859](https://github.com/digitalocean/netbox/issues/859) - Fix Javascript for connection status toggle button on device view -* [#861](https://github.com/digitalocean/netbox/issues/861) - Avoid overwriting device primary IP assignment from alternate family during bulk import of IP addresses -* [#865](https://github.com/digitalocean/netbox/issues/865) - Fix server error when attempting to delete a protected object parent (Python 3) - ---- - -v1.8.3 (2017-01-26) - -## Improvements - -* [#782](https://github.com/digitalocean/netbox/issues/782) - Allow filtering devices list by manufacturer -* [#820](https://github.com/digitalocean/netbox/issues/820) - Add VLAN column to parent prefixes table on IP address view -* [#821](https://github.com/digitalocean/netbox/issues/821) - Support for comma separation in bulk IP/interface creation -* [#827](https://github.com/digitalocean/netbox/issues/827) - **Introduced support for Python 3** -* [#836](https://github.com/digitalocean/netbox/issues/836) - Add "deprecated" status for IP addresses -* [#841](https://github.com/digitalocean/netbox/issues/841) - Merged search and filter forms on all object lists - -## Bug Fixes - -* [#816](https://github.com/digitalocean/netbox/issues/816) - Redirect back to parent prefix view after deleting child prefixes termination -* [#817](https://github.com/digitalocean/netbox/issues/817) - Update last_updated time of a circuit when editing a child termination -* [#830](https://github.com/digitalocean/netbox/issues/830) - Redirect user to device view after editing a device component -* [#840](https://github.com/digitalocean/netbox/issues/840) - Correct API path resolution for secrets when BASE_PATH is configured -* [#844](https://github.com/digitalocean/netbox/issues/844) - Apply order_naturally() to API interfaces list -* [#845](https://github.com/digitalocean/netbox/issues/845) - Fix missing edit/delete buttons on object tables for non-superusers - - ---- - -v1.8.2 (2017-01-18) - -## Improvements - -* [#284](https://github.com/digitalocean/netbox/issues/284) - Enabled toggling of interface display order per device type -* [#760](https://github.com/digitalocean/netbox/issues/760) - Redirect user back to device view after deleting an assigned IP address -* [#783](https://github.com/digitalocean/netbox/issues/783) - Add a description field to the Circuit model -* [#797](https://github.com/digitalocean/netbox/issues/797) - Add description column to VLANs table -* [#803](https://github.com/digitalocean/netbox/issues/803) - Clarify that no child objects are deleted when deleting a prefix -* [#805](https://github.com/digitalocean/netbox/issues/805) - Linkify site column in device table - -## Bug Fixes - -* [#776](https://github.com/digitalocean/netbox/issues/776) - Prevent circuits from appearing twice while searching -* [#778](https://github.com/digitalocean/netbox/issues/778) - Corrected an issue preventing multiple interfaces with the same position ID from appearing in a device's interface list -* [#785](https://github.com/digitalocean/netbox/issues/785) - Trigger validation error when importing a prefix assigned to a nonexistent VLAN -* [#802](https://github.com/digitalocean/netbox/issues/802) - Fixed enforcement of ENFORCE_GLOBAL_UNIQUE for prefixes -* [#807](https://github.com/digitalocean/netbox/issues/807) - Redirect user back to form when adding IP addresses in bulk and "create and add another" is clicked -* [#810](https://github.com/digitalocean/netbox/issues/810) - Suppress unique IP validation on invalid IP addresses and prefixes - ---- - -v1.8.1 (2017-01-04) - -## Improvements - -* [#771](https://github.com/digitalocean/netbox/issues/771) - Don't automatically redirect user when only one object is returned in a list - -## Bug Fixes - -* [#764](https://github.com/digitalocean/netbox/issues/764) - Encapsulate in double quotes values containing commas when exporting to CSV -* [#767](https://github.com/digitalocean/netbox/issues/767) - Fixes xconnect_id error when searching for circuits -* [#769](https://github.com/digitalocean/netbox/issues/769) - Show default value for boolean custom fields -* [#772](https://github.com/digitalocean/netbox/issues/772) - Fixes TypeError in API RackUnitListView when no device is excluded - ---- - -v1.8.0 (2017-01-03) - -## New Features - -### Point-to-Point Circuits ([#49](https://github.com/digitalocean/netbox/issues/49)) - -Until now, NetBox has supported tracking only one end of a data circuit. This is fine for Internet connections where you don't care (or know) much about the provider side of the circuit, but many users need the ability to track inter-site circuits as well. This release expands circuit modeling so that each circuit can have an A and/or Z side. Each endpoint must be terminated to a site, and may optionally be terminated to a specific device and interface within that site. - -### L4 Services ([#539](https://github.com/digitalocean/netbox/issues/539)) - -Our first major community contribution introduces the ability to track discrete TCP and UDP services associated with a device (for example, SSH or HTTP). Each service can optionally be assigned to one or more specific IP addresses belonging to the device. Thanks to [@if-fi](https://github.com/if-fi) for the addition! - -## Improvements - -* [#122](https://github.com/digitalocean/netbox/issues/122) - Added comments field to device types -* [#181](https://github.com/digitalocean/netbox/issues/181) - Implemented support for bulk IP address creation -* [#613](https://github.com/digitalocean/netbox/issues/613) - Added prefixes column to VLAN list; added VLAN column to prefix list -* [#716](https://github.com/digitalocean/netbox/issues/716) - Add ASN field to site bulk edit form -* [#722](https://github.com/digitalocean/netbox/issues/722) - Enabled custom fields for device types -* [#743](https://github.com/digitalocean/netbox/issues/743) - Enabled bulk creation of all device components -* [#756](https://github.com/digitalocean/netbox/issues/756) - Added contact details to site model - -## Bug Fixes - -* [#563](https://github.com/digitalocean/netbox/issues/563) - Allow a device to be flipped from one rack face to the other without moving it -* [#658](https://github.com/digitalocean/netbox/issues/658) - Enabled conditional treatment of network/broadcast IPs for a prefix by defining it as a pool -* [#741](https://github.com/digitalocean/netbox/issues/741) - Hide "select all" button for users without edit permissions -* [#744](https://github.com/digitalocean/netbox/issues/744) - Fixed export of sites without an AS number -* [#747](https://github.com/digitalocean/netbox/issues/747) - Fixed natural_order_by integer cast error on large numbers -* [#751](https://github.com/digitalocean/netbox/issues/751) - Fixed python-cryptography installation issue on Debian -* [#763](https://github.com/digitalocean/netbox/issues/763) - Added missing fields to CSV exports for racks and prefixes - ---- - -v1.7.3 (2016-12-08) - -## Bug Fixes - -* [#724](https://github.com/digitalocean/netbox/issues/724) - Exempt API views from LoginRequiredMiddleware to enable basic HTTP authentication when LOGIN_REQUIRED is true -* [#729](https://github.com/digitalocean/netbox/issues/729) - Corrected cancellation links when editing secondary objects -* [#732](https://github.com/digitalocean/netbox/issues/732) - Allow custom select field values to be deselected if the field is not required -* [#733](https://github.com/digitalocean/netbox/issues/733) - Fixed MAC address filter on device list -* [#734](https://github.com/digitalocean/netbox/issues/734) - Corrected display of device type when editing a device - ---- - -v1.7.2-r1 (2016-12-06) - -## Improvements - -* [#663](https://github.com/digitalocean/netbox/issues/663) - Added MAC address search field to device list -* [#672](https://github.com/digitalocean/netbox/issues/672) - Increased the selection of available colors for rack and device roles -* [#695](https://github.com/digitalocean/netbox/issues/695) - Added is_private field to RIR - -## Bug Fixes - -* [#677](https://github.com/digitalocean/netbox/issues/677) - Fix setuptools installation error on Debian 8.6 -* [#696](https://github.com/digitalocean/netbox/issues/696) - Corrected link to VRF in prefix and IP address breadcrumbs -* [#702](https://github.com/digitalocean/netbox/issues/702) - Improved Unicode support for custom fields -* [#712](https://github.com/digitalocean/netbox/issues/712) - Corrected export of tenants which are not assigned to a group -* [#713](https://github.com/digitalocean/netbox/issues/713) - Include a label for the comments field when editing circuits, providers, or racks in bulk -* [#718](https://github.com/digitalocean/netbox/issues/718) - Restore is_primary field on IP assignment form -* [#723](https://github.com/digitalocean/netbox/issues/723) - API documentation is now accessible when using BASE_PATH -* [#727](https://github.com/digitalocean/netbox/issues/727) - Corrected error in rack elevation display (v1.7.2) - ---- - -v1.7.1 (2016-11-15) - -## Improvements - -* [#667](https://github.com/digitalocean/netbox/issues/667) - Added prefix utilization statistics to the RIR list view -* [#685](https://github.com/digitalocean/netbox/issues/685) - When assigning an IP to a device, automatically select the interface if only one exists - -## Bug Fixes - -* [#674](https://github.com/digitalocean/netbox/issues/674) - Fix assignment of status to imported IP addresses -* [#676](https://github.com/digitalocean/netbox/issues/676) - Server error when bulk editing device types -* [#678](https://github.com/digitalocean/netbox/issues/678) - Server error on device import specifying an invalid device type -* [#691](https://github.com/digitalocean/netbox/issues/691) - Allow the assignment of power ports to PDUs -* [#692](https://github.com/digitalocean/netbox/issues/692) - Form errors are not displayed on checkbox fields - ---- - -v1.7.0 (2016-11-03) - -## New Features - -### IP address statuses ([#87](https://github.com/digitalocean/netbox/issues/87)) - -An IP address can now be designated as active, reserved, or DHCP. The DHCP status implies that the IP address is part of a DHCP pool and may or may not be assigned to a DHCP client. - -### Top-to-bottom rack numbering ([#191](https://github.com/digitalocean/netbox/issues/191)) - -Racks can now be set to have descending rack units, with U1 at the top of the rack. When adding a device to a rack with descending units, be sure to position it in the **lowest-numbered** unit which it occupies (this will be physically the topmost unit). - -## Improvements -* [#211](https://github.com/digitalocean/netbox/issues/211) - Allow device assignment and removal from IP address view -* [#630](https://github.com/digitalocean/netbox/issues/630) - Added a custom 404 page -* [#652](https://github.com/digitalocean/netbox/issues/652) - Use password input controls when editing secrets -* [#654](https://github.com/digitalocean/netbox/issues/654) - Added Cisco FlexStack and FlexStack Plus form factors -* [#661](https://github.com/digitalocean/netbox/issues/661) - Display relevant IP addressing when viewing a circuit - -## Bug Fixes -* [#632](https://github.com/digitalocean/netbox/issues/632) - Use semicolons instead of commas to separate regexes in topology maps -* [#647](https://github.com/digitalocean/netbox/issues/647) - Extend form used when assigning an IP to a device -* [#657](https://github.com/digitalocean/netbox/issues/657) - Unicode error when adding device modules -* [#660](https://github.com/digitalocean/netbox/issues/660) - Corrected calculation of utilized space in rack list -* [#664](https://github.com/digitalocean/netbox/issues/664) - Fixed bulk creation of interfaces across multiple devices - ---- - -v1.6.3 (2016-10-19) - -## Improvements - -* [#353](https://github.com/digitalocean/netbox/issues/353) - Bulk editing of device and device type interfaces -* [#527](https://github.com/digitalocean/netbox/issues/527) - Support for nullification of fields when bulk editing -* [#592](https://github.com/digitalocean/netbox/issues/592) - Allow space-delimited lists of ALLOWED_HOSTS in Docker -* [#608](https://github.com/digitalocean/netbox/issues/608) - Added "select all" button for device and device type components - -## Bug Fixes - -* [#602](https://github.com/digitalocean/netbox/issues/602) - Correct display of custom integer fields with value of 0 or 1 -* [#604](https://github.com/digitalocean/netbox/issues/604) - Correct display of unnamed devices in form selection fields -* [#611](https://github.com/digitalocean/netbox/issues/611) - Power/console/interface connection import: status field should be case-insensitive -* [#615](https://github.com/digitalocean/netbox/issues/615) - Account for BASE_PATH in static URLs and during login -* [#616](https://github.com/digitalocean/netbox/issues/616) - Correct display of custom URL fields - ---- - -v1.6.2-r1 (2016-10-04) - -## Improvements - -* [#212](https://github.com/digitalocean/netbox/issues/212) - Introduced the `BASE_PATH` configuration setting to allow running NetBox in a URL subdirectory -* [#345](https://github.com/digitalocean/netbox/issues/345) - Bulk edit: allow user to select all objects on page or all matching query -* [#475](https://github.com/digitalocean/netbox/issues/475) - Display "add" buttons at top and bottom of all device/device type panels -* [#480](https://github.com/digitalocean/netbox/issues/480) - Improved layout on mobile devices -* [#481](https://github.com/digitalocean/netbox/issues/481) - Require interface creation before trying to assign an IP to a device -* [#575](https://github.com/digitalocean/netbox/issues/575) - Allow all valid URL schemes in custom fields -* [#579](https://github.com/digitalocean/netbox/issues/579) - Add a description field to export templates - -## Bug Fixes - -* [#466](https://github.com/digitalocean/netbox/issues/466) - Validate available free space for all instances when increasing the U height of a device type -* [#571](https://github.com/digitalocean/netbox/issues/571) - Correct rack group filter on device list -* [#576](https://github.com/digitalocean/netbox/issues/576) - Delete all relevant CustomFieldValues when deleting a CustomFieldChoice -* [#581](https://github.com/digitalocean/netbox/issues/581) - Correct initialization of custom boolean and select fields -* [#591](https://github.com/digitalocean/netbox/issues/591) - Correct display of component creation buttons in device type view - ---- - -v1.6.1-r1 (2016-09-21) - -## Improvements -* [#415](https://github.com/digitalocean/netbox/issues/415) - Add an expand/collapse toggle button to the prefix list -* [#552](https://github.com/digitalocean/netbox/issues/552) - Allow filtering on custom select fields by "none" -* [#561](https://github.com/digitalocean/netbox/issues/561) - Make custom fields accessible from within export templates - -## Bug Fixes -* [#493](https://github.com/digitalocean/netbox/issues/493) - CSV import support for UTF-8 -* [#531](https://github.com/digitalocean/netbox/issues/531) - Order prefix list by VRF assignment -* [#542](https://github.com/digitalocean/netbox/issues/542) - Add LDAP support in Docker -* [#557](https://github.com/digitalocean/netbox/issues/557) - Add 'global' choice to VRF filter for prefixes and IP addresses -* [#558](https://github.com/digitalocean/netbox/issues/558) - Update slug field when name is populated without a key press -* [#562](https://github.com/digitalocean/netbox/issues/562) - Fixed bulk interface creation -* [#564](https://github.com/digitalocean/netbox/issues/564) - Display custom fields for all applicable objects - ---- - -v1.6.0 (2016-09-13) - -## New Features - -### Custom Fields ([#129](https://github.com/digitalocean/netbox/issues/129)) - -Users can now create custom fields to associate arbitrary data with core NetBox objects. For example, you might want to add a geolocation tag to IP prefixes, or a ticket number to each device. Text, integer, boolean, date, URL, and selection fields are supported. - -## Improvements - -* [#489](https://github.com/digitalocean/netbox/issues/489) - Docker file now builds from a `python:2.7-wheezy` base instead of `ubuntu:14.04` -* [#540](https://github.com/digitalocean/netbox/issues/540) - Add links for VLAN roles under VLAN navigation menu -* Added new interface form factors -* Added address family filters to aggregate and prefix lists - -## Bug Fixes - -* [#476](https://github.com/digitalocean/netbox/issues/476) - Corrected rack import instructions -* [#484](https://github.com/digitalocean/netbox/issues/484) - Allow bulk deletion of >1K objects -* [#486](https://github.com/digitalocean/netbox/issues/486) - Prompt for secret key only if updating a secret's value -* [#490](https://github.com/digitalocean/netbox/issues/490) - Corrected display of circuit commit rate -* [#495](https://github.com/digitalocean/netbox/issues/495) - Include tenant in prefix and IP CSV export -* [#507](https://github.com/digitalocean/netbox/issues/507) - Corrected rendering of nav menu on screens narrower than 1200px -* [#515](https://github.com/digitalocean/netbox/issues/515) - Clarified instructions for the "face" field when importing devices -* [#522](https://github.com/digitalocean/netbox/issues/522) - Remove obsolete check for staff status when bulk deleting objects -* [#544](https://github.com/digitalocean/netbox/issues/544) - Strip CRLF-style line terminators from rendered export templates - ---- - -v1.5.2 (2016-08-16) - -## Bug Fixes - -* [#460](https://github.com/digitalocean/netbox/issues/460) - Corrected ordering of IP addresses with differing prefix lengths -* [#463](https://github.com/digitalocean/netbox/issues/463) - Prevent pre-population of livesearch field with '---------' -* [#467](https://github.com/digitalocean/netbox/issues/467) - Include prefixes and IPs which inherit tenancy from their VRF in tenant stats -* [#468](https://github.com/digitalocean/netbox/issues/468) - Don't allow connected interfaces to be changed to the "virtual" form factor -* [#469](https://github.com/digitalocean/netbox/issues/469) - Added missing import buttons to list views -* [#472](https://github.com/digitalocean/netbox/issues/472) - Hide the connection button for interfaces which have a circuit terminated to them - ---- - -v1.5.1 (2016-08-11) - -## Improvements - -* [#421](https://github.com/digitalocean/netbox/issues/421) - Added an asset tag field to devices -* [#456](https://github.com/digitalocean/netbox/issues/456) - Added IP search box to home page -* Colorized rack and device roles - -## Bug Fixes - -* [#454](https://github.com/digitalocean/netbox/issues/454) - Corrected error on rack export -* [#457](https://github.com/digitalocean/netbox/issues/457) - Added role field to rack edit form - ---- - -v1.5.0 (2016-08-10) - -## New Features - -### Rack Enhancements ([#180](https://github.com/digitalocean/netbox/issues/180), [#241](https://github.com/digitalocean/netbox/issues/241)) - -Like devices, racks can now be assigned to functional roles. This allows users to group racks by designated function as well as by physical location (rack groups). Additionally, rack can now have a defined rail-to-rail width (19 or 23 inches) and a type (two-post-rack, cabinet, etc.). - -## Improvements - -* [#149](https://github.com/digitalocean/netbox/issues/149) - Added discrete upstream speed field for circuits -* [#157](https://github.com/digitalocean/netbox/issues/157) - Added manufacturer field for device modules -* We have a logo! -* Upgraded to Django 1.10 - -## Bug Fixes - -* [#433](https://github.com/digitalocean/netbox/issues/433) - Corrected form validation when editing child devices -* [#442](https://github.com/digitalocean/netbox/issues/442) - Corrected child device import instructions -* [#443](https://github.com/digitalocean/netbox/issues/443) - Correctly display and initialize VRF for creation of new IP addresses -* [#444](https://github.com/digitalocean/netbox/issues/444) - Corrected prefix model validation -* [#445](https://github.com/digitalocean/netbox/issues/445) - Limit rack height to between 1U and 100U (inclusive) - ---- - -v1.4.2 (2016-08-06) - -## Improvements - -* [#167](https://github.com/digitalocean/netbox/issues/167) - Added new interface form factors -* [#253](https://github.com/digitalocean/netbox/issues/253) - Added new interface form factors -* [#434](https://github.com/digitalocean/netbox/issues/434) - Restored admin UI access to user action history (however bulk deletion is disabled) -* [#435](https://github.com/digitalocean/netbox/issues/435) - Added an "add prefix" button to the VLAN view - -## Bug Fixes - -* [#425](https://github.com/digitalocean/netbox/issues/425) - Ignore leading and trailing periods when generating a slug -* [#427](https://github.com/digitalocean/netbox/issues/427) - Prevent error when duplicate IPs are present in a prefix's IP list -* [#429](https://github.com/digitalocean/netbox/issues/429) - Correct redirection of user when adding a secret to a device - ---- - -v1.4.1 (2016-08-03) - -## Improvements - -* [#289](https://github.com/digitalocean/netbox/issues/289) - Annotate available ranges in prefix IP list -* [#412](https://github.com/digitalocean/netbox/issues/412) - Tenant group assignment is no longer mandatory -* [#422](https://github.com/digitalocean/netbox/issues/422) - CSV import now supports double-quoting values which contain commas - -## Bug Fixes - -* [#395](https://github.com/digitalocean/netbox/issues/395) - Show child prefixes from all VRFs if the parent belongs to the global table -* [#406](https://github.com/digitalocean/netbox/issues/406) - Fixed circuit list rendring when filtering on port speed or commit rate -* [#409](https://github.com/digitalocean/netbox/issues/409) - Filter IPs and prefixes by tenant slug rather than by its PK -* [#411](https://github.com/digitalocean/netbox/issues/411) - Corrected title of secret roles view -* [#419](https://github.com/digitalocean/netbox/issues/419) - Fixed a potential database performance issue when gathering tenant statistics - ---- - -v1.4.0 (2016-08-01) - -## New Features - -### Multitenancy ([#16](https://github.com/digitalocean/netbox/issues/16)) - -NetBox now supports tenants and tenant groups. Sites, racks, devices, VRFs, prefixes, IP addresses, VLANs, and circuits can be assigned to tenants to track the allocation of these resources among customers or internal departments. If a prefix or IP address does not have a tenant assigned, it will fall back to the tenant assigned to its parent VRF (where applicable). - -## Improvements - -* [#176](https://github.com/digitalocean/netbox/issues/176) - Introduced seed data for new installs -* [#358](https://github.com/digitalocean/netbox/issues/358) - Improved search for all objects -* [#394](https://github.com/digitalocean/netbox/issues/394) - Improved VRF selection during bulk editing of prefixes and IP addresses -* Miscellaneous cosmetic improvements to the UI - -## Bug Fixes - -* [#392](https://github.com/digitalocean/netbox/issues/392) - Don't include child devices in non-racked devices table -* [#397](https://github.com/digitalocean/netbox/issues/397) - Only include child IPs which belong to the same VRF as the parent prefix - ---- - -v1.3.2 (2016-07-26) - -## Improvements - -* [#292](https://github.com/digitalocean/netbox/issues/292) - Added part_number field to DeviceType -* [#363](https://github.com/digitalocean/netbox/issues/363) - Added a description field to the VLAN model -* [#374](https://github.com/digitalocean/netbox/issues/374) - Increased VLAN name length to 64 characters -* Enabled bulk deletion of interfaces from devices - -## Bug Fixes - -* [#359](https://github.com/digitalocean/netbox/issues/359) - Corrected the DCIM API endpoint for finding related connections -* [#370](https://github.com/digitalocean/netbox/issues/370) - Notify user when secret decryption fails -* [#381](https://github.com/digitalocean/netbox/issues/381) - Fix 'u_consumed' error on rack import -* [#384](https://github.com/digitalocean/netbox/issues/384) - Fixed description field's maximum length on IPAM bulk edit forms -* [#385](https://github.com/digitalocean/netbox/issues/385) - Fixed error when deleting a user with one or more associated UserActions - ---- - -v1.3.1 (2016-07-21) - -## Improvements - -* [#258](https://github.com/digitalocean/netbox/issues/258) - Add an API endpoint to list interface connections -* [#303](https://github.com/digitalocean/netbox/issues/303) - Improved numeric ordering of sites, racks, and devices -* [#304](https://github.com/digitalocean/netbox/issues/304) - Display utilization percentage on rack list -* [#327](https://github.com/digitalocean/netbox/issues/327) - Disable rack assignment for installed child devices - -## Bug Fixes - -* [#331](https://github.com/digitalocean/netbox/issues/331) - Add group field to VLAN bulk edit form -* Miscellaneous improvements to Unicode handling - ---- - -v1.3.0 (2016-07-18) - -## New Features - -* [#42](https://github.com/digitalocean/netbox/issues/42) - Allow assignment of VLAN on prefix import -* [#43](https://github.com/digitalocean/netbox/issues/43) - Toggling of IP space uniqueness within a VRF -* [#111](https://github.com/digitalocean/netbox/issues/111) - Introduces VLAN groups -* [#227](https://github.com/digitalocean/netbox/issues/227) - Support for bulk import of child devices - -## Bug Fixes - -* [#301](https://github.com/digitalocean/netbox/issues/301) - Prevent deletion of DeviceBay when installed device is deleted -* [#306](https://github.com/digitalocean/netbox/issues/306) - Fixed device import to allow an unspecified rack face -* [#307](https://github.com/digitalocean/netbox/issues/307) - Catch `RelatedObjectDoesNotExist` when an invalid device type is defined during device import -* [#308](https://github.com/digitalocean/netbox/issues/308) - Update rack assignment for all child devices when moving a parent device -* [#311](https://github.com/digitalocean/netbox/issues/311) - Fix assignment of primary_ip on IP address import -* [#317](https://github.com/digitalocean/netbox/issues/317) - Rack elevation display fix for device types greater than 42U in height -* [#320](https://github.com/digitalocean/netbox/issues/320) - Disallow import of prefixes with host masks -* [#322](https://github.com/digitalocean/netbox/issues/320) - Corrected VLAN import behavior - ---- - -v1.2.2 (2016-07-14) - -## Improvements - -* [#174](https://github.com/digitalocean/netbox/issues/174) - Added search and site filter to provider list -* [#270](https://github.com/digitalocean/netbox/issues/270) - Added the ability to filter devices by rack group - -## Bug Fixes - -* [#115](https://github.com/digitalocean/netbox/issues/115) - Fix deprecated django.core.context_processors reference -* [#268](https://github.com/digitalocean/netbox/issues/268) - Added support for entire 32-bit ASN space -* [#282](https://github.com/digitalocean/netbox/issues/282) - De-select "all" checkbox if one or more objects are deselected -* [#290](https://github.com/digitalocean/netbox/issues/290) - Always display management interfaces for a device type (even if `is_network_device` is not set) - ---- - -v1.2.1 (2016-07-13) - -**Note:** This release introduces a new dependency ([natsort](https://pypi.python.org/pypi/natsort)). Be sure to run `upgrade.sh` if upgrading from a previous release. - -## Improvements - -* [#285](https://github.com/digitalocean/netbox/issues/285) - Added the ability to prefer IPv4 over IPv6 for primary device IPs - -## Bug Fixes - -* [#243](https://github.com/digitalocean/netbox/issues/243) - Improved ordering of device object lists -* [#271](https://github.com/digitalocean/netbox/issues/271) - Fixed primary_ip bug in secrets API -* [#274](https://github.com/digitalocean/netbox/issues/274) - Fixed primary_ip bug in DCIM admin UI -* [#275](https://github.com/digitalocean/netbox/issues/275) - Fixed bug preventing the expansion of an existing aggregate - ---- - -v1.2.0 (2016-07-12) - -## New Features - -* [#73](https://github.com/digitalocean/netbox/issues/73) - Added optional persistent banner -* [#93](https://github.com/digitalocean/netbox/issues/73) - Ability to set both IPv4 and IPv6 primary IPs for devices -* [#203](https://github.com/digitalocean/netbox/issues/203) - Introduced support for LDAP - -## Bug Fixes - -* [#162](https://github.com/digitalocean/netbox/issues/228) - Fixed support for Unicode characters in rack/device/VLAN names -* [#228](https://github.com/digitalocean/netbox/issues/228) - Corrected conditional inclusion of device bay templates -* [#246](https://github.com/digitalocean/netbox/issues/246) - Corrected Docker build instructions -* [#260](https://github.com/digitalocean/netbox/issues/260) - Fixed error on admin UI device type list -* Miscellaneous layout improvements for mobile devices - ---- - -v1.1.0 (2016-07-07) - -## New Features - -* [#107](https://github.com/digitalocean/netbox/pull/107) - Docker support -* [#91](https://github.com/digitalocean/netbox/issues/91) - Support for subdevices within a device -* [#170](https://github.com/digitalocean/netbox/pull/170) - Added MAC address field to interfaces - -## Bug Fixes - -* [#169](https://github.com/digitalocean/netbox/issues/169) - Fix rendering of cancellation URL when editing objects -* [#183](https://github.com/digitalocean/netbox/issues/183) - Ignore vi swap files -* [#209](https://github.com/digitalocean/netbox/issues/209) - Corrected error when not confirming component template deletions -* [#214](https://github.com/digitalocean/netbox/issues/214) - Fixed redundant message on bulk interface creation -* [#68](https://github.com/digitalocean/netbox/issues/68) - Improved permissions-related error reporting for secrets - ---- - -v1.0.7-r1 (2016-07-05) - -* [#199](https://github.com/digitalocean/netbox/issues/199) - Correct IP address validation - ---- - -v1.0.7 (2016-06-30) - -**Note:** If upgrading from a previous release, be sure to run ./upgrade.sh after downloading the new code. -* [#135](https://github.com/digitalocean/netbox/issues/135): Fixed display of navigation menu on mobile screens -* [#141](https://github.com/digitalocean/netbox/issues/141): Fixed rendering of "getting started" guide -* Modified upgrade.sh to use sudo for pip installations -* [#109](https://github.com/digitalocean/netbox/issues/109): Hide the navigation menu from anonymous users if login is required -* [#143](https://github.com/digitalocean/netbox/issues/143): Add help_text to Device.position -* [#136](https://github.com/digitalocean/netbox/issues/136): Prefixes which have host bits set will trigger an error instead of being silently corrected -* [#140](https://github.com/digitalocean/netbox/issues/140): Improved support for Unicode in object names - ---- - -1.0.0 (2016-06-27) - -NetBox was originally developed internally at DigitalOcean by the network development team. This release marks the debut of NetBox as an open source project. +v2.5.4 (2019-01-29) + +## Enhancements + +* [#2516](https://github.com/digitalocean/netbox/issues/2516) - Implemented Select2 for all Model backed selection fields +* [#2590](https://github.com/digitalocean/netbox/issues/2590) - Implemented the color picker with Select2 to show colors in the background +* [#2733](https://github.com/digitalocean/netbox/issues/2733) - Enable bulk assignment of MAC addresses to interfaces +* [#2735](https://github.com/digitalocean/netbox/issues/2735) - Implemented Select2 for all list filter form select elements +* [#2753](https://github.com/digitalocean/netbox/issues/2753) - Implemented Select2 to replace most all instances of select fields in forms +* [#2766](https://github.com/digitalocean/netbox/issues/2766) - Extend users admin table to include superuser and active fields +* [#2782](https://github.com/digitalocean/netbox/issues/2782) - Add `is_pool` field for prefix filtering +* [#2807](https://github.com/digitalocean/netbox/issues/2807) - Include device site/rack assignment in cable trace view +* [#2808](https://github.com/digitalocean/netbox/issues/2808) - Loosen version pinning for Django to allow patch releases +* [#2810](https://github.com/digitalocean/netbox/issues/2810) - Include description fields in interface connections export + +## Bug Fixes + +* [#2779](https://github.com/digitalocean/netbox/issues/2779) - Include "none" option when filter IP addresses by role +* [#2783](https://github.com/digitalocean/netbox/issues/2783) - Fix AttributeError exception when attempting to delete region(s) +* [#2795](https://github.com/digitalocean/netbox/issues/2795) - Fix duplicate display of pagination controls on child prefix/IP tables +* [#2798](https://github.com/digitalocean/netbox/issues/2798) - Properly URL-encode "map it" link on site view +* [#2802](https://github.com/digitalocean/netbox/issues/2802) - Better error handling for unsupported NAPALM methods +* [#2816](https://github.com/digitalocean/netbox/issues/2816) - Handle exception when deleting a device with connected components + +--- + +v2.5.3 (2019-01-11) + +## Enhancements + +* [#1630](https://github.com/digitalocean/netbox/issues/1630) - Enable bulk editing of prefix/IP mask length +* [#1870](https://github.com/digitalocean/netbox/issues/1870) - Add per-page toggle to object lists +* [#1871](https://github.com/digitalocean/netbox/issues/1871) - Enable filtering sites by parent region +* [#1983](https://github.com/digitalocean/netbox/issues/1983) - Enable regular expressions when bulk renaming device components +* [#2682](https://github.com/digitalocean/netbox/issues/2682) - Add DAC and AOC cable types +* [#2693](https://github.com/digitalocean/netbox/issues/2693) - Additional cable colors +* [#2726](https://github.com/digitalocean/netbox/issues/2726) - Include cables in global search + +## Bug Fixes + +* [#2742](https://github.com/digitalocean/netbox/issues/2742) - Preserve cluster assignment when editing a device +* [#2757](https://github.com/digitalocean/netbox/issues/2757) - Always treat first/last IPs within a /31 or /127 as usable +* [#2762](https://github.com/digitalocean/netbox/issues/2762) - Add missing DCIM field values to API `_choices` endpoint +* [#2777](https://github.com/digitalocean/netbox/issues/2777) - Fix cable validation to handle duplicate connections on import + + +--- + +v2.5.2 (2018-12-21) + +## Enhancements + +* [#2561](https://github.com/digitalocean/netbox/issues/2561) - Add 200G and 400G interface types +* [#2701](https://github.com/digitalocean/netbox/issues/2701) - Enable filtering of prefixes by exact prefix value + +## Bug Fixes + +* [#2673](https://github.com/digitalocean/netbox/issues/2673) - Fix exception on LLDP neighbors view for device with a circuit connected +* [#2691](https://github.com/digitalocean/netbox/issues/2691) - Cable trace should follow circuits +* [#2698](https://github.com/digitalocean/netbox/issues/2698) - Remove pagination restriction on bulk component creation for devices/VMs +* [#2704](https://github.com/digitalocean/netbox/issues/2704) - Fix form select widget population on parent with null value +* [#2707](https://github.com/digitalocean/netbox/issues/2707) - Correct permission evaluation for circuit termination cabling +* [#2712](https://github.com/digitalocean/netbox/issues/2712) - Preserve list filtering after editing objects in bulk +* [#2717](https://github.com/digitalocean/netbox/issues/2717) - Fix bulk deletion of tags +* [#2721](https://github.com/digitalocean/netbox/issues/2721) - Detect loops when tracing front/rear ports +* [#2723](https://github.com/digitalocean/netbox/issues/2723) - Correct permission evaluation when bulk deleting tags +* [#2724](https://github.com/digitalocean/netbox/issues/2724) - Limit rear port choices to current device when editing a front port + +--- + +v2.5.1 (2018-12-13) + +## Enhancements + +* [#2655](https://github.com/digitalocean/netbox/issues/2655) - Add 128GFC Fibrechannel interface type +* [#2674](https://github.com/digitalocean/netbox/issues/2674) - Enable filtering changelog by object type under web UI + +## Bug Fixes + +* [#2662](https://github.com/digitalocean/netbox/issues/2662) - Fix ImproperlyConfigured exception when rendering API docs +* [#2663](https://github.com/digitalocean/netbox/issues/2663) - Prevent duplicate interfaces from appearing under VLAN members view +* [#2666](https://github.com/digitalocean/netbox/issues/2666) - Correct display of length unit in cables list +* [#2676](https://github.com/digitalocean/netbox/issues/2676) - Fix exception when passing dictionary value to a ChoiceField +* [#2678](https://github.com/digitalocean/netbox/issues/2678) - Fix error when viewing webhook in admin UI without write permission +* [#2680](https://github.com/digitalocean/netbox/issues/2680) - Disallow POST requests to `/dcim/interface-connections/` API endpoint +* [#2683](https://github.com/digitalocean/netbox/issues/2683) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort +* [#2684](https://github.com/digitalocean/netbox/issues/2684) - Fix custom field filtering +* [#2687](https://github.com/digitalocean/netbox/issues/2687) - Correct naming of before/after filters for changelog entries + +--- + +v2.5.0 (2018-12-10) + +## Notes + +### Python 3 Required + +As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://netbox.readthedocs.io/en/stable/installation/migrating-to-python3/) for assistance with upgrading. + +### Removed Deprecated User Activity Log + +The UserAction model, which was deprecated by the new change logging feature in NetBox v2.4, has been removed. If you need to archive legacy user activity, do so prior to upgrading to NetBox v2.5, as the database migration will remove all data associated with this model. + +### View Permissions in Django 2.1 + +Django 2.1 introduces view permissions for object types (not to be confused with object-level permissions). Implementation of [#323](https://github.com/digitalocean/netbox/issues/323) is planned for NetBox v2.6. Users are encourage to begin assigning view permissions as desired in preparation for their eventual enforcement. + +### upgrade.sh No Longer Invokes sudo + +The `upgrade.sh` script has been tweaked so that it no longer invokes `sudo` internally. This was done to ensure compatibility when running NetBox inside a Python virtual environment. If you need elevated permissions when upgrading NetBox, call the upgrade script with `sudo upgrade.sh`. + +## New Features + +### Patch Panels and Cables ([#20](https://github.com/digitalocean/netbox/issues/20)) + +NetBox now supports modeling physical cables for console, power, and interface connections. The new pass-through port component type has also been introduced to model patch panels and similar devices. + +## Enhancements + +* [#450](https://github.com/digitalocean/netbox/issues/450) - Added `outer_width` and `outer_depth` fields to rack model +* [#867](https://github.com/digitalocean/netbox/issues/867) - Added `description` field to circuit terminations +* [#1444](https://github.com/digitalocean/netbox/issues/1444) - Added an `asset_tag` field for racks +* [#1931](https://github.com/digitalocean/netbox/issues/1931) - Added a count of assigned IP addresses to the interface API serializer +* [#2000](https://github.com/digitalocean/netbox/issues/2000) - Dropped support for Python 2 +* [#2053](https://github.com/digitalocean/netbox/issues/2053) - Introduced the `LOGIN_TIMEOUT` configuration setting +* [#2057](https://github.com/digitalocean/netbox/issues/2057) - Added description columns to interface connections list +* [#2104](https://github.com/digitalocean/netbox/issues/2104) - Added a `status` field for racks +* [#2165](https://github.com/digitalocean/netbox/issues/2165) - Improved natural ordering of Interfaces +* [#2292](https://github.com/digitalocean/netbox/issues/2292) - Removed the deprecated UserAction model +* [#2367](https://github.com/digitalocean/netbox/issues/2367) - Removed deprecated RPCClient functionality +* [#2426](https://github.com/digitalocean/netbox/issues/2426) - Introduced `SESSION_FILE_PATH` configuration setting for authentication without write access to database +* [#2594](https://github.com/digitalocean/netbox/issues/2594) - `upgrade.sh` no longer invokes sudo + +## Changes From v2.5-beta2 + +* [#2474](https://github.com/digitalocean/netbox/issues/2474) - Add `cabled` and `connection_status` filters for device components +* [#2616](https://github.com/digitalocean/netbox/issues/2616) - Convert Rack `outer_unit` and Cable `length_unit` to integer-based choice fields +* [#2622](https://github.com/digitalocean/netbox/issues/2622) - Enable filtering cables by multiple types/colors +* [#2624](https://github.com/digitalocean/netbox/issues/2624) - Delete associated content type and permissions when removing InterfaceConnection model +* [#2626](https://github.com/digitalocean/netbox/issues/2626) - Remove extraneous permissions generated from proxy models +* [#2632](https://github.com/digitalocean/netbox/issues/2632) - Change representation of null values from `0` to `null` +* [#2639](https://github.com/digitalocean/netbox/issues/2639) - Fix preservation of length/dimensions unit for racks and cables +* [#2648](https://github.com/digitalocean/netbox/issues/2648) - Include the `connection_status` field in nested represenations of connectable device components +* [#2649](https://github.com/digitalocean/netbox/issues/2649) - Add `connected_endpoint_type` to connectable device component API representations + +## API Changes + +* The `/extras/recent-activity/` endpoint (replaced by change logging in v2.4) has been removed +* The `rpc_client` field has been removed from dcim.Platform (see #2367) +* Introduced a new API endpoint for cables at `/dcim/cables/` +* New endpoints for front and rear pass-through ports (and their templates) in parallel with existing device components +* The fields `interface_connection` on Interface and `interface` on CircuitTermination have been replaced with `connected_endpoint` and `connection_status` +* A new `cable` field has been added to console, power, and interface components and to circuit terminations +* New fields for dcim.Rack: `status`, `asset_tag`, `outer_width`, `outer_depth`, `outer_unit` +* The following boolean filters on dcim.Device and dcim.DeviceType have been renamed: + * `is_console_server`: `console_server_ports` + * `is_pdu`: `power_outlets` + * `is_network_device`: `interfaces` +* The following new boolean filters have been introduced for dcim.Device and dcim.DeviceType: + * `console_ports` + * `power_ports` + * `pass_through_ports` +* The field `interface_ordering` has been removed from the DeviceType serializer +* Added a `description` field to the CircuitTermination serializer +* Added `ipaddress_count` to InterfaceSerializer to show the count of assigned IP addresses for each interface +* The `available-prefixes` and `available-ips` IPAM endpoints now return an HTTP 204 response instead of HTTP 400 when no new objects can be created +* Filtering on null values now uses the string `null` instead of zero + +--- + +v2.4.9 (2018-12-07) + +## Enhancements + +* [#2089](https://github.com/digitalocean/netbox/issues/2089) - Add SONET interface form factors +* [#2495](https://github.com/digitalocean/netbox/issues/2495) - Enable deep-merging of config context data +* [#2597](https://github.com/digitalocean/netbox/issues/2597) - Add FibreChannel SFP28 (32GFC) interface form factor + +## Bug Fixes + +* [#2400](https://github.com/digitalocean/netbox/issues/2400) - Correct representation of nested object assignment in API docs +* [#2576](https://github.com/digitalocean/netbox/issues/2576) - Correct type for count_* fields in site API representation +* [#2606](https://github.com/digitalocean/netbox/issues/2606) - Fixed filtering for interfaces with a virtual form factor +* [#2611](https://github.com/digitalocean/netbox/issues/2611) - Fix error handling when assigning a clustered device to a different site +* [#2613](https://github.com/digitalocean/netbox/issues/2613) - Decrease live search minimum characters to three +* [#2615](https://github.com/digitalocean/netbox/issues/2615) - Tweak live search widget to use brief format for API requests +* [#2623](https://github.com/digitalocean/netbox/issues/2623) - Removed the need to pass the model class to the rqworker process for webhooks +* [#2634](https://github.com/digitalocean/netbox/issues/2634) - Enforce consistent representation of unnamed devices in rack view + +--- + +v2.4.8 (2018-11-20) + +## Enhancements + +* [#2490](https://github.com/digitalocean/netbox/issues/2490) - Added bulk editing for config contexts +* [#2557](https://github.com/digitalocean/netbox/issues/2557) - Added object view for tags + +## Bug Fixes + +* [#2473](https://github.com/digitalocean/netbox/issues/2473) - Fix encoding of long (>127 character) secrets +* [#2558](https://github.com/digitalocean/netbox/issues/2558) - Filter on all tags when multiple are passed +* [#2565](https://github.com/digitalocean/netbox/issues/2565) - Improved rendering of Markdown tables +* [#2575](https://github.com/digitalocean/netbox/issues/2575) - Correct model specified for rack roles table +* [#2588](https://github.com/digitalocean/netbox/issues/2588) - Catch all exceptions from failed NAPALM API Calls +* [#2589](https://github.com/digitalocean/netbox/issues/2589) - Virtual machine API serializer should require cluster assignment + +--- + +v2.4.7 (2018-11-06) + +## Enhancements + +* [#2388](https://github.com/digitalocean/netbox/issues/2388) - Enable filtering of devices/VMs by region +* [#2427](https://github.com/digitalocean/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID +* [#2512](https://github.com/digitalocean/netbox/issues/2512) - Add device field to inventory item filter form + +## Bug Fixes + +* [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF +* [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces +* [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled +* [#2528](https://github.com/digitalocean/netbox/issues/2528) - Enable creating circuit terminations with interface assignment via API +* [#2549](https://github.com/digitalocean/netbox/issues/2549) - Changed naming of `peer_device` and `peer_interface` on API /dcim/connected-device/ endpoint to use underscores + +--- + +v2.4.6 (2018-10-05) + +## Enhancements + +* [#2479](https://github.com/digitalocean/netbox/issues/2479) - Add user permissions for creating/modifying API tokens +* [#2487](https://github.com/digitalocean/netbox/issues/2487) - Return abbreviated API output when passed `?brief=1` + +## Bug Fixes + +* [#2393](https://github.com/digitalocean/netbox/issues/2393) - Fix Unicode support for CSV import under Python 2 +* [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE +* [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form +* [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM +* [#2491](https://github.com/digitalocean/netbox/issues/2491) - Fix exception when importing devices with invalid device type +* [#2492](https://github.com/digitalocean/netbox/issues/2492) - Sanitize hostname and port values returned through LLDP + +--- + +v2.4.5 (2018-10-02) + +## Enhancements + +* [#2392](https://github.com/digitalocean/netbox/issues/2392) - Implemented local context data for devices and virtual machines +* [#2402](https://github.com/digitalocean/netbox/issues/2402) - Order and format JSON data in form fields +* [#2432](https://github.com/digitalocean/netbox/issues/2432) - Link remote interface connections to the Interface view +* [#2438](https://github.com/digitalocean/netbox/issues/2438) - API optimizations for tagged objects + +## Bug Fixes + +* [#2406](https://github.com/digitalocean/netbox/issues/2406) - Remove hard-coded limit of 1000 objects from API-populated form fields +* [#2414](https://github.com/digitalocean/netbox/issues/2414) - Tags field missing from device/VM component creation forms +* [#2442](https://github.com/digitalocean/netbox/issues/2442) - Nullify "next" link in API when limit=0 is passed +* [#2443](https://github.com/digitalocean/netbox/issues/2443) - Enforce JSON object format when creating config contexts +* [#2444](https://github.com/digitalocean/netbox/issues/2444) - Improve validation of interface MAC addresses +* [#2455](https://github.com/digitalocean/netbox/issues/2455) - Ignore unique address enforcement for IPs with a shared/virtual role +* [#2470](https://github.com/digitalocean/netbox/issues/2470) - Log the creation of device/VM components as object changes + +--- + +v2.4.4 (2018-08-22) + +## Enhancements + +* [#2168](https://github.com/digitalocean/netbox/issues/2168) - Added Extreme SummitStack interface form factors +* [#2356](https://github.com/digitalocean/netbox/issues/2356) - Include cluster site as read-only field in VirtualMachine serializer +* [#2362](https://github.com/digitalocean/netbox/issues/2362) - Implemented custom admin site to properly handle BASE_PATH +* [#2254](https://github.com/digitalocean/netbox/issues/2254) - Implemented searchability for Rack Groups + +## Bug Fixes + +* [#2353](https://github.com/digitalocean/netbox/issues/2353) - Handle `DoesNotExist` exception when deleting a device with connected interfaces +* [#2354](https://github.com/digitalocean/netbox/issues/2354) - Increased maximum MTU for interfaces to 65536 bytes +* [#2355](https://github.com/digitalocean/netbox/issues/2355) - Added item count to inventory tab on device view +* [#2368](https://github.com/digitalocean/netbox/issues/2368) - Record change in device changelog when altering cluster assignment +* [#2369](https://github.com/digitalocean/netbox/issues/2369) - Corrected time zone validation on site API serializer +* [#2370](https://github.com/digitalocean/netbox/issues/2370) - Redirect to parent device after deleting device bays +* [#2374](https://github.com/digitalocean/netbox/issues/2374) - Fix toggling display of IP addresses in virtual machine interfaces list +* [#2378](https://github.com/digitalocean/netbox/issues/2378) - Corrected "edit" link for virtual machine interfaces + +--- + +v2.4.3 (2018-08-09) + +## Enhancements + +* [#2333](https://github.com/digitalocean/netbox/issues/2333) - Added search filters for ConfigContexts + +## Bug Fixes + +* [#2334](https://github.com/digitalocean/netbox/issues/2334) - TypeError raised when WritableNestedSerializer receives a non-integer value +* [#2335](https://github.com/digitalocean/netbox/issues/2335) - API requires group field when creating/updating a rack +* [#2336](https://github.com/digitalocean/netbox/issues/2336) - Bulk deleting power outlets and console server ports from a device redirects to home page +* [#2337](https://github.com/digitalocean/netbox/issues/2337) - Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError +* [#2340](https://github.com/digitalocean/netbox/issues/2340) - API requires manufacturer field when creating/updating an inventory item +* [#2342](https://github.com/digitalocean/netbox/issues/2342) - IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM +* [#2344](https://github.com/digitalocean/netbox/issues/2344) - AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site + +--- + +v2.4.2 (2018-08-08) + +## Bug Fixes + +* [#2318](https://github.com/digitalocean/netbox/issues/2318) - ImportError when viewing a report +* [#2319](https://github.com/digitalocean/netbox/issues/2319) - Extend ChoiceField to properly handle true/false choice keys +* [#2320](https://github.com/digitalocean/netbox/issues/2320) - TypeError when dispatching a webhook with a secret key configured +* [#2321](https://github.com/digitalocean/netbox/issues/2321) - Allow explicitly setting a null value on nullable ChoiceFields +* [#2322](https://github.com/digitalocean/netbox/issues/2322) - Webhooks firing on non-enabled event types +* [#2323](https://github.com/digitalocean/netbox/issues/2323) - DoesNotExist raised when deleting devices or virtual machines +* [#2330](https://github.com/digitalocean/netbox/issues/2330) - Incorrect tab link in VRF changelog view + +--- + +v2.4.1 (2018-08-07) + +## Bug Fixes + +* [#2303](https://github.com/digitalocean/netbox/issues/2303) - Always redirect to parent object when bulk editing/deleting components +* [#2308](https://github.com/digitalocean/netbox/issues/2308) - Custom fields panel absent from object view in UI +* [#2310](https://github.com/digitalocean/netbox/issues/2310) - False validation error on certain nested serializers +* [#2311](https://github.com/digitalocean/netbox/issues/2311) - Redirect to parent after editing interface from device/VM view +* [#2312](https://github.com/digitalocean/netbox/issues/2312) - Running a report yields a ValueError exception +* [#2314](https://github.com/digitalocean/netbox/issues/2314) - Serialized representation of object in change log does not include assigned tags + +--- + +v2.4.0 (2018-08-06) + +## New Features + +### Webhooks ([#81](https://github.com/digitalocean/netbox/issues/81)) + +Webhooks enable NetBox to send a representation of an object every time one is created, updated, or deleted. Webhooks are sent from NetBox to external services via HTTP, and can be limited by object type. Services which receive a webhook can act on the data provided by NetBox to automate other tasks. + +Special thanks to [John Anderson](https://github.com/lampwins) for doing the heavy lifting for this feature! + +### Tagging ([#132](https://github.com/digitalocean/netbox/issues/132)) + +Tags are free-form labels which can be assigned to a variety of objects in NetBox. Tags can be used to categorize and filter objects in addition to built-in and custom fields. Objects to which tags apply now include a `tags` field in the API. + +### Contextual Configuration Data ([#1349](https://github.com/digitalocean/netbox/issues/1349)) + +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 (expressed in JSON format) to devices and virtual machines grouped by region, site, role, platform, and/or tenancy. 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. + +### Change Logging ([#1898](https://github.com/digitalocean/netbox/issues/1898)) + +When an object is created, updated, or deleted, NetBox now automatically records a serialized representation of that object (similar to how it appears in the REST API) as well the event time and user account associated with the change. + +## Enhancements + +* [#238](https://github.com/digitalocean/netbox/issues/238) - Allow racks with the same name within a site (but in different groups) +* [#971](https://github.com/digitalocean/netbox/issues/971) - Add a view to show all VLAN IDs available within a group +* [#1673](https://github.com/digitalocean/netbox/issues/1673) - Added object/list views for services +* [#1687](https://github.com/digitalocean/netbox/issues/1687) - Enabled custom fields for services +* [#1739](https://github.com/digitalocean/netbox/issues/1739) - Enabled custom fields for secrets +* [#1794](https://github.com/digitalocean/netbox/issues/1794) - Improved POST/PATCH representation of nested objects +* [#2029](https://github.com/digitalocean/netbox/issues/2029) - Added optional NAPALM arguments to Platform model +* [#2034](https://github.com/digitalocean/netbox/issues/2034) - Include the ID when showing nested interface connections (API change) +* [#2118](https://github.com/digitalocean/netbox/issues/2118) - Added `latitude` and `longitude` fields to Site for GPS coordinates +* [#2131](https://github.com/digitalocean/netbox/issues/2131) - Added `created` and `last_updated` fields to DeviceType +* [#2157](https://github.com/digitalocean/netbox/issues/2157) - Fixed natural ordering of objects when sorted by name +* [#2225](https://github.com/digitalocean/netbox/issues/2225) - Add "view elevations" button for site rack groups + +## Bug Fixes + +* [#2272](https://github.com/digitalocean/netbox/issues/2272) - Allow subdevice_role to be null on DeviceTypeSerializer" +* [#2286](https://github.com/digitalocean/netbox/issues/2286) - Fixed "mark connected" button for PDU outlet connections + +## API Changes + +* Introduced the `/extras/config-contexts/`, `/extras/object-changes/`, and `/extras/tags/` API endpoints +* API writes now return a nested representation of related objects (rather than only a numeric ID) +* The dcim.DeviceType serializer now includes `created` and `last_updated` fields +* The dcim.Site serializer now includes `latitude` and `longitude` fields +* The ipam.Service and secrets.Secret serializers now include custom fields +* The dcim.Platform serializer now includes a free-form (JSON) `napalm_args` field + +## Changes Since v2.4-beta1 + +### Enhancements + +* [#2229](https://github.com/digitalocean/netbox/issues/2229) - Allow mapping of ConfigContexts to tenant groups +* [#2259](https://github.com/digitalocean/netbox/issues/2259) - Add changelog tab to interface view +* [#2264](https://github.com/digitalocean/netbox/issues/2264) - Added "map it" link for site GPS coordinates + +### Bug Fixes + +* [#2137](https://github.com/digitalocean/netbox/issues/2137) - Fixed JSON serialization of dates +* [#2258](https://github.com/digitalocean/netbox/issues/2258) - Include changed object type on home page changelog +* [#2265](https://github.com/digitalocean/netbox/issues/2265) - Include parent regions when filtering applicable ConfigContexts +* [#2288](https://github.com/digitalocean/netbox/issues/2288) - Fix exception when assigning objects to a ConfigContext via the API +* [#2296](https://github.com/digitalocean/netbox/issues/2296) - Fix AttributeError when creating a new object with tags assigned +* [#2300](https://github.com/digitalocean/netbox/issues/2300) - Fix assignment of an interface to an IP address via API PATCH +* [#2301](https://github.com/digitalocean/netbox/issues/2301) - Fix model validation on assignment of ManyToMany fields via API PATCH +* [#2305](https://github.com/digitalocean/netbox/issues/2305) - Make VLAN fields optional when creating a VM interface via the API + +--- + +v2.3.7 (2018-07-26) + +## Enhancements + +* [#2166](https://github.com/digitalocean/netbox/issues/2166) - Enable partial matching on device asset_tag during search + +## Bug Fixes + +* [#1977](https://github.com/digitalocean/netbox/issues/1977) - Fixed exception when creating a virtual chassis with a non-master device in position 1 +* [#1992](https://github.com/digitalocean/netbox/issues/1992) - Isolate errors when one of multiple NAPALM methods fails +* [#2202](https://github.com/digitalocean/netbox/issues/2202) - Ditched half-baked concept of tenancy inheritance via VRF +* [#2222](https://github.com/digitalocean/netbox/issues/2222) - IP addresses created via the `available-ips` API endpoint should have the same mask as their parent prefix (not /32) +* [#2231](https://github.com/digitalocean/netbox/issues/2231) - Remove `get_absolute_url()` from DeviceRole (can apply to devices or VMs) +* [#2250](https://github.com/digitalocean/netbox/issues/2250) - Include stat counters on report result navigation +* [#2255](https://github.com/digitalocean/netbox/issues/2255) - Corrected display of results in reports list +* [#2256](https://github.com/digitalocean/netbox/issues/2256) - Prevent navigation menu overlap when jumping to test results on report page +* [#2257](https://github.com/digitalocean/netbox/issues/2257) - Corrected casting of RIR utilization stats as floats +* [#2266](https://github.com/digitalocean/netbox/issues/2266) - Permit additional logging of exceptions beyond custom middleware + +--- + +v2.3.6 (2018-07-16) + +## Enhancements + +* [#2107](https://github.com/digitalocean/netbox/issues/2107) - Added virtual chassis to global search +* [#2125](https://github.com/digitalocean/netbox/issues/2125) - Show child status in device bay list + +## Bug Fixes + +* [#2214](https://github.com/digitalocean/netbox/issues/2214) - Error when assigning a VLAN to an interface on a VM in a cluster with no assigned site +* [#2239](https://github.com/digitalocean/netbox/issues/2239) - Pin django-filter to version 1.1.0 + +--- + +v2.3.5 (2018-07-02) + +## Enhancements + +* [#2159](https://github.com/digitalocean/netbox/issues/2159) - Allow custom choice field to specify a default choice +* [#2177](https://github.com/digitalocean/netbox/issues/2177) - Include device serial number in rack elevation pop-up +* [#2194](https://github.com/digitalocean/netbox/issues/2194) - Added `address` filter to IPAddress model + +## Bug Fixes + +* [#1826](https://github.com/digitalocean/netbox/issues/1826) - Corrected description of security parameters under API definition +* [#2021](https://github.com/digitalocean/netbox/issues/2021) - Fix recursion error when viewing API docs under Python 3.4 +* [#2064](https://github.com/digitalocean/netbox/issues/2064) - Disable calls to online swagger validator +* [#2173](https://github.com/digitalocean/netbox/issues/2173) - Fixed IndexError when automatically allocating IP addresses from large IPv6 prefixes +* [#2181](https://github.com/digitalocean/netbox/issues/2181) - Raise validation error on invalid `prefix_length` when allocating next-available prefix +* [#2182](https://github.com/digitalocean/netbox/issues/2182) - ValueError can be raised when viewing the interface connections table +* [#2191](https://github.com/digitalocean/netbox/issues/2191) - Added missing static choices to circuits and DCIM API endpoints +* [#2192](https://github.com/digitalocean/netbox/issues/2192) - Prevent a 0U device from being assigned to a rack position + +--- + +v2.3.4 (2018-06-07) + +## Bug Fixes + +* [#2066](https://github.com/digitalocean/netbox/issues/2066) - Catch `AddrFormatError` exception on invalid IP addresses +* [#2075](https://github.com/digitalocean/netbox/issues/2075) - Enable tenant assignment when creating a rack reservation via the API +* [#2083](https://github.com/digitalocean/netbox/issues/2083) - Add missing export button to rack roles list view +* [#2087](https://github.com/digitalocean/netbox/issues/2087) - Don't overwrite existing vc_position of master device when creating a virtual chassis +* [#2093](https://github.com/digitalocean/netbox/issues/2093) - Fix link to circuit termination in device interfaces table +* [#2097](https://github.com/digitalocean/netbox/issues/2097) - Fixed queryset-based bulk deletion of clusters and regions +* [#2098](https://github.com/digitalocean/netbox/issues/2098) - Fixed missing checkboxes for host devices in cluster view +* [#2127](https://github.com/digitalocean/netbox/issues/2127) - Prevent non-conntectable interfaces from being connected +* [#2143](https://github.com/digitalocean/netbox/issues/2143) - Accept null value for empty time zone field +* [#2148](https://github.com/digitalocean/netbox/issues/2148) - Do not force timezone selection when editing sites in bulk +* [#2150](https://github.com/digitalocean/netbox/issues/2150) - Fix display of LLDP neighbors when interface name contains a colon + +--- + +v2.3.3 (2018-04-19) + +## Enhancements + +* [#1990](https://github.com/digitalocean/netbox/issues/1990) - Improved search function when assigning an IP address to an interface + +## Bug Fixes + +* [#1975](https://github.com/digitalocean/netbox/issues/1975) - Correct filtering logic for custom boolean fields +* [#1988](https://github.com/digitalocean/netbox/issues/1988) - Order interfaces naturally when bulk renaming +* [#1993](https://github.com/digitalocean/netbox/issues/1993) - Corrected status choices in site CSV import form +* [#1999](https://github.com/digitalocean/netbox/issues/1999) - Added missing description field to site edit form +* [#2012](https://github.com/digitalocean/netbox/issues/2012) - Fixed deselection of an IP address as the primary IP for its parent device/VM +* [#2014](https://github.com/digitalocean/netbox/issues/2014) - Allow assignment of VLANs to VM interfaces via the API +* [#2019](https://github.com/digitalocean/netbox/issues/2019) - Avoid casting oversized numbers as integers +* [#2022](https://github.com/digitalocean/netbox/issues/2022) - Show 0 for zero-value fields on CSV export +* [#2023](https://github.com/digitalocean/netbox/issues/2023) - Manufacturer should not be a required field when importing platforms +* [#2037](https://github.com/digitalocean/netbox/issues/2037) - Fixed IndexError exception when attempting to create a new rack reservation + +--- + +v2.3.2 (2018-03-22) + +## Enhancements + +* [#1586](https://github.com/digitalocean/netbox/issues/1586) - Extend bulk interface creation to support alphanumeric characters +* [#1866](https://github.com/digitalocean/netbox/issues/1866) - Introduced AnnotatedMultipleChoiceField for filter forms +* [#1930](https://github.com/digitalocean/netbox/issues/1930) - Switched to drf-yasg for Swagger API documentation +* [#1944](https://github.com/digitalocean/netbox/issues/1944) - Enable assigning VLANs to virtual machine interfaces +* [#1945](https://github.com/digitalocean/netbox/issues/1945) - Implemented a VLAN members view +* [#1949](https://github.com/digitalocean/netbox/issues/1949) - Added a button to view elevations on rack groups list +* [#1952](https://github.com/digitalocean/netbox/issues/1952) - Implemented a more robust mechanism for assigning VLANs to interfaces + +## Bug Fixes + +* [#1948](https://github.com/digitalocean/netbox/issues/1948) - Fix TypeError when attempting to add a member to an existing virtual chassis +* [#1951](https://github.com/digitalocean/netbox/issues/1951) - Fix TypeError exception when importing platforms +* [#1953](https://github.com/digitalocean/netbox/issues/1953) - Ignore duplicate IPs when calculating prefix utilization +* [#1955](https://github.com/digitalocean/netbox/issues/1955) - Require a plaintext value when creating a new secret +* [#1978](https://github.com/digitalocean/netbox/issues/1978) - Include all virtual chassis member interfaces in LLDP neighbors view +* [#1980](https://github.com/digitalocean/netbox/issues/1980) - Fixed bug when trying to nullify a selection custom field under Python 2 + +--- + +v2.3.1 (2018-03-01) + +## Enhancements + +* [#1910](https://github.com/digitalocean/netbox/issues/1910) - Added filters for cluster group and cluster type + +## Bug Fixes + +* [#1915](https://github.com/digitalocean/netbox/issues/1915) - Redirect to device view after deleting a component +* [#1919](https://github.com/digitalocean/netbox/issues/1919) - Prevent exception when attempting to create a virtual machine without selecting devices +* [#1921](https://github.com/digitalocean/netbox/issues/1921) - Ignore ManyToManyFields when validating a new object created via the API +* [#1924](https://github.com/digitalocean/netbox/issues/1924) - Include VID in VLAN lists when editing an interface +* [#1926](https://github.com/digitalocean/netbox/issues/1926) - Prevent reassignment of parent device when bulk editing VC member interfaces +* [#1927](https://github.com/digitalocean/netbox/issues/1927) - Include all VC member interfaces on A side when creating a new interface connection +* [#1928](https://github.com/digitalocean/netbox/issues/1928) - Fixed form validation when modifying VLANs assigned to an interface +* [#1934](https://github.com/digitalocean/netbox/issues/1934) - Fixed exception when rendering export template on an object type with custom fields assigned +* [#1935](https://github.com/digitalocean/netbox/issues/1935) - Correct API validation of VLANs assigned to interfaces +* [#1936](https://github.com/digitalocean/netbox/issues/1936) - Trigger validation error when attempting to create a virtual chassis without specifying member positions + +--- + +v2.3.0 (2018-02-26) + +## New Features + +### Virtual Chassis ([#99](https://github.com/digitalocean/netbox/issues/99)) + +A virtual chassis represents a set of physical devices with a shared control plane; for example, a stack of switches managed as a single device. Viewing the master device of a virtual chassis will show all member interfaces and IP addresses. + +### Interface VLAN Assignments ([#150](https://github.com/digitalocean/netbox/issues/150)) + +Interfaces can now be assigned an 802.1Q mode (access or trunked) and associated with particular VLANs. Thanks to [John Anderson](https://github.com/lampwins) for his work on this! + +### Bulk Object Creation via the API ([#1553](https://github.com/digitalocean/netbox/issues/1553)) + +The REST API now supports the creation of multiple objects of the same type using a single POST request. For example, to create multiple devices: + +``` +curl -X POST -H "Authorization: Token " -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[ +{"name": "device1", "device_type": 24, "device_role": 17, "site": 6}, +{"name": "device2", "device_type": 24, "device_role": 17, "site": 6}, +{"name": "device3", "device_type": 24, "device_role": 17, "site": 6}, +]' +``` + +Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. + +### Automatic Provisioning of Next Available Prefixes ([#1694](https://github.com/digitalocean/netbox/issues/1694)) + +Similar to IP addresses, NetBox now supports automated provisioning of available prefixes from within a parent prefix. For example, to retrieve the next three available /28s within a parent /24: + +``` +curl -X POST -H "Authorization: Token " -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/ipam/prefixes/10153/available-prefixes/ --data '[ +{"prefix_length": 28}, +{"prefix_length": 28}, +{"prefix_length": 28} +]' +``` + +If the parent prefix cannot accommodate all requested prefixes, the operation is cancelled and no new prefixes are created. + +### Bulk Renaming of Device/VM Components ([#1781](https://github.com/digitalocean/netbox/issues/1781)) + +Device components (interfaces, console ports, etc.) can now be renamed in bulk via the web interface. This was implemented primarily to support the bulk renumbering of interfaces whose parent is part of a virtual chassis. + +## Enhancements + +* [#1283](https://github.com/digitalocean/netbox/issues/1283) - Added a `time_zone` field to the site model +* [#1321](https://github.com/digitalocean/netbox/issues/1321) - Added `created` and `last_updated` fields for relevant models to their API serializers +* [#1553](https://github.com/digitalocean/netbox/issues/1553) - Introduced support for bulk object creation via the API +* [#1592](https://github.com/digitalocean/netbox/issues/1592) - Added tenancy assignment for rack reservations +* [#1744](https://github.com/digitalocean/netbox/issues/1744) - Allow associating a platform with a specific manufacturer +* [#1758](https://github.com/digitalocean/netbox/issues/1758) - Added a `status` field to the site model +* [#1821](https://github.com/digitalocean/netbox/issues/1821) - Added a `description` field to the site model +* [#1864](https://github.com/digitalocean/netbox/issues/1864) - Added a `status` field to the circuit model + +## Bug Fixes + +* [#1136](https://github.com/digitalocean/netbox/issues/1136) - Enforce model validation during bulk update +* [#1645](https://github.com/digitalocean/netbox/issues/1645) - Simplified interface serialzier for IP addresses and optimized API view queryset +* [#1838](https://github.com/digitalocean/netbox/issues/1838) - Fix KeyError when attempting to create a VirtualChassis with no devices selected +* [#1847](https://github.com/digitalocean/netbox/issues/1847) - RecursionError when a virtual chasis master device has no name +* [#1848](https://github.com/digitalocean/netbox/issues/1848) - Allow null value for interface encapsulation mode +* [#1867](https://github.com/digitalocean/netbox/issues/1867) - Allow filtering on device status with multiple values +* [#1881](https://github.com/digitalocean/netbox/issues/1881)* - Fixed bulk editing of interface 802.1Q settings +* [#1884](https://github.com/digitalocean/netbox/issues/1884)* - Provide additional context to identify devices when creating/editing a virtual chassis +* [#1907](https://github.com/digitalocean/netbox/issues/1907) - Allow removing an IP as the primary for a device when editing the IP directly + +\* New since v2.3-beta2 + +## Breaking Changes + +* Constants representing device status have been renamed for clarity (for example, `STATUS_ACTIVE` is now `DEVICE_STATUS_ACTIVE`). Custom validation reports will need to be updated if they reference any of these constants. + +## API Changes + +* API creation calls now accept either a single JSON object or a list of JSON objects. If multiple objects are passed and one or more them fail validation, no objects will be created. +* Added `created` and `last_updated` fields for objects inheriting from CreatedUpdatedModel. +* Removed the `parent` filter for prefixes (use `within` or `within_include` instead). +* The IP address serializer now includes only a minimal nested representation of the assigned interface (if any) and its parent device or virtual machine. +* The rack reservation serializer now includes a nested representation of its owning user (as well as the assigned tenant, if any). +* Added endpoints for virtual chassis and VC memberships. +* Added `status`, `time_zone` (pytz format), and `description` fields to dcim.Site. +* Added a `manufacturer` foreign key field on dcim.Platform. +* Added a `status` field on circuits.Circuit. + +--- + +v2.2.10 (2018-02-21) + +## Enhancements + +* [#78](https://github.com/digitalocean/netbox/issues/78) - Extended topology maps to support console and power connections +* [#1693](https://github.com/digitalocean/netbox/issues/1693) - Allow specifying loose or exact matching for custom field filters +* [#1714](https://github.com/digitalocean/netbox/issues/1714) - Standardized CSV export functionality for all object lists +* [#1876](https://github.com/digitalocean/netbox/issues/1876) - Added explanatory title text to disabled NAPALM buttons on device view +* [#1885](https://github.com/digitalocean/netbox/issues/1885) - Added a device filter field for primary IP + +## Bug Fixes + +* [#1858](https://github.com/digitalocean/netbox/issues/1858) - Include device/VM count for cluster list in global search results +* [#1859](https://github.com/digitalocean/netbox/issues/1859) - Implemented support for line breaks within CSV fields +* [#1860](https://github.com/digitalocean/netbox/issues/1860) - Do not populate initial values for custom fields when editing objects in bulk +* [#1869](https://github.com/digitalocean/netbox/issues/1869) - Corrected ordering of VRFs with duplicate names +* [#1886](https://github.com/digitalocean/netbox/issues/1886) - Allow setting the primary IPv4/v6 address for a virtual machine via the web UI + +--- + +v2.2.9 (2018-01-31) + +## Enhancements + +* [#144](https://github.com/digitalocean/netbox/issues/144) - Implemented bulk import/edit/delete views for InventoryItems +* [#1073](https://github.com/digitalocean/netbox/issues/1073) - Include prefixes/IPs from all VRFs when viewing the children of a container prefix in the global table +* [#1366](https://github.com/digitalocean/netbox/issues/1366) - Enable searching for regions by name/slug +* [#1406](https://github.com/digitalocean/netbox/issues/1406) - Display tenant description as title text in object tables +* [#1824](https://github.com/digitalocean/netbox/issues/1824) - Add virtual machine count to platforms list +* [#1835](https://github.com/digitalocean/netbox/issues/1835) - Consistent positioning of previous/next rack buttons + +## Bug Fixes + +* [#1621](https://github.com/digitalocean/netbox/issues/1621) - Tweaked LLDP interface name evaluation logic +* [#1765](https://github.com/digitalocean/netbox/issues/1765) - Improved rendering of null options for model choice fields in filter forms +* [#1807](https://github.com/digitalocean/netbox/issues/1807) - Populate VRF from parent when creating a new prefix +* [#1809](https://github.com/digitalocean/netbox/issues/1809) - Populate tenant assignment from parent when creating a new prefix +* [#1818](https://github.com/digitalocean/netbox/issues/1818) - InventoryItem API serializer no longer requires specifying a null value for items with no parent +* [#1845](https://github.com/digitalocean/netbox/issues/1845) - Correct display of VMs in list with no role assigned +* [#1850](https://github.com/digitalocean/netbox/issues/1850) - Fix TypeError when attempting IP address import if only unnamed devices exist + +--- + +v2.2.8 (2017-12-20) + +## Enhancements + +* [#1771](https://github.com/digitalocean/netbox/issues/1771) - Added name filter for racks +* [#1772](https://github.com/digitalocean/netbox/issues/1772) - Added position filter for devices +* [#1773](https://github.com/digitalocean/netbox/issues/1773) - Moved child prefixes table to its own view +* [#1774](https://github.com/digitalocean/netbox/issues/1774) - Include a button to refine search results for all object types under global search +* [#1784](https://github.com/digitalocean/netbox/issues/1784) - Added `cluster_type` filters for virtual machines + +## Bug Fixes + +* [#1766](https://github.com/digitalocean/netbox/issues/1766) - Fixed display of "select all" button on device power outlets list +* [#1767](https://github.com/digitalocean/netbox/issues/1767) - Use proper template for 404 responses +* [#1778](https://github.com/digitalocean/netbox/issues/1778) - Preserve initial VRF assignment when adding IP addresses in bulk from a prefix +* [#1783](https://github.com/digitalocean/netbox/issues/1783) - Added `vm_role` filter for device roles +* [#1785](https://github.com/digitalocean/netbox/issues/1785) - Omit filter forms from browsable API +* [#1787](https://github.com/digitalocean/netbox/issues/1787) - Added missing site field to virtualization cluster CSV export + +--- + +v2.2.7 (2017-12-07) + +## Enhancements + +* [#1722](https://github.com/digitalocean/netbox/issues/1722) - Added virtual machine count to site view +* [#1737](https://github.com/digitalocean/netbox/issues/1737) - Added a `contains` API filter to find all prefixes containing a given IP or prefix + +## Bug Fixes + +* [#1712](https://github.com/digitalocean/netbox/issues/1712) - Corrected tenant inheritance for new IP addresses created from a parent prefix +* [#1721](https://github.com/digitalocean/netbox/issues/1721) - Differentiated child IP count from utilization percentage for prefixes +* [#1740](https://github.com/digitalocean/netbox/issues/1740) - Delete session_key cookie on logout +* [#1741](https://github.com/digitalocean/netbox/issues/1741) - Fixed Unicode support for secret plaintexts +* [#1743](https://github.com/digitalocean/netbox/issues/1743) - Include number of instances for device types in global search +* [#1751](https://github.com/digitalocean/netbox/issues/1751) - Corrected filtering for IPv6 addresses containing letters +* [#1756](https://github.com/digitalocean/netbox/issues/1756) - Improved natural ordering of console server ports and power outlets + +--- + +v2.2.6 (2017-11-16) + +## Enhancements + +* [#1669](https://github.com/digitalocean/netbox/issues/1669) - Clicking "add an IP" from the prefix view will default to the first available IP within the prefix + +## Bug Fixes + +* [#1397](https://github.com/digitalocean/netbox/issues/1397) - Display global search in navigation menu unless display is less than 1200px wide +* [#1599](https://github.com/digitalocean/netbox/issues/1599) - Reduce mobile cut-off for navigation menu to 960px +* [#1715](https://github.com/digitalocean/netbox/issues/1715) - Added missing import buttons on object lists +* [#1717](https://github.com/digitalocean/netbox/issues/1717) - Fixed interface validation for virtual machines +* [#1718](https://github.com/digitalocean/netbox/issues/1718) - Set empty label to "Global" or VRF field in IP assignment form + +--- + +v2.2.5 (2017-11-14) + +## Enhancements + +* [#1512](https://github.com/digitalocean/netbox/issues/1512) - Added a view to search for an IP address being assigned to an interface +* [#1679](https://github.com/digitalocean/netbox/issues/1679) - Added IP address roles to device/VM interface lists +* [#1683](https://github.com/digitalocean/netbox/issues/1683) - Replaced default 500 handler with custom middleware to provide preliminary troubleshooting assistance +* [#1684](https://github.com/digitalocean/netbox/issues/1684) - Replaced prefix `parent` filter with `within` and `within_include` + +## Bug Fixes + +* [#1471](https://github.com/digitalocean/netbox/issues/1471) - Correct bulk selection of IP addresses within a prefix assigned to a VRF +* [#1642](https://github.com/digitalocean/netbox/issues/1642) - Validate device type classification when creating console server ports and power outlets +* [#1650](https://github.com/digitalocean/netbox/issues/1650) - Correct numeric ordering for interfaces with no alphabetic type +* [#1676](https://github.com/digitalocean/netbox/issues/1676) - Correct filtering of child prefixes upon bulk edit/delete from the parent prefix view +* [#1689](https://github.com/digitalocean/netbox/issues/1689) - Disregard IP address mask when filtering for child IPs of a prefix +* [#1696](https://github.com/digitalocean/netbox/issues/1696) - Fix for NAPALM v2.0+ +* [#1699](https://github.com/digitalocean/netbox/issues/1699) - Correct nested representation in the API of primary IPs for virtual machines and add missing primary_ip property +* [#1701](https://github.com/digitalocean/netbox/issues/1701) - Fixed validation in `extras/0008_reports.py` migration for certain versions of PostgreSQL +* [#1703](https://github.com/digitalocean/netbox/issues/1703) - Added API serializer validation for custom integer fields +* [#1705](https://github.com/digitalocean/netbox/issues/1705) - Fixed filtering of devices with a status of offline + +--- + +v2.2.4 (2017-10-31) + +## Bug Fixes + +* [#1670](https://github.com/digitalocean/netbox/issues/1670) - Fixed server error when calling certain filters (regression from #1649) + +--- + +v2.2.3 (2017-10-31) + +## Enhancements + +* [#999](https://github.com/digitalocean/netbox/issues/999) - Display devices on which circuits are terminated in circuits list +* [#1491](https://github.com/digitalocean/netbox/issues/1491) - Added initial data for the virtualization app +* [#1620](https://github.com/digitalocean/netbox/issues/1620) - Loosen IP address search filter to match all IPs that start with the given string +* [#1631](https://github.com/digitalocean/netbox/issues/1631) - Added a `post_run` method to the Report class +* [#1666](https://github.com/digitalocean/netbox/issues/1666) - Allow modifying the owner of a rack reservation + +## Bug Fixes + +* [#1513](https://github.com/digitalocean/netbox/issues/1513) - Correct filtering of custom field choices +* [#1603](https://github.com/digitalocean/netbox/issues/1603) - Hide selection checkboxes for tables with no available actions +* [#1618](https://github.com/digitalocean/netbox/issues/1618) - Allow bulk deletion of all virtual machines +* [#1619](https://github.com/digitalocean/netbox/issues/1619) - Correct text-based filtering of IP network and address fields +* [#1624](https://github.com/digitalocean/netbox/issues/1624) - Add VM count to device roles table +* [#1634](https://github.com/digitalocean/netbox/issues/1634) - Cluster should not be a required field when importing child devices +* [#1649](https://github.com/digitalocean/netbox/issues/1649) - Correct filtering on null values (e.g. ?tenant_id=0) for django-filters v1.1.0+ +* [#1653](https://github.com/digitalocean/netbox/issues/1653) - Remove outdated description for DeviceType's `is_network_device` flag +* [#1664](https://github.com/digitalocean/netbox/issues/1664) - Added missing `serial` field in default rack CSV export + +--- + +v2.2.2 (2017-10-17) + +## Enhancements + +* [#1580](https://github.com/digitalocean/netbox/issues/1580) - Allow cluster assignment when bulk importing devices +* [#1587](https://github.com/digitalocean/netbox/issues/1587) - Add primary IP column for virtual machines in global search results + +## Bug Fixes + +* [#1498](https://github.com/digitalocean/netbox/issues/1498) - Avoid duplicating nodes when generating topology maps +* [#1579](https://github.com/digitalocean/netbox/issues/1579) - Devices already assigned to a cluster cannot be added to a different cluster +* [#1582](https://github.com/digitalocean/netbox/issues/1582) - Add `virtual_machine` attribute to IPAddress +* [#1584](https://github.com/digitalocean/netbox/issues/1584) - Colorized virtual machine role column +* [#1585](https://github.com/digitalocean/netbox/issues/1585) - Fixed slug-based filtering of virtual machines +* [#1605](https://github.com/digitalocean/netbox/issues/1605) - Added clusters and virtual machines to object list for global search +* [#1609](https://github.com/digitalocean/netbox/issues/1609) - Added missing `virtual_machine` field to IP address interface serializer + +--- + +v2.2.1 (2017-10-12) + +## Bug Fixes + +* [#1576](https://github.com/digitalocean/netbox/issues/1576) - Moved PostgreSQL validation logic into the relevant migration (fixed ImproperlyConfigured exception on init) + +--- + +v2.2.0 (2017-10-12) + +**Note:** This release requires PostgreSQL 9.4 or higher. Do not attempt to upgrade unless you are running at least PostgreSQL 9.4. + +**Note:** The release replaces the deprecated pycrypto library with [pycryptodome](https://github.com/Legrandin/pycryptodome). The upgrade script has been extended to automatically uninstall the old library, but please verify your installed packages with `pip freeze | grep pycrypto` if you run into problems. + +## New Features + +### Virtual Machines and Clusters ([#142](https://github.com/digitalocean/netbox/issues/142)) + +Our second-most popular feature request has arrived! NetBox now supports the creation of virtual machines, which can be assigned virtual interfaces and IP addresses. VMs are arranged into clusters, each of which has a type and (optionally) a group. + +### Custom Validation Reports ([#1511](https://github.com/digitalocean/netbox/issues/1511)) + +Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. + +## Enhancements + +* [#494](https://github.com/digitalocean/netbox/issues/494) - Include asset tag in device info pop-up on rack elevation +* [#1444](https://github.com/digitalocean/netbox/issues/1444) - Added a `serial` field to the rack model +* [#1479](https://github.com/digitalocean/netbox/issues/1479) - Added an IP address role for CARP +* [#1506](https://github.com/digitalocean/netbox/issues/1506) - Extended rack facility ID field from 30 to 50 characters +* [#1510](https://github.com/digitalocean/netbox/issues/1510) - Added ability to search by name when adding devices to a cluster +* [#1527](https://github.com/digitalocean/netbox/issues/1527) - Replace deprecated pycrypto library with pycryptodome +* [#1551](https://github.com/digitalocean/netbox/issues/1551) - Added API endpoints listing static field choices for each app +* [#1556](https://github.com/digitalocean/netbox/issues/1556) - Added CPAK, CFP2, and CFP4 100GE interface form factors +* Added CSV import views for all object types + +## Bug Fixes + +* [#1550](https://github.com/digitalocean/netbox/issues/1550) - Corrected interface connections link in navigation menu +* [#1554](https://github.com/digitalocean/netbox/issues/1554) - Don't require form_factor when creating an interface assigned to a virtual machine +* [#1557](https://github.com/digitalocean/netbox/issues/1557) - Added filtering for virtual machine interfaces +* [#1567](https://github.com/digitalocean/netbox/issues/1567) - Prompt user for session key when importing secrets + +## API Changes + +* Introduced the virtualization app and its associated endpoints at `/api/virtualization` +* Added the `/api/extras/reports` endpoint for fetching and running reports +* The `ipam.Service` and `dcim.Interface` models now have a `virtual_machine` field in addition to the `device` field. Only one of the two fields may be defined for each object +* Added a `vm_role` field to `dcim.DeviceRole`, which indicates whether a role is suitable for assigned to a virtual machine +* Added a `serial` field to 'dcim.Rack` for serial numbers +* Each app now has a `_choices` endpoint, which lists the available options for all model field with static choices (e.g. interface form factors) + +--- + +v2.1.6 (2017-10-11) + +## Enhancements + +* [#1548](https://github.com/digitalocean/netbox/issues/1548) - Automatically populate tenant assignment when adding an IP address from the prefix view +* [#1561](https://github.com/digitalocean/netbox/issues/1561) - Added primary IP to the devices table in global search +* [#1563](https://github.com/digitalocean/netbox/issues/1563) - Made necessary updates for Django REST Framework v3.7.0 + +--- + +v2.1.5 (2017-09-25) + +## Enhancements + +* [#1484](https://github.com/digitalocean/netbox/issues/1484) - Added individual "add VLAN" buttons on the VLAN groups list +* [#1485](https://github.com/digitalocean/netbox/issues/1485) - Added `BANNER_LOGIN` configuration setting to display a banner on the login page +* [#1499](https://github.com/digitalocean/netbox/issues/1499) - Added utilization graph to child prefixes table +* [#1523](https://github.com/digitalocean/netbox/issues/1523) - Improved the natural ordering of interfaces (thanks to [@tarkatronic](https://github.com/tarkatronic)) +* [#1536](https://github.com/digitalocean/netbox/issues/1536) - Improved formatting of aggregate prefix statistics + +## Bug Fixes + +* [#1469](https://github.com/digitalocean/netbox/issues/1469) - Allow a NAT IP to be assigned as the primary IP for a device +* [#1472](https://github.com/digitalocean/netbox/issues/1472) - Prevented truncation when displaying secret strings containing HTML characters +* [#1486](https://github.com/digitalocean/netbox/issues/1486) - Ignore subinterface IDs when validating LLDP neighbor connections +* [#1489](https://github.com/digitalocean/netbox/issues/1489) - Corrected server error on validation of empty required custom field +* [#1507](https://github.com/digitalocean/netbox/issues/1507) - Fixed error when creating the next available IP from a prefix within a VRF +* [#1520](https://github.com/digitalocean/netbox/issues/1520) - Redirect on GET request to bulk edit/delete views +* [#1522](https://github.com/digitalocean/netbox/issues/1522) - Removed object create/edit forms from the browsable API + +--- + +v2.1.4 (2017-08-30) + +## Enhancements + +* [#1326](https://github.com/digitalocean/netbox/issues/1326) - Added dropdown widget with common values for circuit speed fields +* [#1341](https://github.com/digitalocean/netbox/issues/1341) - Added a `MEDIA_ROOT` configuration setting to specify where uploaded files are stored on disk +* [#1376](https://github.com/digitalocean/netbox/issues/1376) - Ignore anycast addresses when detecting duplicate IPs +* [#1402](https://github.com/digitalocean/netbox/issues/1402) - Increased max length of name field for device components +* [#1431](https://github.com/digitalocean/netbox/issues/1431) - Added interface form factor for 10GBASE-CX4 +* [#1432](https://github.com/digitalocean/netbox/issues/1432) - Added a `commit_rate` field to the circuits list search form +* [#1460](https://github.com/digitalocean/netbox/issues/1460) - Hostnames with no domain are now acceptable in custom URL fields + +## Bug Fixes + +* [#1429](https://github.com/digitalocean/netbox/issues/1429) - Fixed uptime formatting on device status page +* [#1433](https://github.com/digitalocean/netbox/issues/1433) - Fixed `devicetype_id` filter for DeviceType components +* [#1443](https://github.com/digitalocean/netbox/issues/1443) - Fixed API validation error involving custom field data +* [#1458](https://github.com/digitalocean/netbox/issues/1458) - Corrected permission name on prefix/VLAN roles list + +--- + +v2.1.3 (2017-08-15) + +## Bug Fixes + +* [#1330](https://github.com/digitalocean/netbox/issues/1330) - Raise validation error when assigning an unrelated IP as the primary IP for a device +* [#1389](https://github.com/digitalocean/netbox/issues/1389) - Avoid splitting carat/prefix on prefix list +* [#1400](https://github.com/digitalocean/netbox/issues/1400) - Removed redundant display of assigned device interface from IP address list +* [#1414](https://github.com/digitalocean/netbox/issues/1414) - Selecting a site from the rack filters automatically updates the available rack groups +* [#1419](https://github.com/digitalocean/netbox/issues/1419) - Allow editing image attachments without re-uploading an image +* [#1420](https://github.com/digitalocean/netbox/issues/1420) - Exclude virtual interfaces from device LLDP neighbors view +* [#1421](https://github.com/digitalocean/netbox/issues/1421) - Improved model validation logic for API serializers +* Fixed page title capitalization in the browsable API + +--- + +v2.1.2 (2017-08-04) + +## Enhancements + +* [#992](https://github.com/digitalocean/netbox/issues/992) - Allow the creation of multiple services per device with the same protocol and port +* Tweaked navigation menu styling + +## Bug Fixes + +* [#1388](https://github.com/digitalocean/netbox/issues/1388) - Fixed server error when searching globally for IPs/prefixes (rolled back #1379) +* [#1390](https://github.com/digitalocean/netbox/issues/1390) - Fixed IndexError when viewing available IPs within large IPv6 prefixes + +--- + +v2.1.1 (2017-08-02) + +## Enhancements + +* [#893](https://github.com/digitalocean/netbox/issues/893) - Allow filtering by null values for NullCharacterFields (e.g. return only unnamed devices) +* [#1368](https://github.com/digitalocean/netbox/issues/1368) - Render reservations in rack elevations view +* [#1374](https://github.com/digitalocean/netbox/issues/1374) - Added NAPALM_ARGS and NAPALM_TIMEOUT configiuration parameters +* [#1375](https://github.com/digitalocean/netbox/issues/1375) - Renamed `NETBOX_USERNAME` and `NETBOX_PASSWORD` configuration parameters to `NAPALM_USERNAME` and `NAPALM_PASSWORD` +* [#1379](https://github.com/digitalocean/netbox/issues/1379) - Allow searching devices by interface MAC address in global search + +## Bug Fixes + +* [#461](https://github.com/digitalocean/netbox/issues/461) - Display a validation error when attempting to assigning a new child device to a rack face/position +* [#1385](https://github.com/digitalocean/netbox/issues/1385) - Connected device API endpoint no longer requires authentication if `LOGIN_REQUIRED` is False + +--- + +v2.1.0 (2017-07-25) + +## New Features + +### IP Address Roles ([#819](https://github.com/digitalocean/netbox/issues/819)) + +The IP address model now supports the assignment of a functional role to help identify special-purpose IPs. These include: + +* Loopback +* Secondary +* Anycast +* VIP +* VRRP +* HSRP +* GLBP + +### Automatic Provisioning of Next Available IP ([#1246](https://github.com/digitalocean/netbox/issues/1246)) + +A new API endpoint has been added at `/api/ipam/prefixes//available-ips/`. A GET request to this endpoint will return a list of available IP addresses within the prefix (up to the pagination limit). A POST request will automatically create and return the next available IP address. + +### NAPALM Integration ([#1348](https://github.com/digitalocean/netbox/issues/1348)) + +The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. + +## Enhancements + +* [#838](https://github.com/digitalocean/netbox/issues/838) - Display details of all objects being edited/deleted in bulk +* [#1041](https://github.com/digitalocean/netbox/issues/1041) - Added enabled and MTU fields to the interface model +* [#1121](https://github.com/digitalocean/netbox/issues/1121) - Added asset_tag and description fields to the InventoryItem model +* [#1141](https://github.com/digitalocean/netbox/issues/1141) - Include RD when listing VRFs in a form selection field +* [#1203](https://github.com/digitalocean/netbox/issues/1203) - Implemented query filters for all models +* [#1218](https://github.com/digitalocean/netbox/issues/1218) - Added IEEE 802.11 wireless interface types +* [#1269](https://github.com/digitalocean/netbox/issues/1269) - Added circuit termination to interface serializer +* [#1320](https://github.com/digitalocean/netbox/issues/1320) - Removed checkbox from confirmation dialog + +## Bug Fixes + +* [#1079](https://github.com/digitalocean/netbox/issues/1079) - Order interfaces naturally via API +* [#1285](https://github.com/digitalocean/netbox/issues/1285) - Enforce model validation when creating/editing objects via the API +* [#1358](https://github.com/digitalocean/netbox/issues/1358) - Correct VRF example values in IP/prefix import forms +* [#1362](https://github.com/digitalocean/netbox/issues/1362) - Raise validation error when attempting to create an API key that's too short +* [#1371](https://github.com/digitalocean/netbox/issues/1371) - Extend DeviceSerializer.parent_device to include standard fields + +## API changes + +* Added a new API endpoint which makes [NAPALM](https://github.com/napalm-automation/napalm) accessible via NetBox +* Device components (console ports, power ports, interfaces, etc.) can only be filtered by a single device name or ID. This limitation was necessary to allow the natural ordering of interfaces according to the device's parent device type. +* Added two new fields to the interface serializer: `enabled` (boolean) and `mtu` (unsigned integer) +* Modified the interface serializer to include three discrete fields relating to connections: `is_connected` (boolean), `interface_connection`, and `circuit_termination` +* Added two new fields to the inventory item serializer: `asset_tag` and `description` +* Added "wireless" to interface type filter (in addition to physical, virtual, and LAG) +* Added a new endpoint at /api/ipam/prefixes//available-ips/ to retrieve or create available IPs within a prefix +* Extended `parent_device` on DeviceSerializer to include the `url` and `display_name` of the parent Device, and the `url` of the DeviceBay + +--- + +v2.0.10 (2017-07-14) + +## Bug Fixes + +* [#1312](https://github.com/digitalocean/netbox/issues/1312) - Catch error when attempting to activate a user key with an invalid private key +* [#1333](https://github.com/digitalocean/netbox/issues/1333) - Corrected label on is_console_server field of DeviceType bulk edit form +* [#1338](https://github.com/digitalocean/netbox/issues/1338) - Allow importing prefixes with "container" status +* [#1339](https://github.com/digitalocean/netbox/issues/1339) - Fixed disappearing checkbox column under django-tables2 v1.7+ +* [#1342](https://github.com/digitalocean/netbox/issues/1342) - Allow designation of users and groups when creating/editing a secret role + +--- + +v2.0.9 (2017-07-10) + +## Bug Fixes + +* [#1319](https://github.com/digitalocean/netbox/issues/1319) - Fixed server error when attempting to create console/power connections +* [#1325](https://github.com/digitalocean/netbox/issues/1325) - Retain interface attachment when editing a circuit termination + +--- + +v2.0.8 (2017-07-05) + +## Enhancements + +* [#1298](https://github.com/digitalocean/netbox/issues/1298) - Calculate prefix utilization based on its status (container or non-container) +* [#1303](https://github.com/digitalocean/netbox/issues/1303) - Highlight installed interface connections in green on device view +* [#1315](https://github.com/digitalocean/netbox/issues/1315) - Enforce lowercase file extensions for image attachments + +## Bug Fixes + +* [#1279](https://github.com/digitalocean/netbox/issues/1279) - Fix primary_ip assignment during IP address import +* [#1281](https://github.com/digitalocean/netbox/issues/1281) - Show LLDP neighbors tab on device view only if necessary conditions are met +* [#1282](https://github.com/digitalocean/netbox/issues/1282) - Fixed tooltips on "mark connected/planned" toggle buttons for device connections +* [#1288](https://github.com/digitalocean/netbox/issues/1288) - Corrected permission name for deleting image attachments +* [#1289](https://github.com/digitalocean/netbox/issues/1289) - Retain inside NAT assignment when editing an IP address +* [#1297](https://github.com/digitalocean/netbox/issues/1297) - Allow passing custom field choice selection PKs to API as string-quoted integers +* [#1299](https://github.com/digitalocean/netbox/issues/1299) - Corrected permission name for adding services to devices + +--- + +v2.0.7 (2017-06-15) + +## Enhancements + +* [#626](https://github.com/digitalocean/netbox/issues/626) - Added bulk disconnect function for console/power/interface connections on device view + +## Bug Fixes + +* [#1238](https://github.com/digitalocean/netbox/issues/1238) - Fix error when editing an IP with a NAT assignment which has no assigned device +* [#1263](https://github.com/digitalocean/netbox/issues/1263) - Differentiate add and edit permissions for objects +* [#1265](https://github.com/digitalocean/netbox/issues/1265) - Fix console/power/interface connection validation when selecting a device via live search +* [#1266](https://github.com/digitalocean/netbox/issues/1266) - Prevent terminating a circuit to an already-connected interface +* [#1268](https://github.com/digitalocean/netbox/issues/1268) - Fix CSV import error under Python 3 +* [#1273](https://github.com/digitalocean/netbox/issues/1273) - Corrected status choices in IP address import form +* [#1274](https://github.com/digitalocean/netbox/issues/1274) - Exclude unterminated circuits from topology maps +* [#1275](https://github.com/digitalocean/netbox/issues/1275) - Raise validation error on prefix import when multiple VLANs are found + +--- + +v2.0.6 (2017-06-12) + +## Enhancements + +* [#40](https://github.com/digitalocean/netbox/issues/40) - Added IP utilization graph to prefix list +* [#704](https://github.com/digitalocean/netbox/issues/704) - Allow filtering VLANs by group when editing prefixes +* [#913](https://github.com/digitalocean/netbox/issues/913) - Added headers to object CSV exports +* [#990](https://github.com/digitalocean/netbox/issues/990) - Enable logging configuration in configuration.py +* [#1180](https://github.com/digitalocean/netbox/issues/1180) - Simplified the process of finding related devices when viewing a device + +## Bug Fixes + +* [#1253](https://github.com/digitalocean/netbox/issues/1253) - Improved `upgrade.sh` to allow forcing Python2 + +--- + +v2.0.5 (2017-06-08) + +## Notes + +The maximum number of objects an API consumer can request has been set to 1000 (e.g. `?limit=1000`). This limit can be modified by defining `MAX_PAGE_SIZE` in confgiuration.py. (To remove this limit, set `MAX_PAGE_SIZE=0`.) + +## Enhancements + +* [#655](https://github.com/digitalocean/netbox/issues/655) - Implemented header-based CSV import of objects +* [#1190](https://github.com/digitalocean/netbox/issues/1190) - Allow partial string matching when searching on custom fields +* [#1237](https://github.com/digitalocean/netbox/issues/1237) - Enabled setting limit=0 to disable pagination in API requests; added `MAX_PAGE_SIZE` configuration setting + +## Bug Fixes + +* [#837](https://github.com/digitalocean/netbox/issues/837) - Enforce uniqueness where applicable during bulk import of IP addresses +* [#1226](https://github.com/digitalocean/netbox/issues/1226) - Improved validation for custom field values submitted via the API +* [#1232](https://github.com/digitalocean/netbox/issues/1232) - Improved rack space validation on bulk import of devices (see #655) +* [#1235](https://github.com/digitalocean/netbox/issues/1235) - Fix permission name for adding/editing inventory items +* [#1236](https://github.com/digitalocean/netbox/issues/1236) - Truncate rack names in elevations list; add facility ID +* [#1239](https://github.com/digitalocean/netbox/issues/1239) - Fix server error when creating VLANGroup via API +* [#1243](https://github.com/digitalocean/netbox/issues/1243) - Catch ValueError in IP-based object filters +* [#1244](https://github.com/digitalocean/netbox/issues/1244) - Corrected "device" secrets filter to accept a device name + +--- + +v2.0.4 (2017-05-25) + +## Bug Fixes + +* [#1206](https://github.com/digitalocean/netbox/issues/1206) - Fix redirection in admin UI after activating secret keys when BASE_PATH is set +* [#1207](https://github.com/digitalocean/netbox/issues/1207) - Include nested LAG serializer when showing interface connections (API) +* [#1210](https://github.com/digitalocean/netbox/issues/1210) - Fix TemplateDoesNotExist errors on browsable API views +* [#1212](https://github.com/digitalocean/netbox/issues/1212) - Allow assigning new VLANs to global VLAN groups +* [#1213](https://github.com/digitalocean/netbox/issues/1213) - Corrected table header ordering links on object list views +* [#1214](https://github.com/digitalocean/netbox/issues/1214) - Add status to list of required fields on child device import form +* [#1219](https://github.com/digitalocean/netbox/issues/1219) - Fix image attachment URLs when BASE_PATH is set +* [#1220](https://github.com/digitalocean/netbox/issues/1220) - Suppressed innocuous warning about untracked migrations under Python 3 +* [#1229](https://github.com/digitalocean/netbox/issues/1229) - Fix validation error on forms where API search is used + +--- + +v2.0.3 (2017-05-18) + +## Enhancements + +* [#1196](https://github.com/digitalocean/netbox/issues/1196) - Added a lag_id filter to the API interfaces view +* [#1198](https://github.com/digitalocean/netbox/issues/1198) - Allow filtering unracked devices on device list + +## Bug Fixes + +* [#1157](https://github.com/digitalocean/netbox/issues/1157) - Hide nav menu search bar on small displays +* [#1186](https://github.com/digitalocean/netbox/issues/1186) - Corrected VLAN edit form so that site assignment is not required +* [#1187](https://github.com/digitalocean/netbox/issues/1187) - Fixed table pagination by introducing a custom table template +* [#1188](https://github.com/digitalocean/netbox/issues/1188) - Serialize interface LAG as nested objected (API) +* [#1189](https://github.com/digitalocean/netbox/issues/1189) - Enforce consistent ordering of objects returned by a global search +* [#1191](https://github.com/digitalocean/netbox/issues/1191) - Bulk selection of IPs under a prefix incorrect when "select all" is used +* [#1195](https://github.com/digitalocean/netbox/issues/1195) - Unable to create an interface connection when searching for peer device +* [#1197](https://github.com/digitalocean/netbox/issues/1197) - Fixed status assignment during bulk import of devices, prefixes, IPs, and VLANs +* [#1199](https://github.com/digitalocean/netbox/issues/1199) - Bulk import of secrets does not prompt user to generate a session key +* [#1200](https://github.com/digitalocean/netbox/issues/1200) - Form validation error when connecting power ports to power outlets + +--- + +v2.0.2 (2017-05-15) + +## Enhancements + +* [#1122](https://github.com/digitalocean/netbox/issues/1122) - Include NAT inside IPs in IP address list +* [#1137](https://github.com/digitalocean/netbox/issues/1137) - Allow filtering devices list by rack +* [#1170](https://github.com/digitalocean/netbox/issues/1170) - Include A and Z sites for circuits in global search results +* [#1172](https://github.com/digitalocean/netbox/issues/1172) - Linkify racks in side-by-side elevations view +* [#1177](https://github.com/digitalocean/netbox/issues/1177) - Render planned connections as dashed lines on topology maps +* [#1179](https://github.com/digitalocean/netbox/issues/1179) - Adjust topology map text color based on node background +* On all object edit forms, allow filtering the tenant list by tenant group + +## Bug Fixes + +* [#1158](https://github.com/digitalocean/netbox/issues/1158) - Exception thrown when creating a device component with an invalid name +* [#1159](https://github.com/digitalocean/netbox/issues/1159) - Only superusers can see "edit IP" buttons on the device interfaces list +* [#1160](https://github.com/digitalocean/netbox/issues/1160) - Linkify secrets and tenants in global search results +* [#1161](https://github.com/digitalocean/netbox/issues/1161) - Fix "add another" behavior when creating an API token +* [#1166](https://github.com/digitalocean/netbox/issues/1166) - Fixed bulk IP address creation when assigning tenants +* [#1168](https://github.com/digitalocean/netbox/issues/1168) - Total count of objects missing from list view paginator +* [#1171](https://github.com/digitalocean/netbox/issues/1171) - Allow removing site assignment when bulk editing VLANs +* [#1173](https://github.com/digitalocean/netbox/issues/1173) - Tweak interface manager to fall back to naive ordering + +--- + +v2.0.1 (2017-05-10) + +## Bug Fixes + +* [#1149](https://github.com/digitalocean/netbox/issues/1149) - Port list does not populate when creating a console or power connection +* [#1150](https://github.com/digitalocean/netbox/issues/1150) - Error when uploading image attachments with Unicode names under Python 2 +* [#1151](https://github.com/digitalocean/netbox/issues/1151) - Server error: name 'escape' is not defined +* [#1152](https://github.com/digitalocean/netbox/issues/1152) - Unable to edit user keys +* [#1153](https://github.com/digitalocean/netbox/issues/1153) - UnicodeEncodeError when searching for non-ASCII characters on Python 2 + +--- + +v2.0.0 (2017-05-09) + +## New Features + +### API 2.0 ([#113](https://github.com/digitalocean/netbox/issues/113)) + +The NetBox API has been completely rewritten and now features full read/write ability. + +### Image Attachments ([#152](https://github.com/digitalocean/netbox/issues/152)) + +Users are now able to attach photos and other images to sites, racks, and devices. (Please ensure that the new `media` directory is writable by the system account NetBox runs as.) + +### Global Search ([#159](https://github.com/digitalocean/netbox/issues/159)) + +NetBox now supports searching across all primary object types at once. + +### Rack Elevations View ([#951](https://github.com/digitalocean/netbox/issues/951)) + +A new view has been introduced to display the elevations of multiple racks side-by-side. + +## Enhancements + +* [#154](https://github.com/digitalocean/netbox/issues/154) - Expanded device status field to include options other than active/offline +* [#430](https://github.com/digitalocean/netbox/issues/430) - Include circuits when rendering topology maps +* [#578](https://github.com/digitalocean/netbox/issues/578) - Show topology maps not assigned to a site on the home view +* [#1100](https://github.com/digitalocean/netbox/issues/1100) - Add a "view all" link to completed bulk import views is_pool for prefixes) +* [#1110](https://github.com/digitalocean/netbox/issues/1110) - Expand bulk edit forms to include boolean fields (e.g. toggle is_pool for prefixes) + +## Bug Fixes + +From v1.9.6: + +* [#403](https://github.com/digitalocean/netbox/issues/403) - Record console/power/interface connects and disconnects as user actions +* [#853](https://github.com/digitalocean/netbox/issues/853) - Added "status" field to device bulk import form +* [#1101](https://github.com/digitalocean/netbox/issues/1101) - Fix AJAX scripting for device component selection forms +* [#1103](https://github.com/digitalocean/netbox/issues/1103) - Correct handling of validation errors when creating IP addresses in bulk +* [#1104](https://github.com/digitalocean/netbox/issues/1104) - Fix VLAN assignment on prefix import +* [#1115](https://github.com/digitalocean/netbox/issues/1115) - Enabled responsive (side-scrolling) tables for small screens +* [#1116](https://github.com/digitalocean/netbox/issues/1116) - Correct object links on recursive deletion error +* [#1125](https://github.com/digitalocean/netbox/issues/1125) - Include MAC addresses on a device's interface list +* [#1144](https://github.com/digitalocean/netbox/issues/1144) - Allow multiple status selections for Prefix, IP address, and VLAN filters + +From beta3: + +* [#1113](https://github.com/digitalocean/netbox/issues/1113) - Fixed server error when attempting to delete an image attachment +* [#1114](https://github.com/digitalocean/netbox/issues/1114) - Suppress OSError when attempting to access a deleted image attachment +* [#1126](https://github.com/digitalocean/netbox/issues/1126) - Fixed server error when editing a user key via admin UI attachment +* [#1132](https://github.com/digitalocean/netbox/issues/1132) - Prompt user to unlock session key when importing secrets + +## Additional Changes + +* The Module DCIM model has been renamed to InventoryItem to better reflect its intended function, and to make room for work on [#824](https://github.com/digitalocean/netbox/issues/824). +* Redundant portions of the admin UI have been removed ([#973](https://github.com/digitalocean/netbox/issues/973)). +* The Docker build components have been moved into [their own repository](https://github.com/digitalocean/netbox-docker). + +--- + +v1.9.6 (2017-04-21) + +## Improvements + +* [#878](https://github.com/digitalocean/netbox/issues/878) - Merged IP addresses with interfaces list on device view +* [#1001](https://github.com/digitalocean/netbox/issues/1001) - Interface assignment can be modified when editing an IP address +* [#1084](https://github.com/digitalocean/netbox/issues/1084) - Include custom fields when creating IP addresses in bulk + +## Bug Fixes + +* [#1057](https://github.com/digitalocean/netbox/issues/1057) - Corrected VLAN validation during prefix import +* [#1061](https://github.com/digitalocean/netbox/issues/1061) - Fixed potential for script injection via create/edit/delete messages +* [#1070](https://github.com/digitalocean/netbox/issues/1070) - Corrected installation instructions for Python3 on CentOS/RHEL +* [#1071](https://github.com/digitalocean/netbox/issues/1071) - Protect assigned circuit termination when an interface is deleted +* [#1072](https://github.com/digitalocean/netbox/issues/1072) - Order LAG interfaces naturally on bulk interface edit form +* [#1074](https://github.com/digitalocean/netbox/issues/1074) - Require ncclient 0.5.3 (Python 3 fix) +* [#1090](https://github.com/digitalocean/netbox/issues/1090) - Improved installation documentation for Python 3 +* [#1092](https://github.com/digitalocean/netbox/issues/1092) - Increase randomness in SECRET_KEY generation tool + +--- + +v1.9.5 (2017-04-06) + +## Improvements + +* [#1052](https://github.com/digitalocean/netbox/issues/1052) - Added rack reservation list and bulk delete views + +## Bug Fixes + +* [#1038](https://github.com/digitalocean/netbox/issues/1038) - Suppress upgrading to Django 1.11 (will be supported in v2.0) +* [#1037](https://github.com/digitalocean/netbox/issues/1037) - Fixed error on VLAN import with duplicate VLAN group names +* [#1047](https://github.com/digitalocean/netbox/issues/1047) - Correct ordering of numbered subinterfaces +* [#1051](https://github.com/digitalocean/netbox/issues/1051) - Upgraded django-rest-swagger + +--- + +v1.9.4-r1 (2017-04-04) + +## Improvements + +* [#362](https://github.com/digitalocean/netbox/issues/362) - Added per_page query parameter to control pagination page length + +## Bug Fixes + +* [#991](https://github.com/digitalocean/netbox/issues/991) - Correct server error on "create and connect another" interface connection +* [#1022](https://github.com/digitalocean/netbox/issues/1022) - Record user actions when creating IP addresses in bulk +* [#1027](https://github.com/digitalocean/netbox/issues/1027) - Fixed nav menu highlighting when BASE_PATH is set +* [#1034](https://github.com/digitalocean/netbox/issues/1034) - Added migration missing from v1.9.4 release + +--- + +v1.9.3 (2017-03-23) + +## Improvements + +* [#972](https://github.com/digitalocean/netbox/issues/972) - Add ability to filter connections list by device name +* [#974](https://github.com/digitalocean/netbox/issues/974) - Added MAC address filter to API interfaces list +* [#978](https://github.com/digitalocean/netbox/issues/978) - Allow filtering device types by function and subdevice role +* [#981](https://github.com/digitalocean/netbox/issues/981) - Allow filtering primary objects by a given set of IDs +* [#983](https://github.com/digitalocean/netbox/issues/983) - Include peer device names when listing circuits in device view + +## Bug Fixes + +* [#967](https://github.com/digitalocean/netbox/issues/967) - Fix error when assigning a new interface to a LAG + +--- + +v1.9.2 (2017-03-14) + +## Bug Fixes + +* [#950](https://github.com/digitalocean/netbox/issues/950) - Fix site_id error on child device import +* [#956](https://github.com/digitalocean/netbox/issues/956) - Correct bug affecting unnamed rackless devices +* [#957](https://github.com/digitalocean/netbox/issues/957) - Correct device site filter count to include unracked devices +* [#963](https://github.com/digitalocean/netbox/issues/963) - Fix bug in IPv6 address range expansion +* [#964](https://github.com/digitalocean/netbox/issues/964) - Fix bug when bulk editing/deleting filtered set of objects + +--- + +v1.9.1 (2017-03-08) + +## Improvements + +* [#945](https://github.com/digitalocean/netbox/issues/945) - Display the current user in the navigation menu +* [#946](https://github.com/digitalocean/netbox/issues/946) - Disregard mask length when filtering IP addresses by a parent prefix + +## Bug Fixes + +* [#941](https://github.com/digitalocean/netbox/issues/941) - Corrected old references to rack.site on Device +* [#943](https://github.com/digitalocean/netbox/issues/943) - Child prefixes missing on Python 3 +* [#944](https://github.com/digitalocean/netbox/issues/944) - Corrected console and power connection form behavior +* [#948](https://github.com/digitalocean/netbox/issues/948) - Region name should be hyperlinked to site list + +--- + +v1.9.0-r1 (2017-03-03) + +## New Features + +### Rack Reservations ([#36](https://github.com/digitalocean/netbox/issues/36)) + +Users can now reserve an arbitrary number of units within a rack, adding a comment noting their intentions. Reservations do not interfere with installed devices: It is possible to reserve a unit for future use even if it is currently occupied by a device. + +### Interface Groups ([#105](https://github.com/digitalocean/netbox/issues/105)) + +A new Link Aggregation Group (LAG) virtual form factor has been added. Physical interfaces can be assigned to a parent LAG interface to represent a port-channel or similar logical bundling of links. + +### Regions ([#164](https://github.com/digitalocean/netbox/issues/164)) + +A new region model has been introduced to allow for the geographic organization of sites. Regions can be nested recursively to form a hierarchy. + +### Rackless Devices ([#198](https://github.com/digitalocean/netbox/issues/198)) + +Previous releases required each device to be assigned to a particular rack within a site. This requirement has been relaxed so that devices must only be assigned to a site, and may optionally be assigned to a rack. + +### Global VLANs ([#235](https://github.com/digitalocean/netbox/issues/235)) + +Assignment of VLANs and VLAN groups to sites is now optional, allowing for the representation of a VLAN spanning multiple sites. + +## Improvements + +* [#862](https://github.com/digitalocean/netbox/issues/862) - Show both IPv6 and IPv4 primary IPs in device list +* [#894](https://github.com/digitalocean/netbox/issues/894) - Expand device name max length to 64 characters +* [#898](https://github.com/digitalocean/netbox/issues/898) - Expanded circuits list in provider view rack face +* [#901](https://github.com/digitalocean/netbox/issues/901) - Support for filtering prefixes and IP addresses by mask length + +## Bug Fixes + +* [#872](https://github.com/digitalocean/netbox/issues/872) - Fixed TypeError on bulk IP address creation (Python 3) +* [#884](https://github.com/digitalocean/netbox/issues/884) - Preserve selected rack unit when changing a device's rack face +* [#892](https://github.com/digitalocean/netbox/issues/892) - Restored missing edit/delete buttons when viewing child prefixes and IP addresses from a parent object +* [#897](https://github.com/digitalocean/netbox/issues/897) - Fixed power connections CSV export +* [#903](https://github.com/digitalocean/netbox/issues/903) - Only alert on missing critical connections if present in the parent device type +* [#935](https://github.com/digitalocean/netbox/issues/935) - Fix form validation error when connecting an interface using live search +* [#937](https://github.com/digitalocean/netbox/issues/937) - Region assignment should be optional when creating a site +* [#938](https://github.com/digitalocean/netbox/issues/938) - Provider view yields an error if one or more circuits is assigned to a tenant + +--- + +v1.8.4 (2017-02-03) + +## Improvements + +* [#856](https://github.com/digitalocean/netbox/issues/856) - Strip whitespace from fields during CSV import + +## Bug Fixes + +* [#851](https://github.com/digitalocean/netbox/issues/851) - Resolve encoding issues during import/export (Python 3) +* [#854](https://github.com/digitalocean/netbox/issues/854) - Correct processing of get_return_url() in ObjectDeleteView +* [#859](https://github.com/digitalocean/netbox/issues/859) - Fix Javascript for connection status toggle button on device view +* [#861](https://github.com/digitalocean/netbox/issues/861) - Avoid overwriting device primary IP assignment from alternate family during bulk import of IP addresses +* [#865](https://github.com/digitalocean/netbox/issues/865) - Fix server error when attempting to delete a protected object parent (Python 3) + +--- + +v1.8.3 (2017-01-26) + +## Improvements + +* [#782](https://github.com/digitalocean/netbox/issues/782) - Allow filtering devices list by manufacturer +* [#820](https://github.com/digitalocean/netbox/issues/820) - Add VLAN column to parent prefixes table on IP address view +* [#821](https://github.com/digitalocean/netbox/issues/821) - Support for comma separation in bulk IP/interface creation +* [#827](https://github.com/digitalocean/netbox/issues/827) - **Introduced support for Python 3** +* [#836](https://github.com/digitalocean/netbox/issues/836) - Add "deprecated" status for IP addresses +* [#841](https://github.com/digitalocean/netbox/issues/841) - Merged search and filter forms on all object lists + +## Bug Fixes + +* [#816](https://github.com/digitalocean/netbox/issues/816) - Redirect back to parent prefix view after deleting child prefixes termination +* [#817](https://github.com/digitalocean/netbox/issues/817) - Update last_updated time of a circuit when editing a child termination +* [#830](https://github.com/digitalocean/netbox/issues/830) - Redirect user to device view after editing a device component +* [#840](https://github.com/digitalocean/netbox/issues/840) - Correct API path resolution for secrets when BASE_PATH is configured +* [#844](https://github.com/digitalocean/netbox/issues/844) - Apply order_naturally() to API interfaces list +* [#845](https://github.com/digitalocean/netbox/issues/845) - Fix missing edit/delete buttons on object tables for non-superusers + + +--- + +v1.8.2 (2017-01-18) + +## Improvements + +* [#284](https://github.com/digitalocean/netbox/issues/284) - Enabled toggling of interface display order per device type +* [#760](https://github.com/digitalocean/netbox/issues/760) - Redirect user back to device view after deleting an assigned IP address +* [#783](https://github.com/digitalocean/netbox/issues/783) - Add a description field to the Circuit model +* [#797](https://github.com/digitalocean/netbox/issues/797) - Add description column to VLANs table +* [#803](https://github.com/digitalocean/netbox/issues/803) - Clarify that no child objects are deleted when deleting a prefix +* [#805](https://github.com/digitalocean/netbox/issues/805) - Linkify site column in device table + +## Bug Fixes + +* [#776](https://github.com/digitalocean/netbox/issues/776) - Prevent circuits from appearing twice while searching +* [#778](https://github.com/digitalocean/netbox/issues/778) - Corrected an issue preventing multiple interfaces with the same position ID from appearing in a device's interface list +* [#785](https://github.com/digitalocean/netbox/issues/785) - Trigger validation error when importing a prefix assigned to a nonexistent VLAN +* [#802](https://github.com/digitalocean/netbox/issues/802) - Fixed enforcement of ENFORCE_GLOBAL_UNIQUE for prefixes +* [#807](https://github.com/digitalocean/netbox/issues/807) - Redirect user back to form when adding IP addresses in bulk and "create and add another" is clicked +* [#810](https://github.com/digitalocean/netbox/issues/810) - Suppress unique IP validation on invalid IP addresses and prefixes + +--- + +v1.8.1 (2017-01-04) + +## Improvements + +* [#771](https://github.com/digitalocean/netbox/issues/771) - Don't automatically redirect user when only one object is returned in a list + +## Bug Fixes + +* [#764](https://github.com/digitalocean/netbox/issues/764) - Encapsulate in double quotes values containing commas when exporting to CSV +* [#767](https://github.com/digitalocean/netbox/issues/767) - Fixes xconnect_id error when searching for circuits +* [#769](https://github.com/digitalocean/netbox/issues/769) - Show default value for boolean custom fields +* [#772](https://github.com/digitalocean/netbox/issues/772) - Fixes TypeError in API RackUnitListView when no device is excluded + +--- + +v1.8.0 (2017-01-03) + +## New Features + +### Point-to-Point Circuits ([#49](https://github.com/digitalocean/netbox/issues/49)) + +Until now, NetBox has supported tracking only one end of a data circuit. This is fine for Internet connections where you don't care (or know) much about the provider side of the circuit, but many users need the ability to track inter-site circuits as well. This release expands circuit modeling so that each circuit can have an A and/or Z side. Each endpoint must be terminated to a site, and may optionally be terminated to a specific device and interface within that site. + +### L4 Services ([#539](https://github.com/digitalocean/netbox/issues/539)) + +Our first major community contribution introduces the ability to track discrete TCP and UDP services associated with a device (for example, SSH or HTTP). Each service can optionally be assigned to one or more specific IP addresses belonging to the device. Thanks to [@if-fi](https://github.com/if-fi) for the addition! + +## Improvements + +* [#122](https://github.com/digitalocean/netbox/issues/122) - Added comments field to device types +* [#181](https://github.com/digitalocean/netbox/issues/181) - Implemented support for bulk IP address creation +* [#613](https://github.com/digitalocean/netbox/issues/613) - Added prefixes column to VLAN list; added VLAN column to prefix list +* [#716](https://github.com/digitalocean/netbox/issues/716) - Add ASN field to site bulk edit form +* [#722](https://github.com/digitalocean/netbox/issues/722) - Enabled custom fields for device types +* [#743](https://github.com/digitalocean/netbox/issues/743) - Enabled bulk creation of all device components +* [#756](https://github.com/digitalocean/netbox/issues/756) - Added contact details to site model + +## Bug Fixes + +* [#563](https://github.com/digitalocean/netbox/issues/563) - Allow a device to be flipped from one rack face to the other without moving it +* [#658](https://github.com/digitalocean/netbox/issues/658) - Enabled conditional treatment of network/broadcast IPs for a prefix by defining it as a pool +* [#741](https://github.com/digitalocean/netbox/issues/741) - Hide "select all" button for users without edit permissions +* [#744](https://github.com/digitalocean/netbox/issues/744) - Fixed export of sites without an AS number +* [#747](https://github.com/digitalocean/netbox/issues/747) - Fixed natural_order_by integer cast error on large numbers +* [#751](https://github.com/digitalocean/netbox/issues/751) - Fixed python-cryptography installation issue on Debian +* [#763](https://github.com/digitalocean/netbox/issues/763) - Added missing fields to CSV exports for racks and prefixes + +--- + +v1.7.3 (2016-12-08) + +## Bug Fixes + +* [#724](https://github.com/digitalocean/netbox/issues/724) - Exempt API views from LoginRequiredMiddleware to enable basic HTTP authentication when LOGIN_REQUIRED is true +* [#729](https://github.com/digitalocean/netbox/issues/729) - Corrected cancellation links when editing secondary objects +* [#732](https://github.com/digitalocean/netbox/issues/732) - Allow custom select field values to be deselected if the field is not required +* [#733](https://github.com/digitalocean/netbox/issues/733) - Fixed MAC address filter on device list +* [#734](https://github.com/digitalocean/netbox/issues/734) - Corrected display of device type when editing a device + +--- + +v1.7.2-r1 (2016-12-06) + +## Improvements + +* [#663](https://github.com/digitalocean/netbox/issues/663) - Added MAC address search field to device list +* [#672](https://github.com/digitalocean/netbox/issues/672) - Increased the selection of available colors for rack and device roles +* [#695](https://github.com/digitalocean/netbox/issues/695) - Added is_private field to RIR + +## Bug Fixes + +* [#677](https://github.com/digitalocean/netbox/issues/677) - Fix setuptools installation error on Debian 8.6 +* [#696](https://github.com/digitalocean/netbox/issues/696) - Corrected link to VRF in prefix and IP address breadcrumbs +* [#702](https://github.com/digitalocean/netbox/issues/702) - Improved Unicode support for custom fields +* [#712](https://github.com/digitalocean/netbox/issues/712) - Corrected export of tenants which are not assigned to a group +* [#713](https://github.com/digitalocean/netbox/issues/713) - Include a label for the comments field when editing circuits, providers, or racks in bulk +* [#718](https://github.com/digitalocean/netbox/issues/718) - Restore is_primary field on IP assignment form +* [#723](https://github.com/digitalocean/netbox/issues/723) - API documentation is now accessible when using BASE_PATH +* [#727](https://github.com/digitalocean/netbox/issues/727) - Corrected error in rack elevation display (v1.7.2) + +--- + +v1.7.1 (2016-11-15) + +## Improvements + +* [#667](https://github.com/digitalocean/netbox/issues/667) - Added prefix utilization statistics to the RIR list view +* [#685](https://github.com/digitalocean/netbox/issues/685) - When assigning an IP to a device, automatically select the interface if only one exists + +## Bug Fixes + +* [#674](https://github.com/digitalocean/netbox/issues/674) - Fix assignment of status to imported IP addresses +* [#676](https://github.com/digitalocean/netbox/issues/676) - Server error when bulk editing device types +* [#678](https://github.com/digitalocean/netbox/issues/678) - Server error on device import specifying an invalid device type +* [#691](https://github.com/digitalocean/netbox/issues/691) - Allow the assignment of power ports to PDUs +* [#692](https://github.com/digitalocean/netbox/issues/692) - Form errors are not displayed on checkbox fields + +--- + +v1.7.0 (2016-11-03) + +## New Features + +### IP address statuses ([#87](https://github.com/digitalocean/netbox/issues/87)) + +An IP address can now be designated as active, reserved, or DHCP. The DHCP status implies that the IP address is part of a DHCP pool and may or may not be assigned to a DHCP client. + +### Top-to-bottom rack numbering ([#191](https://github.com/digitalocean/netbox/issues/191)) + +Racks can now be set to have descending rack units, with U1 at the top of the rack. When adding a device to a rack with descending units, be sure to position it in the **lowest-numbered** unit which it occupies (this will be physically the topmost unit). + +## Improvements +* [#211](https://github.com/digitalocean/netbox/issues/211) - Allow device assignment and removal from IP address view +* [#630](https://github.com/digitalocean/netbox/issues/630) - Added a custom 404 page +* [#652](https://github.com/digitalocean/netbox/issues/652) - Use password input controls when editing secrets +* [#654](https://github.com/digitalocean/netbox/issues/654) - Added Cisco FlexStack and FlexStack Plus form factors +* [#661](https://github.com/digitalocean/netbox/issues/661) - Display relevant IP addressing when viewing a circuit + +## Bug Fixes +* [#632](https://github.com/digitalocean/netbox/issues/632) - Use semicolons instead of commas to separate regexes in topology maps +* [#647](https://github.com/digitalocean/netbox/issues/647) - Extend form used when assigning an IP to a device +* [#657](https://github.com/digitalocean/netbox/issues/657) - Unicode error when adding device modules +* [#660](https://github.com/digitalocean/netbox/issues/660) - Corrected calculation of utilized space in rack list +* [#664](https://github.com/digitalocean/netbox/issues/664) - Fixed bulk creation of interfaces across multiple devices + +--- + +v1.6.3 (2016-10-19) + +## Improvements + +* [#353](https://github.com/digitalocean/netbox/issues/353) - Bulk editing of device and device type interfaces +* [#527](https://github.com/digitalocean/netbox/issues/527) - Support for nullification of fields when bulk editing +* [#592](https://github.com/digitalocean/netbox/issues/592) - Allow space-delimited lists of ALLOWED_HOSTS in Docker +* [#608](https://github.com/digitalocean/netbox/issues/608) - Added "select all" button for device and device type components + +## Bug Fixes + +* [#602](https://github.com/digitalocean/netbox/issues/602) - Correct display of custom integer fields with value of 0 or 1 +* [#604](https://github.com/digitalocean/netbox/issues/604) - Correct display of unnamed devices in form selection fields +* [#611](https://github.com/digitalocean/netbox/issues/611) - Power/console/interface connection import: status field should be case-insensitive +* [#615](https://github.com/digitalocean/netbox/issues/615) - Account for BASE_PATH in static URLs and during login +* [#616](https://github.com/digitalocean/netbox/issues/616) - Correct display of custom URL fields + +--- + +v1.6.2-r1 (2016-10-04) + +## Improvements + +* [#212](https://github.com/digitalocean/netbox/issues/212) - Introduced the `BASE_PATH` configuration setting to allow running NetBox in a URL subdirectory +* [#345](https://github.com/digitalocean/netbox/issues/345) - Bulk edit: allow user to select all objects on page or all matching query +* [#475](https://github.com/digitalocean/netbox/issues/475) - Display "add" buttons at top and bottom of all device/device type panels +* [#480](https://github.com/digitalocean/netbox/issues/480) - Improved layout on mobile devices +* [#481](https://github.com/digitalocean/netbox/issues/481) - Require interface creation before trying to assign an IP to a device +* [#575](https://github.com/digitalocean/netbox/issues/575) - Allow all valid URL schemes in custom fields +* [#579](https://github.com/digitalocean/netbox/issues/579) - Add a description field to export templates + +## Bug Fixes + +* [#466](https://github.com/digitalocean/netbox/issues/466) - Validate available free space for all instances when increasing the U height of a device type +* [#571](https://github.com/digitalocean/netbox/issues/571) - Correct rack group filter on device list +* [#576](https://github.com/digitalocean/netbox/issues/576) - Delete all relevant CustomFieldValues when deleting a CustomFieldChoice +* [#581](https://github.com/digitalocean/netbox/issues/581) - Correct initialization of custom boolean and select fields +* [#591](https://github.com/digitalocean/netbox/issues/591) - Correct display of component creation buttons in device type view + +--- + +v1.6.1-r1 (2016-09-21) + +## Improvements +* [#415](https://github.com/digitalocean/netbox/issues/415) - Add an expand/collapse toggle button to the prefix list +* [#552](https://github.com/digitalocean/netbox/issues/552) - Allow filtering on custom select fields by "none" +* [#561](https://github.com/digitalocean/netbox/issues/561) - Make custom fields accessible from within export templates + +## Bug Fixes +* [#493](https://github.com/digitalocean/netbox/issues/493) - CSV import support for UTF-8 +* [#531](https://github.com/digitalocean/netbox/issues/531) - Order prefix list by VRF assignment +* [#542](https://github.com/digitalocean/netbox/issues/542) - Add LDAP support in Docker +* [#557](https://github.com/digitalocean/netbox/issues/557) - Add 'global' choice to VRF filter for prefixes and IP addresses +* [#558](https://github.com/digitalocean/netbox/issues/558) - Update slug field when name is populated without a key press +* [#562](https://github.com/digitalocean/netbox/issues/562) - Fixed bulk interface creation +* [#564](https://github.com/digitalocean/netbox/issues/564) - Display custom fields for all applicable objects + +--- + +v1.6.0 (2016-09-13) + +## New Features + +### Custom Fields ([#129](https://github.com/digitalocean/netbox/issues/129)) + +Users can now create custom fields to associate arbitrary data with core NetBox objects. For example, you might want to add a geolocation tag to IP prefixes, or a ticket number to each device. Text, integer, boolean, date, URL, and selection fields are supported. + +## Improvements + +* [#489](https://github.com/digitalocean/netbox/issues/489) - Docker file now builds from a `python:2.7-wheezy` base instead of `ubuntu:14.04` +* [#540](https://github.com/digitalocean/netbox/issues/540) - Add links for VLAN roles under VLAN navigation menu +* Added new interface form factors +* Added address family filters to aggregate and prefix lists + +## Bug Fixes + +* [#476](https://github.com/digitalocean/netbox/issues/476) - Corrected rack import instructions +* [#484](https://github.com/digitalocean/netbox/issues/484) - Allow bulk deletion of >1K objects +* [#486](https://github.com/digitalocean/netbox/issues/486) - Prompt for secret key only if updating a secret's value +* [#490](https://github.com/digitalocean/netbox/issues/490) - Corrected display of circuit commit rate +* [#495](https://github.com/digitalocean/netbox/issues/495) - Include tenant in prefix and IP CSV export +* [#507](https://github.com/digitalocean/netbox/issues/507) - Corrected rendering of nav menu on screens narrower than 1200px +* [#515](https://github.com/digitalocean/netbox/issues/515) - Clarified instructions for the "face" field when importing devices +* [#522](https://github.com/digitalocean/netbox/issues/522) - Remove obsolete check for staff status when bulk deleting objects +* [#544](https://github.com/digitalocean/netbox/issues/544) - Strip CRLF-style line terminators from rendered export templates + +--- + +v1.5.2 (2016-08-16) + +## Bug Fixes + +* [#460](https://github.com/digitalocean/netbox/issues/460) - Corrected ordering of IP addresses with differing prefix lengths +* [#463](https://github.com/digitalocean/netbox/issues/463) - Prevent pre-population of livesearch field with '---------' +* [#467](https://github.com/digitalocean/netbox/issues/467) - Include prefixes and IPs which inherit tenancy from their VRF in tenant stats +* [#468](https://github.com/digitalocean/netbox/issues/468) - Don't allow connected interfaces to be changed to the "virtual" form factor +* [#469](https://github.com/digitalocean/netbox/issues/469) - Added missing import buttons to list views +* [#472](https://github.com/digitalocean/netbox/issues/472) - Hide the connection button for interfaces which have a circuit terminated to them + +--- + +v1.5.1 (2016-08-11) + +## Improvements + +* [#421](https://github.com/digitalocean/netbox/issues/421) - Added an asset tag field to devices +* [#456](https://github.com/digitalocean/netbox/issues/456) - Added IP search box to home page +* Colorized rack and device roles + +## Bug Fixes + +* [#454](https://github.com/digitalocean/netbox/issues/454) - Corrected error on rack export +* [#457](https://github.com/digitalocean/netbox/issues/457) - Added role field to rack edit form + +--- + +v1.5.0 (2016-08-10) + +## New Features + +### Rack Enhancements ([#180](https://github.com/digitalocean/netbox/issues/180), [#241](https://github.com/digitalocean/netbox/issues/241)) + +Like devices, racks can now be assigned to functional roles. This allows users to group racks by designated function as well as by physical location (rack groups). Additionally, rack can now have a defined rail-to-rail width (19 or 23 inches) and a type (two-post-rack, cabinet, etc.). + +## Improvements + +* [#149](https://github.com/digitalocean/netbox/issues/149) - Added discrete upstream speed field for circuits +* [#157](https://github.com/digitalocean/netbox/issues/157) - Added manufacturer field for device modules +* We have a logo! +* Upgraded to Django 1.10 + +## Bug Fixes + +* [#433](https://github.com/digitalocean/netbox/issues/433) - Corrected form validation when editing child devices +* [#442](https://github.com/digitalocean/netbox/issues/442) - Corrected child device import instructions +* [#443](https://github.com/digitalocean/netbox/issues/443) - Correctly display and initialize VRF for creation of new IP addresses +* [#444](https://github.com/digitalocean/netbox/issues/444) - Corrected prefix model validation +* [#445](https://github.com/digitalocean/netbox/issues/445) - Limit rack height to between 1U and 100U (inclusive) + +--- + +v1.4.2 (2016-08-06) + +## Improvements + +* [#167](https://github.com/digitalocean/netbox/issues/167) - Added new interface form factors +* [#253](https://github.com/digitalocean/netbox/issues/253) - Added new interface form factors +* [#434](https://github.com/digitalocean/netbox/issues/434) - Restored admin UI access to user action history (however bulk deletion is disabled) +* [#435](https://github.com/digitalocean/netbox/issues/435) - Added an "add prefix" button to the VLAN view + +## Bug Fixes + +* [#425](https://github.com/digitalocean/netbox/issues/425) - Ignore leading and trailing periods when generating a slug +* [#427](https://github.com/digitalocean/netbox/issues/427) - Prevent error when duplicate IPs are present in a prefix's IP list +* [#429](https://github.com/digitalocean/netbox/issues/429) - Correct redirection of user when adding a secret to a device + +--- + +v1.4.1 (2016-08-03) + +## Improvements + +* [#289](https://github.com/digitalocean/netbox/issues/289) - Annotate available ranges in prefix IP list +* [#412](https://github.com/digitalocean/netbox/issues/412) - Tenant group assignment is no longer mandatory +* [#422](https://github.com/digitalocean/netbox/issues/422) - CSV import now supports double-quoting values which contain commas + +## Bug Fixes + +* [#395](https://github.com/digitalocean/netbox/issues/395) - Show child prefixes from all VRFs if the parent belongs to the global table +* [#406](https://github.com/digitalocean/netbox/issues/406) - Fixed circuit list rendring when filtering on port speed or commit rate +* [#409](https://github.com/digitalocean/netbox/issues/409) - Filter IPs and prefixes by tenant slug rather than by its PK +* [#411](https://github.com/digitalocean/netbox/issues/411) - Corrected title of secret roles view +* [#419](https://github.com/digitalocean/netbox/issues/419) - Fixed a potential database performance issue when gathering tenant statistics + +--- + +v1.4.0 (2016-08-01) + +## New Features + +### Multitenancy ([#16](https://github.com/digitalocean/netbox/issues/16)) + +NetBox now supports tenants and tenant groups. Sites, racks, devices, VRFs, prefixes, IP addresses, VLANs, and circuits can be assigned to tenants to track the allocation of these resources among customers or internal departments. If a prefix or IP address does not have a tenant assigned, it will fall back to the tenant assigned to its parent VRF (where applicable). + +## Improvements + +* [#176](https://github.com/digitalocean/netbox/issues/176) - Introduced seed data for new installs +* [#358](https://github.com/digitalocean/netbox/issues/358) - Improved search for all objects +* [#394](https://github.com/digitalocean/netbox/issues/394) - Improved VRF selection during bulk editing of prefixes and IP addresses +* Miscellaneous cosmetic improvements to the UI + +## Bug Fixes + +* [#392](https://github.com/digitalocean/netbox/issues/392) - Don't include child devices in non-racked devices table +* [#397](https://github.com/digitalocean/netbox/issues/397) - Only include child IPs which belong to the same VRF as the parent prefix + +--- + +v1.3.2 (2016-07-26) + +## Improvements + +* [#292](https://github.com/digitalocean/netbox/issues/292) - Added part_number field to DeviceType +* [#363](https://github.com/digitalocean/netbox/issues/363) - Added a description field to the VLAN model +* [#374](https://github.com/digitalocean/netbox/issues/374) - Increased VLAN name length to 64 characters +* Enabled bulk deletion of interfaces from devices + +## Bug Fixes + +* [#359](https://github.com/digitalocean/netbox/issues/359) - Corrected the DCIM API endpoint for finding related connections +* [#370](https://github.com/digitalocean/netbox/issues/370) - Notify user when secret decryption fails +* [#381](https://github.com/digitalocean/netbox/issues/381) - Fix 'u_consumed' error on rack import +* [#384](https://github.com/digitalocean/netbox/issues/384) - Fixed description field's maximum length on IPAM bulk edit forms +* [#385](https://github.com/digitalocean/netbox/issues/385) - Fixed error when deleting a user with one or more associated UserActions + +--- + +v1.3.1 (2016-07-21) + +## Improvements + +* [#258](https://github.com/digitalocean/netbox/issues/258) - Add an API endpoint to list interface connections +* [#303](https://github.com/digitalocean/netbox/issues/303) - Improved numeric ordering of sites, racks, and devices +* [#304](https://github.com/digitalocean/netbox/issues/304) - Display utilization percentage on rack list +* [#327](https://github.com/digitalocean/netbox/issues/327) - Disable rack assignment for installed child devices + +## Bug Fixes + +* [#331](https://github.com/digitalocean/netbox/issues/331) - Add group field to VLAN bulk edit form +* Miscellaneous improvements to Unicode handling + +--- + +v1.3.0 (2016-07-18) + +## New Features + +* [#42](https://github.com/digitalocean/netbox/issues/42) - Allow assignment of VLAN on prefix import +* [#43](https://github.com/digitalocean/netbox/issues/43) - Toggling of IP space uniqueness within a VRF +* [#111](https://github.com/digitalocean/netbox/issues/111) - Introduces VLAN groups +* [#227](https://github.com/digitalocean/netbox/issues/227) - Support for bulk import of child devices + +## Bug Fixes + +* [#301](https://github.com/digitalocean/netbox/issues/301) - Prevent deletion of DeviceBay when installed device is deleted +* [#306](https://github.com/digitalocean/netbox/issues/306) - Fixed device import to allow an unspecified rack face +* [#307](https://github.com/digitalocean/netbox/issues/307) - Catch `RelatedObjectDoesNotExist` when an invalid device type is defined during device import +* [#308](https://github.com/digitalocean/netbox/issues/308) - Update rack assignment for all child devices when moving a parent device +* [#311](https://github.com/digitalocean/netbox/issues/311) - Fix assignment of primary_ip on IP address import +* [#317](https://github.com/digitalocean/netbox/issues/317) - Rack elevation display fix for device types greater than 42U in height +* [#320](https://github.com/digitalocean/netbox/issues/320) - Disallow import of prefixes with host masks +* [#322](https://github.com/digitalocean/netbox/issues/320) - Corrected VLAN import behavior + +--- + +v1.2.2 (2016-07-14) + +## Improvements + +* [#174](https://github.com/digitalocean/netbox/issues/174) - Added search and site filter to provider list +* [#270](https://github.com/digitalocean/netbox/issues/270) - Added the ability to filter devices by rack group + +## Bug Fixes + +* [#115](https://github.com/digitalocean/netbox/issues/115) - Fix deprecated django.core.context_processors reference +* [#268](https://github.com/digitalocean/netbox/issues/268) - Added support for entire 32-bit ASN space +* [#282](https://github.com/digitalocean/netbox/issues/282) - De-select "all" checkbox if one or more objects are deselected +* [#290](https://github.com/digitalocean/netbox/issues/290) - Always display management interfaces for a device type (even if `is_network_device` is not set) + +--- + +v1.2.1 (2016-07-13) + +**Note:** This release introduces a new dependency ([natsort](https://pypi.python.org/pypi/natsort)). Be sure to run `upgrade.sh` if upgrading from a previous release. + +## Improvements + +* [#285](https://github.com/digitalocean/netbox/issues/285) - Added the ability to prefer IPv4 over IPv6 for primary device IPs + +## Bug Fixes + +* [#243](https://github.com/digitalocean/netbox/issues/243) - Improved ordering of device object lists +* [#271](https://github.com/digitalocean/netbox/issues/271) - Fixed primary_ip bug in secrets API +* [#274](https://github.com/digitalocean/netbox/issues/274) - Fixed primary_ip bug in DCIM admin UI +* [#275](https://github.com/digitalocean/netbox/issues/275) - Fixed bug preventing the expansion of an existing aggregate + +--- + +v1.2.0 (2016-07-12) + +## New Features + +* [#73](https://github.com/digitalocean/netbox/issues/73) - Added optional persistent banner +* [#93](https://github.com/digitalocean/netbox/issues/73) - Ability to set both IPv4 and IPv6 primary IPs for devices +* [#203](https://github.com/digitalocean/netbox/issues/203) - Introduced support for LDAP + +## Bug Fixes + +* [#162](https://github.com/digitalocean/netbox/issues/228) - Fixed support for Unicode characters in rack/device/VLAN names +* [#228](https://github.com/digitalocean/netbox/issues/228) - Corrected conditional inclusion of device bay templates +* [#246](https://github.com/digitalocean/netbox/issues/246) - Corrected Docker build instructions +* [#260](https://github.com/digitalocean/netbox/issues/260) - Fixed error on admin UI device type list +* Miscellaneous layout improvements for mobile devices + +--- + +v1.1.0 (2016-07-07) + +## New Features + +* [#107](https://github.com/digitalocean/netbox/pull/107) - Docker support +* [#91](https://github.com/digitalocean/netbox/issues/91) - Support for subdevices within a device +* [#170](https://github.com/digitalocean/netbox/pull/170) - Added MAC address field to interfaces + +## Bug Fixes + +* [#169](https://github.com/digitalocean/netbox/issues/169) - Fix rendering of cancellation URL when editing objects +* [#183](https://github.com/digitalocean/netbox/issues/183) - Ignore vi swap files +* [#209](https://github.com/digitalocean/netbox/issues/209) - Corrected error when not confirming component template deletions +* [#214](https://github.com/digitalocean/netbox/issues/214) - Fixed redundant message on bulk interface creation +* [#68](https://github.com/digitalocean/netbox/issues/68) - Improved permissions-related error reporting for secrets + +--- + +v1.0.7-r1 (2016-07-05) + +* [#199](https://github.com/digitalocean/netbox/issues/199) - Correct IP address validation + +--- + +v1.0.7 (2016-06-30) + +**Note:** If upgrading from a previous release, be sure to run ./upgrade.sh after downloading the new code. +* [#135](https://github.com/digitalocean/netbox/issues/135): Fixed display of navigation menu on mobile screens +* [#141](https://github.com/digitalocean/netbox/issues/141): Fixed rendering of "getting started" guide +* Modified upgrade.sh to use sudo for pip installations +* [#109](https://github.com/digitalocean/netbox/issues/109): Hide the navigation menu from anonymous users if login is required +* [#143](https://github.com/digitalocean/netbox/issues/143): Add help_text to Device.position +* [#136](https://github.com/digitalocean/netbox/issues/136): Prefixes which have host bits set will trigger an error instead of being silently corrected +* [#140](https://github.com/digitalocean/netbox/issues/140): Improved support for Unicode in object names + +--- + +1.0.0 (2016-06-27) + +NetBox was originally developed internally at DigitalOcean by the network development team. This release marks the debut of NetBox as an open source project. diff --git a/README.md b/README.md index 5b090048d82..04e61029af8 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,6 @@ 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 or higher is strongly recommended. - | | status | |-------------|------------| | **master** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=master)](https://travis-ci.org/digitalocean/netbox) | diff --git a/base_requirements.txt b/base_requirements.txt index 6012ffa6c19..3d15784007e 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,25 +1,72 @@ -# django-filter-1.1.0 breaks with Django-2.1 -Django>=1.11,<2.1 +# The Python web framework on which NetBox is built +# https://github.com/django/django +Django + +# Django middleware which permits cross-domain API requests +# https://github.com/OttoYiu/django-cors-headers django-cors-headers + +# Runtime UI tool for debugging Django +# https://github.com/jazzband/django-debug-toolbar django-debug-toolbar -# django-filter-2.0.0 drops Python 2 support (blocked by #2000) -django-filter==1.1.0 + +# Library for writing reusable URL query filters +# https://github.com/carltongibson/django-filter +django-filter + +# Modified Preorder Tree Traversal (recursive nesting of objects) +# https://github.com/django-mptt/django-mptt django-mptt + +# Abstraction models for rendering and paginating HTML tables +# https://github.com/jieter/django-tables2 django-tables2 + +# User-defined tags for objects +# https://github.com/alex/django-taggit django-taggit + +# A Django REST Framework serializer which represents tags +# https://github.com/glemmaPaul/django-taggit-serializer django-taggit-serializer + +# A Django field for representing time zones +# https://github.com/mfogel/django-timezone-field/ django-timezone-field -# https://github.com/encode/django-rest-framework/issues/6053 -djangorestframework==3.8.1 + +# A REST API framework for Django projects +# https://github.com/encode/django-rest-framework +djangorestframework + +# Swagger/OpenAPI schema generation for REST APIs +# https://github.com/axnsan12/drf-yasg drf-yasg[validation] + +# Python interface to the graphviz graph rendering utility +# https://github.com/xflr6/graphviz graphviz -Markdown -natsort -ncclient + +# Simple markup language for rendering HTML +# https://github.com/Python-Markdown/markdown +# py-gfm requires Markdown<3.0 +Markdown<3.0 + +# Library for manipulating IP prefixes and addresses +# https://github.com/drkjam/netaddr netaddr -paramiko + +# Fork of PIL (Python Imaging Library) for image processing +# https://github.com/python-pillow/Pillow Pillow + +# PostgreSQL database adapter for Python +# https://github.com/psycopg/psycopg2 psycopg2-binary + +# GitHub-flavored Markdown extensions +# https://github.com/zopieux/py-gfm py-gfm + +# Extensive cryptographic library (fork of pycrypto) +# https://github.com/Legrandin/pycryptodome pycryptodome -xmltodict diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index 234766639d0..2c73850eb6c 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/reports.md @@ -44,7 +44,7 @@ class DeviceConnectionsReport(Report): # Check that every console port for every active device has a connection defined. for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE): - if console_port.cs_port is None: + if console_port.connected_endpoint is None: self.log_failure( console_port.device, "No console connection defined for {}".format(console_port.name) @@ -63,7 +63,7 @@ class DeviceConnectionsReport(Report): for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE): connected_ports = 0 for power_port in PowerPort.objects.filter(device=device): - if power_port.power_outlet is not None: + if power_port.connected_endpoint is not None: connected_ports += 1 if power_port.connection_status == CONNECTION_STATUS_PLANNED: self.log_warning( diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 0e74640faca..68f342e8818 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -4,6 +4,14 @@ A webhook defines an HTTP request that is sent to an external application when c 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. +## Installation + +If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below. + +* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages) +* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration) +* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation) + ## Requests The webhook POST request is structured as so (assuming `application/json` as the Content-Type): diff --git a/docs/additional-features/netbox-shell.md b/docs/administration/netbox-shell.md similarity index 99% rename from docs/additional-features/netbox-shell.md rename to docs/administration/netbox-shell.md index 5afd7876d60..2ebea5ce57f 100644 --- a/docs/additional-features/netbox-shell.md +++ b/docs/administration/netbox-shell.md @@ -9,7 +9,7 @@ This will launch a customized version of [the built-in Django shell](https://doc ``` $ ./manage.py nbshell ### NetBox interactive shell (jstretch-laptop) -### Python 2.7.6 | Django 1.11.3 | NetBox 2.1.0-dev +### Python 3.5.2 | Django 2.0.8 | NetBox 2.4.3 ### lsmodels() will show available models. Use help() for more info. ``` diff --git a/docs/api/overview.md b/docs/api/overview.md index 85d97200816..1115759d812 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu } ``` -Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model. +Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model. When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object. @@ -122,6 +122,52 @@ When a base serializer includes one or more nested serializers, the hierarchical } ``` +## Brief Format + +Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form. + +For example, the default (complete) format of an IP address looks like this: + +``` +GET /api/ipam/prefixes/13980/ + +{ + "id": 13980, + "family": 4, + "prefix": "192.0.2.0/24", + "site": null, + "vrf": null, + "tenant": null, + "vlan": null, + "status": { + "value": 1, + "label": "Active" + }, + "role": null, + "is_pool": false, + "description": "", + "tags": [], + "custom_fields": {}, + "created": "2018-12-11", + "last_updated": "2018-12-11T16:27:55.073174-05:00" +} +``` + +The brief format is much more terse, but includes a link to the object's full representation: + +``` +GET /api/ipam/prefixes/13980/?brief=1 + +{ + "id": 13980, + "url": "https://netbox/api/ipam/prefixes/13980/", + "family": 4, + "prefix": "192.0.2.0/24" +} +``` + +The brief format is supported for both lists and individual objects. + ## Static Choice Fields Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index b4de6fe7b1a..65ac588b66a 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -133,6 +133,14 @@ Setting this to True will permit only authenticated users to access any part of --- +## LOGIN_TIMEOUT + +Default: 1209600 seconds (14 days) + +The liftetime (in seconds) of the authentication cookie issued to a NetBox user upon login. + +--- + ## MAINTENANCE_MODE Default: False @@ -223,6 +231,14 @@ The file path to the location where custom reports will be kept. By default, thi --- +## SESSION_FILE_PATH + +Default: None + +Session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in the PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the user as which NetBox runs must have read and write permissions to this path. + +--- + ## TIME_ZONE Default: UTC @@ -235,7 +251,7 @@ The time zone NetBox will use when dealing with dates and times. It is recommend 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. +Enable this option to run the webhook backend. See the docs section on the webhook backend [here](../additional-features/webhooks/) for more information on setup and use. --- @@ -258,7 +274,7 @@ 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](https://redis.io/) is a key-value store which functions as a very lightweight database. It is required when enabling NetBox [webhooks](../additional-features/webhooks/). A Redis connection is configured using a dictionary similar to the following: ``` REDIS = { diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index e56c9d8c6cc..f41c94ec6c8 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -25,7 +25,7 @@ Circuit types are fully customizable. 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. -Each circuit termination is tied to a site, and optionally to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details. +Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or pass-through port. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details. !!! 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. diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 0d3df016cdf..e51bf541c29 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -4,12 +4,6 @@ A device type represents a particular make and model of hardware that exists in 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) @@ -32,6 +26,8 @@ Each device type is assigned a number of component templates which define the ph * Power ports * Power outlets * Network interfaces +* Front ports +* Rear ports * 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: @@ -56,32 +52,28 @@ When assigning a multi-U device to a rack, it is considered to be mounted in the 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 -# Device Components - -There are six types of device components which comprise all of the interconnection logic with NetBox: +There are eight types of device components which comprise all of the interconnection logic with NetBox: * Console ports * Console server ports * Power ports * Power outlets * Network interfaces +* Front ports +* Rear ports * Device bays -## Console +### Console Console ports connect only to console server ports. Console connections can be marked as either *planned* or *connected*. -## Power +### Power Power ports connect only to power outlets. Power connections can be marked as either *planned* or *connected*. -## Interfaces +### 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*. @@ -91,17 +83,27 @@ Each interface can also be enabled or disabled, and optionally designated as man VLANs can be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) -## Device Bays +### Pass-through Ports + +Pass-through ports are used to model physical terminations which comprise part of a longer path, such as a cable terminated to a patch panel. Each front port maps to a position on a rear port. A 24-port UTP patch panel, for instance, would have 24 front ports and 24 rear ports. Although this relationship is typically one-to-one, a rear port may have multiple front ports mapped to it. This can be useful for modeling instances where multiple paths share a common cable (for example, six different fiber connections sharing a 12-strand MPO cable). + +Pass-through ports can also be used to model "bump in the wire" devices, such as a media convertor or passive tap. + +### 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. +## Device Roles + +Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches. + --- # 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 the 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 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. The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. @@ -118,3 +120,25 @@ Inventory items represent hardware components installed within a device, such as 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. + +--- + +# Cables + +A cable represents a physical connection between two termination points, such as between a console port and a patch panel port, or between two network interfaces. Cables can be traced through pass-through ports to form a complete path between two endpoints. In the example below, three individual cables comprise a path between the two connected endpoints. + +``` +|<------------------------------------------ Cable Path ------------------------------------------->| + + Device A Patch Panel A Patch Panel B Device B ++-----------+ +-------------+ +-------------+ +-----------+ +| Interface | --- Cable --- | Front Port | | Front Port | --- Cable --- | Interface | ++-----------+ +-------------+ +-------------+ +-----------+ + +-------------+ +-------------+ + | Rear Port | --- Cable --- | Rear Port | + +-------------+ +-------------+ +``` + +All connections between device components in NetBox are represented using cables. However, defining the actual cable plant is optional: Components can be be directly connected using cables with no type or other attributes assigned. + +Cables are also used to associated ports and interfaces with circuit terminations. To do this, first create the circuit termination, then navigate the desired component and connect a cable between the two. diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 27bec8e8e4f..05b613da252 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -4,7 +4,7 @@ The first step to documenting your IP space is to define its scope by creating a * 10.0.0.0/8 (RFC 1918) * 100.64.0.0/10 (RFC 6598) -* 172.16.0.0/20 (RFC 1918) +* 172.16.0.0/12 (RFC 1918) * 192.168.0.0/16 (RFC 1918) * One or more /48s within fd00::/8 (IPv6 unique local addressing) diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md new file mode 100644 index 00000000000..1fde8067bdc --- /dev/null +++ b/docs/development/extending-models.md @@ -0,0 +1,74 @@ +# Extending Models + +Below is a list of items to consider when adding a new field to a model: + +### 1. Generate and run database migration + +Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration. + +``` +./manage.py makemigrations -n +./manage.py migrate +``` + +Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists. + +!!! note + Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered. + +### 2. Add validation logic to `clean()` + +If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or agter your custom validation as appropriate: + +``` +class Foo(models.Model): + + def clean(self): + + super(DeviceCSVForm, self).clean() + + # Custom validation goes here + if self.bar is None: + raise ValidationError() +``` + +### 3. Add CSV helpers + +Add the name of the new field to `csv_headers` and included a CSV-friendly representation of its data in the model's `to_csv()` method. These will be used when exporting objects in CSV format. + +### 4. Update relevant querysets + +If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `select_related()` or `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups. + +### 5. Update API serializer + +Extend the model's API serializer in `.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model. + +### 6. Add choices to API view + +If the new field has static choices, add it to the `FieldChoicesViewSet` for the app. + +### 7. Add field to forms + +Extend any forms to include the new field as appropriate. Common forms include: + +* **Credit/edit** - Manipulating a single object +* **Bulk edit** - Performing a change on mnay objects at once +* **CSV import** - The form used when bulk importing objects in CSV format +* **Filter** - Displays the options available for filtering a list of objects (both UI and API) + +### 8. Extend object filter set + +If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method. + +### 9. Add column to object table + +If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column. + +### 10. Update the UI templates + +Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated. + +### 11. Adjust API and model tests + +Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. diff --git a/docs/development/index.md b/docs/development/index.md index 91086c61ea6..5830da7653e 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -28,10 +28,3 @@ NetBox components are arranged into functional subsections called _apps_ (a carr * `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/style-guide.md b/docs/development/style-guide.md new file mode 100644 index 00000000000..138d0e12d4e --- /dev/null +++ b/docs/development/style-guide.md @@ -0,0 +1,54 @@ +# 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/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`. + +## PEP 8 Exceptions + +* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions: + * The library being import contains only constant declarations (`constants.py`) + * The library being imported explicitly defines `__all__` (e.g. `.api.nested_serializers`) + +* Maximum line length is 120 characters (E501) + * This does not apply to HTML templates or to automatically generated code (e.g. database migrations). + +* Line breaks are permitted following binary operators (W504) + +## Enforcing Code Style + +The `pycodestyle` utility (previously `pep8`) is used by the CI process to enforce code style. It is strongly recommended to include as part of your commit process. A git commit hook is provided in the source at `scripts/git-hooks/pre-commit`. Linking to this script from `.git/hooks/` will invoke `pycodestyle` prior to every commit attempt and abort if the validation fails. + +``` +$ cd .git/hooks/ +$ ln -s ../../scripts/git-hooks/pre-commit +``` + +To invoke `pycodestyle` manually, run: + +``` +pycodestyle --ignore=W504,E501 netbox/ +``` + +## Introducing New Dependencies + +The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks. + +If there's a strong case for introducing a new depdency, it must meet the following criteria: + +* Its complete source code must be published and freely accessible without registration. +* Its license must be conducive to inclusion in an open source project. +* It must be actively maintained, with no longer than one year between releases. +* It must be available via the [Python Package Index](https://pypi.org/) (PyPI). + +When adding a new dependency, a short description of the package and the URL of its code repository must be added to `base_requirements.txt`. Additionally, a line specifying the package name pinned to the current stable release must be added to `requirements.txt`. This ensures that NetBox will install only the known-good release and simplify support efforts. + +## General Guidance + +* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point. + +* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely. + +* Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable. + +* Every model should have a docstring. Every custom method should include an expalantion of its function. + +* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index dd38fec69ec..5e9c98c5c95 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -1,7 +1,7 @@ NetBox requires a PostgreSQL database to store data. This can be hosted locally or on a remote server. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).) !!! note - The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 7.4. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. + The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. !!! warning NetBox v2.2 and later requires PostgreSQL 9.4 or higher. @@ -19,7 +19,7 @@ If a recent enough version of PostgreSQL is not available through your distribut **CentOS** -CentOS 7.4 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6. +CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6. ```no-highlight # yum install https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index 8f59adc295a..8941a494934 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -5,16 +5,16 @@ This section of the documentation discusses installing and configuring the NetBo **Ubuntu** ```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 +# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev ``` **CentOS** ```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 +# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config +# easy_install-3.6 pip +# ln -s /usr/bin/python36 /usr/bin/python3 ``` You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. @@ -71,7 +71,7 @@ Checking connectivity... done. `# chown -R netbox:netbox /opt/netbox/netbox/media/` -## Install Python Packages +# Install Python Packages 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.) @@ -82,7 +82,7 @@ Install the required Python packages using pip. (If you encounter any compilatio !!! 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 `pip3 -V`. -### NAPALM Automation (Optional) +## NAPALM Automation (Optional) 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: @@ -90,7 +90,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati # pip3 install napalm ``` -### Webhooks (Optional) +## 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. @@ -246,13 +246,13 @@ At this point, NetBox should be able to run. We can verify this by starting a de Performing system checks... System check identified no issues (0 silenced). -June 17, 2016 - 16:17:36 -Django version 1.9.7, using settings 'netbox.settings' +November 28, 2018 - 09:33:45 +Django version 2.0.9, using settings 'netbox.settings' Starting development server at http://0.0.0.0:8000/ Quit the server with CONTROL-C. ``` -Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.** +Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.** !!! warning If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected. diff --git a/docs/installation/3-http-daemon.md b/docs/installation/3-http-daemon.md index 6ca38783ec5..dcf16101e06 100644 --- a/docs/installation/3-http-daemon.md +++ b/docs/installation/3-http-daemon.md @@ -1,7 +1,7 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence. !!! info - For the sake of brevity, only Ubuntu 16.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed. + For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed. # Web Server Installation diff --git a/docs/installation/4-ldap.md b/docs/installation/4-ldap.md index 8f4501d5720..32623439a57 100644 --- a/docs/installation/4-ldap.md +++ b/docs/installation/4-ldap.md @@ -19,7 +19,7 @@ sudo yum install -y openldap-devel ## Install django-auth-ldap ```no-highlight -sudo pip install django-auth-ldap +pip3 install django-auth-ldap ``` # Configuration @@ -95,6 +95,9 @@ AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() # Define a group required to login. AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com" +# Mirror LDAP group assignments. +AUTH_LDAP_MIRROR_GROUPS = True + # Define special user types using groups. Exercise great caution when assigning superuser status. AUTH_LDAP_USER_FLAGS_BY_GROUP = { "is_active": "cn=active,ou=groups,dc=example,dc=com", @@ -113,3 +116,21 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 * `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in. * `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions. * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions. + +# Troubleshooting LDAP + +`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`. + +For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`. + +```python +import logging, logging.handlers +logfile = "/opt/netbox/logs/django-ldap-debug.log" +my_logger = logging.getLogger('django_auth_ldap') +my_logger.setLevel(logging.DEBUG) +handler = logging.handlers.RotatingFileHandler( + logfile, maxBytes=1024 * 500, backupCount=5) +my_logger.addHandler(handler) +``` + +Ensure the file and path specified in logfile exist and are writable and executable by the application service account. Restart the netbox service and attempt to log into the site to trigger log entries to this file. diff --git a/docs/installation/index.md b/docs/installation/index.md index ae2ffb612f7..54daa62e32a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -11,4 +11,4 @@ The following sections detail how to set up a new instance of NetBox: 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. +NetBox v2.5 and later requires Python 3.5 or higher. Please see the instructions 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 b2efadea194..1d5ceb977dd 100644 --- a/docs/installation/migrating-to-python3.md +++ b/docs/installation/migrating-to-python3.md @@ -36,3 +36,9 @@ If using LDAP authentication, install the `django-auth-ldap` package: ```no-highlight # pip3 install django-auth-ldap ``` + +If using Webhooks, install the `django-rq` package: + +```no-highlight +# pip3 install django-rq +``` diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index bca60ca8911..6dc8a3c7a24 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -64,13 +64,6 @@ Once the new code is in place, run the upgrade script (which may need to be run # ./upgrade.sh ``` -!!! 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. Note that Python 2 will no longer be supported in NetBox v2.5. - -```no-highlight -# ./upgrade.sh -2 -``` - This script: * Installs or upgrades any new required Python packages diff --git a/mkdocs.yml b/mkdocs.yml index 87b7da25450..a0185e56e10 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,7 @@ pages: - Change Logging: 'additional-features/change-logging.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' + - NetBox Shell: 'administration/netbox-shell.md' - API: - Overview: 'api/overview.md' - Authentication: 'api/authentication.md' @@ -45,7 +46,9 @@ pages: - Examples: 'api/examples.md' - Development: - Introduction: 'development/index.md' + - Style Guide: 'development/style-guide.md' - Utility Views: 'development/utility-views.md' + - Extending Models: 'development/extending-models.md' - Release Checklist: 'development/release-checklist.md' markdown_extensions: diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py new file mode 100644 index 00000000000..211dc4007b8 --- /dev/null +++ b/netbox/circuits/api/nested_serializers.py @@ -0,0 +1,52 @@ +from rest_framework import serializers + +from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedCircuitSerializer', + 'NestedCircuitTerminationSerializer', + 'NestedCircuitTypeSerializer', + 'NestedProviderSerializer', +] + + +# +# Providers +# + +class NestedProviderSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + + class Meta: + model = Provider + fields = ['id', 'url', 'name', 'slug'] + + +# +# Circuits +# + +class NestedCircuitTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') + + class Meta: + model = CircuitType + fields = ['id', 'url', 'name', 'slug'] + + +class NestedCircuitSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') + + class Meta: + model = Circuit + fields = ['id', 'url', 'cid'] + + +class NestedCircuitTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') + circuit = NestedCircuitSerializer() + + class Meta: + model = CircuitTermination + fields = ['id', 'url', 'circuit', 'term_side'] diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 739fbf8ff04..e94875c21df 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,14 +1,13 @@ -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 dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer +from dcim.api.serializers import ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer -from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer +from tenancy.api.nested_serializers import NestedTenantSerializer +from utilities.api import ChoiceField, ValidatedModelSerializer +from .nested_serializers import * # @@ -26,16 +25,8 @@ class Meta: ] -class NestedProviderSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') - - class Meta: - model = Provider - fields = ['id', 'url', 'name', 'slug'] - - # -# Circuit types +# Circuits # class CircuitTypeSerializer(ValidatedModelSerializer): @@ -45,18 +36,6 @@ class Meta: fields = ['id', 'name', 'slug'] -class NestedCircuitTypeSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') - - class Meta: - model = CircuitType - fields = ['id', 'url', 'name', 'slug'] - - -# -# Circuits -# - class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): provider = NestedProviderSerializer() status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False) @@ -72,25 +51,14 @@ class Meta: ] -class NestedCircuitSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') - - class Meta: - model = Circuit - fields = ['id', 'url', 'cid'] - - -# -# Circuit Terminations -# - -class CircuitTerminationSerializer(ValidatedModelSerializer): +class CircuitTerminationSerializer(ConnectedEndpointSerializer): circuit = NestedCircuitSerializer() site = NestedSiteSerializer() - interface = InterfaceSerializer(required=False, allow_null=True) + cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 3fb4eda0a77..b9d1b439b1b 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ def get_view_name(self): router.APIRootView = CircuitsRootView # Field choices -router.register(r'_choices', views.CircuitsFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') # Providers router.register(r'providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index eccc1edfc65..877d85f85ef 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response @@ -31,7 +29,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.prefetch_related('tags') serializer_class = serializers.ProviderSerializer - filter_class = filters.ProviderFilter + filterset_class = filters.ProviderFilter @action(detail=True) def graphs(self, request, pk=None): @@ -51,7 +49,7 @@ def graphs(self, request, pk=None): class CircuitTypeViewSet(ModelViewSet): queryset = CircuitType.objects.all() serializer_class = serializers.CircuitTypeSerializer - filter_class = filters.CircuitTypeFilter + filterset_class = filters.CircuitTypeFilter # @@ -61,7 +59,7 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags') serializer_class = serializers.CircuitSerializer - filter_class = filters.CircuitFilter + filterset_class = filters.CircuitFilter # @@ -69,6 +67,8 @@ class CircuitViewSet(CustomFieldModelViewSet): # class CircuitTerminationViewSet(ModelViewSet): - queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') + queryset = CircuitTermination.objects.select_related( + 'circuit', 'site', 'connected_endpoint__device', 'cable' + ) serializer_class = serializers.CircuitTerminationSerializer - filter_class = filters.CircuitTerminationFilter + filterset_class = filters.CircuitTerminationFilter diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index 613c347f216..bc0b7d87de0 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py index c13975b06dd..03a981ea19c 100644 --- a/netbox/circuits/constants.py +++ b/netbox/circuits/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # Circuit statuses CIRCUIT_STATUS_DEPROVISIONING = 0 diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 79efdc95008..0982624d5f6 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,36 +1,35 @@ -from __future__ import unicode_literals - import django_filters from django.db.models import Q from dcim.models import Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NumericInFilter +from utilities.filters import NumericInFilter, TagFilter from .constants import CIRCUIT_STATUS_CHOICES from .models import Provider, Circuit, CircuitTermination, CircuitType class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='circuits__terminations__site', + field_name='circuits__terminations__site', queryset=Site.objects.all(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( - name='circuits__terminations__site__slug', + field_name='circuits__terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Provider @@ -56,7 +55,10 @@ class Meta: class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -66,7 +68,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Provider (ID)', ) provider = django_filters.ModelMultipleChoiceFilter( - name='provider__slug', + field_name='provider__slug', queryset=Provider.objects.all(), to_field_name='slug', label='Provider (slug)', @@ -76,7 +78,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Circuit type (ID)', ) type = django_filters.ModelMultipleChoiceFilter( - name='type__slug', + field_name='type__slug', queryset=CircuitType.objects.all(), to_field_name='slug', label='Circuit type (slug)', @@ -90,25 +92,23 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='terminations__site', + field_name='terminations__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='terminations__site__slug', + field_name='terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Circuit @@ -121,6 +121,7 @@ def search(self, queryset, name, value): Q(cid__icontains=value) | Q(terminations__xconnect_id__icontains=value) | Q(terminations__pp_info__icontains=value) | + Q(terminations__description__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() @@ -140,7 +141,7 @@ class CircuitTerminationFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -156,5 +157,6 @@ def search(self, queryset, name, value): return queryset.filter( Q(circuit__cid__icontains=value) | Q(xconnect_id__icontains=value) | - Q(pp_info__icontains=value) + Q(pp_info__icontains=value) | + Q(description__icontains=value) ).distinct() diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index aae8bb5f654..d481deb54cc 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,16 +1,13 @@ -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 dcim.models import Site from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, - ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField, + APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, + FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple ) from .constants import CIRCUIT_STATUS_CHOICES from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -23,14 +20,22 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags'] + 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}), + 'noc_contact': SmallTextarea( + attrs={'rows': 5} + ), + 'admin_contact': SmallTextarea( + attrs={'rows': 5} + ), } help_texts = { 'name': "Full name of the provider", @@ -56,23 +61,61 @@ class Meta: 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') - portal_url = forms.URLField(required=False, label='Portal') - noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact') - admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact') - comments = CommentField(widget=SmallTextarea) + 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' + ) + portal_url = forms.URLField( + required=False, + label='Portal' + ) + noc_contact = forms.CharField( + required=False, + widget=SmallTextarea, + label='NOC contact' + ) + admin_contact = forms.CharField( + required=False, + widget=SmallTextarea, + label='Admin contact' + ) + comments = CommentField( + widget=SmallTextarea() + ) class Meta: - nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + nullable_fields = [ + 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ] class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider - q = forms.CharField(required=False, label='Search') - site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug') - asn = forms.IntegerField(required=False, label='ASN') + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelect( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + asn = forms.IntegerField( + required=False, + label='ASN' + ) # @@ -84,7 +127,9 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class CircuitTypeCSVForm(forms.ModelForm): @@ -104,7 +149,9 @@ class Meta: class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Circuit @@ -117,6 +164,16 @@ class Meta: 'install_date': "Format: YYYY-MM-DD", 'commit_rate': "Committed rate", } + widgets = { + 'provider': APISelect( + api_url="/api/circuits/providers/" + ), + 'type': APISelect( + api_url="/api/circuits/circuit-types/" + ), + 'status': StaticSelect2(), + + } class CircuitCSVForm(forms.ModelForm): @@ -159,105 +216,117 @@ class Meta: 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) - status = forms.ChoiceField(choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), required=False, initial='') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') - description = forms.CharField(max_length=100, required=False) - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=Circuit.objects.all(), + widget=forms.MultipleHiddenInput + ) + type = forms.ModelChoiceField( + queryset=CircuitType.objects.all(), + required=False, + widget=APISelect( + api_url="/api/circuits/circuit-types/" + ) + ) + provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + required=False, + widget=APISelect( + api_url="/api/circuits/providers/" + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) + ) + commit_rate = forms.IntegerField( + required=False, + label='Commit rate (Kbps)' + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea + ) class Meta: - nullable_fields = ['tenant', 'commit_rate', 'description', 'comments'] + nullable_fields = [ + 'tenant', 'commit_rate', 'description', 'comments', + ] class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) type = FilterChoiceField( - queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), - to_field_name='slug' + queryset=CircuitType.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/circuits/circuit-types/", + value_field="slug", + ) ) provider = FilterChoiceField( - queryset=Provider.objects.annotate(filter_count=Count('circuits')), - to_field_name='slug' + queryset=Provider.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/circuits/providers/", + value_field="slug", + ) ) - status = AnnotatedMultipleChoiceField( + status = forms.MultipleChoiceField( choices=CIRCUIT_STATUS_CHOICES, - annotate=Circuit.objects.all(), - annotate_field='status', - required=False + required=False, + widget=StaticSelect2Multiple() ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('circuits')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')), - to_field_name='slug' + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + commit_rate = forms.IntegerField( + required=False, + min_value=0, + label='Commit rate (Kbps)' ) - commit_rate = forms.IntegerField(required=False, min_value=0, label='Commit rate (Kbps)') # # Circuit terminations # -class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='Rack', - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) - ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - required=False, - label='Device', - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'interface'} - ) - ) - interface = ChainedModelChoiceField( - queryset=Interface.objects.connectable().select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ), - chains=( - ('device', 'device'), - ), - required=False, - label='Interface', - widget=APISelect( - api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical', - disabled_indicator='is_connected' - ) - ) +class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): class Meta: model = CircuitTermination fields = [ - 'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', - 'pp_info', + 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -266,26 +335,7 @@ class Meta: } widgets = { 'term_side': forms.HiddenInput(), - } - - def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - if instance and instance.interface is not None: - initial = kwargs.get('initial', {}).copy() - initial['rack'] = instance.interface.device.rack - initial['device'] = instance.interface.device - kwargs['initial'] = initial - - super(CircuitTerminationForm, self).__init__(*args, **kwargs) - - # Mark connected interfaces as disabled - self.fields['interface'].choices = [] - for iface in self.fields['interface'].queryset: - self.fields['interface'].choices.append( - (iface.id, { - 'label': iface.name, - 'disabled': iface.is_connected and iface.pk != self.initial.get('interface'), - }) + 'site': APISelect( + api_url="/api/dcim/sites/" ) + } diff --git a/netbox/circuits/migrations/0001_initial.py b/netbox/circuits/migrations/0001_initial.py index 470fbee461c..dd4dc612b0c 100644 --- a/netbox/circuits/migrations/0001_initial.py +++ b/netbox/circuits/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py index 1ae1c5d45a6..3fcec7933fa 100644 --- a/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py +++ b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/circuits/migrations/0002_auto_20160622_1821.py b/netbox/circuits/migrations/0002_auto_20160622_1821.py index 32f31b37699..2d350b5f345 100644 --- a/netbox/circuits/migrations/0002_auto_20160622_1821.py +++ b/netbox/circuits/migrations/0002_auto_20160622_1821.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0003_provider_32bit_asn_support.py b/netbox/circuits/migrations/0003_provider_32bit_asn_support.py index f1010064ef1..e1e9adab9ac 100644 --- a/netbox/circuits/migrations/0003_provider_32bit_asn_support.py +++ b/netbox/circuits/migrations/0003_provider_32bit_asn_support.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-13 19:24 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations diff --git a/netbox/circuits/migrations/0004_circuit_add_tenant.py b/netbox/circuits/migrations/0004_circuit_add_tenant.py index 641b13afde8..de81f21eb9d 100644 --- a/netbox/circuits/migrations/0004_circuit_add_tenant.py +++ b/netbox/circuits/migrations/0004_circuit_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 21:59 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py b/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py index f309cb2d819..51b09ad4c8e 100644 --- a/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py +++ b/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-08 20:24 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0006_terminations.py b/netbox/circuits/migrations/0006_terminations.py index e5451498a7b..1a083c3dac4 100644 --- a/netbox/circuits/migrations/0006_terminations.py +++ b/netbox/circuits/migrations/0006_terminations.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-13 16:30 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0007_circuit_add_description.py b/netbox/circuits/migrations/0007_circuit_add_description.py index 023e5890a5c..238cb07dddd 100644 --- a/netbox/circuits/migrations/0007_circuit_add_description.py +++ b/netbox/circuits/migrations/0007_circuit_add_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-17 20:08 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py b/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py index 14ee6686ded..b7ccafd263d 100644 --- a/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py +++ b/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-19 17:17 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0009_unicode_literals.py b/netbox/circuits/migrations/0009_unicode_literals.py index 0f22a2268b4..0cc58fea956 100644 --- a/netbox/circuits/migrations/0009_unicode_literals.py +++ b/netbox/circuits/migrations/0009_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations, models diff --git a/netbox/circuits/migrations/0010_circuit_status.py b/netbox/circuits/migrations/0010_circuit_status.py index 3abe5d31988..675a0c1fba7 100644 --- a/netbox/circuits/migrations/0010_circuit_status.py +++ b/netbox/circuits/migrations/0010_circuit_status.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-06 18:48 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0011_tags.py b/netbox/circuits/migrations/0011_tags.py index b3510f8f43d..11243622386 100644 --- a/netbox/circuits/migrations/0011_tags.py +++ b/netbox/circuits/migrations/0011_tags.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/circuits/migrations/0012_change_logging.py b/netbox/circuits/migrations/0012_change_logging.py index db505785860..c9a3ee41d96 100644 --- a/netbox/circuits/migrations/0012_change_logging.py +++ b/netbox/circuits/migrations/0012_change_logging.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/circuits/migrations/0013_cables.py b/netbox/circuits/migrations/0013_cables.py new file mode 100644 index 00000000000..4e9125a9913 --- /dev/null +++ b/netbox/circuits/migrations/0013_cables.py @@ -0,0 +1,89 @@ +import sys + +from django.db import migrations, models +import django.db.models.deletion + +from dcim.constants import CONNECTION_STATUS_CONNECTED + + +def circuit_terminations_to_cables(apps, schema_editor): + """ + Copy all existing CircuitTermination Interface associations as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + Interface = apps.get_model('dcim', 'Interface') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + circuittermination_type = ContentType.objects.get_for_model(CircuitTermination) + interface_type = ContentType.objects.get_for_model(Interface) + + # Create a new Cable instance from each console connection + if 'test' not in sys.argv: + print("\n Adding circuit terminations... ", end='', flush=True) + for circuittermination in CircuitTermination.objects.filter(interface__isnull=False): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=circuittermination_type, + termination_a_id=circuittermination.id, + termination_b_type=interface_type, + termination_b_id=circuittermination.interface_id, + status=CONNECTION_STATUS_CONNECTED + ) + + # Cache the Cable on its two termination points + CircuitTermination.objects.filter(pk=circuittermination.pk).update( + cable=cable, + connected_endpoint=circuittermination.interface, + connection_status=CONNECTION_STATUS_CONNECTED + ) + # Cache the connected Cable on the Interface + Interface.objects.filter(pk=circuittermination.interface_id).update( + cable=cable, + _connected_circuittermination=circuittermination, + connection_status=CONNECTION_STATUS_CONNECTED + ) + + cable_count = Cable.objects.filter(termination_a_type=circuittermination_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('circuits', '0012_change_logging'), + ('dcim', '0066_cables'), + ] + + operations = [ + + # Add new CircuitTermination fields + migrations.AddField( + model_name='circuittermination', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'), + ), + migrations.AddField( + model_name='circuittermination', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='circuittermination', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + + # Copy CircuitTermination connections to Interfaces as Cables + migrations.RunPython(circuit_terminations_to_cables), + + # Remove interface field from CircuitTermination + migrations.RemoveField( + model_name='circuittermination', + name='interface', + ), + ] diff --git a/netbox/circuits/migrations/0014_circuittermination_description.py b/netbox/circuits/migrations/0014_circuittermination_description.py new file mode 100644 index 00000000000..2b307042721 --- /dev/null +++ b/netbox/circuits/migrations/0014_circuittermination_description.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-05 18:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0013_cables'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 6a2e55afca9..f10221b0b9d 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,20 +1,17 @@ -from __future__ import unicode_literals - 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.constants import CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, STATUS_CLASSES from dcim.fields import ASNField +from dcim.models import CableTermination 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(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -84,7 +81,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class CircuitType(ChangeLoggedModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -116,12 +112,11 @@ def to_csv(self): ) -@python_2_unicode_compatible 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. + circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured + in Kbps. """ cid = models.CharField( max_length=50, @@ -181,7 +176,7 @@ class Meta: unique_together = ['provider', 'cid'] def __str__(self): - return '{} {}'.format(self.provider, self.cid) + return self.cid def get_absolute_url(self): return reverse('circuits:circuit', args=[self.pk]) @@ -217,8 +212,7 @@ def termination_z(self): return self._get_termination('Z') -@python_2_unicode_compatible -class CircuitTermination(models.Model): +class CircuitTermination(CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -234,13 +228,17 @@ class CircuitTermination(models.Model): on_delete=models.PROTECT, related_name='circuit_terminations' ) - interface = models.OneToOneField( + connected_endpoint = models.OneToOneField( to='dcim.Interface', - on_delete=models.PROTECT, - related_name='circuit_termination', + on_delete=models.SET_NULL, + related_name='+', blank=True, null=True ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)' ) @@ -260,13 +258,17 @@ class CircuitTermination(models.Model): blank=True, verbose_name='Patch panel/port(s)' ) + description = models.CharField( + max_length=100, + blank=True + ) class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] def __str__(self): - return '{} (Side {})'.format(self.circuit, self.get_term_side_display()) + return 'Side {}'.format(self.get_term_side_display()) def log_change(self, user, request_id, action): """ diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 40a1e1031c4..bdfe8c0b6a7 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 6bf3114d9c9..c6a215db883 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor @@ -25,12 +23,6 @@ class CircuitTerminationColumn(tables.Column): def render(self, value): - if value.interface: - return mark_safe('{}'.format( - value.interface.device.get_absolute_url(), - value.site, - value.interface.device - )) return mark_safe('{}'.format( value.site.get_absolute_url(), value.site diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index e6c98068fdc..0810f0ff93b 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,11 +1,9 @@ -from __future__ import unicode_literals - from django.urls import reverse from rest_framework import status 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 dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site from extras.constants import GRAPH_TYPE_PROVIDER from extras.models import Graph from utilities.testing import APITestCase @@ -15,7 +13,7 @@ class ProviderTest(APITestCase): def setUp(self): - super(ProviderTest, self).setUp() + super().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') @@ -137,7 +135,7 @@ class CircuitTypeTest(APITestCase): def setUp(self): - super(CircuitTypeTest, self).setUp() + super().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') @@ -212,7 +210,7 @@ class CircuitTest(APITestCase): def setUp(self): - super(CircuitTest, self).setUp() + super().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') @@ -328,23 +326,26 @@ class CircuitTerminationTest(APITestCase): def setUp(self): - super(CircuitTerminationTest, self).setUp() + super().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') provider = Provider.objects.create(name='Test Provider', slug='test-provider') circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype) self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype) self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype) - 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') self.circuittermination1 = CircuitTermination.objects.create( circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 ) self.circuittermination2 = CircuitTermination.objects.create( - circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000 ) self.circuittermination3 = CircuitTermination.objects.create( - circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + ) + self.circuittermination4 = CircuitTermination.objects.create( + circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000 ) def test_get_circuittermination(self): @@ -359,14 +360,14 @@ def test_list_circuitterminations(self): url = reverse('circuits-api:circuittermination-list') response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) + self.assertEqual(response.data['count'], 4) def test_create_circuittermination(self): data = { - 'circuit': self.circuit1.pk, - 'term_side': TERM_SIDE_Z, - 'site': self.site2.pk, + 'circuit': self.circuit3.pk, + 'term_side': TERM_SIDE_A, + 'site': self.site1.pk, 'port_speed': 1000000, } @@ -374,7 +375,7 @@ def test_create_circuittermination(self): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(CircuitTermination.objects.count(), 4) + self.assertEqual(CircuitTermination.objects.count(), 5) circuittermination4 = CircuitTermination.objects.get(pk=response.data['id']) self.assertEqual(circuittermination4.circuit_id, data['circuit']) self.assertEqual(circuittermination4.term_side, data['term_side']) @@ -383,20 +384,23 @@ def test_create_circuittermination(self): def test_update_circuittermination(self): + circuittermination5 = CircuitTermination.objects.create( + circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + ) + data = { - 'circuit': self.circuit1.pk, + 'circuit': self.circuit3.pk, 'term_side': TERM_SIDE_Z, 'site': self.site2.pk, 'port_speed': 1000000, } - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) + url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk}) response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(CircuitTermination.objects.count(), 3) + self.assertEqual(CircuitTermination.objects.count(), 5) circuittermination1 = CircuitTermination.objects.get(pk=response.data['id']) - self.assertEqual(circuittermination1.circuit_id, data['circuit']) self.assertEqual(circuittermination1.term_side, data['term_side']) self.assertEqual(circuittermination1.site_id, data['site']) self.assertEqual(circuittermination1.port_speed, data['port_speed']) @@ -407,4 +411,4 @@ def test_delete_circuittermination(self): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(CircuitTermination.objects.count(), 2) + self.assertEqual(CircuitTermination.objects.count(), 3) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 449da396467..be110630892 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,10 +1,9 @@ -from __future__ import unicode_literals - from django.conf.urls import url +from dcim.views import CableCreateView, CableTraceView from extras.views import ObjectChangeLogView from . import views -from .models import Circuit, CircuitType, Provider +from .models import Circuit, CircuitTermination, CircuitType, Provider app_name = 'circuits' urlpatterns = [ @@ -44,5 +43,7 @@ url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), url(r'^circuit-terminations/(?P\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), url(r'^circuit-terminations/(?P\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), + url(r'^circuit-terminations/(?P\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + url(r'^circuit-terminations/(?P\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e116e455633..661f78e8e3b 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin @@ -134,7 +132,7 @@ class CircuitListView(ObjectListView): queryset = Circuit.objects.select_related( 'provider', 'type', 'tenant' ).prefetch_related( - 'terminations__site', 'terminations__interface__device' + 'terminations__site' ) filter = filters.CircuitFilter filter_form = forms.CircuitFilterForm @@ -148,12 +146,12 @@ def get(self, request, pk): circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) termination_a = CircuitTermination.objects.select_related( - 'site__region', 'interface__device' + 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=TERM_SIDE_A ).first() termination_z = CircuitTermination.objects.select_related( - 'site__region', 'interface__device' + 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=TERM_SIDE_Z ).first() diff --git a/netbox/dcim/api/exceptions.py b/netbox/dcim/api/exceptions.py index 8804da436b2..05ad86b5b48 100644 --- a/netbox/dcim/api/exceptions.py +++ b/netbox/dcim/api/exceptions.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework.exceptions import APIException diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py new file mode 100644 index 00000000000..4d747859545 --- /dev/null +++ b/netbox/dcim/api/nested_serializers.py @@ -0,0 +1,249 @@ +from rest_framework import serializers + +from dcim.constants import CONNECTION_STATUS_CHOICES +from dcim.models import ( + Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, + Interface, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate, + Region, Site, VirtualChassis, +) +from utilities.api import ChoiceField, WritableNestedSerializer + +__all__ = [ + 'NestedCableSerializer', + 'NestedConsolePortSerializer', + 'NestedConsoleServerPortSerializer', + 'NestedDeviceBaySerializer', + 'NestedDeviceRoleSerializer', + 'NestedDeviceSerializer', + 'NestedDeviceTypeSerializer', + 'NestedFrontPortSerializer', + 'NestedFrontPortTemplateSerializer', + 'NestedInterfaceSerializer', + 'NestedManufacturerSerializer', + 'NestedPlatformSerializer', + 'NestedPowerOutletSerializer', + 'NestedPowerPortSerializer', + 'NestedRackGroupSerializer', + 'NestedRackRoleSerializer', + 'NestedRackSerializer', + 'NestedRearPortSerializer', + 'NestedRearPortTemplateSerializer', + 'NestedRegionSerializer', + 'NestedSiteSerializer', + 'NestedVirtualChassisSerializer', +] + + +# +# Regions/sites +# + +class NestedRegionSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + + class Meta: + model = Region + fields = ['id', 'url', 'name', 'slug'] + + +class NestedSiteSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') + + class Meta: + model = Site + fields = ['id', 'url', 'name', 'slug'] + + +# +# Racks +# + +class NestedRackGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') + + class Meta: + model = RackGroup + fields = ['id', 'url', 'name', 'slug'] + + +class NestedRackRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') + + class Meta: + model = RackRole + fields = ['id', 'url', 'name', 'slug'] + + +class NestedRackSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') + + class Meta: + model = Rack + fields = ['id', 'url', 'name', 'display_name'] + + +# +# Device types +# + +class NestedManufacturerSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') + + class Meta: + model = Manufacturer + fields = ['id', 'url', 'name', 'slug'] + + +class NestedDeviceTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') + manufacturer = NestedManufacturerSerializer(read_only=True) + + class Meta: + model = DeviceType + fields = ['id', 'url', 'manufacturer', 'model', 'slug'] + + +class NestedRearPortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') + + class Meta: + model = RearPortTemplate + fields = ['id', 'url', 'name'] + + +class NestedFrontPortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') + + class Meta: + model = FrontPortTemplate + fields = ['id', 'url', 'name'] + + +# +# Devices +# + +class NestedDeviceRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') + + class Meta: + model = DeviceRole + fields = ['id', 'url', 'name', 'slug'] + + +class NestedPlatformSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') + + class Meta: + model = Platform + fields = ['id', 'url', 'name', 'slug'] + + +class NestedDeviceSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + + class Meta: + model = Device + fields = ['id', 'url', 'name', 'display_name'] + + +class NestedConsoleServerPortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = ConsoleServerPort + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedConsolePortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = ConsolePort + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedPowerOutletSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = PowerOutlet + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedPowerPortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = PowerPort + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedInterfaceSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = Interface + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedRearPortSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + + class Meta: + model = RearPort + fields = ['id', 'url', 'device', 'name', 'cable'] + + +class NestedFrontPortSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') + + class Meta: + model = FrontPort + fields = ['id', 'url', 'device', 'name', 'cable'] + + +class NestedDeviceBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + device = NestedDeviceSerializer(read_only=True) + + class Meta: + model = DeviceBay + fields = ['id', 'url', 'device', 'name'] + + +# +# Cables +# + +class NestedCableSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + + class Meta: + model = Cable + fields = ['id', 'url', 'label'] + + +# +# Virtual chassis +# + +class NestedVirtualChassisSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + master = NestedDeviceSerializer() + + class Meta: + model = VirtualChassis + fields = ['id', 'url', 'master'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d0634e040dd..765ed83dd08 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,43 +1,58 @@ -from __future__ import unicode_literals - 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 ( - CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES, - RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, -) +from dcim.constants import * from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) 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 ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.models import VLAN +from tenancy.api.nested_serializers import NestedTenantSerializer +from users.api.nested_serializers import NestedUserSerializer from utilities.api import ( - ChoiceField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, - WritableNestedSerializer, + ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, get_serializer_for_model, ) -from virtualization.models import Cluster +from virtualization.api.nested_serializers import NestedClusterSerializer +from .nested_serializers import * -# -# Regions -# +class ConnectedEndpointSerializer(ValidatedModelSerializer): + connected_endpoint_type = serializers.SerializerMethodField(read_only=True) + connected_endpoint = serializers.SerializerMethodField(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) -class NestedRegionSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + def get_connected_endpoint_type(self, obj): + if hasattr(obj, 'connected_endpoint') and obj.connected_endpoint is not None: + return '{}.{}'.format( + obj.connected_endpoint._meta.app_label, + obj.connected_endpoint._meta.model_name + ) + return None - class Meta: - model = Region - fields = ['id', 'url', 'name', 'slug'] + def get_connected_endpoint(self, obj): + """ + Return the appropriate serializer for the type of connected object. + """ + if getattr(obj, 'connected_endpoint', None) is None: + return None + + serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested') + context = {'request': self.context['request']} + data = serializer(obj.connected_endpoint, context=context).data + + return data +# +# Regions/sites +# + class RegionSerializer(serializers.ModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True) @@ -46,16 +61,17 @@ class Meta: fields = ['id', 'name', 'slug', 'parent'] -# -# Sites -# - 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) + count_prefixes = serializers.IntegerField(read_only=True) + count_vlans = serializers.IntegerField(read_only=True) + count_racks = serializers.IntegerField(read_only=True) + count_devices = serializers.IntegerField(read_only=True) + count_circuits = serializers.IntegerField(read_only=True) class Meta: model = Site @@ -67,16 +83,8 @@ class Meta: ] -class NestedSiteSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') - - class Meta: - model = Site - fields = ['id', 'url', 'name', 'slug'] - - # -# Rack groups +# Racks # class RackGroupSerializer(ValidatedModelSerializer): @@ -87,18 +95,6 @@ class Meta: fields = ['id', 'name', 'slug', 'site'] -class NestedRackGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') - - class Meta: - model = RackGroup - fields = ['id', 'url', 'name', 'slug'] - - -# -# Rack roles -# - class RackRoleSerializer(ValidatedModelSerializer): class Meta: @@ -106,32 +102,23 @@ class Meta: fields = ['id', 'name', 'slug', 'color'] -class NestedRackRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') - - class Meta: - model = RackRole - fields = ['id', 'url', 'name', 'slug'] - - -# -# Racks -# - class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer() group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceField(choices=RACK_STATUS_CHOICES, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True) width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) + outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_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', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial', + 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + '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. @@ -148,31 +135,11 @@ def validate(self, data): validator(data) # Enforce model validation - super(RackSerializer, self).validate(data) + super().validate(data) return data -class NestedRackSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') - - class Meta: - model = Rack - fields = ['id', 'url', 'name', 'display_name'] - - -# -# Rack units -# - -class NestedDeviceSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') - - class Meta: - model = Device - fields = ['id', 'url', 'name', 'display_name'] - - class RackUnitSerializer(serializers.Serializer): """ A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. @@ -183,10 +150,6 @@ class RackUnitSerializer(serializers.Serializer): device = NestedDeviceSerializer(read_only=True) -# -# Rack reservations -# - class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() @@ -198,7 +161,7 @@ class Meta: # -# Manufacturers +# Device types # class ManufacturerSerializer(ValidatedModelSerializer): @@ -208,21 +171,8 @@ class Meta: fields = ['id', 'name', 'slug'] -class NestedManufacturerSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') - - class Meta: - model = Manufacturer - fields = ['id', 'url', 'name', 'slug'] - - -# -# Device types -# - class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False) subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True) instance_count = serializers.IntegerField(source='instances.count', read_only=True) tags = TagListSerializerField(required=False) @@ -230,25 +180,11 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): 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', 'tags', 'custom_fields', - 'created', 'last_updated', 'instance_count', + 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'instance_count', ] -class NestedDeviceTypeSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') - manufacturer = NestedManufacturerSerializer(read_only=True) - - class Meta: - model = DeviceType - fields = ['id', 'url', 'manufacturer', 'model', 'slug'] - - -# -# Console port templates -# - class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -257,10 +193,6 @@ class Meta: fields = ['id', 'device_type', 'name'] -# -# Console server port templates -# - class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -269,10 +201,6 @@ class Meta: fields = ['id', 'device_type', 'name'] -# -# Power port templates -# - class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -281,10 +209,6 @@ class Meta: fields = ['id', 'device_type', 'name'] -# -# Power outlet templates -# - class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -293,10 +217,6 @@ class Meta: fields = ['id', 'device_type', 'name'] -# -# Interface templates -# - class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) @@ -306,9 +226,24 @@ class Meta: fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] -# -# Device bay templates -# +class RearPortTemplateSerializer(ValidatedModelSerializer): + device_type = NestedDeviceTypeSerializer() + type = ChoiceField(choices=PORT_TYPE_CHOICES) + + class Meta: + model = RearPortTemplate + fields = ['id', 'device_type', 'name', 'type', 'positions'] + + +class FrontPortTemplateSerializer(ValidatedModelSerializer): + device_type = NestedDeviceTypeSerializer() + type = ChoiceField(choices=PORT_TYPE_CHOICES) + rear_port = NestedRearPortTemplateSerializer() + + class Meta: + model = FrontPortTemplate + fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position'] + class DeviceBayTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -319,7 +254,7 @@ class Meta: # -# Device roles +# Devices # class DeviceRoleSerializer(ValidatedModelSerializer): @@ -329,64 +264,12 @@ class Meta: fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class NestedDeviceRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') - - class Meta: - model = DeviceRole - fields = ['id', 'url', 'name', 'slug'] - - -# -# Platforms -# - class PlatformSerializer(ValidatedModelSerializer): manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) class Meta: model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] - - -class NestedPlatformSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') - - class Meta: - model = Platform - fields = ['id', 'url', 'name', 'slug'] - - -# -# Devices -# - -# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency -class DeviceIPAddressSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - - class Meta: - model = IPAddress - fields = ['id', 'url', 'family', 'address'] - - -# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency -class NestedClusterSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') - - class Meta: - model = Cluster - fields = ['id', 'url', 'name'] - - -# Cannot import NestedVirtualChassisSerializer due to circular dependency -class DeviceVirtualChassisSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - master = NestedDeviceSerializer() - - class Meta: - model = VirtualChassis - fields = ['id', 'url', 'master'] + fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -398,12 +281,12 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): rack = NestedRackSerializer(required=False, allow_null=True) face = ChoiceField(choices=RACK_FACE_CHOICES, required=False, allow_null=True) 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) + primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) - virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) + virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) class Meta: @@ -411,8 +294,8 @@ class 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', 'created', - 'last_updated', 'local_context_data', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -425,7 +308,7 @@ def validate(self, data): validator(data) # Enforce model validation - super(DeviceSerializer, self).validate(data) + super().validate(data) return data @@ -447,171 +330,90 @@ 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', 'local_context_data', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'config_context', 'created', 'last_updated', ] def get_config_context(self, obj): return obj.get_config_context() -# -# Console server ports -# - -class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer): +class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() + cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name', 'connected_console', 'tags'] - read_only_fields = ['connected_console'] - - -class NestedConsoleServerPortSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') - device = NestedDeviceSerializer(read_only=True) - - class Meta: - model = ConsoleServerPort - fields = ['id', 'url', 'device', 'name'] + fields = [ + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', + ] -# -# Console ports -# - -class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): +class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() - cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True) + cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags'] - - -class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') - device = NestedDeviceSerializer(read_only=True) - - class Meta: - model = ConsolePort - fields = ['id', 'url', 'device', 'name'] - + fields = [ + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', + ] -# -# Power outlets -# -class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer): +class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() + cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: model = PowerOutlet - fields = ['id', 'device', 'name', 'connected_port', 'tags'] - read_only_fields = ['connected_port'] - - -class NestedPowerOutletSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') - device = NestedDeviceSerializer(read_only=True) - - class Meta: - model = PowerOutlet - fields = ['id', 'url', 'device', 'name'] - + fields = [ + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', + ] -# -# Power ports -# -class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): +class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() - power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True) + cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags'] - - -class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') - device = NestedDeviceSerializer(read_only=True) - - class Meta: - model = PowerPort - fields = ['id', 'url', 'device', 'name'] - - -# -# Interfaces -# - -class NestedInterfaceSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - - class Meta: - model = Interface - fields = ['id', 'url', 'device', 'name'] - - -class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') - - class Meta: - model = Circuit - fields = ['id', 'url', 'cid'] - - -class InterfaceCircuitTerminationSerializer(WritableNestedSerializer): - circuit = InterfaceNestedCircuitSerializer(read_only=True) - - class Meta: - model = CircuitTermination fields = [ - 'id', 'circuit', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', ] -# Cannot import ipam.api.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') - - class Meta: - model = VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] - - -class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): +class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() 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(read_only=True) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) - untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), - serializer=InterfaceVLANSerializer, + serializer=NestedVLANSerializer, required=False, many=True ) + cable = NestedCableSerializer(read_only=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', - 'tags', + 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', + 'tagged_vlans', 'tags', 'count_ipaddresses', ] + # TODO: This validation should be handled by Interface.clean() def validate(self, data): # All associated VLANs be global or assigned to the parent device's site. @@ -629,52 +431,51 @@ def validate(self, data): "be global.".format(vlan) }) - return super(InterfaceSerializer, self).validate(data) + return super().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 +class RearPortSerializer(ValidatedModelSerializer): + device = NestedDeviceSerializer() + type = ChoiceField(choices=PORT_TYPE_CHOICES) + cable = NestedCableSerializer(read_only=True) + tags = TagListSerializerField(required=False) -# -# Device bays -# + class Meta: + model = RearPort + fields = ['id', 'device', 'name', 'type', 'positions', 'description', 'cable', 'tags'] -class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): + +class FrontPortRearPortSerializer(WritableNestedSerializer): + """ + NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device) + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + + class Meta: + model = RearPort + fields = ['id', 'url', 'name'] + + +class FrontPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - installed_device = NestedDeviceSerializer(required=False, allow_null=True) + type = ChoiceField(choices=PORT_TYPE_CHOICES) + rear_port = FrontPortRearPortSerializer() + cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: - model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device', 'tags'] + model = FrontPort + fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags'] -class NestedDeviceBaySerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') - device = NestedDeviceSerializer(read_only=True) +class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): + device = NestedDeviceSerializer() + installed_device = NestedDeviceSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) class Meta: model = DeviceBay - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'device', 'name', 'installed_device', 'tags'] # @@ -697,40 +498,75 @@ class Meta: # -# Interface connections +# Cables # -class InterfaceConnectionSerializer(ValidatedModelSerializer): - interface_a = NestedInterfaceSerializer() - interface_b = NestedInterfaceSerializer() - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) +class CableSerializer(ValidatedModelSerializer): + termination_a_type = ContentTypeField() + termination_b_type = ContentTypeField() + termination_a = serializers.SerializerMethodField(read_only=True) + termination_b = serializers.SerializerMethodField(read_only=True) + status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) + length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False) class Meta: - model = InterfaceConnection - fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + model = Cable + fields = [ + 'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', + 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', + ] + + def _get_termination(self, obj, side): + """ + Serialize a nested representation of a termination. + """ + if side.lower() not in ['a', 'b']: + raise ValueError("Termination side must be either A or B.") + termination = getattr(obj, 'termination_{}'.format(side.lower())) + if termination is None: + return None + serializer = get_serializer_for_model(termination, prefix='Nested') + context = {'request': self.context['request']} + data = serializer(termination, context=context).data + return data -class NestedInterfaceConnectionSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') + def get_termination_a(self, obj): + return self._get_termination(obj, 'a') - class Meta: - model = InterfaceConnection - fields = ['id', 'url', 'connection_status'] + def get_termination_b(self, obj): + return self._get_termination(obj, 'b') -class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer): +class TracedCableSerializer(serializers.ModelSerializer): """ - A read-only representation of an InterfaceConnection from the perspective of either of its two connected Interfaces. + Used only while tracing a cable path. """ - interface = serializers.SerializerMethodField(read_only=True) - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + + class Meta: + model = Cable + fields = [ + 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', + ] + + +# +# Interface connections +# + +class InterfaceConnectionSerializer(ValidatedModelSerializer): + interface_a = serializers.SerializerMethodField() + interface_b = NestedInterfaceSerializer(source='connected_endpoint') + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) class Meta: - model = InterfaceConnection - fields = ['id', 'interface', 'connection_status'] + model = Interface + fields = ['interface_a', 'interface_b', 'connection_status'] - def get_interface(self, obj): - return NestedInterfaceSerializer(self.context['interface'], context=self.context).data + def get_interface_a(self, obj): + context = {'request': self.context['request']} + return NestedInterfaceSerializer(instance=obj, context=context).data # @@ -744,11 +580,3 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: model = VirtualChassis fields = ['id', 'master', 'domain', 'tags'] - - -class NestedVirtualChassisSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - - class Meta: - model = VirtualChassis - fields = ['id', 'url'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 145cb7f099c..006a61bad10 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ def get_view_name(self): router.APIRootView = DCIMRootView # Field choices -router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') # Sites router.register(r'regions', views.RegionViewSet) @@ -39,6 +37,8 @@ def get_view_name(self): router.register(r'power-port-templates', views.PowerPortTemplateViewSet) router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet) router.register(r'interface-templates', views.InterfaceTemplateViewSet) +router.register(r'front-port-templates', views.FrontPortTemplateViewSet) +router.register(r'rear-port-templates', views.RearPortTemplateViewSet) router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet) # Devices @@ -52,19 +52,24 @@ def get_view_name(self): router.register(r'power-ports', views.PowerPortViewSet) router.register(r'power-outlets', views.PowerOutletViewSet) router.register(r'interfaces', views.InterfaceViewSet) +router.register(r'front-ports', views.FrontPortViewSet) +router.register(r'rear-ports', views.RearPortViewSet) router.register(r'device-bays', views.DeviceBayViewSet) router.register(r'inventory-items', views.InventoryItemViewSet) # Connections -router.register(r'console-connections', views.ConsoleConnectionViewSet, base_name='consoleconnections') -router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections') -router.register(r'interface-connections', views.InterfaceConnectionViewSet) +router.register(r'console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections') +router.register(r'power-connections', views.PowerConnectionViewSet, basename='powerconnections') +router.register(r'interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections') + +# Cables +router.register(r'cables', views.CableViewSet) # Virtual chassis router.register(r'virtual-chassis', views.VirtualChassisViewSet) # Miscellaneous -router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') +router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device') app_name = 'dcim-api' urlpatterns = router.urls diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ceec6747ddf..d01358447dc 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,8 +1,7 @@ -from __future__ import unicode_literals - from collections import OrderedDict from django.conf import settings +from django.db.models import F, Q from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -15,15 +14,17 @@ from dcim import filters from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable +from utilities.api import ( + get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, +) from . import serializers from .exceptions import MissingFilterException @@ -34,17 +35,56 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): fields = ( - (Device, ['face', 'status']), + (Cable, ['length_unit', 'status', 'type']), (ConsolePort, ['connection_status']), + (Device, ['face', 'status']), + (DeviceType, ['subdevice_role']), + (FrontPort, ['type']), + (FrontPortTemplate, ['type']), (Interface, ['form_factor', 'mode']), - (InterfaceConnection, ['connection_status']), (InterfaceTemplate, ['form_factor']), (PowerPort, ['connection_status']), - (Rack, ['type', 'width']), + (Rack, ['outer_unit', 'status', 'type', 'width']), + (RearPort, ['type']), + (RearPortTemplate, ['type']), (Site, ['status']), ) +# Mixins + +class CableTraceMixin(object): + + @action(detail=True, url_path='trace') + def trace(self, request, pk): + """ + Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination). + """ + obj = get_object_or_404(self.queryset.model, pk=pk) + + # Initialize the path array + path = [] + + for near_end, cable, far_end in obj.trace(follow_circuits=True): + + # Serialize each object + serializer_a = get_serializer_for_model(near_end, prefix='Nested') + x = serializer_a(near_end, context={'request': request}).data + if cable is not None: + y = serializers.TracedCableSerializer(cable, context={'request': request}).data + else: + y = None + if far_end is not None: + serializer_b = get_serializer_for_model(far_end, prefix='Nested') + z = serializer_b(far_end, context={'request': request}).data + else: + z = None + + path.append((x, y, z)) + + return Response(path) + + # # Regions # @@ -52,7 +92,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): class RegionViewSet(ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer - filter_class = filters.RegionFilter + filterset_class = filters.RegionFilter # @@ -62,7 +102,7 @@ class RegionViewSet(ModelViewSet): class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags') serializer_class = serializers.SiteSerializer - filter_class = filters.SiteFilter + filterset_class = filters.SiteFilter @action(detail=True) def graphs(self, request, pk=None): @@ -82,7 +122,7 @@ def graphs(self, request, pk=None): class RackGroupViewSet(ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer - filter_class = filters.RackGroupFilter + filterset_class = filters.RackGroupFilter # @@ -92,7 +132,7 @@ class RackGroupViewSet(ModelViewSet): class RackRoleViewSet(ModelViewSet): queryset = RackRole.objects.all() serializer_class = serializers.RackRoleSerializer - filter_class = filters.RackRoleFilter + filterset_class = filters.RackRoleFilter # @@ -102,7 +142,7 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags') serializer_class = serializers.RackSerializer - filter_class = filters.RackFilter + filterset_class = filters.RackFilter @action(detail=True) def units(self, request, pk=None): @@ -132,7 +172,7 @@ def units(self, request, pk=None): class RackReservationViewSet(ModelViewSet): queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer - filter_class = filters.RackReservationFilter + filterset_class = filters.RackReservationFilter # Assign user from request def perform_create(self, serializer): @@ -146,7 +186,7 @@ def perform_create(self, serializer): class ManufacturerViewSet(ModelViewSet): queryset = Manufacturer.objects.all() serializer_class = serializers.ManufacturerSerializer - filter_class = filters.ManufacturerFilter + filterset_class = filters.ManufacturerFilter # @@ -156,7 +196,7 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags') serializer_class = serializers.DeviceTypeSerializer - filter_class = filters.DeviceTypeFilter + filterset_class = filters.DeviceTypeFilter # @@ -166,37 +206,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet): queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer - filter_class = filters.ConsolePortTemplateFilter + filterset_class = filters.ConsolePortTemplateFilter class ConsoleServerPortTemplateViewSet(ModelViewSet): queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer - filter_class = filters.ConsoleServerPortTemplateFilter + filterset_class = filters.ConsoleServerPortTemplateFilter class PowerPortTemplateViewSet(ModelViewSet): queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer - filter_class = filters.PowerPortTemplateFilter + filterset_class = filters.PowerPortTemplateFilter class PowerOutletTemplateViewSet(ModelViewSet): queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer - filter_class = filters.PowerOutletTemplateFilter + filterset_class = filters.PowerOutletTemplateFilter class InterfaceTemplateViewSet(ModelViewSet): queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer - filter_class = filters.InterfaceTemplateFilter + filterset_class = filters.InterfaceTemplateFilter + + +class FrontPortTemplateViewSet(ModelViewSet): + queryset = FrontPortTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.FrontPortTemplateSerializer + filterset_class = filters.FrontPortTemplateFilter + + +class RearPortTemplateViewSet(ModelViewSet): + queryset = RearPortTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.RearPortTemplateSerializer + filterset_class = filters.RearPortTemplateFilter class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer - filter_class = filters.DeviceBayTemplateFilter + filterset_class = filters.DeviceBayTemplateFilter # @@ -206,7 +258,7 @@ class DeviceBayTemplateViewSet(ModelViewSet): class DeviceRoleViewSet(ModelViewSet): queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer - filter_class = filters.DeviceRoleFilter + filterset_class = filters.DeviceRoleFilter # @@ -216,7 +268,7 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer - filter_class = filters.PlatformFilter + filterset_class = filters.PlatformFilter # @@ -230,7 +282,7 @@ class DeviceViewSet(CustomFieldModelViewSet): ).prefetch_related( 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) - filter_class = filters.DeviceFilter + filterset_class = filters.DeviceFilter def get_serializer_class(self): """ @@ -263,9 +315,9 @@ def napalm(self, request, pk): # Check that NAPALM is installed try: import napalm + from napalm.base.exceptions import ModuleImportError except ImportError: raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") - from napalm.base.exceptions import ModuleImportError # Validate the configured driver try: @@ -309,7 +361,9 @@ def napalm(self, request, pk): try: response[method] = getattr(d, method)() except NotImplementedError: - response[method] = {'error': 'Method not implemented for NAPALM driver {}'.format(driver)} + response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} + except Exception as e: + response[method] = {'error': 'Method {} failed: {}'.format(method, e)} d.close() return Response(response) @@ -319,34 +373,54 @@ def napalm(self, request, pk): # Device components # -class ConsolePortViewSet(ModelViewSet): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').prefetch_related('tags') +class ConsolePortViewSet(CableTraceMixin, ModelViewSet): + queryset = ConsolePort.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.ConsolePortSerializer - filter_class = filters.ConsolePortFilter + filterset_class = filters.ConsolePortFilter -class ConsoleServerPortViewSet(ModelViewSet): - queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device').prefetch_related('tags') +class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): + queryset = ConsoleServerPort.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.ConsoleServerPortSerializer - filter_class = filters.ConsoleServerPortFilter + filterset_class = filters.ConsoleServerPortFilter -class PowerPortViewSet(ModelViewSet): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').prefetch_related('tags') +class PowerPortViewSet(CableTraceMixin, ModelViewSet): + queryset = PowerPort.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.PowerPortSerializer - filter_class = filters.PowerPortFilter + filterset_class = filters.PowerPortFilter -class PowerOutletViewSet(ModelViewSet): - queryset = PowerOutlet.objects.select_related('device', 'connected_port__device').prefetch_related('tags') +class PowerOutletViewSet(CableTraceMixin, ModelViewSet): + queryset = PowerOutlet.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.PowerOutletSerializer - filter_class = filters.PowerOutletFilter + filterset_class = filters.PowerOutletFilter -class InterfaceViewSet(ModelViewSet): - queryset = Interface.objects.select_related('device').prefetch_related('tags') +class InterfaceViewSet(CableTraceMixin, ModelViewSet): + queryset = Interface.objects.select_related( + 'device', '_connected_interface', '_connected_circuittermination', 'cable' + ).prefetch_related( + 'ip_addresses', 'tags' + ) serializer_class = serializers.InterfaceSerializer - filter_class = filters.InterfaceFilter + filterset_class = filters.InterfaceFilter @action(detail=True) def graphs(self, request, pk=None): @@ -359,16 +433,36 @@ def graphs(self, request, pk=None): return Response(serializer.data) +class FrontPortViewSet(ModelViewSet): + queryset = FrontPort.objects.select_related( + 'device__device_type__manufacturer', 'rear_port', 'cable' + ).prefetch_related( + 'tags' + ) + serializer_class = serializers.FrontPortSerializer + filterset_class = filters.FrontPortFilter + + +class RearPortViewSet(ModelViewSet): + queryset = RearPort.objects.select_related( + 'device__device_type__manufacturer', 'cable' + ).prefetch_related( + 'tags' + ) + serializer_class = serializers.RearPortSerializer + filterset_class = filters.RearPortFilter + + class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags') serializer_class = serializers.DeviceBaySerializer - filter_class = filters.DeviceBayFilter + filterset_class = filters.DeviceBayFilter class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags') serializer_class = serializers.InventoryItemSerializer - filter_class = filters.InventoryItemFilter + filterset_class = filters.InventoryItemFilter # @@ -376,21 +470,47 @@ class InventoryItemViewSet(ModelViewSet): # class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False) + queryset = ConsolePort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ) serializer_class = serializers.ConsolePortSerializer - filter_class = filters.ConsoleConnectionFilter + filterset_class = filters.ConsoleConnectionFilter class PowerConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False) + queryset = PowerPort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ) serializer_class = serializers.PowerPortSerializer - filter_class = filters.PowerConnectionFilter + filterset_class = filters.PowerConnectionFilter -class InterfaceConnectionViewSet(ModelViewSet): - queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') +class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): + queryset = Interface.objects.select_related( + 'device', '_connected_interface', '_connected_circuittermination' + ).filter( + # Avoid duplicate connections by only selecting the lower PK in a connected pair + Q(_connected_interface__isnull=False, pk__lt=F('_connected_interface')) | + Q(_connected_circuittermination__isnull=False) + ) serializer_class = serializers.InterfaceConnectionSerializer - filter_class = filters.InterfaceConnectionFilter + filterset_class = filters.InterfaceConnectionFilter + + +# +# Cables +# + +class CableViewSet(ModelViewSet): + queryset = Cable.objects.prefetch_related( + 'termination_a', 'termination_b' + ) + serializer_class = serializers.CableSerializer + filterset_class = filters.CableFilter # @@ -412,30 +532,43 @@ class ConnectedDeviceViewSet(ViewSet): interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors via a protocol such as LLDP. Two query parameters must be included in the request: - * `peer-device`: The name of the peer device - * `peer-interface`: The name of the peer interface + * `peer_device`: The name of the peer device + * `peer_interface`: The name of the peer interface """ permission_classes = [IsAuthenticatedOrLoginNotRequired] - _device_param = Parameter('peer-device', 'query', - description='The name of the peer device', required=True, type=openapi.TYPE_STRING) - _interface_param = Parameter('peer-interface', 'query', - description='The name of the peer interface', required=True, type=openapi.TYPE_STRING) + _device_param = Parameter( + name='peer_device', + in_='query', + description='The name of the peer device', + required=True, + type=openapi.TYPE_STRING + ) + _interface_param = Parameter( + name='peer_interface', + in_='query', + description='The name of the peer interface', + required=True, + type=openapi.TYPE_STRING + ) def get_view_name(self): return "Connected Device Locator" @swagger_auto_schema( - manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer}) + manual_parameters=[_device_param, _interface_param], + responses={'200': serializers.DeviceSerializer} + ) def list(self, request): peer_device_name = request.query_params.get(self._device_param.name) peer_interface_name = request.query_params.get(self._interface_param.name) + if not peer_device_name or not peer_interface_name: - raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.') + raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.') # Determine local interface from peer interface's connection peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name) - local_interface = peer_interface.connected_interface + local_interface = peer_interface._connected_interface if local_interface is None: return Response() diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index d61a46d9819..78a243f8493 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index c4125953364..27f8b6f7980 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # Rack types RACK_TYPE_2POST = 100 @@ -31,6 +29,20 @@ [RACK_FACE_REAR, 'Rear'], ] +# Rack statuses +RACK_STATUS_RESERVED = 0 +RACK_STATUS_AVAILABLE = 1 +RACK_STATUS_PLANNED = 2 +RACK_STATUS_ACTIVE = 3 +RACK_STATUS_DEPRECATED = 4 +RACK_STATUS_CHOICES = [ + [RACK_STATUS_ACTIVE, 'Active'], + [RACK_STATUS_PLANNED, 'Planned'], + [RACK_STATUS_RESERVED, 'Reserved'], + [RACK_STATUS_AVAILABLE, 'Available'], + [RACK_STATUS_DEPRECATED, 'Deprecated'], +] + # Parent/child device roles SUBDEVICE_ROLE_PARENT = True SUBDEVICE_ROLE_CHILD = False @@ -70,18 +82,31 @@ IFACE_FF_100GE_CFP4 = 1520 IFACE_FF_100GE_CPAK = 1550 IFACE_FF_100GE_QSFP28 = 1600 +IFACE_FF_200GE_CFP2 = 1650 +IFACE_FF_200GE_QSFP56 = 1700 +IFACE_FF_400GE_QSFP_DD = 1750 # Wireless IFACE_FF_80211A = 2600 IFACE_FF_80211G = 2610 IFACE_FF_80211N = 2620 IFACE_FF_80211AC = 2630 IFACE_FF_80211AD = 2640 +# SONET +IFACE_FF_SONET_OC3 = 6100 +IFACE_FF_SONET_OC12 = 6200 +IFACE_FF_SONET_OC48 = 6300 +IFACE_FF_SONET_OC192 = 6400 +IFACE_FF_SONET_OC768 = 6500 +IFACE_FF_SONET_OC1920 = 6600 +IFACE_FF_SONET_OC3840 = 6700 # Fibrechannel IFACE_FF_1GFC_SFP = 3010 IFACE_FF_2GFC_SFP = 3020 IFACE_FF_4GFC_SFP = 3040 IFACE_FF_8GFC_SFP_PLUS = 3080 IFACE_FF_16GFC_SFP_PLUS = 3160 +IFACE_FF_32GFC_SFP28 = 3320 +IFACE_FF_128GFC_QSFP28 = 3400 # Serial IFACE_FF_T1 = 4000 IFACE_FF_E1 = 4010 @@ -131,9 +156,12 @@ [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], [IFACE_FF_100GE_CFP, 'CFP (100GE)'], [IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'], + [IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'], [IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'], [IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'], [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], + [IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'], + [IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'], ] ], [ @@ -146,6 +174,18 @@ [IFACE_FF_80211AD, 'IEEE 802.11ad'], ] ], + [ + 'SONET', + [ + [IFACE_FF_SONET_OC3, 'OC-3/STM-1'], + [IFACE_FF_SONET_OC12, 'OC-12/STM-4'], + [IFACE_FF_SONET_OC48, 'OC-48/STM-16'], + [IFACE_FF_SONET_OC192, 'OC-192/STM-64'], + [IFACE_FF_SONET_OC768, 'OC-768/STM-256'], + [IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'], + [IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'], + ] + ], [ 'FibreChannel', [ @@ -154,6 +194,8 @@ [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], + [IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'], + [IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'], ] ], [ @@ -211,6 +253,36 @@ [IFACE_MODE_TAGGED_ALL, 'Tagged All'], ] +# Pass-through port types +PORT_TYPE_8P8C = 1000 +PORT_TYPE_ST = 2000 +PORT_TYPE_SC = 2100 +PORT_TYPE_FC = 2200 +PORT_TYPE_LC = 2300 +PORT_TYPE_MTRJ = 2400 +PORT_TYPE_MPO = 2500 +PORT_TYPE_LSH = 2600 +PORT_TYPE_CHOICES = [ + [ + 'Copper', + [ + [PORT_TYPE_8P8C, '8P8C'], + ], + ], + [ + 'Fiber Optic', + [ + [PORT_TYPE_FC, 'FC'], + [PORT_TYPE_LC, 'LC'], + [PORT_TYPE_LSH, 'LSH'], + [PORT_TYPE_MPO, 'MPO'], + [PORT_TYPE_MTRJ, 'MTRJ'], + [PORT_TYPE_SC, 'SC'], + [PORT_TYPE_ST, 'ST'], + ] + ] +] + # Device statuses DEVICE_STATUS_OFFLINE = 0 DEVICE_STATUS_ACTIVE = 1 @@ -237,7 +309,7 @@ [SITE_STATUS_RETIRED, 'Retired'], ] -# Bootstrap CSS classes for device statuses +# Bootstrap CSS classes for device/rack statuses STATUS_CLASSES = { 0: 'warning', 1: 'success', @@ -255,12 +327,87 @@ [CONNECTION_STATUS_CONNECTED, 'Connected'], ] -# Platform -> RPC client mappings -RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos' -RPC_CLIENT_CISCO_IOS = 'cisco-ios' -RPC_CLIENT_OPENGEAR = 'opengear' -RPC_CLIENT_CHOICES = [ - [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'], - [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'], - [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'], +# Cable endpoint types +CABLE_TERMINATION_TYPES = [ + 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', ] + +# Cable types +CABLE_TYPE_CAT3 = 1300 +CABLE_TYPE_CAT5 = 1500 +CABLE_TYPE_CAT5E = 1510 +CABLE_TYPE_CAT6 = 1600 +CABLE_TYPE_CAT6A = 1610 +CABLE_TYPE_CAT7 = 1700 +CABLE_TYPE_DAC_ACTIVE = 1800 +CABLE_TYPE_DAC_PASSIVE = 1810 +CABLE_TYPE_MMF_OM1 = 3010 +CABLE_TYPE_MMF_OM2 = 3020 +CABLE_TYPE_MMF_OM3 = 3030 +CABLE_TYPE_MMF_OM4 = 3040 +CABLE_TYPE_SMF = 3500 +CABLE_TYPE_AOC = 3800 +CABLE_TYPE_POWER = 5000 +CABLE_TYPE_CHOICES = ( + ( + 'Copper', ( + (CABLE_TYPE_CAT3, 'CAT3'), + (CABLE_TYPE_CAT5, 'CAT5'), + (CABLE_TYPE_CAT5E, 'CAT5e'), + (CABLE_TYPE_CAT6, 'CAT6'), + (CABLE_TYPE_CAT6A, 'CAT6a'), + (CABLE_TYPE_CAT7, 'CAT7'), + (CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'), + (CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'), + ), + ), + ( + 'Fiber', ( + (CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'), + (CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'), + (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'), + (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'), + (CABLE_TYPE_SMF, 'Singlemode Fiber'), + (CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'), + ), + ), + (CABLE_TYPE_POWER, 'Power'), +) + +CABLE_TERMINATION_TYPE_CHOICES = { + # (API endpoint, human-friendly name) + 'consoleport': ('console-ports', 'Console port'), + 'consoleserverport': ('console-server-ports', 'Console server port'), + 'powerport': ('power-ports', 'Power port'), + 'poweroutlet': ('power-outlets', 'Power outlet'), + 'interface': ('interfaces', 'Interface'), + 'frontport': ('front-ports', 'Front panel port'), + 'rearport': ('rear-ports', 'Rear panel port'), +} + +COMPATIBLE_TERMINATION_TYPES = { + 'consoleport': ['consoleserverport', 'frontport', 'rearport'], + 'consoleserverport': ['consoleport', 'frontport', 'rearport'], + 'powerport': ['poweroutlet'], + 'poweroutlet': ['powerport'], + 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], + 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], + 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], + 'circuittermination': ['interface', 'frontport', 'rearport'], +} + +LENGTH_UNIT_METER = 1200 +LENGTH_UNIT_CENTIMETER = 1100 +LENGTH_UNIT_MILLIMETER = 1000 +LENGTH_UNIT_FOOT = 2100 +LENGTH_UNIT_INCH = 2000 +CABLE_LENGTH_UNIT_CHOICES = ( + (LENGTH_UNIT_METER, 'Meters'), + (LENGTH_UNIT_CENTIMETER, 'Centimeters'), + (LENGTH_UNIT_FOOT, 'Feet'), + (LENGTH_UNIT_INCH, 'Inches'), +) +RACK_DIMENSION_UNIT_CHOICES = ( + (LENGTH_UNIT_MILLIMETER, 'Millimeters'), + (LENGTH_UNIT_INCH, 'Inches'), +) diff --git a/netbox/dcim/exceptions.py b/netbox/dcim/exceptions.py new file mode 100644 index 00000000000..e788c9b5fb1 --- /dev/null +++ b/netbox/dcim/exceptions.py @@ -0,0 +1,5 @@ +class LoopDetected(Exception): + """ + A loop has been detected while tracing a cable path. + """ + pass diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 4f38ec24e45..8d4bfba3500 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,10 +1,7 @@ -from __future__ import unicode_literals - -from netaddr import AddrFormatError, EUI, mac_unix_expanded - from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models +from netaddr import AddrFormatError, EUI, mac_unix_expanded class ASNField(models.BigIntegerField): diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index c81af4478b1..e10cfe337df 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,24 +1,21 @@ -from __future__ import unicode_literals - import django_filters from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableCharFieldFilter, NumericInFilter +from utilities.constants import COLOR_CHOICES +from utilities.filters import NullableCharFieldFilter, NumericInFilter, TagFilter from virtualization.models import Cluster -from .constants import ( - DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES, - WIRELESS_IFACE_TYPES, -) +from .constants import * from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) @@ -32,7 +29,7 @@ class RegionFilter(django_filters.FilterSet): label='Parent region (ID)', ) parent = django_filters.ModelMultipleChoiceFilter( - name='parent__slug', + field_name='parent__slug', queryset=Region.objects.all(), to_field_name='slug', label='Parent region (slug)', @@ -53,7 +50,10 @@ def search(self, queryset, name, value): class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -62,14 +62,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=SITE_STATUS_CHOICES, null_value=None ) - region_id = django_filters.ModelMultipleChoiceFilter( - queryset=Region.objects.all(), + region_id = django_filters.NumberFilter( + method='filter_region', + field_name='pk', label='Region (ID)', ) - region = django_filters.ModelMultipleChoiceFilter( - name='region__slug', - queryset=Region.objects.all(), - to_field_name='slug', + region = django_filters.CharFilter( + method='filter_region', + field_name='slug', label='Region (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( @@ -77,14 +77,12 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Site @@ -110,6 +108,16 @@ def search(self, queryset, name, value): pass return queryset.filter(qs_filter) + def filter_region(self, queryset, name, value): + try: + region = Region.objects.get(**{name: value}) + except ObjectDoesNotExist: + return queryset.none() + return queryset.filter( + Q(region=region) | + Q(region__in=region.get_descendants()) + ) + class RackGroupFilter(django_filters.FilterSet): q = django_filters.CharFilter( @@ -121,7 +129,7 @@ class RackGroupFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -149,7 +157,10 @@ class Meta: class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -160,7 +171,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -170,7 +181,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=RackGroup.objects.all(), to_field_name='slug', label='Group', @@ -180,28 +191,34 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=RACK_STATUS_CHOICES, + null_value=None + ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=RackRole.objects.all(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=RackRole.objects.all(), to_field_name='slug', label='Role (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + asset_tag = NullableCharFieldFilter() + tag = TagFilter() class Meta: model = Rack - fields = ['name', 'serial', 'type', 'width', 'u_height', 'desc_units'] + fields = [ + 'name', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', + ] def search(self, queryset, name, value): if not value.strip(): @@ -210,12 +227,16 @@ def search(self, queryset, name, value): Q(name__icontains=value) | Q(facility_id__icontains=value) | Q(serial__icontains=value.strip()) | + Q(asset_tag__icontains=value.strip()) | Q(comments__icontains=value) ) class RackReservationFilter(django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -225,23 +246,23 @@ class RackReservationFilter(django_filters.FilterSet): label='Rack (ID)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='rack__site', + field_name='rack__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='rack__site__slug', + field_name='rack__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) group_id = django_filters.ModelMultipleChoiceFilter( - name='rack__group', + field_name='rack__group', queryset=RackGroup.objects.all(), label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='rack__group__slug', + field_name='rack__group__slug', queryset=RackGroup.objects.all(), to_field_name='slug', label='Group', @@ -251,7 +272,7 @@ class RackReservationFilter(django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -261,7 +282,7 @@ class RackReservationFilter(django_filters.FilterSet): label='User (ID)', ) user = django_filters.ModelMultipleChoiceFilter( - name='user', + field_name='user', queryset=User.objects.all(), to_field_name='username', label='User (name)', @@ -289,8 +310,11 @@ class Meta: fields = ['name', 'slug'] -class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class DeviceTypeFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -300,20 +324,41 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='manufacturer__slug', + field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', + console_ports = django_filters.BooleanFilter( + method='_console_ports', + label='Has console ports', + ) + console_server_ports = django_filters.BooleanFilter( + method='_console_server_ports', + label='Has console server ports', + ) + power_ports = django_filters.BooleanFilter( + method='_power_ports', + label='Has power ports', + ) + power_outlets = django_filters.BooleanFilter( + method='_power_outlets', + label='Has power outlets', + ) + interfaces = django_filters.BooleanFilter( + method='_interfaces', + label='Has interfaces', ) + pass_through_ports = django_filters.BooleanFilter( + method='_pass_through_ports', + label='Has pass-through ports', + ) + tag = TagFilter() class Meta: model = DeviceType fields = [ - 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', + 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', ] def search(self, queryset, name, value): @@ -326,11 +371,32 @@ def search(self, queryset, name, value): Q(comments__icontains=value) ) + def _console_ports(self, queryset, name, value): + return queryset.exclude(consoleport_templates__isnull=value) + + def _console_server_ports(self, queryset, name, value): + return queryset.exclude(consoleserverport_templates__isnull=value) + + def _power_ports(self, queryset, name, value): + return queryset.exclude(powerport_templates__isnull=value) + + def _power_outlets(self, queryset, name, value): + return queryset.exclude(poweroutlet_templates__isnull=value) + + def _interfaces(self, queryset, name, value): + return queryset.exclude(interface_templates__isnull=value) + + def _pass_through_ports(self, queryset, name, value): + return queryset.exclude( + frontport_templates__isnull=value, + rearport_templates__isnull=value + ) + class DeviceTypeComponentFilterSet(django_filters.FilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), - name='device_type_id', + field_name='device_type_id', label='Device type (ID)', ) @@ -370,6 +436,20 @@ class Meta: fields = ['name', 'form_factor', 'mgmt_only'] +class FrontPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = FrontPortTemplate + fields = ['name', 'type'] + + +class RearPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = RearPortTemplate + fields = ['name', 'type'] + + class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): class Meta: @@ -386,12 +466,12 @@ class Meta: class PlatformFilter(django_filters.FilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='manufacturer', + field_name='manufacturer', queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='manufacturer__slug', + field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', @@ -402,19 +482,22 @@ class Meta: fields = ['name', 'slug'] -class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class DeviceFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer', + field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer__slug', + field_name='device_type__manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', @@ -424,12 +507,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device type (ID)', ) role_id = django_filters.ModelMultipleChoiceFilter( - name='device_role_id', + field_name='device_role_id', queryset=DeviceRole.objects.all(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='device_role__slug', + field_name='device_role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -439,7 +522,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -449,30 +532,40 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Platform (ID)', ) platform = django_filters.ModelMultipleChoiceFilter( - name='platform__slug', + field_name='platform__slug', queryset=Platform.objects.all(), to_field_name='slug', label='Platform (slug)', ) name = NullableCharFieldFilter() asset_tag = NullableCharFieldFilter() + region_id = django_filters.NumberFilter( + method='filter_region', + field_name='pk', + label='Region (ID)', + ) + region = django_filters.CharFilter( + method='filter_region', + field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site name (slug)', ) rack_group_id = django_filters.ModelMultipleChoiceFilter( - name='rack__group', + field_name='rack__group', queryset=RackGroup.objects.all(), label='Rack group (ID)', ) rack_id = django_filters.ModelMultipleChoiceFilter( - name='rack', + field_name='rack', queryset=Rack.objects.all(), label='Rack (ID)', ) @@ -481,7 +574,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VM cluster (ID)', ) model = django_filters.ModelMultipleChoiceFilter( - name='device_type__slug', + field_name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', label='Device model (slug)', @@ -491,21 +584,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): null_value=None ) is_full_depth = django_filters.BooleanFilter( - name='device_type__is_full_depth', + field_name='device_type__is_full_depth', label='Is full depth', ) - is_console_server = django_filters.BooleanFilter( - name='device_type__is_console_server', - label='Is a console server', - ) - is_pdu = django_filters.BooleanFilter( - name='device_type__is_pdu', - label='Is a PDU', - ) - is_network_device = django_filters.BooleanFilter( - name='device_type__is_network_device', - label='Is a network device', - ) mac_address = django_filters.CharFilter( method='_mac_address', label='MAC address', @@ -515,13 +596,35 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Has a primary IP', ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( - name='virtual_chassis', + field_name='virtual_chassis', queryset=VirtualChassis.objects.all(), label='Virtual chassis (ID)', ) - tag = django_filters.CharFilter( - name='tags__slug', + console_ports = django_filters.BooleanFilter( + method='_console_ports', + label='Has console ports', + ) + console_server_ports = django_filters.BooleanFilter( + method='_console_server_ports', + label='Has console server ports', + ) + power_ports = django_filters.BooleanFilter( + method='_power_ports', + label='Has power ports', + ) + power_outlets = django_filters.BooleanFilter( + method='_power_outlets', + label='Has power outlets', ) + interfaces = django_filters.BooleanFilter( + method='_interfaces', + label='Has interfaces', + ) + pass_through_ports = django_filters.BooleanFilter( + method='_pass_through_ports', + label='Has pass-through ports', + ) + tag = TagFilter() class Meta: model = Device @@ -538,6 +641,16 @@ def search(self, queryset, name, value): Q(comments__icontains=value) ).distinct() + def filter_region(self, queryset, name, value): + try: + region = Region.objects.get(**{name: value}) + except ObjectDoesNotExist: + return queryset.none() + return queryset.filter( + Q(site__region=region) | + Q(site__region__in=region.get_descendants()) + ) + def _mac_address(self, queryset, name, value): value = value.strip() if not value: @@ -560,6 +673,27 @@ def _has_primary_ip(self, queryset, name, value): Q(primary_ip6__isnull=False) ) + def _console_ports(self, queryset, name, value): + return queryset.exclude(consoleports__isnull=value) + + def _console_server_ports(self, queryset, name, value): + return queryset.exclude(consoleserverports__isnull=value) + + def _power_ports(self, queryset, name, value): + return queryset.exclude(powerports__isnull=value) + + def _power_outlets(self, queryset, name, value): + return queryset.exclude(poweroutlets_isnull=value) + + def _interfaces(self, queryset, name, value): + return queryset.exclude(interfaces__isnull=value) + + def _pass_through_ports(self, queryset, name, value): + return queryset.exclude( + frontports__isnull=value, + rearports__isnull=value + ) + class DeviceComponentFilterSet(django_filters.FilterSet): device_id = django_filters.ModelChoiceFilter( @@ -571,60 +705,86 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class ConsolePortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = ConsolePort - fields = ['name'] + fields = ['name', 'connection_status'] class ConsoleServerPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = ConsoleServerPort - fields = ['name'] + fields = ['name', 'connection_status'] class PowerPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = PowerPort - fields = ['name'] + fields = ['name', 'connection_status'] class PowerOutletFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = PowerOutlet - fields = ['name'] + fields = ['name', 'connection_status'] class InterfaceFilter(django_filters.FilterSet): """ - Not using DeviceComponentFilterSet for Interfaces because we need to glean the ordering logic from the parent - Device's DeviceType. + Not using DeviceComponentFilterSet for Interfaces because we need to check for VirtualChassis membership. """ + q = django_filters.CharFilter( + method='search', + label='Search', + ) device = django_filters.CharFilter( method='filter_device', - name='name', + field_name='name', label='Device', ) device_id = django_filters.NumberFilter( method='filter_device', - name='pk', + field_name='pk', label='Device (ID)', ) + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) type = django_filters.CharFilter( method='filter_type', label='Interface type', ) lag_id = django_filters.ModelMultipleChoiceFilter( - name='lag', + field_name='lag', queryset=Interface.objects.all(), label='LAG interface (ID)', ) @@ -632,23 +792,57 @@ class InterfaceFilter(django_filters.FilterSet): method='_mac_address', label='MAC address', ) - tag = django_filters.CharFilter( - name='tags__slug', + tag = TagFilter() + vlan_id = django_filters.CharFilter( + method='filter_vlan_id', + label='Assigned VLAN' + ) + vlan = django_filters.CharFilter( + method='filter_vlan', + label='Assigned VID' + ) + form_factor = django_filters.MultipleChoiceFilter( + choices=IFACE_FF_CHOICES, + null_value=None ) class Meta: model = Interface - fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] + fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) + ).distinct() def filter_device(self, queryset, name, value): try: - device = Device.objects.select_related('device_type').get(**{name: value}) - vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] - ordering = device.device_type.interface_ordering - return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering) + device = Device.objects.get(**{name: value}) + vc_interface_ids = device.vc_interfaces.values_list('id', flat=True) + return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() + def filter_vlan_id(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + return queryset.filter( + Q(untagged_vlan_id=value) | + Q(tagged_vlans=value) + ) + + def filter_vlan(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + return queryset.filter( + Q(untagged_vlan_id__vid=value) | + Q(tagged_vlans__vid=value) + ) + def filter_type(self, queryset, name, value): value = value.strip().lower() return { @@ -669,6 +863,30 @@ def _mac_address(self, queryset, name, value): return queryset.none() +class FrontPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) + + class Meta: + model = FrontPort + fields = ['name', 'type'] + + +class RearPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) + + class Meta: + model = RearPort + fields = ['name', 'type'] + + class DeviceBayFilter(DeviceComponentFilterSet): class Meta: @@ -681,6 +899,15 @@ class InventoryItemFilter(DeviceComponentFilterSet): method='search', label='Search', ) + device_id = django_filters.ModelChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelChoiceFilter( + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), label='Parent inventory item (ID)', @@ -690,7 +917,7 @@ class InventoryItemFilter(DeviceComponentFilterSet): label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='manufacturer__slug', + field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', @@ -720,30 +947,28 @@ class VirtualChassisFilter(django_filters.FilterSet): label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='master__site', + field_name='master__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='master__site__slug', + field_name='master__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site name (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( - name='master__tenant', + field_name='master__tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='master__tenant__slug', + field_name='master__tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = VirtualChassis @@ -759,6 +984,28 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter) +class CableFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + type = django_filters.MultipleChoiceFilter( + choices=CABLE_TYPE_CHOICES + ) + color = django_filters.MultipleChoiceFilter( + choices=COLOR_CHOICES + ) + + class Meta: + model = Cable + fields = ['type', 'status', 'color', 'length', 'length_unit'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(label__icontains=value) + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', @@ -776,14 +1023,14 @@ class Meta: def filter_site(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter(cs_port__device__site__slug=value) + return queryset.filter(connected_endpoint__device__site__slug=value) def filter_device(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(device__name__icontains=value) | - Q(cs_port__device__name__icontains=value) + Q(connected_endpoint__device__name__icontains=value) ) @@ -804,14 +1051,14 @@ class Meta: def filter_site(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter(power_outlet__device__site__slug=value) + return queryset.filter(connected_endpoint__device__site__slug=value) def filter_device(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(device__name__icontains=value) | - Q(power_outlet__device__name__icontains=value) + Q(connected_endpoint__device__name__icontains=value) ) @@ -826,21 +1073,21 @@ class InterfaceConnectionFilter(django_filters.FilterSet): ) class Meta: - model = InterfaceConnection + model = Interface fields = ['connection_status'] def filter_site(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(interface_a__device__site__slug=value) | - Q(interface_b__device__site__slug=value) + Q(device__site__slug=value) | + Q(_connected_interface__device__site__slug=value) ) def filter_device(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(interface_a__device__name__icontains=value) | - Q(interface_b__device__name__icontains=value) + Q(device__name__icontains=value) | + Q(_connected_interface__device__name__icontains=value) ) diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 761f1ba69e8..215fbb70295 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -76,10 +76,7 @@ "model": "MX960", "slug": "mx960", "u_height": 16, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -92,10 +89,7 @@ "model": "EX9214", "slug": "ex9214", "u_height": 16, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -108,10 +102,7 @@ "model": "QFX5100-24Q", "slug": "qfx5100-24q", "u_height": 1, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -124,10 +115,7 @@ "model": "QFX5100-48S", "slug": "qfx5100-48s", "u_height": 1, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -140,10 +128,7 @@ "model": "CM4148", "slug": "cm4148", "u_height": 1, - "is_full_depth": true, - "is_console_server": true, - "is_pdu": false, - "is_network_device": false + "is_full_depth": true } }, { @@ -156,10 +141,7 @@ "model": "CWG-24VYM415C9", "slug": "cwg-24vym415c9", "u_height": 0, - "is_full_depth": false, - "is_console_server": false, - "is_pdu": true, - "is_network_device": false + "is_full_depth": false } }, { @@ -1903,8 +1885,7 @@ "pk": 1, "fields": { "name": "Juniper Junos", - "slug": "juniper-junos", - "rpc_client": "juniper-junos" + "slug": "juniper-junos" } }, { @@ -1912,8 +1893,7 @@ "pk": 2, "fields": { "name": "Opengear", - "slug": "opengear", - "rpc_client": "opengear" + "slug": "opengear" } }, { @@ -2153,7 +2133,7 @@ "fields": { "device": 1, "name": "Console (RE0)", - "cs_port": 27, + "connected_endpoint": 27, "connection_status": true } }, @@ -2163,7 +2143,7 @@ "fields": { "device": 1, "name": "Console (RE1)", - "cs_port": 38, + "connected_endpoint": 38, "connection_status": true } }, @@ -2173,7 +2153,7 @@ "fields": { "device": 2, "name": "Console (RE0)", - "cs_port": 5, + "connected_endpoint": 5, "connection_status": true } }, @@ -2183,7 +2163,7 @@ "fields": { "device": 2, "name": "Console (RE1)", - "cs_port": 16, + "connected_endpoint": 16, "connection_status": true } }, @@ -2193,7 +2173,7 @@ "fields": { "device": 3, "name": "Console", - "cs_port": 49, + "connected_endpoint": 49, "connection_status": true } }, @@ -2203,7 +2183,7 @@ "fields": { "device": 4, "name": "Console", - "cs_port": 48, + "connected_endpoint": 48, "connection_status": true } }, @@ -2213,7 +2193,7 @@ "fields": { "device": 5, "name": "Console", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2223,7 +2203,7 @@ "fields": { "device": 6, "name": "Console", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2233,7 +2213,7 @@ "fields": { "device": 7, "name": "Console (RE0)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2243,7 +2223,7 @@ "fields": { "device": 7, "name": "Console (RE1)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2253,7 +2233,7 @@ "fields": { "device": 8, "name": "Console (RE0)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2263,7 +2243,7 @@ "fields": { "device": 8, "name": "Console (RE1)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2273,7 +2253,7 @@ "fields": { "device": 9, "name": "Console", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2283,7 +2263,7 @@ "fields": { "device": 11, "name": "Serial", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2293,7 +2273,7 @@ "fields": { "device": 12, "name": "Serial", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2687,7 +2667,7 @@ "fields": { "device": 1, "name": "PEM0", - "power_outlet": 25, + "connected_endpoint": 25, "connection_status": true } }, @@ -2697,7 +2677,7 @@ "fields": { "device": 1, "name": "PEM1", - "power_outlet": 49, + "connected_endpoint": 49, "connection_status": true } }, @@ -2707,7 +2687,7 @@ "fields": { "device": 1, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2717,7 +2697,7 @@ "fields": { "device": 1, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2727,7 +2707,7 @@ "fields": { "device": 2, "name": "PEM0", - "power_outlet": 26, + "connected_endpoint": 26, "connection_status": true } }, @@ -2737,7 +2717,7 @@ "fields": { "device": 2, "name": "PEM1", - "power_outlet": 50, + "connected_endpoint": 50, "connection_status": true } }, @@ -2747,7 +2727,7 @@ "fields": { "device": 2, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2757,7 +2737,7 @@ "fields": { "device": 2, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2767,7 +2747,7 @@ "fields": { "device": 4, "name": "PSU0", - "power_outlet": 28, + "connected_endpoint": 28, "connection_status": true } }, @@ -2777,7 +2757,7 @@ "fields": { "device": 4, "name": "PSU1", - "power_outlet": 52, + "connected_endpoint": 52, "connection_status": true } }, @@ -2787,7 +2767,7 @@ "fields": { "device": 5, "name": "PSU0", - "power_outlet": 56, + "connected_endpoint": 56, "connection_status": true } }, @@ -2797,7 +2777,7 @@ "fields": { "device": 5, "name": "PSU1", - "power_outlet": 32, + "connected_endpoint": 32, "connection_status": true } }, @@ -2807,7 +2787,7 @@ "fields": { "device": 3, "name": "PSU0", - "power_outlet": 27, + "connected_endpoint": 27, "connection_status": true } }, @@ -2817,7 +2797,7 @@ "fields": { "device": 3, "name": "PSU1", - "power_outlet": 51, + "connected_endpoint": 51, "connection_status": true } }, @@ -2827,7 +2807,7 @@ "fields": { "device": 7, "name": "PEM0", - "power_outlet": 53, + "connected_endpoint": 53, "connection_status": true } }, @@ -2837,7 +2817,7 @@ "fields": { "device": 7, "name": "PEM1", - "power_outlet": 29, + "connected_endpoint": 29, "connection_status": true } }, @@ -2847,7 +2827,7 @@ "fields": { "device": 7, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2857,7 +2837,7 @@ "fields": { "device": 7, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2867,7 +2847,7 @@ "fields": { "device": 8, "name": "PEM0", - "power_outlet": 54, + "connected_endpoint": 54, "connection_status": true } }, @@ -2877,7 +2857,7 @@ "fields": { "device": 8, "name": "PEM1", - "power_outlet": 30, + "connected_endpoint": 30, "connection_status": true } }, @@ -2887,7 +2867,7 @@ "fields": { "device": 8, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2897,7 +2877,7 @@ "fields": { "device": 8, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2907,7 +2887,7 @@ "fields": { "device": 6, "name": "PSU0", - "power_outlet": 55, + "connected_endpoint": 55, "connection_status": true } }, @@ -2917,7 +2897,7 @@ "fields": { "device": 6, "name": "PSU1", - "power_outlet": 31, + "connected_endpoint": 31, "connection_status": true } }, @@ -2927,7 +2907,7 @@ "fields": { "device": 9, "name": "PSU", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -5748,158 +5728,5 @@ "mgmt_only": true, "description": "" } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 3, - "fields": { - "interface_a": 99, - "interface_b": 15, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 4, - "fields": { - "interface_a": 100, - "interface_b": 153, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 5, - "fields": { - "interface_a": 46, - "interface_b": 14, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 6, - "fields": { - "interface_a": 47, - "interface_b": 152, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 7, - "fields": { - "interface_a": 91, - "interface_b": 144, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 8, - "fields": { - "interface_a": 92, - "interface_b": 145, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 16, - "fields": { - "interface_a": 189, - "interface_b": 37, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 17, - "fields": { - "interface_a": 192, - "interface_b": 175, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 18, - "fields": { - "interface_a": 195, - "interface_b": 41, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 19, - "fields": { - "interface_a": 198, - "interface_b": 179, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 20, - "fields": { - "interface_a": 191, - "interface_b": 197, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 21, - "fields": { - "interface_a": 194, - "interface_b": 200, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 22, - "fields": { - "interface_a": 9, - "interface_b": 218, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 23, - "fields": { - "interface_a": 8, - "interface_b": 206, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 24, - "fields": { - "interface_a": 7, - "interface_b": 212, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 25, - "fields": { - "interface_a": 217, - "interface_b": 205, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 26, - "fields": { - "interface_a": 216, - "interface_b": 211, - "connection_status": true - } } ] diff --git a/netbox/dcim/fixtures/initial_data.json b/netbox/dcim/fixtures/initial_data.json index e765de2276b..83f79e3a3a1 100644 --- a/netbox/dcim/fixtures/initial_data.json +++ b/netbox/dcim/fixtures/initial_data.json @@ -149,8 +149,7 @@ "pk": 1, "fields": { "name": "Cisco IOS", - "slug": "cisco-ios", - "rpc_client": "cisco-ios" + "slug": "cisco-ios" } }, { @@ -158,8 +157,7 @@ "pk": 2, "fields": { "name": "Cisco NX-OS", - "slug": "cisco-nx-os", - "rpc_client": "" + "slug": "cisco-nx-os" } }, { @@ -167,8 +165,7 @@ "pk": 3, "fields": { "name": "Juniper Junos", - "slug": "juniper-junos", - "rpc_client": "juniper-junos" + "slug": "juniper-junos" } }, { @@ -176,8 +173,7 @@ "pk": 4, "fields": { "name": "Arista EOS", - "slug": "arista-eos", - "rpc_client": "" + "slug": "arista-eos" } }, { @@ -185,8 +181,7 @@ "pk": 5, "fields": { "name": "Linux", - "slug": "linux", - "rpc_client": "" + "slug": "linux" } }, { @@ -194,8 +189,7 @@ "pk": 6, "fields": { "name": "Opengear", - "slug": "opengear", - "rpc_client": "opengear" + "slug": "opengear" } } ] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e7fa15f7b71..5233895d069 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,11 +1,11 @@ -from __future__ import unicode_literals - import re from django import forms from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField -from django.db.models import Count, Q +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Q from mptt.forms import TreeNodeChoiceField from taggit.forms import TagField from timezone_field import TimeZoneFormField @@ -15,23 +15,19 @@ from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, - FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, -) -from virtualization.models import Cluster -from .constants import ( - CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG, - IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, - RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, - SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES, + APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, + BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, + ComponentForm, ConfirmationForm, ContentTypeSelect, CSVChoiceField, ExpandableNameField, + FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES ) +from virtualization.models import Cluster, ClusterGroup +from .constants import * from .models import ( - DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, - Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, - Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Region, Site, VirtualChassis + Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, + Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, + InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, + RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis ) DEVICE_BY_PK_RE = r'{\d+\}' @@ -61,6 +57,22 @@ class BulkRenameForm(forms.Form): """ find = forms.CharField() replace = forms.CharField() + use_regex = forms.BooleanField( + required=False, + initial=True, + label='Use regular expressions' + ) + + def clean(self): + + # Validate regular expression in "find" field + if self.cleaned_data['use_regex']: + try: + re.compile(self.cleaned_data['find']) + except re.error: + raise forms.ValidationError({ + 'find': "Invalid regular expression" + }) # @@ -72,7 +84,14 @@ class RegionForm(BootstrapMixin, forms.ModelForm): class Meta: model = Region - fields = ['parent', 'name', 'slug'] + fields = [ + 'parent', 'name', 'slug', + ] + widgets = { + 'parent': APISelect( + api_url="/api/dcim/regions/" + ) + } class RegionCSVForm(forms.ModelForm): @@ -97,7 +116,10 @@ class Meta: class RegionFilterForm(BootstrapMixin, forms.Form): model = Site - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) # @@ -105,10 +127,18 @@ class RegionFilterForm(BootstrapMixin, forms.Form): # class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): - region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) + region = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/regions/" + ) + ) slug = SlugField() comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Site @@ -118,8 +148,18 @@ class Meta: 'contact_email', 'comments', 'tags', ] widgets = { - 'physical_address': SmallTextarea(attrs={'rows': 3}), - 'shipping_address': SmallTextarea(attrs={'rows': 3}), + 'physical_address': SmallTextarea( + attrs={ + 'rows': 3, + } + ), + 'shipping_address': SmallTextarea( + attrs={ + 'rows': 3, + } + ), + 'status': StaticSelect2(), + 'time_zone': StaticSelect2(), } help_texts = { 'name': "Full name of the site", @@ -177,15 +217,22 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor status = forms.ChoiceField( choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, - initial='' + initial='', + widget=StaticSelect2() ) region = TreeNodeChoiceField( queryset=Region.objects.all(), - required=False + required=False, + widget=APISelect( + api_url="/api/dcim/regions/" + ) ) tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), - required=False + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants", + ) ) asn = forms.IntegerField( min_value=1, @@ -199,31 +246,45 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), - required=False + required=False, + widget=StaticSelect2() ) class Meta: - nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone'] + nullable_fields = [ + 'region', 'tenant', 'asn', 'description', 'time_zone', + ] class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site - q = forms.CharField(required=False, label='Search') - status = AnnotatedMultipleChoiceField( + q = forms.CharField( + required=False, + label='Search' + ) + status = forms.MultipleChoiceField( choices=SITE_STATUS_CHOICES, - annotate=Site.objects.all(), - annotate_field='status', - required=False + required=False, + widget=StaticSelect2Multiple() ) - region = FilterTreeNodeMultipleChoiceField( - queryset=Region.objects.annotate(filter_count=Count('sites')), + region = forms.ModelMultipleChoiceField( + queryset=Region.objects.all(), to_field_name='slug', required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + ) ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('sites')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) @@ -236,7 +297,14 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackGroup - fields = ['site', 'name', 'slug'] + fields = [ + 'site', 'name', 'slug', + ] + widgets = { + 'site': APISelect( + api_url="/api/dcim/sites/" + ) + } class RackGroupCSVForm(forms.ModelForm): @@ -259,7 +327,14 @@ class Meta: class RackGroupFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug') + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) # @@ -271,7 +346,9 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = [ + 'name', 'slug', 'color', + ] class RackRoleCSVForm(forms.ModelForm): @@ -298,17 +375,19 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): ), required=False, widget=APISelect( - api_url='/api/dcim/rack-groups/?site_id={{site}}', + api_url='/api/dcim/rack-groups/', ) ) comments = CommentField() - tags = TagField(required=False) + 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', 'tags', + 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', + 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -317,7 +396,19 @@ class Meta: 'u_height': "Height in rack units", } widgets = { - 'site': forms.Select(attrs={'filter-for': 'group'}), + 'site': APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'group': 'site_id', + } + ), + 'status': StaticSelect2(), + 'role': APISelect( + api_url="/api/dcim/rack-roles/" + ), + 'type': StaticSelect2(), + 'width': StaticSelect2(), + 'outer_unit': StaticSelect2(), } @@ -343,6 +434,11 @@ class RackCSVForm(forms.ModelForm): 'invalid_choice': 'Tenant not found.', } ) + status = CSVChoiceField( + choices=RACK_STATUS_CHOICES, + required=False, + help_text='Operational status' + ) role = forms.ModelChoiceField( queryset=RackRole.objects.all(), required=False, @@ -364,6 +460,11 @@ class RackCSVForm(forms.ModelForm): ), help_text='Rail-to-rail width (in inches)' ) + outer_unit = CSVChoiceField( + choices=RACK_DIMENSION_UNIT_CHOICES, + required=False, + help_text='Unit for outer dimensions' + ) class Meta: model = Rack @@ -375,7 +476,7 @@ class Meta: def clean(self): - super(RackCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') @@ -403,43 +504,145 @@ def clean(self): 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') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False) - serial = forms.CharField(max_length=50, required=False, label='Serial Number') - type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type') - width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width') - u_height = forms.IntegerField(required=False, label='Height (U)') - desc_units = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Descending units') - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=Rack.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites", + filter_for={ + 'group': 'site_id', + } + ) + ) + group = forms.ModelChoiceField( + queryset=RackGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/rack-groups", + ) + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants", + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(RACK_STATUS_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + role = forms.ModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/rack-roles", + ) + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + asset_tag = forms.CharField( + max_length=50, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(RACK_TYPE_CHOICES), + required=False, + widget=StaticSelect2() + ) + width = forms.ChoiceField( + choices=add_blank_choice(RACK_WIDTH_CHOICES), + required=False, + widget=StaticSelect2() + ) + u_height = forms.IntegerField( + required=False, + label='Height (U)' + ) + desc_units = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Descending units' + ) + outer_width = forms.IntegerField( + required=False, + min_value=1 + ) + outer_depth = forms.IntegerField( + required=False, + min_value=1 + ) + outer_unit = forms.ChoiceField( + choices=add_blank_choice(RACK_DIMENSION_UNIT_CHOICES), + required=False, + widget=StaticSelect2() + ) + comments = CommentField( + widget=SmallTextarea + ) class Meta: - nullable_fields = ['group', 'tenant', 'role', 'serial', 'comments'] + nullable_fields = [ + 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + ] class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('racks')), - to_field_name='slug' + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) ) group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')), + queryset=RackGroup.objects.select_related('site'), label='Rack group', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + null_option=True, + ) ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('racks')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) + ) + status = forms.MultipleChoiceField( + choices=RACK_STATUS_CHOICES, + required=False, + widget=StaticSelect2Multiple() ) role = FilterChoiceField( - queryset=RackRole.objects.annotate(filter_count=Count('racks')), + queryset=RackRole.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-roles/", + value_field="slug", + null_option=True, + ) ) @@ -448,16 +651,30 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): - units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10})) - user = forms.ModelChoiceField(queryset=User.objects.order_by('username')) + units = SimpleArrayField( + base_field=forms.IntegerField(), + widget=ArrayFieldSelectMultiple( + attrs={ + 'size': 10, + } + ) + ) + user = forms.ModelChoiceField( + queryset=User.objects.order_by( + 'username' + ), + widget=StaticSelect2() + ) class Meta: model = RackReservation - fields = ['units', 'user', 'tenant_group', 'tenant', 'description'] + fields = [ + 'units', 'user', 'tenant_group', 'tenant', 'description', + ] def __init__(self, *args, **kwargs): - super(RackReservationForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Populate rack unit choices self.fields['units'].widget.choices = self._get_unit_choices() @@ -473,28 +690,62 @@ def _get_unit_choices(self): class RackReservationFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('racks__reservations')), - to_field_name='slug' + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) ) group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')), + queryset=RackGroup.objects.select_related('site'), label='Rack group', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + null_option=True, + ) ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput) - user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=RackReservation.objects.all(), + widget=forms.MultipleHiddenInput() + ) + user = forms.ModelChoiceField( + queryset=User.objects.order_by( + 'username' + ), + required=False, + widget=StaticSelect2() + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenant", + ) + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: nullable_fields = [] @@ -509,10 +760,13 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): class Meta: model = Manufacturer - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class ManufacturerCSVForm(forms.ModelForm): + class Meta: model = Manufacturer fields = Manufacturer.csv_headers @@ -527,17 +781,24 @@ class Meta: # class DeviceTypeForm(BootstrapMixin, CustomFieldForm): - slug = SlugField(slug_source='model') - tags = TagField(required=False) + 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', 'tags', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', + 'tags', ] - labels = { - 'interface_ordering': 'Order interfaces by', + widgets = { + 'manufacturer': APISelect( + api_url="/api/dcim/manufacturers/" + ), + 'subdevice_role': StaticSelect2() } @@ -556,11 +817,6 @@ class DeviceTypeCSVForm(forms.ModelForm): required=False, help_text='Parent/child status' ) - interface_ordering = CSVChoiceField( - choices=IFACE_ORDERING_CHOICES, - required=False, - help_text='Interface ordering' - ) class Meta: model = DeviceType @@ -572,17 +828,25 @@ class Meta: 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) - is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is full depth') - interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False) - is_console_server = forms.NullBooleanField( - required=False, widget=BulkEditNullBooleanSelect, label='Is a console server' + pk = forms.ModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufactureres" + ) + ) + u_height = forms.IntegerField( + min_value=1, + required=False ) - is_pdu = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a PDU') - is_network_device = forms.NullBooleanField( - required=False, widget=BulkEditNullBooleanSelect, label='Is a network device' + is_full_depth = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Is full depth' ) class Meta: @@ -591,25 +855,66 @@ class Meta: class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): model = DeviceType - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) manufacturer = FilterChoiceField( - queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), - to_field_name='slug' + queryset=Manufacturer.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/manufacturers/", + value_field="slug", + ) ) - is_console_server = forms.BooleanField( - required=False, label='Is a console server', widget=forms.CheckboxInput(attrs={'value': 'True'})) - is_pdu = forms.BooleanField( - required=False, label='Is a PDU', widget=forms.CheckboxInput(attrs={'value': 'True'}) + subdevice_role = forms.NullBooleanField( + required=False, + label='Subdevice role', + widget=StaticSelect2( + choices=add_blank_choice(SUBDEVICE_ROLE_CHOICES) + ) ) - is_network_device = forms.BooleanField( - required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'}) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) - subdevice_role = forms.NullBooleanField( - required=False, label='Subdevice role', widget=forms.Select(choices=( - ('', '---------'), - (SUBDEVICE_ROLE_PARENT, 'Parent'), - (SUBDEVICE_ROLE_CHILD, 'Child'), - )) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) @@ -621,98 +926,240 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class ConsolePortTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class ConsoleServerPortTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class PowerPortTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutletTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class PowerOutletTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate - fields = ['device_type', 'name', 'form_factor', 'mgmt_only'] + fields = [ + 'device_type', 'name', 'form_factor', 'mgmt_only', + ] widgets = { 'device_type': forms.HiddenInput(), + 'form_factor': StaticSelect2(), } class InterfaceTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) - mgmt_only = forms.BooleanField(required=False, label='OOB Management') + name_pattern = ExpandableNameField( + label='Name' + ) + form_factor = forms.ChoiceField( + choices=IFACE_FF_CHOICES, + widget=StaticSelect2() + ) + mgmt_only = forms.BooleanField( + required=False, + label='Management only' + ) class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput) - form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) - mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') + pk = forms.ModelMultipleChoiceField( + queryset=InterfaceTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + form_factor = forms.ChoiceField( + choices=add_blank_choice(IFACE_FF_CHOICES), + required=False, + widget=StaticSelect2() + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Management only' + ) class Meta: nullable_fields = [] -class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): +class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: - model = DeviceBayTemplate - fields = ['device_type', 'name'] + model = FrontPortTemplate + fields = [ + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + ] widgets = { 'device_type': forms.HiddenInput(), + 'rear_port': StaticSelect2(), } -class DeviceBayTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') +class FrontPortTemplateCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES, + widget=StaticSelect2() + ) + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + widget=StaticSelect2(), + ) + def __init__(self, *args, **kwargs): -# + super().__init__(*args, **kwargs) + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in self.parent.frontport_templates.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPortTemplate.objects.filter(device_type=self.parent) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def clean(self): + + # Validate that the number of ports being created equals the number of selected (rear port, position) tuples + front_port_count = len(self.cleaned_data['name_pattern']) + rear_port_count = len(self.cleaned_data['rear_port_set']) + if front_port_count != rear_port_count: + raise forms.ValidationError({ + 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' + 'were selected. These counts must match.'.format(front_port_count, rear_port_count) + }) + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = RearPortTemplate + fields = [ + 'device_type', 'name', 'type', 'positions', + ] + widgets = { + 'device_type': forms.HiddenInput(), + 'type': StaticSelect2(), + } + + +class RearPortTemplateCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES, + widget=StaticSelect2(), + ) + positions = forms.IntegerField( + min_value=1, + max_value=64, + initial=1, + help_text='The number of front ports which may be mapped to each rear port' + ) + + +class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = DeviceBayTemplate + fields = [ + 'device_type', 'name', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class DeviceBayTemplateCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + + +# # Device roles # @@ -721,7 +1168,9 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color', 'vm_role'] + fields = [ + 'name', 'slug', 'color', 'vm_role', + ] class DeviceRoleCSVForm(forms.ModelForm): @@ -745,8 +1194,13 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] + fields = [ + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', + ] widgets = { + 'manufacturer': APISelect( + api_url="/api/dcim/manufacturers/" + ), 'napalm_args': SmallTextarea(), } @@ -778,8 +1232,11 @@ class Meta: class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), - widget=forms.Select( - attrs={'filter-for': 'rack'} + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack': 'site_id' + } ) ) rack = ChainedModelChoiceField( @@ -789,9 +1246,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): ), required=False, widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', + api_url='/api/dcim/racks/', display_field='display_name', - attrs={'filter-for': 'position'} ) ) position = forms.TypedChoiceField( @@ -799,14 +1255,17 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): empty_value=None, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( - api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', + api_url='/api/dcim/racks/{{rack}}/units/', disabled_indicator='device' ) ) manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), - widget=forms.Select( - attrs={'filter-for': 'device_type'} + widget=APISelect( + api_url="/api/dcim/manufacturers/", + filter_for={ + 'device_type': 'manufacturer_id' + } ) ) device_type = ChainedModelChoiceField( @@ -816,10 +1275,33 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): ), label='Device type', widget=APISelect( - api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', + api_url='/api/dcim/device-types/', display_field='model' ) ) + cluster_group = forms.ModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/virtualization/cluster-groups/", + filter_for={ + 'cluster': 'group_id' + }, + attrs={ + 'nullable': 'true' + } + ) + ) + cluster = ChainedModelChoiceField( + queryset=Cluster.objects.all(), + chains=( + ('group', 'cluster_group'), + ), + required=False, + widget=APISelect( + api_url='/api/virtualization/clusters/', + ) + ) comments = CommentField() tags = TagField(required=False) local_context_data = JSONField(required=False) @@ -828,29 +1310,45 @@ 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', 'tags', - 'local_context_data' + 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', + 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", 'serial': "Chassis serial number", - 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context" + 'local_context_data': "Local config context data overwrites all source contexts in the final rendered " + "config context", } widgets = { - 'face': forms.Select(attrs={'filter-for': 'position'}), + 'face': StaticSelect2( + filter_for={ + 'position': 'face' + } + ), + 'device_role': APISelect( + api_url='/api/dcim/device-roles/' + ), + 'status': StaticSelect2(), + 'platform': APISelect( + api_url="/api/dcim/platforms/" + ), + 'primary_ip4': StaticSelect2(), + 'primary_ip6': StaticSelect2(), } def __init__(self, *args, **kwargs): # Initialize helper selectors instance = kwargs.get('instance') + if 'initial' not in kwargs: + kwargs['initial'] = {} # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field if instance and hasattr(instance, 'device_type'): - initial = kwargs.get('initial', {}).copy() - initial['manufacturer'] = instance.device_type.manufacturer - kwargs['initial'] = initial + kwargs['initial']['manufacturer'] = instance.device_type.manufacturer + if instance and instance.cluster is not None: + kwargs['initial']['cluster_group'] = instance.cluster.group - super(DeviceForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.pk: @@ -879,7 +1377,7 @@ def __init__(self, *args, **kwargs): # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device # can be flipped from one face to another. - self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk) + self.fields['position'].widget.add_additional_query_param('exclude', self.instance.pk) # Limit platform by manufacturer self.fields['platform'].queryset = Platform.objects.filter( @@ -974,7 +1472,7 @@ class Meta: def clean(self): - super(BaseDeviceCSVForm, self).clean() + super().clean() manufacturer = self.cleaned_data.get('manufacturer') model_name = self.cleaned_data.get('model_name') @@ -1027,7 +1525,7 @@ class Meta(BaseDeviceCSVForm.Meta): def clean(self): - super(DeviceCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') rack_group = self.cleaned_data.get('rack_group') @@ -1076,7 +1574,7 @@ class Meta(BaseDeviceCSVForm.Meta): def clean(self): - super(ChildDeviceCSVForm, self).clean() + super().clean() parent = self.cleaned_data.get('parent') device_bay_name = self.cleaned_data.get('device_bay_name') @@ -1093,70 +1591,215 @@ def clean(self): 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') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(DEVICE_STATUS_CHOICES), required=False, initial='') - serial = forms.CharField(max_length=50, required=False, label='Serial Number') + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) + device_type = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label='Type', + widget=APISelect( + api_url="/api/dcim/device-types/" + ) + ) + device_role = forms.ModelChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label='Role', + widget=APISelect( + api_url="/api/dcim/device-roles/" + ) + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) + ) + platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/platforms/" + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(DEVICE_STATUS_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) class Meta: - nullable_fields = ['tenant', 'platform', 'serial'] + nullable_fields = [ + 'tenant', 'platform', 'serial', + ] class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('devices')), + queryset=Site.objects.all(), to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + filter_for={ + 'rack_group_id': 'site', + 'rack_id': 'site', + } + ) ) rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')), + queryset=RackGroup.objects.select_related( + 'site' + ), label='Rack group', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + filter_for={ + 'rack_id': 'rack_group_id', + } + ) ) rack_id = FilterChoiceField( - queryset=Rack.objects.annotate(filter_count=Count('devices')), + queryset=Rack.objects.all(), label='Rack', null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/racks/", + null_option=True, + ) ) role = FilterChoiceField( - queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), + queryset=DeviceRole.objects.all(), to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/device-roles/", + value_field="slug", + null_option=True, + ) ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('devices')), + queryset=Tenant.objects.all(), to_field_name='slug', null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) + ) + manufacturer_id = FilterChoiceField( + queryset=Manufacturer.objects.all(), + label='Manufacturer', + widget=APISelectMultiple( + api_url="/api/dcim/manufacturers/", + filter_for={ + 'device_type_id': 'manufacturer_id', + } + ) ) - manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer') device_type_id = FilterChoiceField( - queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate( - filter_count=Count('instances'), + queryset=DeviceType.objects.select_related( + 'manufacturer' ), label='Model', + widget=APISelectMultiple( + api_url="/api/dcim/device-types/", + display_field="model", + ) ) platform = FilterChoiceField( - queryset=Platform.objects.annotate(filter_count=Count('devices')), + queryset=Platform.objects.all(), to_field_name='slug', null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/platforms/", + value_field="slug", + null_option=True, + ) ) - status = AnnotatedMultipleChoiceField( + status = forms.MultipleChoiceField( choices=DEVICE_STATUS_CHOICES, - annotate=Device.objects.all(), - annotate_field='status', - required=False + required=False, + widget=StaticSelect2Multiple() + ) + mac_address = forms.CharField( + required=False, + label='MAC address' ) - mac_address = forms.CharField(required=False, label='MAC address') has_primary_ip = forms.NullBooleanField( required=False, label='Has a primary IP', - widget=forms.Select(choices=[ - ('', '---------'), - ('True', 'Yes'), - ('False', 'No'), - ]) + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) @@ -1165,16 +1808,38 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): # class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) - name_pattern = ExpandableNameField(label='Name') + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) + name_pattern = ExpandableNameField( + label='Name' + ) class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): - form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) - enabled = forms.BooleanField(required=False, initial=True) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mgmt_only = forms.BooleanField(required=False, label='OOB Management') - description = forms.CharField(max_length=100, required=False) + form_factor = forms.ChoiceField( + choices=IFACE_FF_CHOICES, + widget=StaticSelect2() + ) + enabled = forms.BooleanField( + required=False, + initial=True + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mgmt_only = forms.BooleanField( + required=False, + label='Management only' + ) + description = forms.CharField( + max_length=100, + required=False + ) # @@ -1182,268 +1847,69 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): # class ConsolePortForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = ConsolePort - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class ConsolePortCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class ConsoleConnectionCSVForm(forms.ModelForm): - console_server = FlexibleModelChoiceField( - queryset=Device.objects.filter(device_type__is_console_server=True), - to_field_name='name', - help_text='Console server name or ID', - error_messages={ - 'invalid_choice': 'Console server not found', - } - ) - cs_port = forms.CharField( - help_text='Console server port name' - ) - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found', - } - ) - console_port = forms.CharField( - help_text='Console port name' - ) - connection_status = CSVChoiceField( - choices=CONNECTION_STATUS_CHOICES, - help_text='Connection status' - ) - - class Meta: - model = ConsolePort - fields = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] - - def clean_console_port(self): - - console_port_name = self.cleaned_data.get('console_port') - if not self.cleaned_data.get('device') or not console_port_name: - return None - - try: - # Retrieve console port by name - consoleport = ConsolePort.objects.get( - device=self.cleaned_data['device'], name=console_port_name - ) - # Check if the console port is already connected - if consoleport.cs_port is not None: - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device'], console_port_name - )) - except ConsolePort.DoesNotExist: - raise forms.ValidationError("Invalid console port ({} {})".format( - self.cleaned_data['device'], console_port_name - )) - - self.instance = consoleport - return consoleport - - def clean_cs_port(self): - - cs_port_name = self.cleaned_data.get('cs_port') - if not self.cleaned_data.get('console_server') or not cs_port_name: - return None - - try: - # Retrieve console server port by name - cs_port = ConsoleServerPort.objects.get( - device=self.cleaned_data['console_server'], name=cs_port_name - ) - # Check if the console server port is already connected - if ConsolePort.objects.filter(cs_port=cs_port).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['console_server'], cs_port_name - )) - except ConsoleServerPort.DoesNotExist: - raise forms.ValidationError("Invalid console server port ({} {})".format( - self.cleaned_data['console_server'], cs_port_name - )) - - return cs_port - - -class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'console_server', 'nullable': 'true'} - ) - ) - console_server = ChainedModelChoiceField( - queryset=Device.objects.filter(device_type__is_console_server=True), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Console Server', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_console_server=True', - display_field='display_name', - attrs={'filter-for': 'cs_port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Console Server', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='console_server', - ) + name_pattern = ExpandableNameField( + label='Name' ) - cs_port = ChainedModelChoiceField( - queryset=ConsoleServerPort.objects.all(), - chains=( - ('device', 'console_server'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', - disabled_indicator='connected_console', - ) + tags = TagField( + required=False ) - class Meta: - model = ConsolePort - fields = ['site', 'rack', 'console_server', 'livesearch', 'cs_port', 'connection_status'] - labels = { - 'cs_port': 'Port', - 'connection_status': 'Status', - } - - def __init__(self, *args, **kwargs): - - super(ConsolePortConnectionForm, self).__init__(*args, **kwargs) - - if not self.instance.pk: - raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") - # # Console server ports # class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = ConsoleServerPort - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class ConsoleServerPortCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) - ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='device' - ) - ) - port = ChainedModelChoiceField( - queryset=ConsolePort.objects.all(), - chains=( - ('device', 'device'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/console-ports/?device_id={{device}}', - disabled_indicator='cs_port' - ) - ) - connection_status = forms.BooleanField( - required=False, - initial=CONNECTION_STATUS_CONNECTED, - label='Status', - widget=forms.Select( - choices=CONNECTION_STATUS_CHOICES - ) - ) - - class Meta: - fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] - labels = { - 'connection_status': 'Status', - } + name_pattern = ExpandableNameField( + label='Name' + ) + tags = TagField( + required=False + ) class ConsoleServerPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) # @@ -1451,268 +1917,69 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): # class PowerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = PowerPort - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class PowerPortCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class PowerConnectionCSVForm(forms.ModelForm): - pdu = FlexibleModelChoiceField( - queryset=Device.objects.filter(device_type__is_pdu=True), - to_field_name='name', - help_text='PDU name or ID', - error_messages={ - 'invalid_choice': 'PDU not found.', - } - ) - power_outlet = forms.CharField( - help_text='Power outlet name' - ) - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found', - } - ) - power_port = forms.CharField( - help_text='Power port name' - ) - connection_status = CSVChoiceField( - choices=CONNECTION_STATUS_CHOICES, - help_text='Connection status' - ) - - class Meta: - model = PowerPort - fields = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] - - def clean_power_port(self): - - power_port_name = self.cleaned_data.get('power_port') - if not self.cleaned_data.get('device') or not power_port_name: - return None - - try: - # Retrieve power port by name - powerport = PowerPort.objects.get( - device=self.cleaned_data['device'], name=power_port_name - ) - # Check if the power port is already connected - if powerport.power_outlet is not None: - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device'], power_port_name - )) - except PowerPort.DoesNotExist: - raise forms.ValidationError("Invalid power port ({} {})".format( - self.cleaned_data['device'], power_port_name - )) - - self.instance = powerport - return powerport - - def clean_power_outlet(self): - - power_outlet_name = self.cleaned_data.get('power_outlet') - if not self.cleaned_data.get('pdu') or not power_outlet_name: - return None - - try: - # Retrieve power outlet by name - power_outlet = PowerOutlet.objects.get( - device=self.cleaned_data['pdu'], name=power_outlet_name - ) - # Check if the power outlet is already connected - if PowerPort.objects.filter(power_outlet=power_outlet).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['pdu'], power_outlet_name - )) - except PowerOutlet.DoesNotExist: - raise forms.ValidationError("Invalid power outlet ({} {})".format( - self.cleaned_data['pdu'], power_outlet_name - )) - - return power_outlet - - -class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'pdu', 'nullable': 'true'} - ) - ) - pdu = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='PDU', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_pdu=True', - display_field='display_name', - attrs={'filter-for': 'power_outlet'} - ) - ) - livesearch = forms.CharField( - required=False, - label='PDU', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='pdu' - ) + name_pattern = ExpandableNameField( + label='Name' ) - power_outlet = ChainedModelChoiceField( - queryset=PowerOutlet.objects.all(), - chains=( - ('device', 'pdu'), - ), - label='Outlet', - widget=APISelect( - api_url='/api/dcim/power-outlets/?device_id={{pdu}}', - disabled_indicator='connected_port' - ) + tags = TagField( + required=False ) - class Meta: - model = PowerPort - fields = ['site', 'rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status'] - labels = { - 'power_outlet': 'Outlet', - 'connection_status': 'Status', - } - - def __init__(self, *args, **kwargs): - - super(PowerPortConnectionForm, self).__init__(*args, **kwargs) - - if not self.instance.pk: - raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") - # # Power outlets # class PowerOutletForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = PowerOutlet - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class PowerOutletCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) + name_pattern = ExpandableNameField( + label='Name' ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='device' - ) - ) - port = ChainedModelChoiceField( - queryset=PowerPort.objects.all(), - chains=( - ('device', 'device'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/power-ports/?device_id={{device}}', - disabled_indicator='power_outlet' - ) - ) - connection_status = forms.BooleanField( - required=False, - initial=CONNECTION_STATUS_CONNECTED, - label='Status', - widget=forms.Select( - choices=CONNECTION_STATUS_CHOICES - ) + tags = TagField( + required=False ) - class Meta: - fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] - labels = { - 'connection_status': 'Status', - } - class PowerOutletBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput + ) class PowerOutletBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput + ) # @@ -1720,7 +1987,9 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # class InterfaceForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Interface @@ -1730,6 +1999,9 @@ class Meta: ] widgets = { 'device': forms.HiddenInput(), + 'form_factor': StaticSelect2(), + 'lag': StaticSelect2(), + 'mode': StaticSelect2(), } labels = { 'mode': '802.1Q Mode', @@ -1739,23 +2011,23 @@ class Meta: } def __init__(self, *args, **kwargs): - super(InterfaceForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device (or VC master) if self.is_bound: device = Device.objects.get(pk=self.data['device']) - self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + self.fields['lag'].queryset = Interface.objects.filter( device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG ) else: device = self.instance.device - self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + self.fields['lag'].queryset = Interface.objects.filter( device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG ) def clean(self): - super(InterfaceForm, self).clean() + super().clean() # Validate VLAN assignments tagged_vlans = self.cleaned_data['tagged_vlans'] @@ -1775,7 +2047,11 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): vlans = forms.MultipleChoiceField( choices=[], label='VLANs', - widget=forms.SelectMultiple(attrs={'size': 20}) + widget=StaticSelect2Multiple( + attrs={ + 'size': 20, + } + ) ) tagged = forms.BooleanField( required=False, @@ -1788,7 +2064,7 @@ class Meta: def __init__(self, *args, **kwargs): - super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.mode == IFACE_MODE_ACCESS: self.initial['tagged'] = False @@ -1803,8 +2079,8 @@ def __init__(self, *args, **kwargs): # Add non-grouped global VLANs global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) - vlan_choices.append(( - 'Global', [(vlan.pk, vlan) for vlan in global_vlans]) + vlan_choices.append( + ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) ) # Add grouped global VLANs @@ -1833,7 +2109,7 @@ def __init__(self, *args, **kwargs): def clean(self): - super(InterfaceAssignVLANsForm, self).clean() + super().clean() # Only untagged VLANs permitted on an access interface if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1: @@ -1851,24 +2127,53 @@ def save(self, *args, **kwargs): else: self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] - return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs) + return super().save(*args, **kwargs) class InterfaceCreateForm(ComponentForm, forms.Form): - name_pattern = ExpandableNameField(label='Name') - form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) - enabled = forms.BooleanField(required=False) - lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mac_address = forms.CharField(required=False, label='MAC Address') + name_pattern = ExpandableNameField( + label='Name' + ) + form_factor = forms.ChoiceField( + choices=IFACE_FF_CHOICES, + widget=StaticSelect2(), + ) + enabled = forms.BooleanField( + required=False + ) + lag = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent LAG', + widget=StaticSelect2(), + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mac_address = forms.CharField( + required=False, + label='MAC Address' + ) mgmt_only = forms.BooleanField( required=False, - label='OOB Management', + label='Management only', help_text='This interface is used only for out-of-band management' ) - description = forms.CharField(max_length=100, required=False) - mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - tags = TagField(required=False) + description = forms.CharField( + max_length=100, + required=False + ) + mode = forms.ChoiceField( + choices=add_blank_choice(IFACE_MODE_CHOICES), + required=False, + widget=StaticSelect2(), + ) + tags = TagField( + required=False + ) def __init__(self, *args, **kwargs): @@ -1876,11 +2181,11 @@ def __init__(self, *args, **kwargs): kwargs['initial'] = kwargs.get('initial', {}).copy() kwargs['initial'].update({'enabled': True}) - super(InterfaceCreateForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device (or its VC master) if self.parent is not None: - self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + self.fields['lag'].queryset = Interface.objects.filter( device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG ) else: @@ -1888,205 +2193,533 @@ def __init__(self, *args, **kwargs): 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) - lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') - description = forms.CharField(max_length=100, required=False) - mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + form_factor = forms.ChoiceField( + choices=add_blank_choice(IFACE_FF_CHOICES), + required=False, + widget=StaticSelect2() + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + lag = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent LAG', + widget=StaticSelect2() + ) + mac_address = forms.CharField( + required=False, + label='MAC Address' + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Management only' + ) + description = forms.CharField( + max_length=100, + required=False + ) + mode = forms.ChoiceField( + choices=add_blank_choice(IFACE_MODE_CHOICES), + required=False, + widget=StaticSelect2() + ) class Meta: - nullable_fields = ['lag', 'mtu', 'description', 'mode'] + nullable_fields = [ + 'lag', 'mac_address', 'mtu', 'description', 'mode', + ] def __init__(self, *args, **kwargs): - super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit LAG choices to interfaces which belong to the parent device (or VC master) device = self.parent_obj if device is not None: - interface_ordering = device.device_type.interface_ordering - self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter( - device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], + form_factor=IFACE_FF_LAG ) else: self.fields['lag'].choices = [] class InterfaceBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) class InterfaceBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) # -# Interface connections +# Front pass-through ports # -class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - interface_a = forms.ChoiceField( +class FrontPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) + + class Meta: + model = FrontPort + fields = [ + 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + 'type': StaticSelect2(), + 'rear_port': StaticSelect2(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit RearPort choices to the local device + if hasattr(self.instance, 'device'): + self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter( + device=self.instance.device + ) + + +# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic +class FrontPortCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES, + widget=StaticSelect2(), + ) + rear_port_set = forms.MultipleChoiceField( choices=[], - widget=SelectWithDisabled, - label='Interface' + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + description = forms.CharField( + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in self.parent.frontports.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPort.objects.filter(device=self.parent) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def clean(self): + + # Validate that the number of ports being created equals the number of selected (rear port, position) tuples + front_port_count = len(self.cleaned_data['name_pattern']) + rear_port_count = len(self.cleaned_data['rear_port_set']) + if front_port_count != rear_port_count: + raise forms.ValidationError({ + 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' + 'were selected. These counts must match.'.format(front_port_count, rear_port_count) + }) + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class FrontPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +class FrontPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +# +# Rear pass-through ports +# + +class RearPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) + + class Meta: + model = RearPort + fields = [ + 'device', 'name', 'type', 'positions', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + 'type': StaticSelect2(), + } + + +class RearPortCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES, + widget=StaticSelect2(), + ) + positions = forms.IntegerField( + min_value=1, + max_value=64, + initial=1, + help_text='The number of front ports which may be mapped to each rear port' + ) + description = forms.CharField( + required=False + ) + + +class RearPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput ) - site_b = forms.ModelChoiceField( + + +class RearPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +# +# Cables +# + +class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, - widget=forms.Select( - attrs={'filter-for': 'rack_b'} + widget=APISelect( + api_url='/api/dcim/sites/', + filter_for={ + 'termination_b_rack': 'site_id', + 'termination_b_device': 'site_id', + } ) ) - rack_b = ChainedModelChoiceField( + termination_b_rack = ChainedModelChoiceField( queryset=Rack.objects.all(), chains=( - ('site', 'site_b'), + ('site', 'termination_b_site'), ), label='Rack', required=False, widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site_b}}', - attrs={'filter-for': 'device_b', 'nullable': 'true'} + api_url='/api/dcim/racks/', + filter_for={ + 'termination_b_device': 'rack_id', + }, + attrs={ + 'nullable': 'true', + } ) ) - device_b = ChainedModelChoiceField( + termination_b_device = ChainedModelChoiceField( queryset=Device.objects.all(), chains=( - ('site', 'site_b'), - ('rack', 'rack_b'), + ('site', 'termination_b_site'), + ('rack', 'termination_b_rack'), ), label='Device', required=False, widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site_b}}&rack_id={{rack_b}}', + api_url='/api/dcim/devices/', display_field='display_name', - attrs={'filter-for': 'interface_b'} + filter_for={ + 'termination_b_id': 'device_id', + } ) ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='device_b' - ) + termination_b_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + label='Type', + widget=ContentTypeSelect() ) - interface_b = ChainedModelChoiceField( - queryset=Interface.objects.connectable().select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ), - chains=( - ('device', 'device_b'), - ), - label='Interface', + termination_b_id = forms.IntegerField( + label='Name', widget=APISelect( - api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', - disabled_indicator='is_connected' + api_url='/api/dcim/{{termination_b_type}}s/', + disabled_indicator='cable', + conditional_query_params={ + 'termination_b_type__interface': 'type=physical', + } ) ) class Meta: - model = InterfaceConnection - fields = ['interface_a', 'site_b', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status'] + model = Cable + fields = [ + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_type', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Define available types for endpoint B based on the type of endpoint A + termination_a_type = self.instance.termination_a._meta.model_name + self.fields['termination_b_type'].queryset = ContentType.objects.filter( + model__in=COMPATIBLE_TERMINATION_TYPES.get(termination_a_type) + ).exclude( + model='circuittermination' + ) - def __init__(self, device_a, *args, **kwargs): - super(InterfaceConnectionForm, self).__init__(*args, **kwargs) +class CableForm(BootstrapMixin, forms.ModelForm): - # Initialize interface A choices - device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ) - self.fields['interface_a'].choices = [ - (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces + class Meta: + model = Cable + fields = [ + 'type', 'status', 'label', 'color', 'length', 'length_unit', ] - # Mark connected interfaces as disabled - if self.data.get('device_b'): - self.fields['interface_b'].choices = [] - for iface in self.fields['interface_b'].queryset: - self.fields['interface_b'].choices.append( - (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) - ) +class CableCSVForm(forms.ModelForm): -class InterfaceConnectionCSVForm(forms.ModelForm): - device_a = FlexibleModelChoiceField( + # Termination A + side_a_device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Name or ID of device A', - error_messages={'invalid_choice': 'Device A not found.'} + help_text='Side A device name or ID', + error_messages={ + 'invalid_choice': 'Side A device not found', + } ) - interface_a = forms.CharField( - help_text='Name of interface A' + side_a_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to={ + 'model__in': CABLE_TERMINATION_TYPES, + }, + to_field_name='model', + help_text='Side A type' ) - device_b = FlexibleModelChoiceField( + side_a_name = forms.CharField( + help_text='Side A component' + ) + + # Termination B + side_b_device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Name or ID of device B', - error_messages={'invalid_choice': 'Device B not found.'} + help_text='Side B device name or ID', + error_messages={ + 'invalid_choice': 'Side B device not found', + } ) - interface_b = forms.CharField( - help_text='Name of interface B' + side_b_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to={ + 'model__in': CABLE_TERMINATION_TYPES, + }, + to_field_name='model', + help_text='Side B type' ) - connection_status = CSVChoiceField( + side_b_name = forms.CharField( + help_text='Side B component' + ) + + # Cable attributes + status = CSVChoiceField( choices=CONNECTION_STATUS_CHOICES, + required=False, help_text='Connection status' ) + type = CSVChoiceField( + choices=CABLE_TYPE_CHOICES, + required=False, + help_text='Cable type' + ) + length_unit = CSVChoiceField( + choices=CABLE_LENGTH_UNIT_CHOICES, + required=False, + help_text='Length unit' + ) class Meta: - model = InterfaceConnection - fields = InterfaceConnection.csv_headers + model = Cable + fields = [ + 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', + 'status', 'label', 'color', 'length', 'length_unit', + ] + help_texts = { + 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + } - def clean_interface_a(self): + # TODO: Merge the clean() methods for either end + def clean_side_a_name(self): - interface_name = self.cleaned_data.get('interface_a') - if not interface_name: + device = self.cleaned_data.get('side_a_device') + content_type = self.cleaned_data.get('side_a_type') + name = self.cleaned_data.get('side_a_name') + if not device or not content_type or not name: return None + model = content_type.model_class() try: - # Retrieve interface by name - interface = Interface.objects.get( - device=self.cleaned_data['device_a'], name=interface_name + termination_object = model.objects.get( + device=device, + name=name + ) + if termination_object.cable is not None: + raise forms.ValidationError( + "Side A: {} {} is already connected".format(device, termination_object) + ) + except ObjectDoesNotExist: + raise forms.ValidationError( + "A side termination not found: {} {}".format(device, name) ) - # Check for an existing connection to this interface - if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device_a'], interface_name - )) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})".format( - self.cleaned_data['device_a'], interface_name - )) - return interface + self.instance.termination_a = termination_object + return termination_object - def clean_interface_b(self): + def clean_side_b_name(self): - interface_name = self.cleaned_data.get('interface_b') - if not interface_name: + device = self.cleaned_data.get('side_b_device') + content_type = self.cleaned_data.get('side_b_type') + name = self.cleaned_data.get('side_b_name') + if not device or not content_type or not name: return None + model = content_type.model_class() try: - # Retrieve interface by name - interface = Interface.objects.get( - device=self.cleaned_data['device_b'], name=interface_name + termination_object = model.objects.get( + device=device, + name=name ) - # Check for an existing connection to this interface - if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device_b'], interface_name - )) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})".format( - self.cleaned_data['device_b'], interface_name - )) + if termination_object.cable is not None: + raise forms.ValidationError( + "Side B: {} {} is already connected".format(device, termination_object) + ) + except ObjectDoesNotExist: + raise forms.ValidationError( + "B side termination not found: {} {}".format(device, name) + ) + + self.instance.termination_b = termination_object + return termination_object + + def clean_length_unit(self): + # Avoid trying to save as NULL + length_unit = self.cleaned_data.get('length_unit', None) + return length_unit if length_unit is not None else '' + + +class CableBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Cable.objects.all(), + widget=forms.MultipleHiddenInput + ) + type = forms.ChoiceField( + choices=add_blank_choice(CABLE_TYPE_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + status = forms.ChoiceField( + choices=add_blank_choice(CONNECTION_STATUS_CHOICES), + required=False, + initial='' + ) + label = forms.CharField( + max_length=100, + required=False, + widget=StaticSelect2() + ) + color = forms.CharField( + max_length=6, + required=False, + widget=ColorSelect() + ) + length = forms.IntegerField( + min_value=1, + required=False + ) + length_unit = forms.ChoiceField( + choices=add_blank_choice(CABLE_LENGTH_UNIT_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + + class Meta: + nullable_fields = [ + 'type', 'status', 'label', 'color', 'length', + ] + + def clean(self): + + # Validate length/unit + length = self.cleaned_data.get('length') + length_unit = self.cleaned_data.get('length_unit') + if length and not length_unit: + raise forms.ValidationError({ + 'length_unit': "Must specify a unit when setting length" + }) + - return interface +class CableFilterForm(BootstrapMixin, forms.Form): + model = Cable + q = forms.CharField( + required=False, + label='Search' + ) + type = forms.MultipleChoiceField( + choices=CABLE_TYPE_CHOICES, + required=False, + widget=StaticSelect2() + ) + color = forms.CharField( + max_length=6, + required=False, + widget=ColorSelect() + ) # @@ -2094,31 +2727,40 @@ def clean_interface_b(self): # class DeviceBayForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = DeviceBay - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class DeviceBayCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) + name_pattern = ExpandableNameField( + label='Name' + ) + tags = TagField( + required=False + ) class PopulateDeviceBayForm(BootstrapMixin, forms.Form): installed_device = forms.ModelChoiceField( queryset=Device.objects.all(), label='Child Device', - help_text="Child devices must first be created and assigned to the site/rack of the parent device." + help_text="Child devices must first be created and assigned to the site/rack of the parent device.", + widget=StaticSelect2(), ) def __init__(self, device_bay, *args, **kwargs): - super(PopulateDeviceBayForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['installed_device'].queryset = Device.objects.filter( site=device_bay.device.site, @@ -2130,7 +2772,10 @@ def __init__(self, device_bay, *args, **kwargs): class DeviceBayBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=DeviceBay.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBay.objects.all(), + widget=forms.MultipleHiddenInput() + ) # @@ -2138,18 +2783,39 @@ class DeviceBayBulkRenameForm(BulkRenameForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') - device = forms.CharField(required=False, label='Device name') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='slug' + ) + device = forms.CharField( + required=False, + label='Device name' + ) class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') - device = forms.CharField(required=False, label='Device name') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='slug' + ) + device = forms.CharField( + required=False, + label='Device name' + ) class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') - device = forms.CharField(required=False, label='Device name') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='slug' + ) + device = forms.CharField( + required=False, + label='Device name' + ) # @@ -2157,11 +2823,20 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = InventoryItem - fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags'] + fields = [ + 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', + ] + widgets = { + 'manufacturer': APISelect( + api_url="/api/dcim/manufacturers/" + ) + } class InventoryItemCSVForm(forms.ModelForm): @@ -2189,23 +2864,51 @@ class Meta: class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput) - manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) - part_id = forms.CharField(max_length=50, required=False, label='Part ID') - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItem.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + part_id = forms.CharField( + max_length=50, + required=False, + label='Part ID' + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['manufacturer', 'part_id', 'description'] + nullable_fields = [ + 'manufacturer', 'part_id', 'description', + ] class InventoryItemFilterForm(BootstrapMixin, forms.Form): model = InventoryItem - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) + device = forms.CharField( + required=False, + label='Device name' + ) manufacturer = FilterChoiceField( - queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')), + queryset=Manufacturer.objects.all(), to_field_name='slug', null_label='-- None --' ) + discovered = forms.NullBooleanField( + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) # @@ -2213,24 +2916,31 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): # class DeviceSelectionForm(forms.Form): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) class VirtualChassisForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = VirtualChassis - fields = ['master', 'domain', 'tags'] + fields = [ + 'master', 'domain', 'tags', + ] widgets = { - 'master': SelectWithPK, + 'master': SelectWithPK(), } class BaseVCMemberFormSet(forms.BaseModelFormSet): def clean(self): - super(BaseVCMemberFormSet, self).clean() + super().clean() # Check for duplicate VC position values vc_position_list = [] @@ -2247,14 +2957,16 @@ class DeviceVCMembershipForm(forms.ModelForm): class Meta: model = Device - fields = ['vc_position', 'vc_priority'] + fields = [ + 'vc_position', 'vc_priority', + ] labels = { 'vc_position': 'Position', 'vc_priority': 'Priority', } def __init__(self, validate_vc_position=False, *args, **kwargs): - super(DeviceVCMembershipForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Require VC position (only required when the Device is a VirtualChassis member) self.fields['vc_position'].required = True @@ -2284,8 +2996,12 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): queryset=Site.objects.all(), label='Site', required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack': 'site_id', + 'device': 'site_id', + } ) ) rack = ChainedModelChoiceField( @@ -2296,19 +3012,26 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): label='Rack', required=False, widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} + api_url='/api/dcim/racks/', + filter_for={ + 'device': 'rack_id' + }, + attrs={ + 'nullable': 'true', + } ) ) device = ChainedModelChoiceField( - queryset=Device.objects.filter(virtual_chassis__isnull=True), + queryset=Device.objects.filter( + virtual_chassis__isnull=True + ), chains=( ('site', 'site'), ('rack', 'rack'), ), label='Device', widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + api_url='/api/dcim/devices/', display_field='display_name', disabled_indicator='virtual_chassis' ) @@ -2317,13 +3040,18 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): def clean_device(self): device = self.cleaned_data['device'] if device.virtual_chassis is not None: - raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device)) + raise forms.ValidationError( + "Device {} is already assigned to a virtual chassis.".format(device) + ) return device class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualChassis - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py new file mode 100644 index 00000000000..52df1afe836 --- /dev/null +++ b/netbox/dcim/managers.py @@ -0,0 +1,85 @@ +from django.db.models import Manager, QuerySet +from django.db.models.expressions import RawSQL + +from .constants import NONCONNECTABLE_IFACE_TYPES + +# Regular expressions for parsing Interface names +TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')" +SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)" +SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)" +POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)" +SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)" +ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)" +CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" +VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)" + + +class DeviceComponentManager(Manager): + + def get_queryset(self): + + queryset = super().get_queryset() + table_name = self.model._meta.db_table + sql = r"CONCAT(REGEXP_REPLACE({}.name, '\d+$', ''), LPAD(SUBSTRING({}.name FROM '\d+$'), 8, '0'))" + + # Pad any trailing digits to effect natural sorting + return queryset.extra( + select={ + 'name_padded': sql.format(table_name, table_name), + } + ).order_by('name_padded') + + +class InterfaceQuerySet(QuerySet): + + def connectable(self): + """ + Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or + wireless). + """ + return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) + + +class InterfaceManager(Manager): + + def get_queryset(self): + """ + Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field + is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel, + and virtual circuit: + + {type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc} + + Components absent from the interface name are coalesced to zero or null. For example, an interface named + GigabitEthernet1/2/3 would be parsed as follows: + + type = 'GigabitEthernet' + slot = 1 + subslot = 2 + position = 3 + subposition = None + id = None + channel = 0 + vc = 0 + + The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not + match any of the prescribed fields. + """ + + sql_col = '{}.name'.format(self.model._meta.db_table) + ordering = [ + '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', + ] + + fields = { + '_type': RawSQL(TYPE_RE.format(sql_col), []), + '_id': RawSQL(ID_RE.format(sql_col), []), + '_slot': RawSQL(SLOT_RE.format(sql_col), []), + '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), + '_position': RawSQL(POSITION_RE.format(sql_col), []), + '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), + '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), + '_vc': RawSQL(VC_RE.format(sql_col), []), + } + + return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering) diff --git a/netbox/dcim/migrations/0001_initial.py b/netbox/dcim/migrations/0001_initial.py index da18bdbfe17..db5f3faf2b8 100644 --- a/netbox/dcim/migrations/0001_initial.py +++ b/netbox/dcim/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0002_auto_20160622_1821.py b/netbox/dcim/migrations/0002_auto_20160622_1821.py index e269d43f4b0..1e3aa4d2a6f 100644 --- a/netbox/dcim/migrations/0002_auto_20160622_1821.py +++ b/netbox/dcim/migrations/0002_auto_20160622_1821.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion 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 index a641c3a2f2e..c3412cf10e9 100644 --- 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 @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/dcim/migrations/0003_auto_20160628_1721.py b/netbox/dcim/migrations/0003_auto_20160628_1721.py index deebc8518b2..312d0456c32 100644 --- a/netbox/dcim/migrations/0003_auto_20160628_1721.py +++ b/netbox/dcim/migrations/0003_auto_20160628_1721.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-28 17:21 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0004_auto_20160701_2049.py b/netbox/dcim/migrations/0004_auto_20160701_2049.py index e051daded96..0806acb8262 100644 --- a/netbox/dcim/migrations/0004_auto_20160701_2049.py +++ b/netbox/dcim/migrations/0004_auto_20160701_2049.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-01 20:49 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0005_auto_20160706_1722.py b/netbox/dcim/migrations/0005_auto_20160706_1722.py index 83a5cf7cbff..a286d6ff35b 100644 --- a/netbox/dcim/migrations/0005_auto_20160706_1722.py +++ b/netbox/dcim/migrations/0005_auto_20160706_1722.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-06 17:22 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations, models diff --git a/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py b/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py index 670a174f97d..6038cc02718 100644 --- a/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py +++ b/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-11 18:40 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0007_device_copy_primary_ip.py b/netbox/dcim/migrations/0007_device_copy_primary_ip.py index 055eac7d07f..0d53337f7e7 100644 --- a/netbox/dcim/migrations/0007_device_copy_primary_ip.py +++ b/netbox/dcim/migrations/0007_device_copy_primary_ip.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-11 18:40 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/dcim/migrations/0008_device_remove_primary_ip.py b/netbox/dcim/migrations/0008_device_remove_primary_ip.py index 91465e878ec..f43452de2ff 100644 --- a/netbox/dcim/migrations/0008_device_remove_primary_ip.py +++ b/netbox/dcim/migrations/0008_device_remove_primary_ip.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-11 19:01 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/dcim/migrations/0009_site_32bit_asn_support.py b/netbox/dcim/migrations/0009_site_32bit_asn_support.py index c93340ceacd..0a72a6cf4ee 100644 --- a/netbox/dcim/migrations/0009_site_32bit_asn_support.py +++ b/netbox/dcim/migrations/0009_site_32bit_asn_support.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-13 19:24 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations diff --git a/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py b/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py index bf2f31c575d..769a6f67874 100644 --- a/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py +++ b/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-14 21:38 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0011_devicetype_part_number.py b/netbox/dcim/migrations/0011_devicetype_part_number.py index 62c97abc63d..eb77ea50046 100644 --- a/netbox/dcim/migrations/0011_devicetype_part_number.py +++ b/netbox/dcim/migrations/0011_devicetype_part_number.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 15:05 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py index 8dcf8f81a5f..b01f507c301 100644 --- a/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py +++ b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 21:59 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0013_add_interface_form_factors.py b/netbox/dcim/migrations/0013_add_interface_form_factors.py index 310eb1eb687..478cb59ff8d 100644 --- a/netbox/dcim/migrations/0013_add_interface_form_factors.py +++ b/netbox/dcim/migrations/0013_add_interface_form_factors.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-06 20:24 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0014_rack_add_type_width.py b/netbox/dcim/migrations/0014_rack_add_type_width.py index c14768c0f53..a3922c8cdbc 100644 --- a/netbox/dcim/migrations/0014_rack_add_type_width.py +++ b/netbox/dcim/migrations/0014_rack_add_type_width.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-08 21:11 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0015_rack_add_u_height_validator.py b/netbox/dcim/migrations/0015_rack_add_u_height_validator.py index 8e555204be5..167dd8f5424 100644 --- a/netbox/dcim/migrations/0015_rack_add_u_height_validator.py +++ b/netbox/dcim/migrations/0015_rack_add_u_height_validator.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-09 21:18 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0016_module_add_manufacturer.py b/netbox/dcim/migrations/0016_module_add_manufacturer.py index 6a2264a8392..7204e66260c 100644 --- a/netbox/dcim/migrations/0016_module_add_manufacturer.py +++ b/netbox/dcim/migrations/0016_module_add_manufacturer.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-10 13:45 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0017_rack_add_role.py b/netbox/dcim/migrations/0017_rack_add_role.py index eb3560b37b8..48500f4b415 100644 --- a/netbox/dcim/migrations/0017_rack_add_role.py +++ b/netbox/dcim/migrations/0017_rack_add_role.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-10 14:58 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0018_device_add_asset_tag.py b/netbox/dcim/migrations/0018_device_add_asset_tag.py index 706b42ac4d1..84d1cef3586 100644 --- a/netbox/dcim/migrations/0018_device_add_asset_tag.py +++ b/netbox/dcim/migrations/0018_device_add_asset_tag.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-08-11 15:42 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0019_new_iface_form_factors.py b/netbox/dcim/migrations/0019_new_iface_form_factors.py index b2358ba5e35..b2d8be53302 100644 --- a/netbox/dcim/migrations/0019_new_iface_form_factors.py +++ b/netbox/dcim/migrations/0019_new_iface_form_factors.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-09-13 15:20 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0020_rack_desc_units.py b/netbox/dcim/migrations/0020_rack_desc_units.py index d5a74706d3a..7408c82ef14 100644 --- a/netbox/dcim/migrations/0020_rack_desc_units.py +++ b/netbox/dcim/migrations/0020_rack_desc_units.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-10-28 15:01 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0021_add_ff_flexstack.py b/netbox/dcim/migrations/0021_add_ff_flexstack.py index 9e85ac90933..bb4c4f4be22 100644 --- a/netbox/dcim/migrations/0021_add_ff_flexstack.py +++ b/netbox/dcim/migrations/0021_add_ff_flexstack.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-10-31 18:47 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0022_color_names_to_rgb.py b/netbox/dcim/migrations/0022_color_names_to_rgb.py index 97e5de9ca59..87fba47870c 100644 --- a/netbox/dcim/migrations/0022_color_names_to_rgb.py +++ b/netbox/dcim/migrations/0022_color_names_to_rgb.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-06 16:35 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0023_devicetype_comments.py b/netbox/dcim/migrations/0023_devicetype_comments.py index 677a8af9de8..5f70e80760b 100644 --- a/netbox/dcim/migrations/0023_devicetype_comments.py +++ b/netbox/dcim/migrations/0023_devicetype_comments.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-16 16:08 -from __future__ import unicode_literals - from django.db import migrations, models 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 index a613552ad55..4d4cfb60392 100644 --- 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 @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/dcim/migrations/0024_site_add_contact_fields.py b/netbox/dcim/migrations/0024_site_add_contact_fields.py index 34e17561f7b..218107ba2a8 100644 --- a/netbox/dcim/migrations/0024_site_add_contact_fields.py +++ b/netbox/dcim/migrations/0024_site_add_contact_fields.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2016-12-29 16:23 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py b/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py index d1263cb89ad..56db88f1cd8 100644 --- a/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py +++ b/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-06 16:56 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0026_add_rack_reservations.py b/netbox/dcim/migrations/0026_add_rack_reservations.py index b9d4f821421..ba66feea5d1 100644 --- a/netbox/dcim/migrations/0026_add_rack_reservations.py +++ b/netbox/dcim/migrations/0026_add_rack_reservations.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 18:43 -from __future__ import unicode_literals - from django.conf import settings import django.contrib.postgres.fields from django.db import migrations, models diff --git a/netbox/dcim/migrations/0027_device_add_site.py b/netbox/dcim/migrations/0027_device_add_site.py index 12d85f53e7c..bef85a82255 100644 --- a/netbox/dcim/migrations/0027_device_add_site.py +++ b/netbox/dcim/migrations/0027_device_add_site.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 21:21 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0028_device_copy_rack_to_site.py b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py index 6e7c5211482..a67f34b3890 100644 --- a/netbox/dcim/migrations/0028_device_copy_rack_to_site.py +++ b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 21:23 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/dcim/migrations/0029_allow_rackless_devices.py b/netbox/dcim/migrations/0029_allow_rackless_devices.py index 83906fc76f5..dd9f30bf2fb 100644 --- a/netbox/dcim/migrations/0029_allow_rackless_devices.py +++ b/netbox/dcim/migrations/0029_allow_rackless_devices.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 21:25 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0030_interface_add_lag.py b/netbox/dcim/migrations/0030_interface_add_lag.py index 6f5be67a4db..1ffd74f0452 100644 --- a/netbox/dcim/migrations/0030_interface_add_lag.py +++ b/netbox/dcim/migrations/0030_interface_add_lag.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-27 19:55 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0031_regions.py b/netbox/dcim/migrations/0031_regions.py index d4fd4db5e54..73bb77b3f5d 100644 --- a/netbox/dcim/migrations/0031_regions.py +++ b/netbox/dcim/migrations/0031_regions.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-28 17:14 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import mptt.fields diff --git a/netbox/dcim/migrations/0032_device_increase_name_length.py b/netbox/dcim/migrations/0032_device_increase_name_length.py index e11e75bab3a..ff0cd137f80 100644 --- a/netbox/dcim/migrations/0032_device_increase_name_length.py +++ b/netbox/dcim/migrations/0032_device_increase_name_length.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-03-02 15:09 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0033_rackreservation_rack_editable.py b/netbox/dcim/migrations/0033_rackreservation_rack_editable.py index b327bad1263..567de43454f 100644 --- a/netbox/dcim/migrations/0033_rackreservation_rack_editable.py +++ b/netbox/dcim/migrations/0033_rackreservation_rack_editable.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-03-17 18:39 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py b/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py index ff430c0676b..db2f0577a08 100644 --- a/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py +++ b/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-03-21 14:55 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0035_device_expand_status_choices.py b/netbox/dcim/migrations/0035_device_expand_status_choices.py index 16ea807c933..a6f7aa5639b 100644 --- a/netbox/dcim/migrations/0035_device_expand_status_choices.py +++ b/netbox/dcim/migrations/0035_device_expand_status_choices.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.7 on 2017-05-08 15:57 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py b/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py index ac0f89f41ef..ceed2263851 100644 --- a/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py +++ b/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-05-09 16:00 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0037_unicode_literals.py b/netbox/dcim/migrations/0037_unicode_literals.py index cba05beccdb..57ad7a744ef 100644 --- a/netbox/dcim/migrations/0037_unicode_literals.py +++ b/netbox/dcim/migrations/0037_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - import dcim.fields import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0038_wireless_interfaces.py b/netbox/dcim/migrations/0038_wireless_interfaces.py index 61cdb3996cf..78ea103e5e4 100644 --- a/netbox/dcim/migrations/0038_wireless_interfaces.py +++ b/netbox/dcim/migrations/0038_wireless_interfaces.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-06-16 21:38 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py index 4cc7e96161a..c5f8dc83d88 100644 --- a/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py +++ b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-06-23 17:05 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py index c7d49fe2ca9..aaca23ea826 100644 --- a/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py +++ b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-06-23 20:44 -from __future__ import unicode_literals - from django.db import migrations, models import utilities.fields diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py index 73ca8f3ee7d..50c2fbd99cf 100644 --- a/netbox/dcim/migrations/0041_napalm_integration.py +++ b/netbox/dcim/migrations/0041_napalm_integration.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-07-14 17:26 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py b/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py index 77bea6bc6f0..e667d9451f7 100644 --- a/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py +++ b/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-29 21:00 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0043_device_component_name_lengths.py b/netbox/dcim/migrations/0043_device_component_name_lengths.py index a52f5085923..9f0ba224321 100644 --- a/netbox/dcim/migrations/0043_device_component_name_lengths.py +++ b/netbox/dcim/migrations/0043_device_component_name_lengths.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-29 21:26 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0044_virtualization.py b/netbox/dcim/migrations/0044_virtualization.py index b1e250bc2af..362979aefa7 100644 --- a/netbox/dcim/migrations/0044_virtualization.py +++ b/netbox/dcim/migrations/0044_virtualization.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-31 14:15 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py index 42fc5f3177f..78b4e3a4144 100644 --- a/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py +++ b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/dcim/migrations/0045_devicerole_vm_role.py b/netbox/dcim/migrations/0045_devicerole_vm_role.py index 775effaf268..306a5a80620 100644 --- a/netbox/dcim/migrations/0045_devicerole_vm_role.py +++ b/netbox/dcim/migrations/0045_devicerole_vm_role.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-29 16:09 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py index d040065242b..f6e93a43d5c 100644 --- a/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py +++ b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 17:43 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0047_more_100ge_form_factors.py b/netbox/dcim/migrations/0047_more_100ge_form_factors.py index dafa81a5426..a76ef6c8d14 100644 --- a/netbox/dcim/migrations/0047_more_100ge_form_factors.py +++ b/netbox/dcim/migrations/0047_more_100ge_form_factors.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 18:43 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0048_rack_serial.py b/netbox/dcim/migrations/0048_rack_serial.py index 8e060c86503..3fb7c0d2e2c 100644 --- a/netbox/dcim/migrations/0048_rack_serial.py +++ b/netbox/dcim/migrations/0048_rack_serial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 18:50 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0049_rackreservation_change_user.py b/netbox/dcim/migrations/0049_rackreservation_change_user.py index ae9f95246ec..2d03db58781 100644 --- a/netbox/dcim/migrations/0049_rackreservation_change_user.py +++ b/netbox/dcim/migrations/0049_rackreservation_change_user.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-10-31 17:32 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0050_interface_vlan_tagging.py b/netbox/dcim/migrations/0050_interface_vlan_tagging.py index 1906b9179f5..8acaf4eec0c 100644 --- a/netbox/dcim/migrations/0050_interface_vlan_tagging.py +++ b/netbox/dcim/migrations/0050_interface_vlan_tagging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-11-10 20:10 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0051_rackreservation_tenant.py b/netbox/dcim/migrations/0051_rackreservation_tenant.py index 90a551eb81a..ca0513ab070 100644 --- a/netbox/dcim/migrations/0051_rackreservation_tenant.py +++ b/netbox/dcim/migrations/0051_rackreservation_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-11-15 18:56 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py index 334f60ca7d7..56777744ca3 100644 --- a/netbox/dcim/migrations/0052_virtual_chassis.py +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-11-27 17:27 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0053_platform_manufacturer.py b/netbox/dcim/migrations/0053_platform_manufacturer.py index 62797716ef0..bb5f24c91c6 100644 --- a/netbox/dcim/migrations/0053_platform_manufacturer.py +++ b/netbox/dcim/migrations/0053_platform_manufacturer.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-12-19 20:56 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0054_site_status_timezone_description.py b/netbox/dcim/migrations/0054_site_status_timezone_description.py index 723f61fc80c..554bf554cd8 100644 --- a/netbox/dcim/migrations/0054_site_status_timezone_description.py +++ b/netbox/dcim/migrations/0054_site_status_timezone_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2018-01-25 18:21 -from __future__ import unicode_literals - from django.db import migrations, models import timezone_field.fields diff --git a/netbox/dcim/migrations/0055_virtualchassis_ordering.py b/netbox/dcim/migrations/0055_virtualchassis_ordering.py index 51cda0ff69a..ab23f403f7a 100644 --- a/netbox/dcim/migrations/0055_virtualchassis_ordering.py +++ b/netbox/dcim/migrations/0055_virtualchassis_ordering.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-21 14:41 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0057_tags.py b/netbox/dcim/migrations/0057_tags.py index b0cccfdf32e..44ed0949769 100644 --- a/netbox/dcim/migrations/0057_tags.py +++ b/netbox/dcim/migrations/0057_tags.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py index e4974be2f27..9676e973d8d 100644 --- a/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py +++ b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/dcim/migrations/0059_site_latitude_longitude.py b/netbox/dcim/migrations/0059_site_latitude_longitude.py index 15e666f3535..7c019ed5dd9 100644 --- a/netbox/dcim/migrations/0059_site_latitude_longitude.py +++ b/netbox/dcim/migrations/0059_site_latitude_longitude.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/dcim/migrations/0060_change_logging.py b/netbox/dcim/migrations/0060_change_logging.py index 8a40f4e4efc..12a9f95ada5 100644 --- a/netbox/dcim/migrations/0060_change_logging.py +++ b/netbox/dcim/migrations/0060_change_logging.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/dcim/migrations/0062_interface_mtu.py b/netbox/dcim/migrations/0062_interface_mtu.py index 592f11bb79d..d1ae9252096 100644 --- a/netbox/dcim/migrations/0062_interface_mtu.py +++ b/netbox/dcim/migrations/0062_interface_mtu.py @@ -19,11 +19,11 @@ class Migration(migrations.Migration): 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'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200), + 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']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['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'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['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'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200), + 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']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['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'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200), ), ] diff --git a/netbox/dcim/migrations/0064_remove_platform_rpc_client.py b/netbox/dcim/migrations/0064_remove_platform_rpc_client.py new file mode 100644 index 00000000000..4926c4b322b --- /dev/null +++ b/netbox/dcim/migrations/0064_remove_platform_rpc_client.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.8 on 2018-08-22 16:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0063_device_local_context_data'), + ] + + operations = [ + migrations.RemoveField( + model_name='platform', + name='rpc_client', + ), + ] diff --git a/netbox/dcim/migrations/0065_front_rear_ports.py b/netbox/dcim/migrations/0065_front_rear_ports.py new file mode 100644 index 00000000000..a7fe9eab97e --- /dev/null +++ b/netbox/dcim/migrations/0065_front_rear_ports.py @@ -0,0 +1,131 @@ +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('dcim', '0064_remove_platform_rpc_client'), + ] + + operations = [ + migrations.CreateModel( + name='FrontPort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('rear_port_position', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.Device')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='FrontPortTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('rear_port_position', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.CreateModel( + name='RearPort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('positions', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.Device')), + ('tags', taggit.managers.TaggableManager(through='taggit.TaggedItem', to='taggit.Tag')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='RearPortTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('positions', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.AddField( + model_name='rearporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearport_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='frontporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontport_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='frontporttemplate', + name='rear_port', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontport_templates', to='dcim.RearPortTemplate'), + ), + migrations.AddField( + model_name='frontport', + name='rear_port', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.RearPort'), + ), + migrations.AddField( + model_name='frontport', + name='tags', + field=taggit.managers.TaggableManager(through='taggit.TaggedItem', to='taggit.Tag'), + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together={('device_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='rearport', + unique_together={('device', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together={('rear_port', 'rear_port_position'), ('device_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontport', + unique_together={('device', 'name'), ('rear_port', 'rear_port_position')}, + ), + + # Rename reverse relationships of component templates to DeviceType + migrations.AlterField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleport_templates', to='dcim.DeviceType'), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverport_templates', to='dcim.DeviceType'), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlet_templates', to='dcim.DeviceType'), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerport_templates', to='dcim.DeviceType'), + ), + ] diff --git a/netbox/dcim/migrations/0066_cables.py b/netbox/dcim/migrations/0066_cables.py new file mode 100644 index 00000000000..253167392a1 --- /dev/null +++ b/netbox/dcim/migrations/0066_cables.py @@ -0,0 +1,322 @@ +import sys + +from django.db import migrations, models +import django.db.models.deletion + +import utilities.fields + + +def console_connections_to_cables(apps, schema_editor): + """ + Copy all existing console connections as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + ConsolePort = apps.get_model('dcim', 'ConsolePort') + ConsoleServerPort = apps.get_model('dcim', 'ConsoleServerPort') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + consoleport_type = ContentType.objects.get_for_model(ConsolePort) + consoleserverport_type = ContentType.objects.get_for_model(ConsoleServerPort) + + # Create a new Cable instance from each console connection + if 'test' not in sys.argv: + print("\n Adding console connections... ", end='', flush=True) + for consoleport in ConsolePort.objects.filter(connected_endpoint__isnull=False): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=consoleport_type, + termination_a_id=consoleport.id, + termination_b_type=consoleserverport_type, + termination_b_id=consoleport.connected_endpoint_id, + status=consoleport.connection_status + ) + + # Cache the Cable on its two termination points + ConsolePort.objects.filter(pk=consoleport.id).update( + cable=cable + ) + ConsoleServerPort.objects.filter(pk=consoleport.connected_endpoint_id).update( + connection_status=consoleport.connection_status, + cable=cable + ) + + cable_count = Cable.objects.filter(termination_a_type=consoleport_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + # Normalize connection_status for all non-connected ConsolePorts + ConsolePort.objects.filter(connected_endpoint__isnull=True).update(connection_status=None) + + +def power_connections_to_cables(apps, schema_editor): + """ + Copy all existing power connections as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + PowerPort = apps.get_model('dcim', 'PowerPort') + PowerOutlet = apps.get_model('dcim', 'PowerOutlet') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + powerport_type = ContentType.objects.get_for_model(PowerPort) + poweroutlet_type = ContentType.objects.get_for_model(PowerOutlet) + + # Create a new Cable instance from each power connection + if 'test' not in sys.argv: + print(" Adding power connections... ", end='', flush=True) + for powerport in PowerPort.objects.filter(connected_endpoint__isnull=False): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=powerport_type, + termination_a_id=powerport.id, + termination_b_type=poweroutlet_type, + termination_b_id=powerport.connected_endpoint_id, + status=powerport.connection_status + ) + + # Cache the Cable on its two termination points + PowerPort.objects.filter(pk=powerport.id).update( + cable=cable + ) + PowerOutlet.objects.filter(pk=powerport.connected_endpoint_id).update( + connection_status=powerport.connection_status, + cable=cable + ) + + cable_count = Cable.objects.filter(termination_a_type=powerport_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + # Normalize connection_status for all non-connected PowerPorts + PowerPort.objects.filter(connected_endpoint__isnull=True).update(connection_status=None) + + +def interface_connections_to_cables(apps, schema_editor): + """ + Copy all InterfaceConnections as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Interface = apps.get_model('dcim', 'Interface') + InterfaceConnection = apps.get_model('dcim', 'InterfaceConnection') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + interface_type = ContentType.objects.get_for_model(Interface) + + # Create a new Cable instance from each InterfaceConnection + if 'test' not in sys.argv: + print(" Adding interface connections... ", end='', flush=True) + for conn in InterfaceConnection.objects.all(): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=interface_type, + termination_a_id=conn.interface_a_id, + termination_b_type=interface_type, + termination_b_id=conn.interface_b_id, + status=conn.connection_status + ) + + # Cache the connected Cable on each Interface + Interface.objects.filter(pk=conn.interface_a_id).update( + _connected_interface=conn.interface_b, + connection_status=conn.connection_status, + cable=cable + ) + Interface.objects.filter(pk=conn.interface_b_id).update( + _connected_interface=conn.interface_a, + connection_status=conn.connection_status, + cable=cable + ) + + cable_count = Cable.objects.filter(termination_a_type=interface_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + +def delete_interfaceconnection_content_type(apps, schema_editor): + """ + Delete the ContentType for the InterfaceConnection model. (This is not done automatically upon model deletion.) + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + InterfaceConnection = apps.get_model('dcim', 'InterfaceConnection') + ContentType.objects.get_for_model(InterfaceConnection).delete() + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('circuits', '0006_terminations'), + ('dcim', '0065_front_rear_ports'), + ] + + operations = [ + + # Create the Cable model + migrations.CreateModel( + name='Cable', + options={'ordering': ['pk']}, + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('termination_a_id', models.PositiveIntegerField()), + ('termination_b_id', models.PositiveIntegerField()), + ('type', models.PositiveSmallIntegerField(blank=True, null=True)), + ('status', models.BooleanField(default=True)), + ('label', models.CharField(blank=True, max_length=100)), + ('color', utilities.fields.ColorField(blank=True, max_length=6)), + ('length', models.PositiveSmallIntegerField(blank=True, null=True)), + ('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)), + ('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)), + ('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ], + ), + migrations.AlterUniqueTogether( + name='cable', + unique_together={('termination_b_type', 'termination_b_id'), ('termination_a_type', 'termination_a_id')}, + ), + + # Alter console port models + migrations.RenameField( + model_name='consoleport', + old_name='cs_port', + new_name='connected_endpoint' + ), + migrations.AlterField( + model_name='consoleport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.Device'), + ), + migrations.AlterField( + model_name='consoleport', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_endpoint', to='dcim.ConsoleServerPort'), + ), + migrations.AlterField( + model_name='consoleport', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='consoleport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AlterField( + model_name='consoleserverport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.Device'), + ), + migrations.AddField( + model_name='consoleserverport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AddField( + model_name='consoleserverport', + name='connection_status', + field=models.NullBooleanField(), + ), + + # Alter power port models + migrations.RenameField( + model_name='powerport', + old_name='power_outlet', + new_name='connected_endpoint' + ), + migrations.AlterField( + model_name='powerport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.Device'), + ), + migrations.AlterField( + model_name='powerport', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_endpoint', to='dcim.PowerOutlet'), + ), + migrations.AlterField( + model_name='powerport', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='powerport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AlterField( + model_name='poweroutlet', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.Device'), + ), + migrations.AddField( + model_name='poweroutlet', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AddField( + model_name='poweroutlet', + name='connection_status', + field=models.NullBooleanField(), + ), + + # Alter the Interface model + migrations.AddField( + model_name='interface', + name='_connected_circuittermination', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.CircuitTermination'), + ), + migrations.AddField( + model_name='interface', + name='_connected_interface', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'), + ), + migrations.AddField( + model_name='interface', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='interface', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + + # Alter front/rear port models + migrations.AddField( + model_name='frontport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AddField( + model_name='rearport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + + # Copy console/power/interface connections as Cables + migrations.RunPython(console_connections_to_cables), + migrations.RunPython(power_connections_to_cables), + migrations.RunPython(interface_connections_to_cables), + + # Delete the InterfaceConnection model and its ContentType + migrations.RunPython(delete_interfaceconnection_content_type), + migrations.RemoveField( + model_name='interfaceconnection', + name='interface_a', + ), + migrations.RemoveField( + model_name='interfaceconnection', + name='interface_b', + ), + migrations.DeleteModel( + name='InterfaceConnection', + ), + ] diff --git a/netbox/dcim/migrations/0067_device_type_remove_qualifiers.py b/netbox/dcim/migrations/0067_device_type_remove_qualifiers.py new file mode 100644 index 00000000000..e78ccd8b6cd --- /dev/null +++ b/netbox/dcim/migrations/0067_device_type_remove_qualifiers.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.8 on 2018-10-26 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0066_cables'), + ] + + operations = [ + migrations.RemoveField( + model_name='devicetype', + name='is_console_server', + ), + migrations.RemoveField( + model_name='devicetype', + name='is_network_device', + ), + migrations.RemoveField( + model_name='devicetype', + name='is_pdu', + ), + migrations.RemoveField( + model_name='devicetype', + name='interface_ordering', + ), + ] diff --git a/netbox/dcim/migrations/0068_rack_new_fields.py b/netbox/dcim/migrations/0068_rack_new_fields.py new file mode 100644 index 00000000000..5ad4703e4ef --- /dev/null +++ b/netbox/dcim/migrations/0068_rack_new_fields.py @@ -0,0 +1,38 @@ +from django.db import migrations, models + +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0067_device_type_remove_qualifiers'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='status', + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.AddField( + model_name='rack', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, unique=True), + ), + migrations.AddField( + model_name='rack', + name='outer_depth', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='outer_unit', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='outer_width', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d8c39283877..89e786a1b6a 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,64 +1,172 @@ -from __future__ import unicode_literals - from collections import OrderedDict 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.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q 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.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange -from extras.rpc import RPC_CLIENTS from utilities.fields import ColorField, NullableCharField -from utilities.managers import NaturalOrderByManager +from utilities.managers import NaturalOrderingManager from utilities.models import ChangeLoggedModel -from utilities.utils import serialize_object +from utilities.utils import serialize_object, to_meters from .constants import * +from .exceptions import LoopDetected from .fields import ASNField, MACAddressField -from .querysets import InterfaceQuerySet +from .managers import DeviceComponentManager, InterfaceManager -class ComponentModel(models.Model): +class ComponentTemplateModel(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 DeviceType. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.device_type, + action=action, + object_data=serialize_object(self) + ).save() + + +class ComponentModel(models.Model): + + class Meta: + abstract = True def log_change(self, user, request_id, action): """ Log an ObjectChange including the parent Device/VM. """ + try: + parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None) + except ObjectDoesNotExist: + # The parent device/VM has already been deleted + parent = None ObjectChange( user=user, request_id=request_id, changed_object=self, - related_object=self.get_component_parent(), + related_object=parent, action=action, object_data=serialize_object(self) ).save() +class CableTermination(models.Model): + cable = models.ForeignKey( + to='dcim.Cable', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + + # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted. + _cabled_as_a = GenericRelation( + to='dcim.Cable', + content_type_field='termination_a_type', + object_id_field='termination_a_id' + ) + _cabled_as_b = GenericRelation( + to='dcim.Cable', + content_type_field='termination_b_type', + object_id_field='termination_b_id' + ) + + class Meta: + abstract = True + + def trace(self, position=1, follow_circuits=False, cable_history=None): + """ + Return a list representing a complete cable path, with each individual segment represented as a three-tuple: + [ + (termination A, cable, termination B), + (termination C, cable, termination D), + (termination E, cable, termination F) + ] + """ + def get_peer_port(termination, position=1, follow_circuits=False): + from circuits.models import CircuitTermination + + # Map a front port to its corresponding rear port + if isinstance(termination, FrontPort): + return termination.rear_port, termination.rear_port_position + + # Map a rear port/position to its corresponding front port + elif isinstance(termination, RearPort): + if position not in range(1, termination.positions + 1): + raise Exception("Invalid position for {} ({} positions): {})".format( + termination, termination.positions, position + )) + try: + peer_port = FrontPort.objects.get( + rear_port=termination, + rear_port_position=position, + ) + return peer_port, 1 + except ObjectDoesNotExist: + return None, None + + # Follow a circuit to its other termination + elif isinstance(termination, CircuitTermination) and follow_circuits: + peer_termination = termination.get_peer_termination() + if peer_termination is None: + return None, None + return peer_termination, position + + # Termination is not a pass-through port + else: + return None, None + + if not self.cable: + return [(self, None, None)] + + # Record cable history to detect loops + if cable_history is None: + cable_history = [] + elif self.cable in cable_history: + raise LoopDetected() + cable_history.append(self.cable) + + far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a + path = [(self, self.cable, far_end)] + + peer_port, position = get_peer_port(far_end, position, follow_circuits) + if peer_port is None: + return path + + try: + next_segment = peer_port.trace(position, follow_circuits, cable_history) + except LoopDetected: + return path + + if next_segment is None: + return path + [(peer_port, None, None)] + + return path + next_segment + + # # Regions # -@python_2_unicode_compatible class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. @@ -97,16 +205,18 @@ def to_csv(self): self.parent.name if self.parent else None, ) + @property + def site_count(self): + return Site.objects.filter( + Q(region=self) | + Q(region__in=self.get_descendants()) + ).count() + # # Sites # -class SiteManager(NaturalOrderByManager): - natural_order_field = 'name' - - -@python_2_unicode_compatible class Site(ChangeLoggedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -197,7 +307,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = SiteManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ @@ -256,6 +366,7 @@ def count_devices(self): @property def count_circuits(self): + from circuits.models import Circuit return Circuit.objects.filter(terminations__site=self).count() @property @@ -268,7 +379,6 @@ def count_vms(self): # Racks # -@python_2_unicode_compatible 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 @@ -308,7 +418,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class RackRole(ChangeLoggedModel): """ Racks can be organized by functional role, similar to Devices. @@ -341,11 +450,6 @@ def to_csv(self): ) -class RackManager(NaturalOrderByManager): - natural_order_field = 'name' - - -@python_2_unicode_compatible 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. @@ -379,6 +483,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) + status = models.PositiveSmallIntegerField( + choices=RACK_STATUS_CHOICES, + default=RACK_STATUS_ACTIVE + ) role = models.ForeignKey( to='dcim.RackRole', on_delete=models.PROTECT, @@ -391,6 +499,14 @@ class Rack(ChangeLoggedModel, CustomFieldModel): blank=True, verbose_name='Serial number' ) + asset_tag = NullableCharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', + help_text='A unique tag used to identify this rack' + ) type = models.PositiveSmallIntegerField( choices=RACK_TYPE_CHOICES, blank=True, @@ -413,6 +529,19 @@ class Rack(ChangeLoggedModel, CustomFieldModel): verbose_name='Descending units', help_text='Units are numbered top-to-bottom' ) + outer_width = models.PositiveSmallIntegerField( + blank=True, + null=True + ) + outer_depth = models.PositiveSmallIntegerField( + blank=True, + null=True + ) + outer_unit = models.PositiveSmallIntegerField( + choices=RACK_DIMENSION_UNIT_CHOICES, + blank=True, + null=True + ) comments = models.TextField( blank=True ) @@ -425,12 +554,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = RackManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', - 'desc_units', 'comments', + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', + 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] class Meta: @@ -441,13 +570,19 @@ class Meta: ] def __str__(self): - return self.display_name or super(Rack, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('dcim:rack', args=[self.pk]) def clean(self): + # Validate outer dimensions and unit + if (self.outer_width is not None or self.outer_depth is not None) and self.outer_unit is None: + raise ValidationError("Must specify a unit when setting an outer width/depth") + elif self.outer_width is None and self.outer_depth is None: + self.outer_unit = None + if self.pk: # Validate that Rack is tall enough to house the installed Devices top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first() @@ -473,7 +608,7 @@ def save(self, *args, **kwargs): if self.pk: _site_id = Rack.objects.get(pk=self.pk).site_id - super(Rack, self).save(*args, **kwargs) + super().save(*args, **kwargs) # Update racked devices if the assigned Site has been changed. if _site_id is not None and self.site_id != _site_id: @@ -486,12 +621,17 @@ def to_csv(self): self.name, self.facility_id, self.tenant.name if self.tenant else None, + self.get_status_display(), self.role.name if self.role else None, self.get_type_display() if self.type else None, self.serial, + self.asset_tag, self.width, self.u_height, self.desc_units, + self.outer_width, + self.outer_depth, + self.outer_unit, self.comments, ) @@ -510,6 +650,9 @@ def display_name(self): return self.name return "" + def get_status_class(self): + return STATUS_CLASSES[self.status] + def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False): """ Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'} @@ -603,7 +746,6 @@ def get_utilization(self): return int(float(self.u_height - u_available) / self.u_height * 100) -@python_2_unicode_compatible class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. @@ -677,7 +819,6 @@ def unit_list(self): # Device Types # -@python_2_unicode_compatible class Manufacturer(ChangeLoggedModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -708,7 +849,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class DeviceType(ChangeLoggedModel, CustomFieldModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -747,25 +887,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): 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', @@ -785,8 +906,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() csv_headers = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', ] class Meta: @@ -800,7 +920,7 @@ def __str__(self): return self.model def __init__(self, *args, **kwargs): - super(DeviceType, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Save a copy of u_height for validation in clean() self._original_u_height = self.u_height @@ -816,11 +936,7 @@ def to_csv(self): self.part_number, self.u_height, self.is_full_depth, - self.is_console_server, - self.is_pdu, - self.is_network_device, self.get_subdevice_role_display() if self.subdevice_role else None, - self.get_interface_ordering_display(), self.comments, ) @@ -840,24 +956,6 @@ def clean(self): "{}U".format(d, d.rack, self.u_height) }) - if not self.is_console_server and self.cs_port_templates.count(): - raise ValidationError({ - 'is_console_server': "Must delete all console server port templates associated with this device before " - "declassifying it as a console server." - }) - - if not self.is_pdu and self.power_outlet_templates.count(): - raise ValidationError({ - 'is_pdu': "Must delete all power outlet templates associated with this device before declassifying it " - "as a PDU." - }) - - if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count(): - raise ValidationError({ - 'is_network_device': "Must delete all non-management-only interface templates associated with this " - "device before declassifying it as a network device." - }) - if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count(): raise ValidationError({ 'subdevice_role': "Must delete all device bay templates associated with this device before " @@ -882,20 +980,21 @@ def is_child_device(self): return bool(self.subdevice_role is False) -@python_2_unicode_compatible -class ConsolePortTemplate(ComponentModel): +class ConsolePortTemplate(ComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='console_port_templates' + related_name='consoleport_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -903,24 +1002,22 @@ class Meta: def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class ConsoleServerPortTemplate(ComponentModel): +class ConsoleServerPortTemplate(ComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='cs_port_templates' + related_name='consoleserverport_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -928,24 +1025,22 @@ class Meta: def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class PowerPortTemplate(ComponentModel): +class PowerPortTemplate(ComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='power_port_templates' + related_name='powerport_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -953,24 +1048,22 @@ class Meta: def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class PowerOutletTemplate(ComponentModel): +class PowerOutletTemplate(ComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='power_outlet_templates' + related_name='poweroutlet_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -978,12 +1071,8 @@ class Meta: def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class InterfaceTemplate(ComponentModel): +class InterfaceTemplate(ComponentTemplateModel): """ A template for a physical data interface on a new Device. """ @@ -1004,7 +1093,7 @@ class InterfaceTemplate(ComponentModel): verbose_name='Management only' ) - objects = InterfaceQuerySet.as_manager() + objects = InterfaceManager() class Meta: ordering = ['device_type', 'name'] @@ -1013,12 +1102,92 @@ class Meta: def __str__(self): return self.name - def get_component_parent(self): - return self.device_type +class FrontPortTemplate(ComponentTemplateModel): + """ + Template for a pass-through port on the front of a new Device. + """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='frontport_templates' + ) + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + rear_port = models.ForeignKey( + to='dcim.RearPortTemplate', + on_delete=models.CASCADE, + related_name='frontport_templates' + ) + rear_port_position = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) -@python_2_unicode_compatible -class DeviceBayTemplate(ComponentModel): + objects = DeviceComponentManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = [ + ['device_type', 'name'], + ['rear_port', 'rear_port_position'], + ] + + def __str__(self): + return self.name + + def clean(self): + + # Validate rear port assignment + if self.rear_port.device_type != self.device_type: + raise ValidationError( + "Rear port ({}) must belong to the same device type".format(self.rear_port) + ) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError( + "Invalid rear port position ({}); rear port {} has only {} positions".format( + self.rear_port_position, self.rear_port.name, self.rear_port.positions + ) + ) + + +class RearPortTemplate(ComponentTemplateModel): + """ + Template for a pass-through port on the rear of a new Device. + """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='rearport_templates' + ) + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + positions = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + + objects = DeviceComponentManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __str__(self): + return self.name + + +class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. """ @@ -1031,6 +1200,8 @@ class DeviceBayTemplate(ComponentModel): max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -1038,15 +1209,11 @@ class Meta: def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - # # Devices # -@python_2_unicode_compatible class DeviceRole(ChangeLoggedModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -1084,7 +1251,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class Platform(ChangeLoggedModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". @@ -1118,12 +1284,6 @@ class Platform(ChangeLoggedModel): 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' - ) csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] @@ -1146,11 +1306,6 @@ def to_csv(self): ) -class DeviceManager(NaturalOrderByManager): - natural_order_field = 'name' - - -@python_2_unicode_compatible class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -1288,7 +1443,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = DeviceManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ @@ -1308,7 +1463,7 @@ class Meta: ) def __str__(self): - return self.display_name or super(Device, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) @@ -1423,30 +1578,47 @@ def save(self, *args, **kwargs): is_new = not bool(self.pk) - super(Device, self).save(*args, **kwargs) + super().save(*args, **kwargs) # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( [ConsolePort(device=self, name=template.name) for template in - self.device_type.console_port_templates.all()] + self.device_type.consoleport_templates.all()] ) ConsoleServerPort.objects.bulk_create( [ConsoleServerPort(device=self, name=template.name) for template in - self.device_type.cs_port_templates.all()] + self.device_type.consoleserverport_templates.all()] ) PowerPort.objects.bulk_create( [PowerPort(device=self, name=template.name) for template in - self.device_type.power_port_templates.all()] + self.device_type.powerport_templates.all()] ) PowerOutlet.objects.bulk_create( [PowerOutlet(device=self, name=template.name) for template in - self.device_type.power_outlet_templates.all()] + self.device_type.poweroutlet_templates.all()] ) Interface.objects.bulk_create( [Interface(device=self, name=template.name, form_factor=template.form_factor, mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()] ) + RearPort.objects.bulk_create([ + RearPort( + device=self, + name=template.name, + type=template.type, + positions=template.positions + ) for template in self.device_type.rearport_templates.all() + ]) + FrontPort.objects.bulk_create([ + FrontPort( + device=self, + name=template.name, + type=template.type, + rear_port=RearPort.objects.get(device=self, name=template.rear_port.name), + rear_port_position=template.rear_port_position, + ) for template in self.device_type.frontport_templates.all() + ]) DeviceBay.objects.bulk_create( [DeviceBay(device=self, name=template.name) for template in self.device_type.device_bay_templates.all()] @@ -1530,48 +1702,39 @@ def get_children(self): def get_status_class(self): return STATUS_CLASSES[self.status] - def get_rpc_client(self): - """ - Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined. - """ - if not self.platform: - return None - return RPC_CLIENTS.get(self.platform.rpc_client) - # # Console ports # -@python_2_unicode_compatible -class ConsolePort(ComponentModel): +class ConsolePort(CableTermination, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='console_ports' + related_name='consoleports' ) name = models.CharField( max_length=50 ) - cs_port = models.OneToOneField( + connected_endpoint = models.OneToOneField( to='dcim.ConsoleServerPort', on_delete=models.SET_NULL, - related_name='connected_console', - verbose_name='Console server port', + related_name='connected_endpoint', blank=True, null=True ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, - default=CONNECTION_STATUS_CONNECTED + blank=True ) + objects = DeviceComponentManager() tags = TaggableManager() - csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] + csv_headers = ['device', 'name'] class Meta: ordering = ['device', 'name'] @@ -1583,16 +1746,10 @@ def __str__(self): 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, - self.cs_port.name if self.cs_port else None, self.device.identifier, self.name, - self.get_connection_status_display(), ) @@ -1600,33 +1757,28 @@ def to_csv(self): # Console server ports # -class ConsoleServerPortManager(models.Manager): - - def get_queryset(self): - # Pad any trailing digits to effect natural sorting - return super(ConsoleServerPortManager, self).get_queryset().extra(select={ - 'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), " - r"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))", - }).order_by('device', 'name_padded') - - -@python_2_unicode_compatible -class ConsoleServerPort(ComponentModel): +class ConsoleServerPort(CableTermination, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='cs_ports' + related_name='consoleserverports' ) name = models.CharField( max_length=50 ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) - objects = ConsoleServerPortManager() + objects = DeviceComponentManager() tags = TaggableManager() + csv_headers = ['device', 'name'] + class Meta: unique_together = ['device', 'name'] @@ -1636,53 +1788,45 @@ def __str__(self): 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 - if self.device is None: - raise ValidationError("Console server ports must be assigned to devices.") - device_type = self.device.device_type - if not device_type.is_console_server: - raise ValidationError("The {} {} device type does not support assignment of console server ports.".format( - device_type.manufacturer, device_type - )) + def to_csv(self): + return ( + self.device.identifier, + self.name, + ) # # Power ports # -@python_2_unicode_compatible -class PowerPort(ComponentModel): +class PowerPort(CableTermination, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='power_ports' + related_name='powerports' ) name = models.CharField( max_length=50 ) - power_outlet = models.OneToOneField( + connected_endpoint = models.OneToOneField( to='dcim.PowerOutlet', on_delete=models.SET_NULL, - related_name='connected_port', + related_name='connected_endpoint', blank=True, null=True ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, - default=CONNECTION_STATUS_CONNECTED + blank=True ) + objects = DeviceComponentManager() tags = TaggableManager() - csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] + csv_headers = ['device', 'name'] class Meta: ordering = ['device', 'name'] @@ -1694,16 +1838,10 @@ def __str__(self): 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, - self.power_outlet.name if self.power_outlet else None, self.device.identifier, self.name, - self.get_connection_status_display(), ) @@ -1711,33 +1849,28 @@ def to_csv(self): # Power outlets # -class PowerOutletManager(models.Manager): - - def get_queryset(self): - # Pad any trailing digits to effect natural sorting - return super(PowerOutletManager, self).get_queryset().extra(select={ - 'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), " - r"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))", - }).order_by('device', 'name_padded') - - -@python_2_unicode_compatible -class PowerOutlet(ComponentModel): +class PowerOutlet(CableTermination, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='power_outlets' + related_name='poweroutlets' ) name = models.CharField( max_length=50 ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) - objects = PowerOutletManager() + objects = DeviceComponentManager() tags = TaggableManager() + csv_headers = ['device', 'name'] + class Meta: unique_together = ['device', 'name'] @@ -1747,30 +1880,21 @@ def __str__(self): 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 - if self.device is None: - raise ValidationError("Power outlets must be assigned to devices.") - device_type = self.device.device_type - if not device_type.is_pdu: - raise ValidationError("The {} {} device type does not support assignment of power outlets.".format( - device_type.manufacturer, device_type - )) + def to_csv(self): + return ( + self.device.identifier, + self.name, + ) # # Interfaces # -@python_2_unicode_compatible -class Interface(ComponentModel): +class Interface(CableTermination, 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. + Interface. """ device = models.ForeignKey( to='Device', @@ -1786,6 +1910,27 @@ class Interface(ComponentModel): null=True, blank=True ) + name = models.CharField( + max_length=64 + ) + _connected_interface = models.OneToOneField( + to='self', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + _connected_circuittermination = models.OneToOneField( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -1794,9 +1939,6 @@ class Interface(ComponentModel): 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 @@ -1844,9 +1986,14 @@ class Interface(ComponentModel): verbose_name='Tagged VLANs' ) - objects = InterfaceQuerySet.as_manager() + objects = InterfaceManager() tags = TaggableManager() + csv_headers = [ + 'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only', + 'description', 'mode', + ] + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -1857,19 +2004,23 @@ def __str__(self): def get_absolute_url(self): return reverse('dcim:interface', kwargs={'pk': self.pk}) - def get_component_parent(self): - return self.device or self.virtual_machine + def to_csv(self): + return ( + self.device.identifier if self.device else None, + self.virtual_machine.name if self.virtual_machine else None, + self.name, + self.lag.name if self.lag else None, + self.get_form_factor_display(), + self.enabled, + self.mac_address, + self.mtu, + self.mgmt_only, + self.description, + self.get_mode_display(), + ) def clean(self): - # Check that the parent device's DeviceType is a network device - if self.device is not None: - device_type = self.device.device_type - if not device_type.is_network_device: - raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format( - device_type.manufacturer, device_type - )) - # An Interface must belong to a Device *or* to a VirtualMachine if self.device and self.virtual_machine: raise ValidationError("An interface cannot belong to both a device and a virtual machine.") @@ -1883,7 +2034,9 @@ def clean(self): }) # Virtual interfaces cannot be connected - if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected: + if self.form_factor in NONCONNECTABLE_IFACE_TYPES and ( + self.cable or getattr(self, 'circuit_termination', False) + ): raise ValidationError({ 'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " "Disconnect the interface or choose a suitable form factor." @@ -1928,7 +2081,7 @@ def save(self, *args, **kwargs): if self.pk and self.mode is not IFACE_MODE_TAGGED: self.tagged_vlans.clear() - return super(Interface, self).save(*args, **kwargs) + return super().save(*args, **kwargs) def log_change(self, user, request_id, action): """ @@ -1939,7 +2092,7 @@ def log_change(self, user, request_id, action): # the component parent will raise DoesNotExist. For more discussion, see # https://github.com/digitalocean/netbox/issues/2323 try: - parent_obj = self.get_component_parent() + parent_obj = self.device or self.virtual_machine except ObjectDoesNotExist: parent_obj = None @@ -1949,13 +2102,33 @@ def log_change(self, user, request_id, action): changed_object=self, related_object=parent_obj, 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, - }) + object_data=serialize_object(self) ).save() - # TODO: Replace `parent` with get_component_parent() (from ComponentModel) + @property + def connected_endpoint(self): + if self._connected_interface: + return self._connected_interface + return self._connected_circuittermination + + @connected_endpoint.setter + def connected_endpoint(self, value): + from circuits.models import CircuitTermination + + if value is None: + self._connected_interface = None + self._connected_circuittermination = None + elif isinstance(value, Interface): + self._connected_interface = value + self._connected_circuittermination = None + elif isinstance(value, CircuitTermination): + self._connected_interface = None + self._connected_circuittermination = value + else: + raise ValueError( + "Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value)) + ) + @property def parent(self): return self.device or self.virtual_machine @@ -1977,133 +2150,135 @@ def is_lag(self): return self.form_factor == IFACE_FF_LAG @property - def is_connected(self): - try: - return bool(self.circuit_termination) - except ObjectDoesNotExist: - pass - return bool(self.connection) + def count_ipaddresses(self): + return self.ip_addresses.count() - @property - def connection(self): - try: - return self.connected_as_a - except ObjectDoesNotExist: - pass - try: - return self.connected_as_b - except ObjectDoesNotExist: - pass - return None - - @property - def connected_interface(self): - try: - if self.connected_as_a: - return self.connected_as_a.interface_b - except ObjectDoesNotExist: - pass - try: - if self.connected_as_b: - return self.connected_as_b.interface_a - except ObjectDoesNotExist: - pass - return None +# +# Pass-through ports +# -class InterfaceConnection(models.Model): +class FrontPort(CableTermination, ComponentModel): """ - 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. + A pass-through port on the front of a Device. """ - interface_a = models.OneToOneField( - to='dcim.Interface', + device = models.ForeignKey( + to='dcim.Device', on_delete=models.CASCADE, - related_name='connected_as_a' + related_name='frontports' ) - interface_b = models.OneToOneField( - to='dcim.Interface', + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + rear_port = models.ForeignKey( + to='dcim.RearPort', on_delete=models.CASCADE, - related_name='connected_as_b' + related_name='frontports' ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - default=CONNECTION_STATUS_CONNECTED, - verbose_name='Status' + rear_port_position = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + description = models.CharField( + max_length=100, + blank=True ) - csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] + objects = DeviceComponentManager() + tags = TaggableManager() - def clean(self): - try: - if self.interface_a == self.interface_b: - raise ValidationError({ - 'interface_b': "Cannot connect an interface to itself." - }) - if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_a': '{} is not a connectable interface type.'.format( - self.interface_a.get_form_factor_display() - ) - }) - if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_b': '{} is not a connectable interface type.'.format( - self.interface_b.get_form_factor_display() - ) - }) - except ObjectDoesNotExist: - pass + csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] + + class Meta: + ordering = ['device', 'name'] + unique_together = [ + ['device', 'name'], + ['rear_port', 'rear_port_position'], + ] + + def __str__(self): + return self.name def to_csv(self): return ( - self.interface_a.device.identifier, - self.interface_a.name, - self.interface_b.device.identifier, - self.interface_b.name, - self.get_connection_status_display(), + self.device.identifier, + self.name, + self.get_type_display(), + self.rear_port.name, + self.rear_port_position, + self.description, ) - 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), - ) + def clean(self): - 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 - } + # Validate rear port assignment + if self.rear_port.device != self.device: + raise ValidationError( + "Rear port ({}) must belong to the same device".format(self.rear_port) + ) - try: - parent_obj = interface.parent - except ObjectDoesNotExist: - parent_obj = None + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError( + "Invalid rear port position ({}); rear port {} has only {} positions".format( + self.rear_port_position, self.rear_port.name, self.rear_port.positions + ) + ) - ObjectChange( - user=user, - request_id=request_id, - changed_object=interface, - related_object=parent_obj, - action=OBJECTCHANGE_ACTION_UPDATE, - object_data=serialize_object(interface, extra=connection_data) - ).save() + +class RearPort(CableTermination, ComponentModel): + """ + A pass-through port on the rear of a Device. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='rearports' + ) + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + positions = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + description = models.CharField( + max_length=100, + blank=True + ) + + objects = DeviceComponentManager() + tags = TaggableManager() + + csv_headers = ['device', 'name', 'type', 'positions', 'description'] + + class Meta: + ordering = ['device', 'name'] + unique_together = ['device', 'name'] + + def __str__(self): + return self.name + + def to_csv(self): + return ( + self.device.identifier, + self.name, + self.get_type_display(), + self.positions, + self.description, + ) # # Device bays # -@python_2_unicode_compatible class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -2125,8 +2300,11 @@ class DeviceBay(ComponentModel): null=True ) + objects = DeviceComponentManager() tags = TaggableManager() + csv_headers = ['device', 'name', 'installed_device'] + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -2137,8 +2315,12 @@ def __str__(self): 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.identifier, + self.name, + self.installed_device.identifier if self.installed_device else None, + ) def clean(self): @@ -2157,7 +2339,6 @@ def clean(self): # Inventory items # -@python_2_unicode_compatible class InventoryItem(ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. @@ -2229,9 +2410,6 @@ def __str__(self): 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 + '}', @@ -2249,7 +2427,6 @@ def to_csv(self): # Virtual chassis # -@python_2_unicode_compatible class VirtualChassis(ChangeLoggedModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). @@ -2292,3 +2469,196 @@ def to_csv(self): self.master, self.domain, ) + + +# +# Cables +# + +class Cable(ChangeLoggedModel): + """ + A physical connection between two endpoints. + """ + termination_a_type = models.ForeignKey( + to=ContentType, + limit_choices_to={'model__in': CABLE_TERMINATION_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + termination_a_id = models.PositiveIntegerField() + termination_a = GenericForeignKey( + ct_field='termination_a_type', + fk_field='termination_a_id' + ) + termination_b_type = models.ForeignKey( + to=ContentType, + limit_choices_to={'model__in': CABLE_TERMINATION_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + termination_b_id = models.PositiveIntegerField() + termination_b = GenericForeignKey( + ct_field='termination_b_type', + fk_field='termination_b_id' + ) + type = models.PositiveSmallIntegerField( + choices=CABLE_TYPE_CHOICES, + blank=True, + null=True + ) + status = models.BooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) + label = models.CharField( + max_length=100, + blank=True + ) + color = ColorField( + blank=True + ) + length = models.PositiveSmallIntegerField( + blank=True, + null=True + ) + length_unit = models.PositiveSmallIntegerField( + choices=CABLE_LENGTH_UNIT_CHOICES, + blank=True, + null=True + ) + # Stores the normalized length (in meters) for database ordering + _abs_length = models.DecimalField( + max_digits=10, + decimal_places=4, + blank=True, + null=True + ) + + csv_headers = [ + 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', + 'color', 'length', 'length_unit', + ] + + class Meta: + ordering = ['pk'] + unique_together = ( + ('termination_a_type', 'termination_a_id'), + ('termination_b_type', 'termination_b_id'), + ) + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Create an ID string for use by __str__(). We have to save a copy of pk since it's nullified after .delete() + # is called. + self.id_string = '#{}'.format(self.pk) + + def __str__(self): + return self.label or self.id_string + + def get_absolute_url(self): + return reverse('dcim:cable', args=[self.pk]) + + def clean(self): + + if self.termination_a and self.termination_b: + + type_a = self.termination_a_type.model + type_b = self.termination_b_type.model + + # Check that termination types are compatible + if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): + raise ValidationError("Incompatible termination types: {} and {}".format( + self.termination_a_type, self.termination_b_type + )) + + # A termination point cannot be connected to itself + if self.termination_a == self.termination_b: + raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) + + # A front port cannot be connected to its corresponding rear port + if ( + type_a in ['frontport', 'rearport'] and + type_b in ['frontport', 'rearport'] and + ( + getattr(self.termination_a, 'rear_port', None) == self.termination_b or + getattr(self.termination_b, 'rear_port', None) == self.termination_a + ) + ): + raise ValidationError("A front port cannot be connected to it corresponding rear port") + + # Check for an existing Cable connected to either termination object + if self.termination_a.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_a, self.termination_a.cable_id + )) + if self.termination_b.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_b, self.termination_b.cable_id + )) + + # Virtual interfaces cannot be connected + endpoint_a, endpoint_b, _ = self.get_path_endpoints() + if ( + ( + isinstance(endpoint_a, Interface) and + endpoint_a.form_factor == IFACE_FF_VIRTUAL + ) or + ( + isinstance(endpoint_b, Interface) and + endpoint_b.form_factor == IFACE_FF_VIRTUAL + ) + ): + raise ValidationError("Cannot connect to a virtual interface") + + # Validate length and length_unit + if self.length is not None and self.length_unit is None: + raise ValidationError("Must specify a unit when setting a cable length") + elif self.length is None: + self.length_unit = None + + def save(self, *args, **kwargs): + + # Store the given length (if any) in meters for use in database ordering + if self.length and self.length_unit: + self._abs_length = to_meters(self.length, self.length_unit) + + super().save(*args, **kwargs) + + def to_csv(self): + return ( + '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model), + self.termination_a_id, + '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model), + self.termination_b_id, + self.get_type_display(), + self.get_status_display(), + self.label, + self.color, + self.length, + self.length_unit, + ) + + def get_path_endpoints(self): + """ + Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be + None. + """ + a_path = self.termination_b.trace() + b_path = self.termination_a.trace() + + # Determine overall path status (connected or planned) + if self.status == CONNECTION_STATUS_PLANNED: + path_status = CONNECTION_STATUS_PLANNED + else: + path_status = CONNECTION_STATUS_CONNECTED + for segment in a_path[1:] + b_path[1:]: + if segment[1] is None or segment[1].status == CONNECTION_STATUS_PLANNED: + path_status = CONNECTION_STATUS_PLANNED + break + + a_endpoint = a_path[-1][2] + b_endpoint = b_path[-1][2] + + return a_endpoint, b_endpoint, path_status diff --git a/netbox/dcim/querysets.py b/netbox/dcim/querysets.py deleted file mode 100644 index 32275ce01b2..00000000000 --- a/netbox/dcim/querysets.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import unicode_literals - -from django.db.models import QuerySet -from django.db.models.expressions import RawSQL - -from .constants import IFACE_ORDERING_NAME, IFACE_ORDERING_POSITION, NONCONNECTABLE_IFACE_TYPES - - -class InterfaceQuerySet(QuerySet): - - def order_naturally(self, method=IFACE_ORDERING_POSITION): - """ - Naturally order interfaces by their type and numeric position. The sort method must be one of the defined - IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). - - To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), - slot, subslot, position, channel, and virtual circuit: - - {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc} - - Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3 - would be parsed as follows: - - name = 'GigabitEthernet' - slot = 1 - subslot = 2 - position = 3 - subposition = 0 - channel = None - vc = 0 - - The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of - the prescribed fields. - """ - sql_col = '{}.name'.format(self.model._meta.db_table) - ordering = { - IFACE_ORDERING_POSITION: ( - '_slot', '_subslot', '_position', '_subposition', '_channel', '_type', '_vc', '_id', 'name', - ), - IFACE_ORDERING_NAME: ( - '_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name', - ), - }[method] - - TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" - ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)" - SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)" - SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)" - POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)" - SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)" - CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" - VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)" - - fields = { - '_type': RawSQL(TYPE_RE.format(sql_col), []), - '_id': RawSQL(ID_RE.format(sql_col), []), - '_slot': RawSQL(SLOT_RE.format(sql_col), []), - '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), - '_position': RawSQL(POSITION_RE.format(sql_col), []), - '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), - '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), - '_vc': RawSQL(VC_RE.format(sql_col), []), - } - - return self.annotate(**fields).order_by(*ordering) - - def connectable(self): - """ - Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or - wireless). - """ - return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 80e47391a05..67479262bc4 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,9 +1,7 @@ -from __future__ import unicode_literals - from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver -from .models import Device, VirtualChassis +from .models import Cable, Device, VirtualChassis @receiver(post_save, sender=VirtualChassis) @@ -21,3 +19,53 @@ def clear_virtualchassis_members(instance, **kwargs): When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members. """ Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None) + + +@receiver(post_save, sender=Cable) +def update_connected_endpoints(instance, **kwargs): + """ + When a Cable is saved, check for and update its two connected endpoints + """ + + # Cache the Cable on its two termination points + if instance.termination_a.cable != instance: + instance.termination_a.cable = instance + instance.termination_a.save() + if instance.termination_b.cable != instance: + instance.termination_b.cable = instance + instance.termination_b.save() + + # Check if this Cable has formed a complete path. If so, update both endpoints. + endpoint_a, endpoint_b, path_status = instance.get_path_endpoints() + if endpoint_a is not None and endpoint_b is not None: + endpoint_a.connected_endpoint = endpoint_b + endpoint_a.connection_status = path_status + endpoint_a.save() + endpoint_b.connected_endpoint = endpoint_a + endpoint_b.connection_status = path_status + endpoint_b.save() + + +@receiver(pre_delete, sender=Cable) +def nullify_connected_endpoints(instance, **kwargs): + """ + When a Cable is deleted, check for and update its two connected endpoints + """ + endpoint_a, endpoint_b, _ = instance.get_path_endpoints() + + # Disassociate the Cable from its termination points + if instance.termination_a is not None: + instance.termination_a.cable = None + instance.termination_a.save() + if instance.termination_b is not None: + instance.termination_b.cable = None + instance.termination_b.save() + + # If this Cable was part of a complete path, tear it down + if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'): + endpoint_a.connected_endpoint = None + endpoint_a.connection_status = None + endpoint_a.save() + endpoint_b.connected_endpoint = None + endpoint_b.connection_status = None + endpoint_b.save() diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 2630a9ba250..0ab1d594c42 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -1,15 +1,13 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem, - Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) REGION_LINK = """ @@ -31,7 +29,8 @@ """ COLOR_LABEL = """ - +{% load helpers %} + """ DEVICE_LINK = """ @@ -172,6 +171,18 @@ {% endif %} """ +CABLE_TERMINATION_PARENT = """ +{% if value.device %} + {{ value.device }} +{% else %} + {{ value.circuit }} +{% endif %} +""" + +CABLE_LENGTH = """ +{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} +""" + # # Regions @@ -250,7 +261,7 @@ class RackRoleTable(BaseTable): verbose_name='') class Meta(BaseTable.Meta): - model = RackGroup + model = RackRole fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions') @@ -264,12 +275,13 @@ class RackTable(BaseTable): 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(RACK_ROLE) u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') class Meta(BaseTable.Meta): model = Rack - fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height') + fields = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height') class RackDetailTable(RackTable): @@ -281,24 +293,11 @@ class RackDetailTable(RackTable): class Meta(RackTable.Meta): fields = ( - 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', + 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'get_utilization', ) -class RackImportTable(BaseTable): - name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - facility_id = tables.Column(verbose_name='Facility ID') - tenant = tables.TemplateColumn(template_code=COL_TENANT) - u_height = tables.Column(verbose_name='Height (U)') - - class Meta(BaseTable.Meta): - model = Rack - fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'u_height') - - # # Rack reservations # @@ -347,9 +346,6 @@ class DeviceTypeTable(BaseTable): verbose_name='Device Type' ) 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' @@ -362,8 +358,8 @@ class DeviceTypeTable(BaseTable): class Meta(BaseTable.Meta): model = DeviceType fields = ( - 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', 'instance_count', + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'instance_count', ) @@ -417,6 +413,24 @@ class Meta(BaseTable.Meta): empty_text = "None" +class FrontPortTemplateTable(BaseTable): + pk = ToggleColumn() + + class Meta(BaseTable.Meta): + model = FrontPortTemplate + fields = ('pk', 'name', 'type', 'rear_port', 'rear_port_position') + empty_text = "None" + + +class RearPortTemplateTable(BaseTable): + pk = ToggleColumn() + + class Meta(BaseTable.Meta): + model = RearPortTemplate + fields = ('pk', 'name', 'type', 'positions') + empty_text = "None" + + class DeviceBayTemplateTable(BaseTable): pk = ToggleColumn() @@ -576,6 +590,22 @@ class Meta(BaseTable.Meta): fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description') +class FrontPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = FrontPort + fields = ('name', 'type', 'rear_port', 'rear_port_position', 'description') + empty_text = "None" + + +class RearPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = RearPort + fields = ('name', 'type', 'positions', 'description') + empty_text = "None" + + class DeviceBayTable(BaseTable): class Meta(BaseTable.Meta): @@ -583,47 +613,142 @@ class Meta(BaseTable.Meta): fields = ('name',) +# +# Cables +# + +class CableTable(BaseTable): + pk = ToggleColumn() + id = tables.LinkColumn( + viewname='dcim:cable', + args=[Accessor('pk')], + verbose_name='ID' + ) + termination_a_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='Termination A' + ) + termination_a = tables.Column( + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='' + ) + termination_b_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='Termination B' + ) + termination_b = tables.Column( + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='' + ) + length = tables.TemplateColumn( + template_code=CABLE_LENGTH, + order_by='_abs_length' + ) + color = ColorColumn() + + class Meta(BaseTable.Meta): + model = Cable + fields = ( + 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', + 'status', 'type', 'color', 'length', + ) + + # # Device connections # class ConsoleConnectionTable(BaseTable): - console_server = tables.LinkColumn('dcim:device', accessor=Accessor('cs_port.device'), - args=[Accessor('cs_port.device.pk')], verbose_name='Console server') - cs_port = tables.Column(verbose_name='Port') - device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') - name = tables.Column(verbose_name='Console port') + console_server = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint.device'), + args=[Accessor('connected_endpoint.device.pk')], + verbose_name='Console Server' + ) + connected_endpoint = tables.Column( + verbose_name='Port' + ) + device = tables.LinkColumn( + viewname='dcim:device', + args=[Accessor('device.pk')] + ) + name = tables.Column( + verbose_name='Console Port' + ) class Meta(BaseTable.Meta): model = ConsolePort - fields = ('console_server', 'cs_port', 'device', 'name') + fields = ('console_server', 'connected_endpoint', 'device', 'name', 'connection_status') class PowerConnectionTable(BaseTable): - pdu = tables.LinkColumn('dcim:device', accessor=Accessor('power_outlet.device'), - args=[Accessor('power_outlet.device.pk')], verbose_name='PDU') - power_outlet = tables.Column(verbose_name='Outlet') - device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') - name = tables.Column(verbose_name='Power Port') + pdu = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint.device'), + args=[Accessor('connected_endpoint.device.pk')], + verbose_name='PDU' + ) + connected_endpoint = tables.Column( + verbose_name='Outlet' + ) + device = tables.LinkColumn( + viewname='dcim:device', + args=[Accessor('device.pk')] + ) + name = tables.Column( + verbose_name='Power Port' + ) class Meta(BaseTable.Meta): model = PowerPort - fields = ('pdu', 'power_outlet', 'device', 'name') + fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status') class InterfaceConnectionTable(BaseTable): - device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'), - args=[Accessor('interface_a.device.pk')], verbose_name='Device A') - interface_a = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_a'), - args=[Accessor('interface_a.pk')], verbose_name='Interface A') - device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'), - args=[Accessor('interface_b.device.pk')], verbose_name='Device B') - interface_b = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_b'), - args=[Accessor('interface_b.pk')], verbose_name='Interface B') - - class Meta(BaseTable.Meta): - model = InterfaceConnection - fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + device_a = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('device'), + args=[Accessor('device.pk')], + verbose_name='Device A' + ) + interface_a = tables.LinkColumn( + viewname='dcim:interface', + accessor=Accessor('name'), + args=[Accessor('pk')], + verbose_name='Interface A' + ) + description_a = tables.Column( + accessor=Accessor('description'), + verbose_name='Description' + ) + device_b = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint.device'), + args=[Accessor('connected_endpoint.device.pk')], + verbose_name='Device B' + ) + interface_b = tables.LinkColumn( + viewname='dcim:interface', + accessor=Accessor('connected_endpoint.name'), + args=[Accessor('connected_endpoint.pk')], + verbose_name='Interface B' + ) + description_b = tables.Column( + accessor=Accessor('connected_endpoint.description'), + verbose_name='Description' + ) + + class Meta(BaseTable.Meta): + model = Interface + fields = ( + 'device_a', 'interface_a', 'description_a', 'device_b', 'interface_b', 'description_b', 'connection_status', + ) # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c227179f4d1..980c57e865b 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,18 +1,14 @@ -from __future__ import unicode_literals - from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from dcim.constants import ( - IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SITE_STATUS_ACTIVE, SUBDEVICE_ROLE_CHILD, - SUBDEVICE_ROLE_PARENT, -) +from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from dcim.constants import * from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) from ipam.models import IPAddress, VLAN from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE @@ -24,7 +20,7 @@ class RegionTest(APITestCase): def setUp(self): - super(RegionTest, self).setUp() + super().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') @@ -125,7 +121,7 @@ class SiteTest(APITestCase): def setUp(self): - super(SiteTest, self).setUp() + super().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') @@ -260,7 +256,7 @@ class RackGroupTest(APITestCase): def setUp(self): - super(RackGroupTest, self).setUp() + super().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') @@ -370,7 +366,7 @@ class RackRoleTest(APITestCase): def setUp(self): - super(RackRoleTest, self).setUp() + super().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') @@ -478,7 +474,7 @@ class RackTest(APITestCase): def setUp(self): - super(RackTest, self).setUp() + super().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') @@ -612,7 +608,7 @@ class RackReservationTest(APITestCase): def setUp(self): - super(RackReservationTest, self).setUp() + super().setUp() 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') @@ -723,7 +719,7 @@ class ManufacturerTest(APITestCase): def setUp(self): - super(ManufacturerTest, self).setUp() + super().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') @@ -824,7 +820,7 @@ class DeviceTypeTest(APITestCase): def setUp(self): - super(DeviceTypeTest, self).setUp() + super().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') @@ -940,7 +936,7 @@ class ConsolePortTemplateTest(APITestCase): def setUp(self): - super(ConsolePortTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1040,7 +1036,7 @@ class ConsoleServerPortTemplateTest(APITestCase): def setUp(self): - super(ConsoleServerPortTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1140,7 +1136,7 @@ class PowerPortTemplateTest(APITestCase): def setUp(self): - super(PowerPortTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1240,7 +1236,7 @@ class PowerOutletTemplateTest(APITestCase): def setUp(self): - super(PowerOutletTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1340,7 +1336,7 @@ class InterfaceTemplateTest(APITestCase): def setUp(self): - super(InterfaceTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1440,7 +1436,7 @@ class DeviceBayTemplateTest(APITestCase): def setUp(self): - super(DeviceBayTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1540,7 +1536,7 @@ class DeviceRoleTest(APITestCase): def setUp(self): - super(DeviceRoleTest, self).setUp() + super().setUp() self.devicerole1 = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -1654,7 +1650,7 @@ class PlatformTest(APITestCase): def setUp(self): - super(PlatformTest, self).setUp() + super().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') @@ -1755,7 +1751,7 @@ class DeviceTest(APITestCase): def setUp(self): - super(DeviceTest, self).setUp() + super().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') @@ -1917,7 +1913,7 @@ class ConsolePortTest(APITestCase): def setUp(self): - super(ConsolePortTest, self).setUp() + super().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') @@ -1955,7 +1951,7 @@ def test_list_consoleports_brief(self): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_consoleport(self): @@ -2007,7 +2003,6 @@ def test_update_consoleport(self): data = { 'device': self.device.pk, 'name': 'Test Console Port X', - 'cs_port': consoleserverport.pk, } url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) @@ -2017,7 +2012,6 @@ def test_update_consoleport(self): self.assertEqual(ConsolePort.objects.count(), 3) consoleport1 = ConsolePort.objects.get(pk=response.data['id']) self.assertEqual(consoleport1.name, data['name']) - self.assertEqual(consoleport1.cs_port_id, data['cs_port']) def test_delete_consoleport(self): @@ -2032,12 +2026,12 @@ class ConsoleServerPortTest(APITestCase): def setUp(self): - super(ConsoleServerPortTest, self).setUp() + super().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') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_console_server=True + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -2070,7 +2064,7 @@ def test_list_consoleserverports_brief(self): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_consoleserverport(self): @@ -2143,7 +2137,7 @@ class PowerPortTest(APITestCase): def setUp(self): - super(PowerPortTest, self).setUp() + super().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') @@ -2181,7 +2175,7 @@ def test_list_powerports_brief(self): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_powerport(self): @@ -2233,7 +2227,6 @@ def test_update_powerport(self): data = { 'device': self.device.pk, 'name': 'Test Power Port X', - 'power_outlet': poweroutlet.pk, } url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) @@ -2243,7 +2236,6 @@ def test_update_powerport(self): self.assertEqual(PowerPort.objects.count(), 3) powerport1 = PowerPort.objects.get(pk=response.data['id']) self.assertEqual(powerport1.name, data['name']) - self.assertEqual(powerport1.power_outlet_id, data['power_outlet']) def test_delete_powerport(self): @@ -2258,12 +2250,12 @@ class PowerOutletTest(APITestCase): def setUp(self): - super(PowerOutletTest, self).setUp() + super().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') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_pdu=True + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -2296,7 +2288,7 @@ def test_list_poweroutlets_brief(self): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_poweroutlet(self): @@ -2369,12 +2361,12 @@ class InterfaceTest(APITestCase): def setUp(self): - super(InterfaceTest, self).setUp() + super().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') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -2395,6 +2387,7 @@ def test_get_interface(self): url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.interface1.name) def test_get_interface_graphs(self): @@ -2432,7 +2425,7 @@ def test_list_interfaces_brief(self): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_interface(self): @@ -2567,7 +2560,7 @@ class DeviceBayTest(APITestCase): def setUp(self): - super(DeviceBayTest, self).setUp() + super().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') @@ -2690,7 +2683,7 @@ class InventoryItemTest(APITestCase): def setUp(self): - super(InventoryItemTest, self).setUp() + super().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') @@ -2802,228 +2795,516 @@ def test_delete_inventoryitem(self): self.assertEqual(InventoryItem.objects.count(), 2) -class ConsoleConnectionTest(APITestCase): +class CableTest(APITestCase): def setUp(self): - super(ConsoleConnectionTest, self).setUp() + super().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') + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - device1 = Device.objects.create( + self.device1 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site ) - device2 = Device.objects.create( + self.device2 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site ) - cs_port1 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 1') - cs_port2 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 2') - cs_port3 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 3') - ConsolePort.objects.create( - device=device2, cs_port=cs_port1, name='Test Console Port 1', connection_status=True + for device in [self.device1, self.device2]: + for i in range(0, 10): + Interface(device=device, form_factor=IFACE_FF_1GE_FIXED, name='eth{}'.format(i)).save() + + self.cable1 = Cable( + termination_a=self.device1.interfaces.get(name='eth0'), + termination_b=self.device2.interfaces.get(name='eth0'), + label='Test Cable 1' ) - ConsolePort.objects.create( - device=device2, cs_port=cs_port2, name='Test Console Port 2', connection_status=True + self.cable1.save() + self.cable2 = Cable( + termination_a=self.device1.interfaces.get(name='eth1'), + termination_b=self.device2.interfaces.get(name='eth1'), + label='Test Cable 2' ) - ConsolePort.objects.create( - device=device2, cs_port=cs_port3, name='Test Console Port 3', connection_status=True + self.cable2.save() + self.cable3 = Cable( + termination_a=self.device1.interfaces.get(name='eth2'), + termination_b=self.device2.interfaces.get(name='eth2'), + label='Test Cable 3' ) + self.cable3.save() - def test_list_consoleconnections(self): + def test_get_cable(self): - url = reverse('dcim-api:consoleconnections-list') + url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], self.cable1.pk) + + def test_list_cables(self): + + url = reverse('dcim-api:cable-list') response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) + def test_create_cable(self): + + interface_a = self.device1.interfaces.get(name='eth3') + interface_b = self.device2.interfaces.get(name='eth3') + data = { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface_a.pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': interface_b.pk, + 'status': CONNECTION_STATUS_PLANNED, + 'label': 'Test Cable 4', + } + + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 4) + cable4 = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable4.termination_a, interface_a) + self.assertEqual(cable4.termination_b, interface_b) + self.assertEqual(cable4.status, data['status']) + self.assertEqual(cable4.label, data['label']) + + def test_create_cable_bulk(self): + + data = [ + { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': self.device1.interfaces.get(name='eth3').pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': self.device2.interfaces.get(name='eth3').pk, + 'label': 'Test Cable 4', + }, + { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': self.device1.interfaces.get(name='eth4').pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': self.device2.interfaces.get(name='eth4').pk, + 'label': 'Test Cable 5', + }, + { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': self.device1.interfaces.get(name='eth5').pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': self.device2.interfaces.get(name='eth5').pk, + 'label': 'Test Cable 6', + }, + ] -class PowerConnectionTest(APITestCase): + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 6) + self.assertEqual(response.data[0]['label'], data[0]['label']) + self.assertEqual(response.data[1]['label'], data[1]['label']) + self.assertEqual(response.data[2]['label'], data[2]['label']) + + def test_update_cable(self): + + data = { + 'label': 'Test Cable X', + 'status': CONNECTION_STATUS_CONNECTED, + } + + url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk}) + response = self.client.patch(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Cable.objects.count(), 3) + cable1 = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable1.status, data['status']) + self.assertEqual(cable1.label, data['label']) + + def test_delete_cable(self): + + url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Cable.objects.count(), 2) + + +class ConnectionTest(APITestCase): def setUp(self): - super(PowerConnectionTest, self).setUp() + super().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') + self.site = Site.objects.create( + name='Test Site 1', slug='test-site-1' + ) + manufacturer = Manufacturer.objects.create( + name='Test Manufacturer 1', slug='test-manufacturer-1' + ) devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - device1 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site - ) - device2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site + self.device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site ) - power_outlet1 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 1') - power_outlet2 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 2') - power_outlet3 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 3') - PowerPort.objects.create( - device=device2, power_outlet=power_outlet1, name='Test Power Port 1', connection_status=True + self.device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site ) - PowerPort.objects.create( - device=device2, power_outlet=power_outlet2, name='Test Power Port 2', connection_status=True + self.panel1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=self.site ) - PowerPort.objects.create( - device=device2, power_outlet=power_outlet3, name='Test Power Port 3', connection_status=True + self.panel2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=self.site ) - def test_list_powerconnections(self): + def test_create_direct_console_connection(self): - url = reverse('dcim-api:powerconnections-list') - response = self.client.get(url, **self.header) + consoleport1 = ConsolePort.objects.create( + device=self.device1, name='Test Console Port 1' + ) + consoleserverport1 = ConsoleServerPort.objects.create( + device=self.device2, name='Test Console Server Port 1' + ) - self.assertEqual(response.data['count'], 3) + data = { + 'termination_a_type': 'dcim.consoleport', + 'termination_a_id': consoleport1.pk, + 'termination_b_type': 'dcim.consoleserverport', + 'termination_b_id': consoleserverport1.pk, + } + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) -class InterfaceConnectionTest(APITestCase): + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 1) - def setUp(self): + cable = Cable.objects.get(pk=response.data['id']) + consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) - super(InterfaceConnectionTest, self).setUp() + self.assertEqual(cable.termination_a, consoleport1) + self.assertEqual(cable.termination_b, consoleserverport1) + self.assertEqual(consoleport1.cable, cable) + self.assertEqual(consoleserverport1.cable, cable) + self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) + self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) - site = Site.objects.create(name='Test Site 1', slug='test-site-1') - manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') - devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + def test_create_patched_console_connection(self): + + consoleport1 = ConsolePort.objects.create( + device=self.device1, name='Test Console Port 1' ) - devicerole = DeviceRole.objects.create( - name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + consoleserverport1 = ConsoleServerPort.objects.create( + device=self.device2, name='Test Console Server Port 1' ) - self.device = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + rearport1 = RearPort.objects.create( + device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C ) - self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1') - self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2') - self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3') - self.interface4 = Interface.objects.create(device=self.device, name='Test Interface 4') - self.interface5 = Interface.objects.create(device=self.device, name='Test Interface 5') - self.interface6 = Interface.objects.create(device=self.device, name='Test Interface 6') - self.interface7 = Interface.objects.create(device=self.device, name='Test Interface 7') - self.interface8 = Interface.objects.create(device=self.device, name='Test Interface 8') - self.interface9 = Interface.objects.create(device=self.device, name='Test Interface 9') - self.interface10 = Interface.objects.create(device=self.device, name='Test Interface 10') - self.interface11 = Interface.objects.create(device=self.device, name='Test Interface 11') - self.interface12 = Interface.objects.create(device=self.device, name='Test Interface 12') - self.interfaceconnection1 = InterfaceConnection.objects.create( - interface_a=self.interface1, interface_b=self.interface2 + frontport1 = FrontPort.objects.create( + device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1 ) - self.interfaceconnection2 = InterfaceConnection.objects.create( - interface_a=self.interface3, interface_b=self.interface4 + rearport2 = RearPort.objects.create( + device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C ) - self.interfaceconnection3 = InterfaceConnection.objects.create( - interface_a=self.interface5, interface_b=self.interface6 + frontport2 = FrontPort.objects.create( + device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2 ) - def test_get_interfaceconnection(self): + url = reverse('dcim-api:cable-list') + cables = [ + # Console port to panel1 front + { + 'termination_a_type': 'dcim.consoleport', + 'termination_a_id': consoleport1.pk, + 'termination_b_type': 'dcim.frontport', + 'termination_b_id': frontport1.pk, + }, + # Panel1 rear to panel2 rear + { + 'termination_a_type': 'dcim.rearport', + 'termination_a_id': rearport1.pk, + 'termination_b_type': 'dcim.rearport', + 'termination_b_id': rearport2.pk, + }, + # Panel2 front to console server port + { + 'termination_a_type': 'dcim.frontport', + 'termination_a_id': frontport2.pk, + 'termination_b_type': 'dcim.consoleserverport', + 'termination_b_id': consoleserverport1.pk, + }, + ] - url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) - response = self.client.get(url, **self.header) + for data in cables: - self.assertEqual(response.data['interface_a']['id'], self.interfaceconnection1.interface_a_id) - self.assertEqual(response.data['interface_b']['id'], self.interfaceconnection1.interface_b_id) + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) - def test_list_interfaceconnections(self): + cable = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable.termination_a.cable, cable) + self.assertEqual(cable.termination_b.cable, cable) - url = reverse('dcim-api:interfaceconnection-list') - response = self.client.get(url, **self.header) + consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) + self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) + self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) - self.assertEqual(response.data['count'], 3) + def test_create_direct_power_connection(self): - def test_list_interfaceconnections_brief(self): + powerport1 = PowerPort.objects.create( + device=self.device1, name='Test Power Port 1' + ) + poweroutlet1 = PowerOutlet.objects.create( + device=self.device2, name='Test Power Outlet 1' + ) - url = reverse('dcim-api:interfaceconnection-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) + data = { + 'termination_a_type': 'dcim.powerport', + 'termination_a_id': powerport1.pk, + 'termination_b_type': 'dcim.poweroutlet', + 'termination_b_id': poweroutlet1.pk, + } - self.assertEqual( - sorted(response.data['results'][0]), - ['connection_status', 'id', 'url'] - ) + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 1) - def test_create_interfaceconnection(self): + cable = Cable.objects.get(pk=response.data['id']) + powerport1 = PowerPort.objects.get(pk=powerport1.pk) + poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk) + + self.assertEqual(cable.termination_a, powerport1) + self.assertEqual(cable.termination_b, poweroutlet1) + self.assertEqual(powerport1.cable, cable) + self.assertEqual(poweroutlet1.cable, cable) + self.assertEqual(powerport1.connected_endpoint, poweroutlet1) + self.assertEqual(poweroutlet1.connected_endpoint, powerport1) + + # Note: Power connections via patch ports are not supported. + + def test_create_direct_interface_connection(self): + + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + interface2 = Interface.objects.create( + device=self.device2, name='Test Interface 2' + ) data = { - 'interface_a': self.interface7.pk, - 'interface_b': self.interface8.pk, + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': interface2.pk, } - url = reverse('dcim-api:interfaceconnection-list') + url = reverse('dcim-api:cable-list') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(InterfaceConnection.objects.count(), 4) - interfaceconnection4 = InterfaceConnection.objects.get(pk=response.data['id']) - self.assertEqual(interfaceconnection4.interface_a_id, data['interface_a']) - self.assertEqual(interfaceconnection4.interface_b_id, data['interface_b']) + self.assertEqual(Cable.objects.count(), 1) - def test_create_interfaceconnection_bulk(self): + cable = Cable.objects.get(pk=response.data['id']) + interface1 = Interface.objects.get(pk=interface1.pk) + interface2 = Interface.objects.get(pk=interface2.pk) - data = [ + self.assertEqual(cable.termination_a, interface1) + self.assertEqual(cable.termination_b, interface2) + self.assertEqual(interface1.cable, cable) + self.assertEqual(interface2.cable, cable) + self.assertEqual(interface1.connected_endpoint, interface2) + self.assertEqual(interface2.connected_endpoint, interface1) + + def test_create_patched_interface_connection(self): + + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + interface2 = Interface.objects.create( + device=self.device2, name='Test Interface 2' + ) + rearport1 = RearPort.objects.create( + device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C + ) + frontport1 = FrontPort.objects.create( + device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1 + ) + rearport2 = RearPort.objects.create( + device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C + ) + frontport2 = FrontPort.objects.create( + device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2 + ) + + url = reverse('dcim-api:cable-list') + cables = [ + # Interface1 to panel1 front { - 'interface_a': self.interface7.pk, - 'interface_b': self.interface8.pk, + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'dcim.frontport', + 'termination_b_id': frontport1.pk, }, + # Panel1 rear to panel2 rear { - 'interface_a': self.interface9.pk, - 'interface_b': self.interface10.pk, + 'termination_a_type': 'dcim.rearport', + 'termination_a_id': rearport1.pk, + 'termination_b_type': 'dcim.rearport', + 'termination_b_id': rearport2.pk, }, + # Panel2 front to interface2 { - 'interface_a': self.interface11.pk, - 'interface_b': self.interface12.pk, + 'termination_a_type': 'dcim.frontport', + 'termination_a_id': frontport2.pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': interface2.pk, }, ] - url = reverse('dcim-api:interfaceconnection-list') - response = self.client.post(url, data, format='json', **self.header) + for data in cables: - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(InterfaceConnection.objects.count(), 6) - 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']) + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + cable = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable.termination_a.cable, cable) + self.assertEqual(cable.termination_b.cable, cable) - def test_update_interfaceconnection(self): + interface1 = Interface.objects.get(pk=interface1.pk) + interface2 = Interface.objects.get(pk=interface2.pk) + self.assertEqual(interface1.connected_endpoint, interface2) + self.assertEqual(interface2.connected_endpoint, interface1) - new_connection_status = not self.interfaceconnection1.connection_status + def test_create_direct_circuittermination_connection(self): + + provider = Provider.objects.create( + name='Test Provider 1', slug='test-provider-1' + ) + circuittype = CircuitType.objects.create( + name='Test Circuit Type 1', slug='test-circuit-type-1' + ) + circuit = Circuit.objects.create( + provider=provider, type=circuittype, cid='Test Circuit 1' + ) + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + circuittermination1 = CircuitTermination.objects.create( + circuit=circuit, term_side='A', site=self.site, port_speed=10000 + ) data = { - 'interface_a': self.interface7.pk, - 'interface_b': self.interface8.pk, - 'connection_status': new_connection_status, + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'circuits.circuittermination', + 'termination_b_id': circuittermination1.pk, } - url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) - response = self.client.put(url, data, format='json', **self.header) + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(InterfaceConnection.objects.count(), 3) - interfaceconnection1 = InterfaceConnection.objects.get(pk=response.data['id']) - self.assertEqual(interfaceconnection1.interface_a_id, data['interface_a']) - self.assertEqual(interfaceconnection1.interface_b_id, data['interface_b']) - self.assertEqual(interfaceconnection1.connection_status, data['connection_status']) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 1) - def test_delete_interfaceconnection(self): + cable = Cable.objects.get(pk=response.data['id']) + interface1 = Interface.objects.get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) - url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) - response = self.client.delete(url, **self.header) + self.assertEqual(cable.termination_a, interface1) + self.assertEqual(cable.termination_b, circuittermination1) + self.assertEqual(interface1.cable, cable) + self.assertEqual(circuittermination1.cable, cable) + self.assertEqual(interface1.connected_endpoint, circuittermination1) + self.assertEqual(circuittermination1.connected_endpoint, interface1) - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(InterfaceConnection.objects.count(), 2) + def test_create_patched_circuittermination_connection(self): + + provider = Provider.objects.create( + name='Test Provider 1', slug='test-provider-1' + ) + circuittype = CircuitType.objects.create( + name='Test Circuit Type 1', slug='test-circuit-type-1' + ) + circuit = Circuit.objects.create( + provider=provider, type=circuittype, cid='Test Circuit 1' + ) + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + circuittermination1 = CircuitTermination.objects.create( + circuit=circuit, term_side='A', site=self.site, port_speed=10000 + ) + rearport1 = RearPort.objects.create( + device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C + ) + frontport1 = FrontPort.objects.create( + device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1 + ) + rearport2 = RearPort.objects.create( + device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C + ) + frontport2 = FrontPort.objects.create( + device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2 + ) + + url = reverse('dcim-api:cable-list') + cables = [ + # Interface to panel1 front + { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'dcim.frontport', + 'termination_b_id': frontport1.pk, + }, + # Panel1 rear to panel2 rear + { + 'termination_a_type': 'dcim.rearport', + 'termination_a_id': rearport1.pk, + 'termination_b_type': 'dcim.rearport', + 'termination_b_id': rearport2.pk, + }, + # Panel2 front to circuit termination + { + 'termination_a_type': 'dcim.frontport', + 'termination_a_id': frontport2.pk, + 'termination_b_type': 'circuits.circuittermination', + 'termination_b_id': circuittermination1.pk, + }, + ] + + for data in cables: + + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + cable = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable.termination_a.cable, cable) + self.assertEqual(cable.termination_b.cable, cable) + + interface1 = Interface.objects.get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) + self.assertEqual(interface1.connected_endpoint, circuittermination1) + self.assertEqual(circuittermination1.connected_endpoint, interface1) class ConnectedDeviceTest(APITestCase): def setUp(self): - super(ConnectedDeviceTest, self).setUp() + super().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') @@ -3048,12 +3329,14 @@ def setUp(self): ) self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0') - InterfaceConnection.objects.create(interface_a=self.interface1, interface_b=self.interface2) + + cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + cable.save() def test_get_connected_device(self): url = reverse('dcim-api:connected-device-list') - response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header) + response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.device1.name) @@ -3063,7 +3346,7 @@ class VirtualChassisTest(APITestCase): def setUp(self): - super(VirtualChassisTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site', slug='test-site') manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer') @@ -3150,7 +3433,7 @@ def test_list_virtualchassis_brief(self): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'url'] + ['id', 'master', 'url'] ) def test_create_virtualchassis(self): diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index c8d4387282f..2f333ea6915 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.test import TestCase from dcim.forms import * diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 5b2cdbd51cd..757af61f4bf 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,7 +1,6 @@ -from __future__ import unicode_literals - from django.test import TestCase +from dcim.constants import * from dcim.models import * @@ -153,110 +152,196 @@ def test_mount_zero_ru(self): self.assertTrue(pdu) -class InterfaceTestCase(TestCase): +class CableTestCase(TestCase): def setUp(self): - self.site = Site.objects.create( - name='TestSite1', - slug='my-test-site' + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) - self.rack = Rack.objects.create( - name='TestRack1', - facility_id='A101', - site=self.site, - u_height=42 + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' - ) - - self.device_type = DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' + self.device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site ) - self.role = DeviceRole.objects.create( - name='Switch', - slug='switch', + self.device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site ) + self.interface1 = Interface.objects.create(device=self.device1, name='eth0') + self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + self.cable.save() - def test_interface_order_natural(self): - device1 = Device.objects.create( - name='TestSwitch1', - device_type=self.device_type, - device_role=self.role, - site=self.site, - rack=self.rack, - position=10, - face=RACK_FACE_REAR, - ) - interface1 = Interface.objects.create( - device=device1, - name='Ethernet1/3/1' - ) - interface2 = Interface.objects.create( - device=device1, - name='Ethernet1/5/1' - ) - interface3 = Interface.objects.create( - device=device1, - name='Ethernet1/4' - ) - interface4 = Interface.objects.create( - device=device1, - name='Ethernet1/3/2/4' - ) - interface5 = Interface.objects.create( - device=device1, - name='Ethernet1/3/2/1' + self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1') + self.patch_pannel = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site ) - interface6 = Interface.objects.create( - device=device1, - name='Loopback1' + self.rear_port = RearPort.objects.create(device=self.patch_pannel, name='R1', type=1000) + self.front_port = FrontPort.objects.create( + device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port ) - self.assertEqual( - list(Interface.objects.all().order_naturally()), - [interface1, interface5, interface4, interface3, interface2, interface6] - ) + def test_cable_creation(self): + """ + When a new Cable is created, it must be cached on either termination point. + """ + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(self.cable.termination_a, interface1) + interface2 = Interface.objects.get(pk=self.interface2.pk) + self.assertEqual(self.cable.termination_b, interface2) - def test_interface_order_natural_subinterfaces(self): - device1 = Device.objects.create( - name='TestSwitch1', - device_type=self.device_type, - device_role=self.role, - site=self.site, - rack=self.rack, - position=10, - face=RACK_FACE_REAR, + def test_cable_deletion(self): + """ + When a Cable is deleted, the `cable` field on its termination points must be nullified. + """ + self.cable.delete() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.cable) + interface2 = Interface.objects.get(pk=self.interface2.pk) + self.assertIsNone(interface2.cable) + + def test_cabletermination_deletion(self): + """ + When a CableTermination object is deleted, its attached Cable (if any) must also be deleted. + """ + self.interface1.delete() + cable = Cable.objects.filter(pk=self.cable.pk).first() + self.assertIsNone(cable) + + def test_cable_validates_compatibale_types(self): + """ + The clean method should have a check to ensure only compatiable port types can be connected by a cable + """ + # An interface cannot be connected to a power port + cable = Cable(termination_a=self.interface1, termination_b=self.power_port1) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_cannot_have_the_same_terminination_on_both_ends(self): + """ + A cable cannot be made with the same A and B side terminations + """ + cable = Cable(termination_a=self.interface1, termination_b=self.interface1) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self): + """ + A cable cannot connect a front port to its sorresponding rear port + """ + cable = Cable(termination_a=self.front_port, termination_b=self.rear_port) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_cannot_be_connected_to_an_existing_connection(self): + """ + Either side of a cable cannot be terminated when that side aready has a connection + """ + # Try to create a cable with the same interface terminations + cable = Cable(termination_a=self.interface2, termination_b=self.interface1) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_cannot_connect_to_a_virtual_inteface(self): + """ + A cable connection cannot include a virtual interface + """ + virtual_interface = Interface(device=self.device1, name="V1", form_factor=0) + cable = Cable(termination_a=self.interface2, termination_b=virtual_interface) + with self.assertRaises(ValidationError): + cable.clean() + + +class CablePathTestCase(TestCase): + + def setUp(self): + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - interface1 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/3' + self.device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site ) - interface2 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/2.2' + self.device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site ) - interface3 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/0.120' + self.interface1 = Interface.objects.create(device=self.device1, name='eth0') + self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + self.panel1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=site ) - interface4 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/0' + self.panel2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=site ) - interface5 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/1.117' + self.rear_port1 = RearPort.objects.create( + device=self.panel1, name='Rear Port 1', type=PORT_TYPE_8P8C ) - interface6 = Interface.objects.create( - device=device1, - name='GigabitEthernet0' + self.front_port1 = FrontPort.objects.create( + device=self.panel1, name='Front Port 1', type=PORT_TYPE_8P8C, rear_port=self.rear_port1 ) - self.assertEqual( - list(Interface.objects.all().order_naturally()), - [interface4, interface3, interface5, interface2, interface1, interface6] + self.rear_port2 = RearPort.objects.create( + device=self.panel2, name='Rear Port 2', type=PORT_TYPE_8P8C ) + self.front_port2 = FrontPort.objects.create( + device=self.panel2, name='Front Port 2', type=PORT_TYPE_8P8C, rear_port=self.rear_port2 + ) + + def test_path_completion(self): + + # First segment + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1) + cable1.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.connected_endpoint) + self.assertIsNone(interface1.connection_status) + + # Second segment + cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) + cable2.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.connected_endpoint) + self.assertIsNone(interface1.connection_status) + + # Third segment + cable3 = Cable(termination_a=self.front_port2, termination_b=self.interface2, status=CONNECTION_STATUS_PLANNED) + cable3.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(interface1.connected_endpoint, self.interface2) + self.assertEqual(interface1.connection_status, CONNECTION_STATUS_PLANNED) + + # Switch third segment from planned to connected + cable3.status = CONNECTION_STATUS_CONNECTED + cable3.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(interface1.connected_endpoint, self.interface2) + self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED) + + def test_path_teardown(self): + + # Build the path + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1) + cable1.save() + cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) + cable2.save() + cable3 = Cable(termination_a=self.front_port2, termination_b=self.interface2) + cable3.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(interface1.connected_endpoint, self.interface2) + self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED) + + # Remove a cable + cable2.delete() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.connected_endpoint) + self.assertIsNone(interface1.connection_status) + interface2 = Interface.objects.get(pk=self.interface2.pk) + self.assertIsNone(interface2.connected_endpoint) + self.assertIsNone(interface2.connection_status) diff --git a/netbox/dcim/tests/test_natural_ordering.py b/netbox/dcim/tests/test_natural_ordering.py new file mode 100644 index 00000000000..d4dca43d719 --- /dev/null +++ b/netbox/dcim/tests/test_natural_ordering.py @@ -0,0 +1,157 @@ +from django.test import TestCase + +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site + + +class NaturalOrderingTestCase(TestCase): + + def setUp(self): + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + + def _compare_names(self, queryset, names): + + for i, obj in enumerate(queryset): + self.assertEqual(obj.name, names[i]) + + def test_interface_ordering_numeric(self): + + INTERFACES = ( + '0', + '0.1', + '0.2', + '0.10', + '0.100', + '0:1', + '0:1.1', + '0:1.2', + '0:1.10', + '0:2', + '0:2.1', + '0:2.2', + '0:2.10', + '1', + '1.1', + '1.2', + '1.10', + '1.100', + '1:1', + '1:1.1', + '1:1.2', + '1:1.10', + '1:2', + '1:2.1', + '1:2.2', + '1:2.10', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) + + def test_interface_ordering_linux(self): + + INTERFACES = ( + 'eth0', + 'eth0.1', + 'eth0.2', + 'eth0.10', + 'eth0.100', + 'eth1', + 'eth1.1', + 'eth1.2', + 'eth1.100', + 'lo0', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) + + def test_interface_ordering_junos(self): + + INTERFACES = ( + 'xe-0/0/0', + 'xe-0/0/1', + 'xe-0/0/2', + 'xe-0/0/3', + 'xe-0/1/0', + 'xe-0/1/1', + 'xe-0/1/2', + 'xe-0/1/3', + 'xe-1/0/0', + 'xe-1/0/1', + 'xe-1/0/2', + 'xe-1/0/3', + 'xe-1/1/0', + 'xe-1/1/1', + 'xe-1/1/2', + 'xe-1/1/3', + 'xe-2/0/0.1', + 'xe-2/0/0.2', + 'xe-2/0/0.10', + 'xe-2/0/0.11', + 'xe-2/0/0.100', + 'xe-3/0/0:1', + 'xe-3/0/0:2', + 'xe-3/0/0:10', + 'xe-3/0/0:11', + 'xe-3/0/0:100', + 'xe-10/1/0', + 'xe-10/1/1', + 'xe-10/1/2', + 'xe-10/1/3', + 'ae1', + 'ae2', + 'ae10.1', + 'ae10.10', + 'irb.1', + 'irb.2', + 'irb.10', + 'irb.100', + 'lo0', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) + + def test_interface_ordering_ios(self): + + INTERFACES = ( + 'GigabitEthernet0/1', + 'GigabitEthernet0/2', + 'GigabitEthernet0/10', + 'TenGigabitEthernet0/20', + 'TenGigabitEthernet0/21', + 'GigabitEthernet1/1', + 'GigabitEthernet1/2', + 'GigabitEthernet1/10', + 'TenGigabitEthernet1/20', + 'TenGigabitEthernet1/21', + 'FastEthernet1', + 'FastEthernet2', + 'FastEthernet10', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 7345cdacd3a..dc1fbbf39c4 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView, ImageAttachmentEditView @@ -7,8 +5,8 @@ from secrets.views import secret_add from . import views from .models import ( - Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, - Region, Site, VirtualChassis, + Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, + PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) app_name = 'dcim' @@ -111,6 +109,14 @@ url(r'^device-types/(?P\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), url(r'^device-types/(?P\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), + # Front port templates + url(r'^device-types/(?P\d+)/front-ports/add/$', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'), + url(r'^device-types/(?P\d+)/front-ports/delete/$', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'), + + # Rear port templates + url(r'^device-types/(?P\d+)/rear-ports/add/$', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'), + url(r'^device-types/(?P\d+)/rear-ports/delete/$', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'), + # Device bay templates url(r'^device-types/(?P\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), url(r'^device-types/(?P\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), @@ -155,56 +161,78 @@ url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'), url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - url(r'^console-ports/(?P\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'), - url(r'^console-ports/(?P\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'), + url(r'^console-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + url(r'^console-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), # Console server ports url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), - url(r'^devices/(?P\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - url(r'^console-server-ports/(?P\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'), - url(r'^console-server-ports/(?P\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'), + url(r'^console-server-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), url(r'^console-server-ports/(?P\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + url(r'^console-server-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), + url(r'^console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), # Power ports url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'), url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - url(r'^power-ports/(?P\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'), - url(r'^power-ports/(?P\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'), + url(r'^power-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + url(r'^power-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), # Power outlets url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), - url(r'^devices/(?P\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - url(r'^power-outlets/(?P\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'), - url(r'^power-outlets/(?P\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'), + url(r'^power-outlets/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), url(r'^power-outlets/(?P\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + url(r'^power-outlets/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), + url(r'^power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), # Interfaces url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), url(r'^devices/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - url(r'^devices/(?P\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), 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+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), 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/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), + url(r'^interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), + + # Front ports + # url(r'^devices/front-ports/add/$', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), + url(r'^devices/(?P\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'), + url(r'^devices/(?P\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), + url(r'^front-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + url(r'^front-ports/(?P\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'), + url(r'^front-ports/(?P\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + url(r'^front-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + url(r'^front-ports/rename/$', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), + url(r'^front-ports/disconnect/$', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), + + # Rear ports + # url(r'^devices/rear-ports/add/$', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), + url(r'^devices/(?P\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'), + url(r'^devices/(?P\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), + url(r'^rear-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + url(r'^rear-ports/(?P\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'), + url(r'^rear-ports/(?P\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'), + url(r'^rear-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + url(r'^rear-ports/rename/$', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), + url(r'^rear-ports/disconnect/$', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), # Device bays url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), @@ -225,13 +253,20 @@ url(r'^inventory-items/(?P\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), url(r'^devices/(?P\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), - # Console/power/interface connections + # Cables + url(r'^cables/$', views.CableListView.as_view(), name='cable_list'), + url(r'^cables/import/$', views.CableBulkImportView.as_view(), name='cable_import'), + url(r'^cables/edit/$', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), + url(r'^cables/delete/$', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), + url(r'^cables/(?P\d+)/$', views.CableView.as_view(), name='cable'), + url(r'^cables/(?P\d+)/edit/$', views.CableEditView.as_view(), name='cable_edit'), + url(r'^cables/(?P\d+)/delete/$', views.CableDeleteView.as_view(), name='cable_delete'), + url(r'^cables/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + + # Console/power/interface connections (read-only) url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), - url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'), url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'), - url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'), url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), - url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), # Virtual chassis url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index eb7f71a25a5..350ed15420c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,41 +1,36 @@ -from __future__ import unicode_literals - -from operator import attrgetter +import re from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction -from django.db.models import Count, Q +from django.db.models import Count, F from django.forms import modelformset_factory -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 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 from extras.views import ObjectConfigContextView -from ipam.models import Prefix, Service, VLAN +from ipam.models import Prefix, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator +from utilities.utils import csv_format from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables -from .constants import CONNECTION_STATUS_CONNECTED from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) @@ -57,7 +52,16 @@ def post(self, request): if form.is_valid(): for obj in selected_objects: - obj.new_name = obj.name.replace(form.cleaned_data['find'], form.cleaned_data['replace']) + find = form.cleaned_data['find'] + replace = form.cleaned_data['replace'] + if form.cleaned_data['use_regex']: + try: + obj.new_name = re.sub(find, replace, obj.name) + # Catch regex group reference errors + except re.error: + obj.new_name = obj.name + else: + obj.new_name = obj.name.replace(find, replace) if '_apply' in request.POST: for obj in selected_objects: @@ -81,7 +85,7 @@ def post(self, request): }) -class BulkDisconnectView(View): +class BulkDisconnectView(GetReturnURLMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. """ @@ -89,22 +93,30 @@ class BulkDisconnectView(View): form = None template_name = 'dcim/bulk_disconnect.html' - def disconnect_objects(self, objects): - raise NotImplementedError() - - def post(self, request, pk): + def post(self, request): - device = get_object_or_404(Device, pk=pk) selected_objects = [] + return_url = self.get_return_url(request) if '_confirm' in request.POST: form = self.form(request.POST) + if form.is_valid(): - count = self.disconnect_objects(form.cleaned_data['pk']) - messages.success(request, "Disconnected {} {} on {}".format( - count, self.model._meta.verbose_name_plural, device + + with transaction.atomic(): + + count = 0 + for obj in self.model.objects.filter(pk__in=form.cleaned_data['pk']): + if obj.cable is None: + continue + obj.cable.delete() + count += 1 + + messages.success(request, "Disconnected {} {}".format( + count, self.model._meta.verbose_name_plural )) - return redirect(device.get_absolute_url()) + + return redirect(return_url) else: form = self.form(initial={'pk': request.POST.getlist('pk')}) @@ -112,10 +124,9 @@ def post(self, request, pk): return render(request, self.template_name, { 'form': form, - 'device': device, 'obj_type_plural': self.model._meta.verbose_name_plural, 'selected_objects': selected_objects, - 'return_url': device.get_absolute_url(), + 'return_url': return_url, }) @@ -124,7 +135,7 @@ def post(self, request, pk): # class RegionListView(ObjectListView): - queryset = Region.objects.annotate(site_count=Count('sites')) + queryset = Region.objects.all() filter = filters.RegionFilter filter_form = forms.RegionFilterForm table = tables.RegionTable @@ -151,7 +162,7 @@ class RegionBulkImportView(PermissionRequiredMixin, BulkImportView): class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' - queryset = Region.objects.annotate(site_count=Count('sites')) + queryset = Region.objects.all() filter = filters.RegionFilter table = tables.RegionTable default_return_url = 'dcim:region_list' @@ -405,7 +416,7 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rack' model_form = forms.RackCSVForm - table = tables.RackImportTable + table = tables.RackTable default_return_url = 'dcim:rack_list' @@ -540,29 +551,35 @@ def get(self, request, pk): # Component tables consoleport_table = tables.ConsolePortTemplateTable( - natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + ConsolePortTemplate.objects.filter(device_type=devicetype), orderable=False ) consoleserverport_table = tables.ConsoleServerPortTemplateTable( - natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + ConsoleServerPortTemplate.objects.filter(device_type=devicetype), orderable=False ) powerport_table = tables.PowerPortTemplateTable( - natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + PowerPortTemplate.objects.filter(device_type=devicetype), orderable=False ) poweroutlet_table = tables.PowerOutletTemplateTable( - natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + PowerOutletTemplate.objects.filter(device_type=devicetype), orderable=False ) interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.order_naturally( - devicetype.interface_ordering - ).filter(device_type=devicetype)), + list(InterfaceTemplate.objects.filter(device_type=devicetype)), + orderable=False + ) + front_port_table = tables.FrontPortTemplateTable( + FrontPortTemplate.objects.filter(device_type=devicetype), + orderable=False + ) + rear_port_table = tables.RearPortTemplateTable( + RearPortTemplate.objects.filter(device_type=devicetype), orderable=False ) devicebay_table = tables.DeviceBayTemplateTable( - natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + DeviceBayTemplate.objects.filter(device_type=devicetype), orderable=False ) if request.user.has_perm('dcim.change_devicetype'): @@ -571,6 +588,8 @@ def get(self, request, pk): powerport_table.columns.show('pk') poweroutlet_table.columns.show('pk') interface_table.columns.show('pk') + front_port_table.columns.show('pk') + rear_port_table.columns.show('pk') devicebay_table.columns.show('pk') return render(request, 'dcim/devicetype.html', { @@ -580,6 +599,8 @@ def get(self, request, pk): 'powerport_table': powerport_table, 'poweroutlet_table': poweroutlet_table, 'interface_table': interface_table, + 'front_port_table': front_port_table, + 'rear_port_table': rear_port_table, 'devicebay_table': devicebay_table, }) @@ -723,6 +744,40 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InterfaceTemplateTable +class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_frontporttemplate' + parent_model = DeviceType + parent_field = 'device_type' + model = FrontPortTemplate + form = forms.FrontPortTemplateCreateForm + model_form = forms.FrontPortTemplateForm + template_name = 'dcim/device_component_add.html' + + +class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_frontporttemplate' + queryset = FrontPortTemplate.objects.all() + parent_model = DeviceType + table = tables.FrontPortTemplateTable + + +class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_rearporttemplate' + parent_model = DeviceType + parent_field = 'device_type' + model = RearPortTemplate + form = forms.RearPortTemplateCreateForm + model_form = forms.RearPortTemplateForm + template_name = 'dcim/device_component_add.html' + + +class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rearporttemplate' + queryset = RearPortTemplate.objects.all() + parent_model = DeviceType + table = tables.RearPortTemplateTable + + class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' parent_model = DeviceType @@ -815,8 +870,9 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceListView(ObjectListView): - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', - 'primary_ip4', 'primary_ip6') + queryset = Device.objects.select_related( + 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' + ) filter = filters.DeviceFilter filter_form = forms.DeviceFilterForm table = tables.DeviceDetailTable @@ -833,42 +889,42 @@ def get(self, request, pk): # VirtualChassis members if device.virtual_chassis is not None: - vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis).order_by('vc_position') + vc_members = Device.objects.filter( + virtual_chassis=device.virtual_chassis + ).order_by('vc_position') else: vc_members = [] # Console ports - console_ports = natsorted( - ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') - ) + console_ports = device.consoleports.select_related('connected_endpoint__device', 'cable') # Console server ports - cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') # Power ports - power_ports = natsorted( - PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') - ) + power_ports = device.powerports.select_related('connected_endpoint__device', 'cable') # Power outlets - power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') + poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable') # Interfaces - interfaces = device.vc_interfaces.order_naturally( - device.device_type.interface_ordering - ).select_related( - 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', - 'circuit_termination__circuit' - ).prefetch_related('ip_addresses') + interfaces = device.vc_interfaces.select_related( + 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable' + ).prefetch_related( + 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' + ) + + # Front ports + front_ports = device.frontports.select_related('rear_port', 'cable') + + # Rear ports + rear_ports = device.rearports.select_related('cable') # Device bays - device_bays = natsorted( - DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), - key=attrgetter('name') - ) + device_bays = device.device_bays.select_related('installed_device__device_type__manufacturer') # Services - services = Service.objects.filter(device=device) + services = device.services.all() # Secrets secrets = device.secrets.all() @@ -888,11 +944,13 @@ def get(self, request, pk): return render(request, 'dcim/device.html', { 'device': device, 'console_ports': console_ports, - 'cs_ports': cs_ports, + 'consoleserverports': consoleserverports, 'power_ports': power_ports, - 'power_outlets': power_outlets, + 'poweroutlets': poweroutlets, 'interfaces': interfaces, 'device_bays': device_bays, + 'front_ports': front_ports, + 'rear_ports': rear_ports, 'services': services, 'secrets': secrets, 'vc_members': vc_members, @@ -940,10 +998,8 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): def get(self, request, pk): device = get_object_or_404(Device, pk=pk) - interfaces = device.vc_interfaces.order_naturally( - device.device_type.interface_ordering - ).connectable().select_related( - 'connected_as_a', 'connected_as_b' + interfaces = device.vc_interfaces.connectable().select_related( + '_connected_interface__device' ) return render(request, 'dcim/device_lldp_neighbors.html', { @@ -1047,102 +1103,6 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class ConsolePortConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleport' - - def get(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = forms.ConsolePortConnectionForm(instance=consoleport, initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'console_server': request.GET.get('console_server'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/consoleport_connect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - def post(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport) - - if form.is_valid(): - - consoleport = form.save() - msg = 'Connected {} {} to {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - consoleport.cs_port.device.get_absolute_url(), - escape(consoleport.cs_port.device), - escape(consoleport.cs_port.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleport.device.pk) - - return render(request, 'dcim/consoleport_connect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - -class ConsolePortDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleport' - - def get(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = ConfirmationForm() - - if not consoleport.cs_port: - messages.warning( - request, "Cannot disconnect console port {}: It is not connected to anything.".format(consoleport) - ) - return redirect('dcim:device', pk=consoleport.device.pk) - - return render(request, 'dcim/consoleport_disconnect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - def post(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - cs_port = consoleport.cs_port - consoleport.cs_port = None - consoleport.connection_status = None - consoleport.save() - msg = 'Disconnected {} {} from {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - cs_port.device.get_absolute_url(), - escape(cs_port.device), - escape(cs_port.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleport.device.pk) - - return render(request, 'dcim/consoleport_disconnect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort @@ -1161,13 +1121,6 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.ConsolePortTable -class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_consoleport' - model_form = forms.ConsoleConnectionCSVForm - table = tables.ConsoleConnectionTable - default_return_url = 'dcim:console_connections_list' - - # # Console server ports # @@ -1182,106 +1135,6 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class ConsoleServerPortConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleserverport' - - def get(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = forms.ConsoleServerPortConnectionForm(initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'device': request.GET.get('device'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/consoleserverport_connect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - def post(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = forms.ConsoleServerPortConnectionForm(request.POST) - - if form.is_valid(): - - consoleport = form.cleaned_data['port'] - consoleport.cs_port = consoleserverport - consoleport.connection_status = form.cleaned_data['connection_status'] - consoleport.save() - msg = 'Connected {} {} to {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - consoleserverport.device.get_absolute_url(), - escape(consoleserverport.device), - escape(consoleserverport.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleserverport.device.pk) - - return render(request, 'dcim/consoleserverport_connect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - -class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleserverport' - - def get(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = ConfirmationForm() - - if not hasattr(consoleserverport, 'connected_console'): - messages.warning( - request, - "Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport) - ) - return redirect('dcim:device', pk=consoleserverport.device.pk) - - return render(request, 'dcim/consoleserverport_disconnect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - def post(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - consoleport = consoleserverport.connected_console - consoleport.cs_port = None - consoleport.connection_status = None - consoleport.save() - msg = 'Disconnected {} {} from {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - consoleserverport.device.get_absolute_url(), - escape(consoleserverport.device), - escape(consoleserverport.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleserverport.device.pk) - - return render(request, 'dcim/consoleserverport_disconnect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort @@ -1304,9 +1157,6 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec model = ConsoleServerPort form = forms.ConsoleServerPortBulkDisconnectForm - def disconnect_objects(self, cs_ports): - return ConsolePort.objects.filter(cs_port__in=cs_ports).update(cs_port=None, connection_status=None) - class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' @@ -1329,102 +1179,6 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class PowerPortConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_powerport' - - def get(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = forms.PowerPortConnectionForm(instance=powerport, initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'pdu': request.GET.get('pdu'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/powerport_connect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - def post(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = forms.PowerPortConnectionForm(request.POST, instance=powerport) - - if form.is_valid(): - - powerport = form.save() - msg = 'Connected {} {} to {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - powerport.power_outlet.device.get_absolute_url(), - escape(powerport.power_outlet.device), - escape(powerport.power_outlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=powerport.device.pk) - - return render(request, 'dcim/powerport_connect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - -class PowerPortDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_powerport' - - def get(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = ConfirmationForm() - - if not powerport.power_outlet: - messages.warning( - request, "Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport) - ) - return redirect('dcim:device', pk=powerport.device.pk) - - return render(request, 'dcim/powerport_disconnect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - def post(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - power_outlet = powerport.power_outlet - powerport.power_outlet = None - powerport.connection_status = None - powerport.save() - msg = 'Disconnected {} {} from {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - power_outlet.device.get_absolute_url(), - escape(power_outlet.device), - escape(power_outlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=powerport.device.pk) - - return render(request, 'dcim/powerport_disconnect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerport' model = PowerPort @@ -1443,13 +1197,6 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.PowerPortTable -class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_powerport' - model_form = forms.PowerConnectionCSVForm - table = tables.PowerConnectionTable - default_return_url = 'dcim:power_connections_list' - - # # Power outlets # @@ -1464,104 +1211,6 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class PowerOutletConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_poweroutlet' - - def get(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = forms.PowerOutletConnectionForm(initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'device': request.GET.get('device'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/poweroutlet_connect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - def post(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = forms.PowerOutletConnectionForm(request.POST) - - if form.is_valid(): - powerport = form.cleaned_data['port'] - powerport.power_outlet = poweroutlet - powerport.connection_status = form.cleaned_data['connection_status'] - powerport.save() - msg = 'Connected {} {} to {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - poweroutlet.device.get_absolute_url(), - escape(poweroutlet.device), - escape(poweroutlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=poweroutlet.device.pk) - - return render(request, 'dcim/poweroutlet_connect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - -class PowerOutletDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_poweroutlet' - - def get(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = ConfirmationForm() - - if not hasattr(poweroutlet, 'connected_port'): - messages.warning( - request, "Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet) - ) - return redirect('dcim:device', pk=poweroutlet.device.pk) - - return render(request, 'dcim/poweroutlet_disconnect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - def post(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - powerport = poweroutlet.connected_port - powerport.power_outlet = None - powerport.connection_status = None - powerport.save() - msg = 'Disconnected {} {} from {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - poweroutlet.device.get_absolute_url(), - escape(poweroutlet.device), - escape(poweroutlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=poweroutlet.device.pk) - - return render(request, 'dcim/poweroutlet_disconnect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet @@ -1584,11 +1233,6 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView) model = PowerOutlet form = forms.PowerOutletBulkDisconnectForm - def disconnect_objects(self, power_outlets): - return PowerPort.objects.filter(power_outlet__in=power_outlets).update( - power_outlet=None, connection_status=None - ) - class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' @@ -1607,13 +1251,6 @@ 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'), @@ -1636,7 +1273,8 @@ def get(self, request, pk): return render(request, 'dcim/interface.html', { 'interface': interface, - 'connected_interface': connected_interface, + 'connected_interface': interface._connected_interface, + 'connected_circuittermination': interface._connected_circuittermination, 'ipaddress_table': ipaddress_table, 'vlan_table': vlan_table, }) @@ -1670,18 +1308,6 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = Interface -class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_interface' - model = Interface - form = forms.InterfaceBulkDisconnectForm - - def disconnect_objects(self, interfaces): - count, _ = InterfaceConnection.objects.filter( - Q(interface_a__in=interfaces) | Q(interface_b__in=interfaces) - ).delete() - return count - - class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' queryset = Interface.objects.all() @@ -1692,10 +1318,16 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): permission_required = 'dcim.change_interface' - queryset = Interface.objects.order_naturally() + queryset = Interface.objects.all() form = forms.InterfaceBulkRenameForm +class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_interface' + model = Interface + form = forms.InterfaceBulkDisconnectForm + + class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' queryset = Interface.objects.all() @@ -1703,6 +1335,94 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InterfaceTable +# +# Front ports +# + +class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_frontport' + parent_model = Device + parent_field = 'device' + model = FrontPort + form = forms.FrontPortCreateForm + model_form = forms.FrontPortForm + template_name = 'dcim/device_component_add.html' + + +class FrontPortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_frontport' + model = FrontPort + model_form = forms.FrontPortForm + + +class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_frontport' + model = FrontPort + + +class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_frontport' + queryset = FrontPort.objects.all() + form = forms.FrontPortBulkRenameForm + + +class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_frontport' + model = FrontPort + form = forms.FrontPortBulkDisconnectForm + + +class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_frontport' + queryset = FrontPort.objects.all() + parent_model = Device + table = tables.FrontPortTable + + +# +# Rear ports +# + +class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_rearport' + parent_model = Device + parent_field = 'device' + model = RearPort + form = forms.RearPortCreateForm + model_form = forms.RearPortForm + template_name = 'dcim/device_component_add.html' + + +class RearPortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_rearport' + model = RearPort + model_form = forms.RearPortForm + + +class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rearport' + model = RearPort + + +class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_rearport' + queryset = RearPort.objects.all() + form = forms.RearPortBulkRenameForm + + +class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_rearport' + model = RearPort + form = forms.RearPortBulkDisconnectForm + + +class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rearport' + queryset = RearPort.objects.all() + parent_model = Device + table = tables.RearPortTable + + # # Device bays # @@ -1821,6 +1541,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV form = forms.DeviceBulkAddComponentForm model = ConsolePort model_form = forms.ConsolePortForm + filter = filters.DeviceFilter table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1832,6 +1553,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC form = forms.DeviceBulkAddComponentForm model = ConsoleServerPort model_form = forms.ConsoleServerPortForm + filter = filters.DeviceFilter table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1843,6 +1565,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie form = forms.DeviceBulkAddComponentForm model = PowerPort model_form = forms.PowerPortForm + filter = filters.DeviceFilter table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1854,6 +1577,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV form = forms.DeviceBulkAddComponentForm model = PowerOutlet model_form = forms.PowerOutletForm + filter = filters.DeviceFilter table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1865,6 +1589,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie form = forms.DeviceBulkAddInterfaceForm model = Interface model_form = forms.InterfaceForm + filter = filters.DeviceFilter table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1876,117 +1601,103 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie form = forms.DeviceBulkAddComponentForm model = DeviceBay model_form = forms.DeviceBayForm + filter = filters.DeviceFilter table = tables.DeviceTable default_return_url = 'dcim:device_list' # -# Interface connections +# Cables # -class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.add_interfaceconnection' - default_return_url = 'dcim:device_list' +class CableListView(ObjectListView): + queryset = Cable.objects.prefetch_related( + 'termination_a', 'termination_b' + ) + filter = filters.CableFilter + filter_form = forms.CableFilterForm + table = tables.CableTable + template_name = 'dcim/cable_list.html' + + +class CableView(View): def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) - form = forms.InterfaceConnectionForm(device, initial={ - 'interface_a': request.GET.get('interface_a'), - 'site_b': request.GET.get('site_b'), - 'rack_b': request.GET.get('rack_b'), - 'device_b': request.GET.get('device_b'), - 'interface_b': request.GET.get('interface_b'), - }) + cable = get_object_or_404(Cable, pk=pk) - return render(request, 'dcim/interfaceconnection_edit.html', { - 'device': device, - 'form': form, - 'return_url': device.get_absolute_url(), + return render(request, 'dcim/cable.html', { + 'cable': cable, }) - def post(self, request, pk): - device = get_object_or_404(Device, pk=pk) - form = forms.InterfaceConnectionForm(device, request.POST) +class CableTraceView(View): + """ + Trace a cable path beginning from the given termination. + """ - if form.is_valid(): + def get(self, request, model, pk): - interfaceconnection = form.save() - msg = 'Connected {} {} to {} {}'.format( - interfaceconnection.interface_a.device.get_absolute_url(), - escape(interfaceconnection.interface_a.device), - escape(interfaceconnection.interface_a.name), - interfaceconnection.interface_b.device.get_absolute_url(), - escape(interfaceconnection.interface_b.device), - escape(interfaceconnection.interface_b.name), - ) - messages.success(request, mark_safe(msg)) - - if '_addanother' in request.POST: - base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) - device_b = interfaceconnection.interface_b.device - params = urlencode({ - 'rack_b': device_b.rack.pk if device_b.rack else '', - 'device_b': device_b.pk, - }) - return HttpResponseRedirect('{}?{}'.format(base_url, params)) - else: - return redirect('dcim:device', pk=device.pk) - - return render(request, 'dcim/interfaceconnection_edit.html', { - 'device': device, - 'form': form, - 'return_url': device.get_absolute_url(), + obj = get_object_or_404(model, pk=pk) + + return render(request, 'dcim/cable_trace.html', { + 'obj': obj, + 'trace': obj.trace(follow_circuits=True), }) -class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.delete_interfaceconnection' - default_return_url = 'dcim:device_list' +class CableCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_cable' + model = Cable + model_form = forms.CableCreateForm + template_name = 'dcim/cable_connect.html' - def get(self, request, pk): + def alter_obj(self, obj, request, url_args, url_kwargs): - interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) - form = forms.ConfirmationForm() + # Retrieve endpoint A based on the given type and PK + termination_a_type = url_kwargs.get('termination_a_type') + termination_a_id = url_kwargs.get('termination_a_id') + obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) - return render(request, 'dcim/interfaceconnection_delete.html', { - 'interfaceconnection': interfaceconnection, - 'form': form, - 'return_url': self.get_return_url(request, interfaceconnection), - }) + return obj - def post(self, request, pk): - interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) - form = forms.ConfirmationForm(request.POST) +class CableEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_cable' + model = Cable + model_form = forms.CableForm + template_name = 'dcim/cable_edit.html' + default_return_url = 'dcim:cable_list' - if form.is_valid(): - interfaceconnection.delete() - msg = 'Disconnected {} {} from {} {}'.format( - interfaceconnection.interface_a.device.get_absolute_url(), - escape(interfaceconnection.interface_a.device), - escape(interfaceconnection.interface_a.name), - interfaceconnection.interface_b.device.get_absolute_url(), - escape(interfaceconnection.interface_b.device), - escape(interfaceconnection.interface_b.name), - ) - messages.success(request, mark_safe(msg)) - return redirect(self.get_return_url(request, interfaceconnection)) +class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_cable' + model = Cable + default_return_url = 'dcim:cable_list' - return render(request, 'dcim/interfaceconnection_delete.html', { - 'interfaceconnection': interfaceconnection, - 'form': form, - 'return_url': self.get_return_url(request, interfaceconnection), - }) +class CableBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_cable' + model_form = forms.CableCSVForm + table = tables.CableTable + default_return_url = 'dcim:cable_list' -class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_interface' - model_form = forms.InterfaceConnectionCSVForm - table = tables.InterfaceConnectionTable - default_return_url = 'dcim:interface_connections_list' + +class CableBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_cable' + queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + filter = filters.CableFilter + table = tables.CableTable + form = forms.CableBulkEditForm + default_return_url = 'dcim:cable_list' + + +class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_cable' + queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + filter = filters.CableFilter + table = tables.CableTable + default_return_url = 'dcim:cable_list' # @@ -1994,34 +1705,102 @@ class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView # class ConsoleConnectionsListView(ObjectListView): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False) \ - .order_by('cs_port__device__name', 'cs_port__name') + queryset = ConsolePort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ).order_by( + 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + ) filter = filters.ConsoleConnectionFilter filter_form = forms.ConsoleConnectionFilterForm table = tables.ConsoleConnectionTable template_name = 'dcim/console_connections_list.html' + def queryset_to_csv(self): + csv_data = [ + # Headers + ','.join(['console_server', 'port', 'device', 'console_port', 'connection_status']) + ] + for obj in self.queryset: + csv = csv_format([ + obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, + obj.connected_endpoint.name if obj.connected_endpoint else None, + obj.device.identifier, + obj.name, + obj.get_connection_status_display(), + ]) + csv_data.append(csv) + return csv_data + class PowerConnectionsListView(ObjectListView): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False) \ - .order_by('power_outlet__device__name', 'power_outlet__name') + queryset = PowerPort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ).order_by( + 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + ) filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm table = tables.PowerConnectionTable template_name = 'dcim/power_connections_list.html' + def queryset_to_csv(self): + csv_data = [ + # Headers + ','.join(['pdu', 'outlet', 'device', 'power_port', 'connection_status']) + ] + for obj in self.queryset: + csv = csv_format([ + obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, + obj.connected_endpoint.name if obj.connected_endpoint else None, + obj.device.identifier, + obj.name, + obj.get_connection_status_display(), + ]) + csv_data.append(csv) + return csv_data + class InterfaceConnectionsListView(ObjectListView): - queryset = InterfaceConnection.objects.select_related( - 'interface_a__device', 'interface_b__device' + queryset = Interface.objects.select_related( + 'device', 'cable', '_connected_interface__device' + ).filter( + # Avoid duplicate connections by only selecting the lower PK in a connected pair + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') ).order_by( - 'interface_a__device__name', 'interface_a__name' + 'device' ) filter = filters.InterfaceConnectionFilter filter_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable template_name = 'dcim/interface_connections_list.html' + def queryset_to_csv(self): + csv_data = [ + # Headers + ','.join([ + 'device_a', 'interface_a', 'interface_a_description', + 'device_b', 'interface_b', 'interface_b_description', + 'connection_status' + ]) + ] + for obj in self.queryset: + csv = csv_format([ + obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, + obj.connected_endpoint.name if obj.connected_endpoint else None, + obj.connected_endpoint.description if obj.connected_endpoint else None, + obj.device.identifier, + obj.name, + obj.description, + obj.get_connection_status_display(), + ]) + csv_data.append(csv) + return csv_data + # # Inventory items diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 0549ce3172f..b4962dfd7b2 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,12 +1,9 @@ -from __future__ import unicode_literals - from django import forms from django.contrib import admin -from django.utils.safestring import mark_safe from netbox.admin import admin_site from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, Webhook +from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, Webhook def order_content_types(field): @@ -31,9 +28,10 @@ class Meta: exclude = [] def __init__(self, *args, **kwargs): - super(WebhookForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) - order_content_types(self.fields['obj_type']) + if 'obj_type' in self.fields: + order_content_types(self.fields['obj_type']) @admin.register(Webhook, site=admin_site) @@ -59,7 +57,7 @@ class Meta: exclude = [] def __init__(self, *args, **kwargs): - super(CustomFieldForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) order_content_types(self.fields['obj_type']) @@ -99,7 +97,7 @@ class Meta: exclude = [] def __init__(self, *args, **kwargs): - super(ExportTemplateForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Format ContentType choices order_content_types(self.fields['content_type']) @@ -122,16 +120,3 @@ class TopologyMapAdmin(admin.ModelAdmin): prepopulated_fields = { 'slug': ['name'], } - - -# -# User actions -# - -@admin.register(UserAction, site=admin_site) -class UserActionAdmin(admin.ModelAdmin): - actions = None - list_display = ['user', 'action', 'content_type', 'object_id', '_message'] - - def _message(self, obj): - return mark_safe(obj.message) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 0497138c4ba..7bf1c07447c 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from datetime import datetime from django.contrib.contenttypes.models import ContentType @@ -107,7 +105,7 @@ def _populate_custom_fields(instance, fields): custom_fields[cfv.field.name] = cfv.value instance.custom_fields = custom_fields - super(CustomFieldModelSerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance is not None: @@ -139,7 +137,7 @@ def create(self, validated_data): with transaction.atomic(): - instance = super(CustomFieldModelSerializer, self).create(validated_data) + instance = super().create(validated_data) # Save custom fields if custom_fields is not None: @@ -154,7 +152,7 @@ def update(self, instance, validated_data): with transaction.atomic(): - instance = super(CustomFieldModelSerializer, self).update(instance, validated_data) + instance = super().update(instance, validated_data) # Save custom fields if custom_fields is not None: diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py new file mode 100644 index 00000000000..11367aba94e --- /dev/null +++ b/netbox/extras/api/nested_serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from extras.models import ReportResult + +__all__ = [ + 'NestedReportResultSerializer', +] + + +# +# Reports +# + +class NestedReportResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:report-detail', + lookup_field='report', + lookup_url_kwarg='pk' + ) + + class Meta: + model = ReportResult + fields = ['url', 'created', 'user', 'failed'] diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index d0d2c67b089..7643562bb1d 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,24 +1,23 @@ -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 ( +from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, ) from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from extras.constants import * from extras.models import ( - ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, + ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, ) -from extras.constants import * -from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer +from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup -from users.api.serializers import NestedUserSerializer +from users.api.nested_serializers import NestedUserSerializer from utilities.api import ( ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer, ) +from .nested_serializers import * # @@ -109,7 +108,7 @@ def validate(self, data): ) # Enforce model validation - super(ImageAttachmentSerializer, self).validate(data) + super().validate(data) return data @@ -189,18 +188,6 @@ class Meta: fields = ['created', 'user', 'failed', 'data'] -class NestedReportResultSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='extras-api:report-detail', - lookup_field='report', - lookup_url_kwarg='pk' - ) - - class Meta: - model = ReportResult - fields = ['url', 'created', 'user', 'failed'] - - class ReportSerializer(serializers.Serializer): module = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255) @@ -240,16 +227,3 @@ def get_changed_object(self, obj): context = {'request': self.context['request']} data = serializer(obj.changed_object, context=context).data return data - - -# -# User actions -# - -class UserActionSerializer(serializers.ModelSerializer): - user = NestedUserSerializer() - action = ChoiceField(choices=ACTION_CHOICES) - - class Meta: - model = UserAction - fields = ['id', 'time', 'user', 'action', 'message'] diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index cf61841ddfc..1bdcf181b34 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ def get_view_name(self): router.APIRootView = ExtrasRootView # Field choices -router.register(r'_choices', views.ExtrasFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') # Graphs router.register(r'graphs', views.GraphViewSet) @@ -38,13 +36,10 @@ def get_view_name(self): router.register(r'config-contexts', views.ConfigContextViewSet) # Reports -router.register(r'reports', views.ReportViewSet, base_name='report') +router.register(r'reports', views.ReportViewSet, basename='report') # Change logging router.register(r'object-changes', views.ObjectChangeViewSet) -# Recent activity -router.register(r'recent-activity', views.RecentActivityViewSet) - app_name = 'extras-api' urlpatterns = router.urls diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 0fefa7ae600..0453b1f1cfb 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.http import Http404, HttpResponse @@ -13,7 +11,6 @@ from extras import filters 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 @@ -53,7 +50,7 @@ def get_serializer_context(self): custom_field_choices[cfc.id] = cfc.value custom_field_choices = custom_field_choices - context = super(CustomFieldModelViewSet, self).get_serializer_context() + context = super().get_serializer_context() context.update({ 'custom_fields': custom_fields, 'custom_field_choices': custom_field_choices, @@ -62,7 +59,7 @@ def get_serializer_context(self): def get_queryset(self): # Prefetch custom field values - return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') + return super().get_queryset().prefetch_related('custom_field_values__field') # @@ -72,7 +69,7 @@ def get_queryset(self): class GraphViewSet(ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer - filter_class = filters.GraphFilter + filterset_class = filters.GraphFilter # @@ -82,7 +79,7 @@ class GraphViewSet(ModelViewSet): class ExportTemplateViewSet(ModelViewSet): queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer - filter_class = filters.ExportTemplateFilter + filterset_class = filters.ExportTemplateFilter # @@ -92,7 +89,7 @@ class ExportTemplateViewSet(ModelViewSet): class TopologyMapViewSet(ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer - filter_class = filters.TopologyMapFilter + filterset_class = filters.TopologyMapFilter @action(detail=True) def render(self, request, pk): @@ -102,10 +99,9 @@ def render(self, request, pk): try: data = tmap.render(img_format=img_format) - except Exception: + except Exception as e: return HttpResponse( - "There was an error generating the requested graph. Ensure that the GraphViz executables have been " - "installed correctly." + "There was an error generating the requested graph: %s" % e ) response = HttpResponse(data, content_type='image/{}'.format(img_format)) @@ -121,7 +117,7 @@ def render(self, request, pk): class TagViewSet(ModelViewSet): queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items')) serializer_class = serializers.TagSerializer - filter_class = filters.TagFilter + filterset_class = filters.TagFilter # @@ -142,7 +138,7 @@ class ConfigContextViewSet(ModelViewSet): 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer - filter_class = filters.ConfigContextFilter + filterset_class = filters.ConfigContextFilter # @@ -231,17 +227,4 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): """ queryset = ObjectChange.objects.select_related('user') serializer_class = serializers.ObjectChangeSerializer - filter_class = filters.ObjectChangeFilter - - -# -# User activity -# - -class RecentActivityViewSet(ReadOnlyModelViewSet): - """ - DEPRECATED: List all UserActions to provide a log of recent activity. - """ - queryset = UserAction.objects.all() - serializer_class = serializers.UserActionSerializer - filter_class = filters.UserActionFilter + filterset_class = filters.ObjectChangeFilter diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 4520b1923b0..2d4517c26af 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - from django.apps import AppConfig -from django.core.exceptions import ImproperlyConfigured from django.conf import settings +from django.core.exceptions import ImproperlyConfigured class ExtrasConfig(AppConfig): diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 9707d91211c..51fc398f767 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # Models which support custom fields CUSTOMFIELD_MODELS = ( @@ -51,7 +49,7 @@ EXPORTTEMPLATE_MODELS = [ 'provider', 'circuit', # Circuits 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM - 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', # DCIM + 'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM 'secret', # Secrets 'tenant', # Tenancy diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 3abd5b4cfa1..d0a801b481f 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,7 +1,4 @@ -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 @@ -9,7 +6,7 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT -from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction +from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap class CustomFieldFilter(django_filters.Filter): @@ -20,12 +17,12 @@ class CustomFieldFilter(django_filters.Filter): def __init__(self, custom_field, *args, **kwargs): self.cf_type = custom_field.type self.filter_logic = custom_field.filter_logic - super(CustomFieldFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def filter(self, queryset, value): # Skip filter on empty value - if not value.strip(): + if value is None or not value.strip(): return queryset # Selection fields get special treatment (values must be integers) @@ -34,12 +31,12 @@ def filter(self, queryset, value): # Treat 0 as None if int(value) == 0: return queryset.exclude( - custom_field_values__field__name=self.name, + custom_field_values__field__name=self.field_name, ) # Match on exact CustomFieldChoice PK else: return queryset.filter( - custom_field_values__field__name=self.name, + custom_field_values__field__name=self.field_name, custom_field_values__serialized_value=value, ) except ValueError: @@ -48,12 +45,12 @@ def filter(self, queryset, value): # Apply the assigned filter logic (exact or loose) if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT: queryset = queryset.filter( - custom_field_values__field__name=self.name, + custom_field_values__field__name=self.field_name, custom_field_values__serialized_value=value ) else: queryset = queryset.filter( - custom_field_values__field__name=self.name, + custom_field_values__field__name=self.field_name, custom_field_values__serialized_value__icontains=value ) @@ -66,12 +63,12 @@ class CustomFieldFilterSet(django_filters.FilterSet): """ def __init__(self, *args, **kwargs): - super(CustomFieldFilterSet, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) obj_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED) for cf in custom_fields: - self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf) + self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) class GraphFilter(django_filters.FilterSet): @@ -109,12 +106,12 @@ def search(self, queryset, name, value): class TopologyMapFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( - name='site', + field_name='site', queryset=Site.objects.all(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -131,67 +128,67 @@ class ConfigContextFilter(django_filters.FilterSet): label='Search', ) region_id = django_filters.ModelMultipleChoiceFilter( - name='regions', + field_name='regions', queryset=Region.objects.all(), label='Region', ) region = django_filters.ModelMultipleChoiceFilter( - name='regions__slug', + field_name='regions__slug', queryset=Region.objects.all(), to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='sites', + field_name='sites', queryset=Site.objects.all(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( - name='sites__slug', + field_name='sites__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) role_id = django_filters.ModelMultipleChoiceFilter( - name='roles', + field_name='roles', queryset=DeviceRole.objects.all(), label='Role', ) role = django_filters.ModelMultipleChoiceFilter( - name='roles__slug', + field_name='roles__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', ) platform_id = django_filters.ModelMultipleChoiceFilter( - name='platforms', + field_name='platforms', queryset=Platform.objects.all(), label='Platform', ) platform = django_filters.ModelMultipleChoiceFilter( - name='platforms__slug', + field_name='platforms__slug', queryset=Platform.objects.all(), to_field_name='slug', label='Platform (slug)', ) tenant_group_id = django_filters.ModelMultipleChoiceFilter( - name='tenant_groups', + field_name='tenant_groups', queryset=TenantGroup.objects.all(), label='Tenant group', ) tenant_group = django_filters.ModelMultipleChoiceFilter( - name='tenant_groups__slug', + field_name='tenant_groups__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', label='Tenant group (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( - name='tenants', + field_name='tenants', queryset=Tenant.objects.all(), label='Tenant', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenants__slug', + field_name='tenants__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -229,15 +226,3 @@ def search(self, queryset, name, value): Q(user_name__icontains=value) | Q(object_repr__icontains=value) ) - - -class UserActionFilter(django_filters.FilterSet): - username = django_filters.ModelMultipleChoiceFilter( - name='user__username', - queryset=User.objects.all(), - to_field_name='username', - ) - - class Meta: - model = UserAction - fields = ['user'] diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 7dfceb39009..3b7b26b66b6 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from collections import OrderedDict from django import forms @@ -13,8 +11,8 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, - JSONField, SlugField, + add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField, + FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, ) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, @@ -104,7 +102,7 @@ def __init__(self, *args, **kwargs): self.custom_fields = [] self.obj_type = ContentType.objects.get_for_model(self._meta.model) - super(CustomFieldForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Add all applicable CustomFields to the form custom_fields = [] @@ -140,7 +138,7 @@ def _save_custom_fields(self): cfv.save() def save(self, commit=True): - obj = super(CustomFieldForm, self).save(commit) + obj = super().save(commit) # Handle custom fields the same way we do M2M fields if commit: @@ -154,7 +152,7 @@ def save(self, commit=True): class CustomFieldBulkEditForm(BulkEditForm): def __init__(self, *args, **kwargs): - super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.custom_fields = [] self.obj_type = ContentType.objects.get_for_model(self.model) @@ -177,7 +175,7 @@ def __init__(self, *args, **kwargs): self.obj_type = ContentType.objects.get_for_model(self.model) - super(CustomFieldFilterForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Add all applicable CustomFields to the form custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items() @@ -195,19 +193,29 @@ class TagForm(BootstrapMixin, forms.ModelForm): class Meta: model = Tag - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class AddRemoveTagsForm(forms.Form): def __init__(self, *args, **kwargs): - super(AddRemoveTagsForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Add add/remove tags fields self.fields['add_tags'] = TagField(required=False) self.fields['remove_tags'] = TagField(required=False) +class TagFilterForm(BootstrapMixin, forms.Form): + model = Tag + q = forms.CharField( + required=False, + label='Search' + ) + + # # Config contexts # @@ -227,6 +235,30 @@ class Meta: ] +class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConfigContext.objects.all(), + widget=forms.MultipleHiddenInput + ) + weight = forms.IntegerField( + required=False, + min_value=0 + ) + is_active = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + description = forms.CharField( + required=False, + max_length=100 + ) + + class Meta: + nullable_fields = [ + 'description', + ] + + class ConfigContextFilterForm(BootstrapMixin, forms.Form): q = forms.CharField( required=False, @@ -266,28 +298,29 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class Meta: model = ImageAttachment - fields = ['name', 'image'] + fields = [ + 'name', 'image', + ] # # Change logging # -class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm): +class ObjectChangeFilterForm(BootstrapMixin, forms.Form): 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( + time_after = forms.DateTimeField( label='After', required=False, widget=forms.TextInput( attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'} ) ) - time_1 = forms.DateTimeField( + time_before = forms.DateTimeField( label='Before', required=False, widget=forms.TextInput( @@ -302,3 +335,9 @@ class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=User.objects.order_by('username'), required=False ) + changed_object_type = forms.ModelChoiceField( + queryset=ContentType.objects.order_by('model'), + required=False, + widget=ContentTypeSelect(), + label='Object Type' + ) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 15b8acac5f5..c5a2fa1ecfa 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import code import platform import sys diff --git a/netbox/extras/management/commands/run_inventory.py b/netbox/extras/management/commands/run_inventory.py deleted file mode 100644 index c42bdf50aa4..00000000000 --- a/netbox/extras/management/commands/run_inventory.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import unicode_literals - -from getpass import getpass - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError -from django.db import transaction -from ncclient.transport.errors import AuthenticationError -from paramiko import AuthenticationException - -from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site - - -class Command(BaseCommand): - help = "Update inventory information for specified devices" - username = settings.NAPALM_USERNAME - password = settings.NAPALM_PASSWORD - - def add_arguments(self, parser): - parser.add_argument('-u', '--username', dest='username', help="Specify the username to use") - parser.add_argument('-p', '--password', action='store_true', default=False, help="Prompt for password to use") - parser.add_argument('-s', '--site', dest='site', action='append', - help="Filter devices by site (include argument once per site)") - parser.add_argument('-n', '--name', dest='name', help="Filter devices by name (regular expression)") - parser.add_argument('--full', action='store_true', default=False, help="For inventory update for all devices") - parser.add_argument('--fake', action='store_true', default=False, help="Do not actually update database") - - def handle(self, *args, **options): - - def create_inventory_items(inventory_items, parent=None): - for item in inventory_items: - i = InventoryItem(device=device, parent=parent, name=item['name'], part_id=item['part_id'], - serial=item['serial'], discovered=True) - i.save() - create_inventory_items(item.get('items', []), parent=i) - - # Credentials - if options['username']: - self.username = options['username'] - if options['password']: - self.password = getpass("Password: ") - - # Attempt to inventory only active devices - device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE) - - # --site: Include only devices belonging to specified site(s) - if options['site']: - sites = Site.objects.filter(slug__in=options['site']) - if sites: - site_names = [s.name for s in sites] - self.stdout.write("Running inventory for these sites: {}".format(', '.join(site_names))) - else: - raise CommandError("One or more sites specified but none found.") - device_list = device_list.filter(site__in=sites) - - # --name: Filter devices by name matching a regex - if options['name']: - device_list = device_list.filter(name__iregex=options['name']) - - # --full: Gather inventory data for *all* devices - if options['full']: - self.stdout.write("WARNING: Running inventory for all devices! Prior data will be overwritten. (--full)") - - # --fake: Gathering data but not updating the database - if options['fake']: - self.stdout.write("WARNING: Inventory data will not be saved! (--fake)") - - device_count = device_list.count() - self.stdout.write("** Found {} devices...".format(device_count)) - - for i, device in enumerate(device_list, start=1): - - self.stdout.write("[{}/{}] {}: ".format(i, device_count, device.name), ending='') - - # Skip inactive devices - if not device.status: - self.stdout.write("Skipped (not active)") - continue - - # Skip devices without primary_ip set - if not device.primary_ip: - self.stdout.write("Skipped (no primary IP set)") - continue - - # Skip devices which have already been inventoried if not doing a full update - if device.serial and not options['full']: - self.stdout.write("Skipped (Serial: {})".format(device.serial)) - continue - - RPC = device.get_rpc_client() - if not RPC: - self.stdout.write("Skipped (no RPC client available for platform {})".format(device.platform)) - continue - - # Connect to device and retrieve inventory info - try: - with RPC(device, self.username, self.password) as rpc_client: - inventory = rpc_client.get_inventory() - except KeyboardInterrupt: - raise - except (AuthenticationError, AuthenticationException): - self.stdout.write("Authentication error!") - continue - except Exception as e: - self.stdout.write("Error: {}".format(e)) - continue - - if options['verbosity'] > 1: - self.stdout.write("") - self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial'])) - self.stdout.write("\tDescription: {}".format(inventory['chassis']['description'])) - for item in inventory['items']: - self.stdout.write("\tItem: {} / {} ({})".format(item['name'], item['part_id'], - item['serial'])) - else: - self.stdout.write("{} ({})".format(inventory['chassis']['description'], inventory['chassis']['serial'])) - - if not options['fake']: - with transaction.atomic(): - # Update device serial - if device.serial != inventory['chassis']['serial']: - device.serial = inventory['chassis']['serial'] - device.save() - InventoryItem.objects.filter(device=device, discovered=True).delete() - create_inventory_items(inventory.get('items', [])) - - self.stdout.write("Finished!") diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 96efc43a042..efc789021c8 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.core.management.base import BaseCommand from django.utils import timezone diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 7dfddbad6f8..16461c32a27 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -1,9 +1,7 @@ -from __future__ import unicode_literals - -from datetime import timedelta import random import threading import uuid +from datetime import timedelta from django.conf import settings from django.db.models.signals import post_delete, post_save @@ -16,7 +14,6 @@ ) from .models import ObjectChange - _thread_locals = threading.local() diff --git a/netbox/extras/migrations/0001_initial.py b/netbox/extras/migrations/0001_initial.py index 949b3a2d804..be9b952640c 100644 --- a/netbox/extras/migrations/0001_initial.py +++ b/netbox/extras/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion 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 index 0ac826ba4bf..c6167ff9f05 100644 --- a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py +++ b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py @@ -1,9 +1,5 @@ # -*- 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 @@ -19,13 +15,14 @@ def verify_postgresql_version(apps, schema_editor): """ Verify that PostgreSQL is version 9.4 or higher. """ + # https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION + DB_MINIMUM_VERSION = 90400 # 9.4.0 + 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)) + pg_version = connection.pg_version + + if pg_version < DB_MINIMUM_VERSION: + raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version)) # Skip if the database is missing (e.g. for CI testing) or misconfigured. except OperationalError: diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index 1d33ca28176..300ae758a8d 100644 --- a/netbox/extras/migrations/0002_custom_fields.py +++ b/netbox/extras/migrations/0002_custom_fields.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-08-23 20:33 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/extras/migrations/0003_exporttemplate_add_description.py b/netbox/extras/migrations/0003_exporttemplate_add_description.py index 6355955b5f6..fc45f525521 100644 --- a/netbox/extras/migrations/0003_exporttemplate_add_description.py +++ b/netbox/extras/migrations/0003_exporttemplate_add_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-09-27 20:20 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py index ee838046d03..b35c641dad5 100644 --- a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py +++ b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-11-03 18:33 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0005_useraction_add_bulk_create.py b/netbox/extras/migrations/0005_useraction_add_bulk_create.py index 0f20e521492..58b66fe1ac1 100644 --- a/netbox/extras/migrations/0005_useraction_add_bulk_create.py +++ b/netbox/extras/migrations/0005_useraction_add_bulk_create.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-04 19:45 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0006_add_imageattachments.py b/netbox/extras/migrations/0006_add_imageattachments.py index c4c589a9ead..6842cced016 100644 --- a/netbox/extras/migrations/0006_add_imageattachments.py +++ b/netbox/extras/migrations/0006_add_imageattachments.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-04 19:58 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import extras.models diff --git a/netbox/extras/migrations/0007_unicode_literals.py b/netbox/extras/migrations/0007_unicode_literals.py index cda07583fde..fecb33b7b31 100644 --- a/netbox/extras/migrations/0007_unicode_literals.py +++ b/netbox/extras/migrations/0007_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models import extras.models diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index fbfde2cbae6..e0c74753200 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-26 21:25 -from __future__ import unicode_literals -from distutils.version import StrictVersion -import re from django.conf import settings import django.contrib.postgres.fields.jsonb @@ -15,13 +12,14 @@ def verify_postgresql_version(apps, schema_editor): """ Verify that PostgreSQL is version 9.4 or higher. """ + # https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION + DB_MINIMUM_VERSION = 90400 # 9.4.0 + 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)) + pg_version = connection.pg_version + + if pg_version < DB_MINIMUM_VERSION: + raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version)) # Skip if the database is missing (e.g. for CI testing) or misconfigured. except OperationalError: diff --git a/netbox/extras/migrations/0009_topologymap_type.py b/netbox/extras/migrations/0009_topologymap_type.py index b062c58af71..bc9ec07d549 100644 --- a/netbox/extras/migrations/0009_topologymap_type.py +++ b/netbox/extras/migrations/0009_topologymap_type.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-15 16:28 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0010_customfield_filter_logic.py b/netbox/extras/migrations/0010_customfield_filter_logic.py index e35a2f835b9..dbff03e2de3 100644 --- a/netbox/extras/migrations/0010_customfield_filter_logic.py +++ b/netbox/extras/migrations/0010_customfield_filter_logic.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-21 19:48 -from __future__ import unicode_literals - from django.db import migrations, models from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT diff --git a/netbox/extras/migrations/0012_webhooks.py b/netbox/extras/migrations/0012_webhooks.py index 70c8e9c145e..8f7fcf36fb7 100644 --- a/netbox/extras/migrations/0012_webhooks.py +++ b/netbox/extras/migrations/0012_webhooks.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py index de4762a4622..01d73a84198 100644 --- a/netbox/extras/migrations/0013_objectchange.py +++ b/netbox/extras/migrations/0013_objectchange.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/extras/migrations/0015_remove_useraction.py b/netbox/extras/migrations/0015_remove_useraction.py new file mode 100644 index 00000000000..eb750bc365c --- /dev/null +++ b/netbox/extras/migrations/0015_remove_useraction.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.8 on 2018-08-14 16:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0014_configcontexts'), + ] + + operations = [ + migrations.RemoveField( + model_name='useraction', + name='content_type', + ), + migrations.RemoveField( + model_name='useraction', + name='user', + ), + migrations.DeleteModel( + name='UserAction', + ), + ] diff --git a/netbox/extras/migrations/0016_exporttemplate_add_cable.py b/netbox/extras/migrations/0016_exporttemplate_add_cable.py new file mode 100644 index 00000000000..3b8852f44d6 --- /dev/null +++ b/netbox/extras/migrations/0016_exporttemplate_add_cable.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.3 on 2018-11-07 20:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0015_remove_useraction'), + ] + + operations = [ + 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', 'interface', 'cable', 'virtualchassis', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index de3edca9b38..d3b9f4eff07 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from collections import OrderedDict from datetime import date @@ -10,15 +8,13 @@ from django.contrib.postgres.fields import JSONField from django.core.validators import ValidationError from django.db import models -from django.db.models import Q +from django.db.models import F, 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 utilities.utils import deepmerge, foreground_color from .constants import * from .querysets import ConfigContextQuerySet @@ -27,7 +23,6 @@ # 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 @@ -136,7 +131,6 @@ def get_custom_fields(self): return OrderedDict([(field, None) for field in fields]) -@python_2_unicode_compatible class CustomField(models.Model): obj_type = models.ManyToManyField( to=ContentType, @@ -227,7 +221,6 @@ def deserialize_value(self, serialized_value): return serialized_value -@python_2_unicode_compatible class CustomFieldValue(models.Model): field = models.ForeignKey( to='extras.CustomField', @@ -268,10 +261,9 @@ def save(self, *args, **kwargs): if self.pk and self.value is None: self.delete() else: - super(CustomFieldValue, self).save(*args, **kwargs) + super().save(*args, **kwargs) -@python_2_unicode_compatible class CustomFieldChoice(models.Model): field = models.ForeignKey( to='extras.CustomField', @@ -301,7 +293,7 @@ def clean(self): def delete(self, using=None, keep_parents=False): # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it pk = self.pk - super(CustomFieldChoice, self).delete(using, keep_parents) + super().delete(using, keep_parents) CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete() @@ -309,7 +301,6 @@ def delete(self, using=None, keep_parents=False): # Graphs # -@python_2_unicode_compatible class Graph(models.Model): type = models.PositiveSmallIntegerField( choices=GRAPH_TYPE_CHOICES @@ -351,7 +342,6 @@ def embed_link(self, obj): # Export templates # -@python_2_unicode_compatible class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, @@ -410,7 +400,6 @@ def render_to_response(self, queryset): # Topology maps # -@python_2_unicode_compatible class TopologyMap(models.Model): name = models.CharField( max_length=50, @@ -515,18 +504,22 @@ def render(self, img_format='png'): def add_network_connections(self, devices): from circuits.models import CircuitTermination - from dcim.models import InterfaceConnection + from dcim.models import Interface # Add all interface connections to the graph - connections = InterfaceConnection.objects.filter( - interface_a__device__in=devices, interface_b__device__in=devices + connected_interfaces = Interface.objects.select_related( + '_connected_interface__device' + ).filter( + Q(device__in=devices) | Q(_connected_interface__device__in=devices), + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') ) - for c in connections: - style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style) + for interface in connected_interfaces: + style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' + self.graph.edge(interface.device.name, interface.connected_endpoint.device.name, style=style) # Add all circuits to the graph - for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): + for termination in CircuitTermination.objects.filter(term_side='A', connected_endpoint__device__in=devices): peer_termination = termination.get_peer_termination() if (peer_termination is not None and peer_termination.interface is not None and peer_termination.interface.device in devices): @@ -537,20 +530,18 @@ def add_console_connections(self, devices): from dcim.models import ConsolePort # Add all console connections to the graph - console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices) - for cp in console_ports: + for cp in ConsolePort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style) + self.graph.edge(cp.connected_endpoint.device.name, cp.device.name, style=style) def add_power_connections(self, devices): from dcim.models import PowerPort # Add all power connections to the graph - power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices) - for pp in power_ports: + for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style) + self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style) # @@ -571,7 +562,6 @@ def image_upload(instance, filename): return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) -@python_2_unicode_compatible class ImageAttachment(models.Model): """ An uploaded image which is associated with an object. @@ -613,7 +603,7 @@ def delete(self, *args, **kwargs): _name = self.image.name - super(ImageAttachment, self).delete(*args, **kwargs) + super().delete(*args, **kwargs) # Delete file from disk self.image.delete(save=False) @@ -727,11 +717,11 @@ def get_config_context(self): # 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) + data = deepmerge(data, context.data) - # If the object has local config context data defined, that data overwrites all rendered data + # If the object has local config context data defined, merge it last if self.local_context_data is not None: - data.update(self.local_context_data) + data = deepmerge(data, self.local_context_data) return data @@ -769,7 +759,6 @@ class Meta: # 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 @@ -852,7 +841,7 @@ def save(self, *args, **kwargs): self.user_name = self.user.username self.object_repr = str(self.changed_object) - return super(ObjectChange, self).save(*args, **kwargs) + return super().save(*args, **kwargs) def get_absolute_url(self): return reverse('extras:objectchange', args=[self.pk]) @@ -871,101 +860,3 @@ def to_csv(self): self.object_repr, self.object_data, ) - - -# -# User actions -# - -class UserActionManager(models.Manager): - - # Actions affecting a single object - def log_action(self, user, obj, action, message): - self.model.objects.create( - content_type=ContentType.objects.get_for_model(obj), - object_id=obj.pk, - user=user, - action=action, - message=message, - ) - - def log_create(self, user, obj, message=''): - self.log_action(user, obj, ACTION_CREATE, message) - - def log_edit(self, user, obj, message=''): - self.log_action(user, obj, ACTION_EDIT, message) - - def log_delete(self, user, obj, message=''): - self.log_action(user, obj, ACTION_DELETE, message) - - # Actions affecting multiple objects - def log_bulk_action(self, user, content_type, action, message): - self.model.objects.create( - content_type=content_type, - user=user, - action=action, - message=message, - ) - - def log_import(self, user, content_type, message=''): - self.log_bulk_action(user, content_type, ACTION_IMPORT, message) - - def log_bulk_create(self, user, content_type, message=''): - self.log_bulk_action(user, content_type, ACTION_BULK_CREATE, message) - - def log_bulk_edit(self, user, content_type, message=''): - self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message) - - def log_bulk_delete(self, user, content_type, message=''): - 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): - """ - 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( - 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() - - class Meta: - ordering = ['-time'] - - def __str__(self): - if self.message: - return '{} {}'.format(self.user, self.message) - return '{} {} {}'.format(self.user, self.get_action_display(), self.content_type) - - def icon(self): - if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]: - return mark_safe('') - elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]: - return mark_safe('') - elif self.action in [ACTION_DELETE, ACTION_BULK_DELETE]: - return mark_safe('') - else: - return '' diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index bcc6f1e5487..439323c943a 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models import Q, QuerySet diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 52883063c7c..fc41b45f97b 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -1,10 +1,7 @@ -from __future__ import unicode_literals - -from collections import OrderedDict import importlib import inspect import pkgutil -import sys +from collections import OrderedDict from django.conf import settings from django.utils import timezone @@ -26,22 +23,12 @@ def get_report(module_name, report_name): """ file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name) - # Python 3.5+ - if sys.version_info >= (3, 5): - spec = importlib.util.spec_from_file_location(module_name, file_path) - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except FileNotFoundError: - return None - - # Python 2.7 - else: - import imp - try: - module = imp.load_source(module_name, file_path) - except IOError: - return None + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except FileNotFoundError: + return None report = getattr(module, report_name, None) if report is None: diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py deleted file mode 100644 index 552f592c7bd..00000000000 --- a/netbox/extras/rpc.py +++ /dev/null @@ -1,237 +0,0 @@ -from __future__ import unicode_literals - -import re -import time - -import paramiko -import xmltodict -from ncclient import manager - -CONNECT_TIMEOUT = 5 # seconds - - -class RPCClient(object): - - def __init__(self, device, username='', password=''): - self.username = username - self.password = password - try: - self.host = str(device.primary_ip.address.ip) - except AttributeError: - raise Exception("Specified device ({}) does not have a primary IP defined.".format(device)) - - def get_inventory(self): - """ - Returns a dictionary representing the device chassis and installed inventory items. - - { - 'chassis': { - 'serial': , - 'description': , - } - 'items': [ - { - 'name': , - 'part_id': , - 'serial': , - }, - ... - ] - } - """ - raise NotImplementedError("Feature not implemented for this platform.") - - -class SSHClient(RPCClient): - def __enter__(self): - - self.ssh = paramiko.SSHClient() - self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - self.ssh.connect( - self.host, - username=self.username, - password=self.password, - timeout=CONNECT_TIMEOUT, - allow_agent=False, - look_for_keys=False, - ) - except paramiko.AuthenticationException: - # Try default credentials if the configured creds don't work - try: - default_creds = self.default_credentials - if default_creds.get('username') and default_creds.get('password'): - self.ssh.connect( - self.host, - username=default_creds['username'], - password=default_creds['password'], - timeout=CONNECT_TIMEOUT, - allow_agent=False, - look_for_keys=False, - ) - else: - raise ValueError('default_credentials are incomplete.') - except AttributeError: - raise paramiko.AuthenticationException - - self.session = self.ssh.invoke_shell() - self.session.recv(1000) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.ssh.close() - - def _send(self, cmd, pause=1): - self.session.send('{}\n'.format(cmd)) - data = '' - time.sleep(pause) - while self.session.recv_ready(): - data += self.session.recv(4096).decode() - if not data: - break - return data - - -class JunosNC(RPCClient): - """ - NETCONF client for Juniper Junos devices - """ - - def __enter__(self): - - # Initiate a connection to the device - self.manager = manager.connect(host=self.host, username=self.username, password=self.password, - hostkey_verify=False, timeout=CONNECT_TIMEOUT) - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - - # Close the connection to the device - self.manager.close_session() - - def get_inventory(self): - - def glean_items(node, depth=0): - items = [] - items_list = node.get('chassis{}-module'.format('-sub' * depth), []) - # Junos like to return single children directly instead of as a single-item list - if hasattr(items_list, 'items'): - items_list = [items_list] - for item in items_list: - m = { - 'name': item['name'], - 'part_id': item.get('model-number') or item.get('part-number', ''), - 'serial': item.get('serial-number', ''), - } - child_items = glean_items(item, depth + 1) - if child_items: - m['items'] = child_items - items.append(m) - return items - - rpc_reply = self.manager.dispatch('get-chassis-inventory') - inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis'] - - result = dict() - - # Gather chassis data - result['chassis'] = { - 'serial': inventory_raw['serial-number'], - 'description': inventory_raw['description'], - } - - # Gather inventory items - result['items'] = glean_items(inventory_raw) - - return result - - -class IOSSSH(SSHClient): - """ - SSH client for Cisco IOS devices - """ - - def get_inventory(self): - def version(): - - def parse(cmd_out, rex): - for i in cmd_out: - match = re.search(rex, i) - if match: - return match.groups()[0] - - sh_ver = self._send('show version').split('\r\n') - return { - 'serial': parse(sh_ver, r'Processor board ID ([^\s]+)'), - 'description': parse(sh_ver, r'cisco ([^\s]+)') - } - - def items(chassis_serial=None): - cmd = self._send('show inventory').split('\r\n\r\n') - for i in cmd: - i_fmt = i.replace('\r\n', ' ') - try: - m_name = re.search(r'NAME: "([^"]+)"', i_fmt).group(1) - m_pid = re.search(r'PID: ([^\s]+)', i_fmt).group(1) - m_serial = re.search(r'SN: ([^\s]+)', i_fmt).group(1) - # Omit built-in items and those with no PID - if m_serial != chassis_serial and m_pid.lower() != 'unspecified': - yield { - 'name': m_name, - 'part_id': m_pid, - 'serial': m_serial, - } - except AttributeError: - continue - - self._send('term length 0') - sh_version = version() - - return { - 'chassis': sh_version, - 'items': list(items(chassis_serial=sh_version.get('serial'))) - } - - -class OpengearSSH(SSHClient): - """ - SSH client for Opengear devices - """ - default_credentials = { - 'username': 'root', - 'password': 'default', - } - - def get_inventory(self): - - try: - stdin, stdout, stderr = self.ssh.exec_command("showserial") - serial = stdout.readlines()[0].strip() - except Exception: - raise RuntimeError("Failed to glean chassis serial from device.") - # Older models don't provide serial info - if serial == "No serial number information available": - serial = '' - - try: - stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model") - description = stdout.readlines()[0].split(' ', 1)[1].strip() - except Exception: - raise RuntimeError("Failed to glean chassis description from device.") - - return { - 'chassis': { - 'serial': serial, - 'description': description, - }, - 'items': [], - } - - -# For mapping platform -> NC client -RPC_CLIENTS = { - 'juniper-junos': JunosNC, - 'cisco-ios': IOSSSH, - 'opengear': OpengearSSH, -} diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 22bf26cced1..5fab8910f3d 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,7 +1,6 @@ -from __future__ import unicode_literals - import django_tables2 as tables -from taggit.models import Tag +from django_tables2.utils import Accessor +from taggit.models import Tag, TaggedItem from utilities.tables import BaseTable, BooleanColumn, ToggleColumn from .models import ConfigContext, ObjectChange @@ -15,6 +14,14 @@ {% endif %} """ +TAGGED_ITEM = """ +{% if value.get_absolute_url %} + {{ value }} +{% else %} + {{ value }} +{% endif %} +""" + CONFIGCONTEXT_ACTIONS = """ {% if perms.extras.change_configcontext %} @@ -55,6 +62,10 @@ class TagTable(BaseTable): pk = ToggleColumn() + name = tables.LinkColumn( + viewname='extras:tag', + args=[Accessor('slug')] + ) actions = tables.TemplateColumn( template_code=TAG_ACTIONS, attrs={'td': {'class': 'text-right'}}, @@ -66,6 +77,21 @@ class Meta(BaseTable.Meta): fields = ('pk', 'name', 'items', 'slug', 'actions') +class TaggedItemTable(BaseTable): + content_object = tables.TemplateColumn( + template_code=TAGGED_ITEM, + orderable=False, + verbose_name='Object' + ) + content_type = tables.Column( + verbose_name='Type' + ) + + class Meta(BaseTable.Meta): + model = TaggedItem + fields = ('content_object', 'content_type') + + class ConfigContextTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 3d0e5d1f702..cccb00a8a26 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status @@ -16,7 +14,7 @@ class GraphTest(APITestCase): def setUp(self): - super(GraphTest, self).setUp() + super().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' @@ -120,7 +118,7 @@ class ExportTemplateTest(APITestCase): def setUp(self): - super(ExportTemplateTest, self).setUp() + super().setUp() self.content_type = ContentType.objects.get_for_model(Device) self.exporttemplate1 = ExportTemplate.objects.create( @@ -227,7 +225,7 @@ class TagTest(APITestCase): def setUp(self): - super(TagTest, self).setUp() + super().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') @@ -318,7 +316,7 @@ class ConfigContextTest(APITestCase): def setUp(self): - super(ConfigContextTest, self).setUp() + super().setUp() self.configcontext1 = ConfigContext.objects.create( name='Test Config Context 1', diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 97eb69cd946..b02e787c11f 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from datetime import date from django.contrib.contenttypes.models import ContentType @@ -103,7 +101,7 @@ class CustomFieldAPITest(APITestCase): def setUp(self): - super(CustomFieldAPITest, self).setUp() + super().setUp() content_type = ContentType.objects.get_for_model(Site) diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index d4c0a79c67e..4f509a5e9ca 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from rest_framework import status @@ -14,7 +12,7 @@ class TaggedItemTest(APITestCase): def setUp(self): - super(TaggedItemTest, self).setUp() + super().setUp() def test_create_tagged_item(self): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index e56652280b7..35a6fb11029 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras import views @@ -9,17 +7,19 @@ # Tags url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), + url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + url(r'^tags/(?P[\w-]+)/$', views.TagView.as_view(), name='tag'), 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/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), + url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), 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'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 7626d401253..713143af8f3 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,6 +1,5 @@ -from __future__ import unicode_literals - from django import template +from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType @@ -9,15 +8,20 @@ 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 django_tables2 import RequestConfig +from taggit.models import Tag, TaggedItem from utilities.forms import ConfirmationForm -from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView +from utilities.paginator import EnhancedPaginator +from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView from . import filters -from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm +from .forms import ( + ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, + TagFilterForm, TagForm, +) from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult from .reports import get_report, get_reports -from .tables import ConfigContextTable, ObjectChangeTable, TagTable +from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable # @@ -25,11 +29,45 @@ # class TagListView(ObjectListView): - queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + queryset = Tag.objects.annotate( + items=Count('taggit_taggeditem_items') + ).order_by( + 'name' + ) + filter = filters.TagFilter + filter_form = TagFilterForm table = TagTable template_name = 'extras/tag_list.html' +class TagView(View): + + def get(self, request, slug): + + tag = get_object_or_404(Tag, slug=slug) + tagged_items = TaggedItem.objects.filter( + tag=tag + ).select_related( + 'content_type' + ).prefetch_related( + 'content_object' + ) + + # Generate a table of all items tagged with this Tag + items_table = TaggedItemTable(tagged_items) + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(items_table) + + return render(request, 'extras/tag.html', { + 'tag': tag, + 'items_count': tagged_items.count(), + 'items_table': items_table, + }) + + class TagEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'taggit.change_tag' model = Tag @@ -44,8 +82,12 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuittype' - queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name') + permission_required = 'taggit.delete_tag' + queryset = Tag.objects.annotate( + items=Count('taggit_taggeditem_items') + ).order_by( + 'name' + ) table = TagTable default_return_url = 'extras:tag_list' @@ -85,6 +127,15 @@ class ConfigContextEditView(ConfigContextCreateView): permission_required = 'extras.change_configcontext' +class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'extras.change_configcontext' + queryset = ConfigContext.objects.all() + filter = filters.ConfigContextFilter + table = ConfigContextTable + form = ConfigContextBulkEditForm + default_return_url = 'extras:configcontext_list' + + class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'extras.delete_configcontext' model = ConfigContext diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index a0c927b6484..12dc7558b6f 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -3,8 +3,8 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType -from extras.models import Webhook from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from extras.models import Webhook from utilities.api import get_serializer_for_model from .constants import WEBHOOK_MODELS @@ -45,7 +45,7 @@ def enqueue_webhooks(instance, action): "extras.webhooks_worker.process_webhook", webhook, serializer.data, - instance.__class__, + instance._meta.model_name, action, str(datetime.datetime.now()) ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 2122d115471..5a680f5d130 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,8 +1,8 @@ import hashlib import hmac -import requests import json +import requests from django_rq import job from rest_framework.utils.encoders import JSONEncoder @@ -10,14 +10,14 @@ @job('default') -def process_webhook(webhook, data, model_class, event, timestamp): +def process_webhook(webhook, data, model_name, 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, + 'model': model_name, 'data': data } headers = { diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py new file mode 100644 index 00000000000..2ffaa0ae21f --- /dev/null +++ b/netbox/ipam/api/nested_serializers.py @@ -0,0 +1,100 @@ +from rest_framework import serializers + +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedAggregateSerializer', + 'NestedIPAddressSerializer', + 'NestedPrefixSerializer', + 'NestedRIRSerializer', + 'NestedRoleSerializer', + 'NestedVLANGroupSerializer', + 'NestedVLANSerializer', + 'NestedVRFSerializer', +] + + +# +# VRFs +# + +class NestedVRFSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') + + class Meta: + model = VRF + fields = ['id', 'url', 'name', 'rd'] + + +# +# RIRs/aggregates +# + +class NestedRIRSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') + + class Meta: + model = RIR + fields = ['id', 'url', 'name', 'slug'] + + +class NestedAggregateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') + + class Meta: + model = Aggregate + fields = ['id', 'url', 'family', 'prefix'] + + +# +# VLANs +# + +class NestedRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') + + class Meta: + model = Role + fields = ['id', 'url', 'name', 'slug'] + + +class NestedVLANGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') + + class Meta: + model = VLANGroup + fields = ['id', 'url', 'name', 'slug'] + + +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 NestedPrefixSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') + + class Meta: + model = Prefix + fields = ['id', 'url', 'family', 'prefix'] + + +# +# IP addresses +# + + +class NestedIPAddressSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') + + class Meta: + model = IPAddress + fields = ['id', 'url', 'family', 'address'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 4ba62e8da0a..030266188b1 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from collections import OrderedDict from rest_framework import serializers @@ -7,18 +5,17 @@ from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField -from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer -from ipam.constants import ( - IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES, -) +from ipam.constants import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from tenancy.api.serializers import NestedTenantSerializer +from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, ) -from virtualization.api.serializers import NestedVirtualMachineSerializer +from virtualization.api.nested_serializers import NestedVirtualMachineSerializer +from .nested_serializers import * # @@ -37,35 +34,8 @@ class Meta: ] -class NestedVRFSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') - - class Meta: - model = VRF - fields = ['id', 'url', 'name', 'rd'] - - # -# Roles -# - -class RoleSerializer(ValidatedModelSerializer): - - class Meta: - model = Role - fields = ['id', 'name', 'slug', 'weight'] - - -class NestedRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') - - class Meta: - model = Role - fields = ['id', 'url', 'name', 'slug'] - - -# -# RIRs +# RIRs/aggregates # class RIRSerializer(ValidatedModelSerializer): @@ -75,18 +45,6 @@ class Meta: fields = ['id', 'name', 'slug', 'is_private'] -class NestedRIRSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') - - class Meta: - model = RIR - fields = ['id', 'url', 'name', 'slug'] - - -# -# Aggregates -# - class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): rir = NestedRIRSerializer() tags = TagListSerializerField(required=False) @@ -100,17 +58,16 @@ class Meta: read_only_fields = ['family'] -class NestedAggregateSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') +# +# VLANs +# - class Meta(AggregateSerializer.Meta): - model = Aggregate - fields = ['id', 'url', 'family', 'prefix'] +class RoleSerializer(ValidatedModelSerializer): + class Meta: + model = Role + fields = ['id', 'name', 'slug', 'weight'] -# -# VLAN groups -# class VLANGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) @@ -130,23 +87,11 @@ def validate(self, data): validator(data) # Enforce model validation - super(VLANGroupSerializer, self).validate(data) + super().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(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True) @@ -173,19 +118,11 @@ def validate(self, data): validator(data) # Enforce model validation - super(VLANSerializer, self).validate(data) + super().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 # @@ -208,16 +145,10 @@ class Meta: read_only_fields = ['family'] -class NestedPrefixSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') - - class Meta: - model = Prefix - fields = ['id', 'url', 'family', 'prefix'] - - class AvailablePrefixSerializer(serializers.Serializer): - + """ + Representation of a prefix which does not exist in the database. + """ def to_representation(self, instance): if self.context.get('vrf'): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data @@ -235,11 +166,14 @@ def to_representation(self, instance): # class IPAddressInterfaceSerializer(WritableNestedSerializer): + """ + Nested representation of an Interface which may belong to a Device *or* a VirtualMachine. + """ url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here device = NestedDeviceSerializer(read_only=True) virtual_machine = NestedVirtualMachineSerializer(read_only=True) - class Meta(InterfaceSerializer.Meta): + class Meta: model = Interface fields = [ 'id', 'url', 'device', 'virtual_machine', 'name', @@ -260,6 +194,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False, allow_null=True) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) + nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) + nat_outside = NestedIPAddressSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: @@ -271,20 +207,10 @@ class Meta: read_only_fields = ['family'] -class NestedIPAddressSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - - class Meta: - model = IPAddress - fields = ['id', 'url', 'family', 'address'] - - -IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True) -IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True) - - class AvailableIPSerializer(serializers.Serializer): - + """ + Representation of an IP address which does not exist in the database. + """ def to_representation(self, instance): if self.context.get('vrf'): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index ca046cd93e3..9a2e1bc1f15 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ def get_view_name(self): router.APIRootView = IPAMRootView # Field choices -router.register(r'_choices', views.IPAMFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice') # VRFs router.register(r'vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 41cea7eaabb..e846f048902 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings from django.shortcuts import get_object_or_404 from rest_framework import status @@ -35,7 +33,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet): class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.select_related('tenant').prefetch_related('tags') serializer_class = serializers.VRFSerializer - filter_class = filters.VRFFilter + filterset_class = filters.VRFFilter # @@ -45,7 +43,7 @@ class VRFViewSet(CustomFieldModelViewSet): class RIRViewSet(ModelViewSet): queryset = RIR.objects.all() serializer_class = serializers.RIRSerializer - filter_class = filters.RIRFilter + filterset_class = filters.RIRFilter # @@ -55,7 +53,7 @@ class RIRViewSet(ModelViewSet): class AggregateViewSet(CustomFieldModelViewSet): queryset = Aggregate.objects.select_related('rir').prefetch_related('tags') serializer_class = serializers.AggregateSerializer - filter_class = filters.AggregateFilter + filterset_class = filters.AggregateFilter # @@ -65,7 +63,7 @@ class AggregateViewSet(CustomFieldModelViewSet): class RoleViewSet(ModelViewSet): queryset = Role.objects.all() serializer_class = serializers.RoleSerializer - filter_class = filters.RoleFilter + filterset_class = filters.RoleFilter # @@ -75,7 +73,7 @@ class RoleViewSet(ModelViewSet): class PrefixViewSet(CustomFieldModelViewSet): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role').prefetch_related('tags') serializer_class = serializers.PrefixSerializer - filter_class = filters.PrefixFilter + filterset_class = filters.PrefixFilter @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) def available_prefixes(self, request, pk=None): @@ -98,25 +96,34 @@ def available_prefixes(self, request, pk=None): for i, requested_prefix in enumerate(requested_prefixes): # Validate requested prefix size - error_msg = None - if 'prefix_length' not in requested_prefix: - error_msg = "Item {}: prefix_length field missing".format(i) - elif not isinstance(requested_prefix['prefix_length'], int): - error_msg = "Item {}: Invalid prefix length ({})".format( - i, requested_prefix['prefix_length'] + prefix_length = requested_prefix.get('prefix_length') + if prefix_length is None: + return Response( + { + "detail": "Item {}: prefix_length field missing".format(i) + }, + status=status.HTTP_400_BAD_REQUEST ) - elif prefix.family == 4 and requested_prefix['prefix_length'] > 32: - error_msg = "Item {}: Invalid prefix length ({}) for IPv4".format( - i, requested_prefix['prefix_length'] + try: + prefix_length = int(prefix_length) + except ValueError: + return Response( + { + "detail": "Item {}: Invalid prefix length ({})".format(i, prefix_length), + }, + status=status.HTTP_400_BAD_REQUEST ) - elif prefix.family == 6 and requested_prefix['prefix_length'] > 128: - error_msg = "Item {}: Invalid prefix length ({}) for IPv6".format( - i, requested_prefix['prefix_length'] + if prefix.family == 4 and prefix_length > 32: + return Response( + { + "detail": "Item {}: Invalid prefix length ({}) for IPv4".format(i, prefix_length), + }, + status=status.HTTP_400_BAD_REQUEST ) - if error_msg: + elif prefix.family == 6 and prefix_length > 128: return Response( { - "detail": error_msg + "detail": "Item {}: Invalid prefix length ({}) for IPv6".format(i, prefix_length), }, status=status.HTTP_400_BAD_REQUEST ) @@ -133,7 +140,7 @@ def available_prefixes(self, request, pk=None): { "detail": "Insufficient space is available to accommodate the requested prefix size(s)" }, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_204_NO_CONTENT ) # Remove the allocated prefix from the list of available prefixes @@ -189,7 +196,7 @@ def available_ips(self, request, pk=None): "detail": "An insufficient number of IP addresses are available within the prefix {} ({} " "requested, {} available)".format(prefix, len(requested_ips), len(available_ips)) }, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_204_NO_CONTENT ) # Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix @@ -248,7 +255,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): 'nat_outside', 'tags', ) serializer_class = serializers.IPAddressSerializer - filter_class = filters.IPAddressFilter + filterset_class = filters.IPAddressFilter # @@ -258,7 +265,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(ModelViewSet): queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer - filter_class = filters.VLANGroupFilter + filterset_class = filters.VLANGroupFilter # @@ -268,7 +275,7 @@ class VLANGroupViewSet(ModelViewSet): class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('tags') serializer_class = serializers.VLANSerializer - filter_class = filters.VLANFilter + filterset_class = filters.VLANFilter # @@ -278,4 +285,4 @@ class VLANViewSet(CustomFieldModelViewSet): class ServiceViewSet(ModelViewSet): queryset = Service.objects.select_related('device').prefetch_related('tags') serializer_class = serializers.ServiceSerializer - filter_class = filters.ServiceFilter + filterset_class = filters.ServiceFilter diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index c944d1b2c6d..fd4af74b07c 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index a675d3ca9be..eeb17eddd4b 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # IP address families AF_CHOICES = ( diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 8c7dbb6909d..1ddf545ea7d 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -1,11 +1,9 @@ -from __future__ import unicode_literals - from django.core.exceptions import ValidationError from django.db import models from netaddr import AddrFormatError, IPNetwork -from .formfields import IPFormField from . import lookups +from .formfields import IPFormField def prefix_validator(prefix): @@ -18,7 +16,7 @@ class BaseIPField(models.Field): def python_type(self): return IPNetwork - def from_db_value(self, value, expression, connection, context): + def from_db_value(self, value, expression, connection): return self.to_python(value) def to_python(self, value): @@ -42,7 +40,7 @@ def form_class(self): def formfield(self, **kwargs): defaults = {'form_class': self.form_class()} defaults.update(kwargs) - return super(BaseIPField, self).formfield(**defaults) + return super().formfield(**defaults) class IPNetworkField(BaseIPField): diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 0a8606e5296..22df1b1c922 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,22 +1,23 @@ -from __future__ import unicode_literals - import django_filters +import netaddr from django.core.exceptions import ValidationError from django.db.models import Q -import netaddr from netaddr.core import AddrFormatError from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NumericInFilter +from utilities.filters import NumericInFilter, TagFilter from virtualization.models import VirtualMachine from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -26,14 +27,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -50,7 +49,10 @@ class Meta: class RIRFilter(django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) class Meta: model = RIR @@ -58,7 +60,10 @@ class Meta: class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -68,14 +73,12 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): label='RIR (ID)', ) rir = django_filters.ModelMultipleChoiceFilter( - name='rir__slug', + field_name='rir__slug', queryset=RIR.objects.all(), to_field_name='slug', label='RIR (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Aggregate @@ -101,11 +104,18 @@ class Meta: class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', ) + prefix = django_filters.CharFilter( + method='filter_prefix', + label='Prefix', + ) within = django_filters.CharFilter( method='search_within', label='Within prefix', @@ -127,7 +137,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF', ) vrf = django_filters.ModelMultipleChoiceFilter( - name='vrf__rd', + field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', label='VRF (RD)', @@ -137,7 +147,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -147,7 +157,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -157,7 +167,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VLAN (ID)', ) vlan_vid = django_filters.NumberFilter( - name='vlan__vid', + field_name='vlan__vid', label='VLAN number (1-4095)', ) role_id = django_filters.ModelMultipleChoiceFilter( @@ -165,7 +175,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', label='Role (slug)', @@ -174,9 +184,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=PREFIX_STATUS_CHOICES, null_value=None ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Prefix @@ -193,6 +201,15 @@ def search(self, queryset, name, value): pass return queryset.filter(qs_filter) + def filter_prefix(self, queryset, name, value): + if not value.strip(): + return queryset + try: + query = str(netaddr.IPNetwork(value).cidr) + return queryset.filter(prefix=query) + except ValidationError: + return queryset.none() + def search_within(self, queryset, name, value): value = value.strip() if not value: @@ -234,7 +251,10 @@ def filter_mask_length(self, queryset, name, value): class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -256,7 +276,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF', ) vrf = django_filters.ModelMultipleChoiceFilter( - name='vrf__rd', + field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', label='VRF (RD)', @@ -266,28 +286,28 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) device = django_filters.CharFilter( method='filter_device', - name='name', + field_name='name', label='Device', ) device_id = django_filters.NumberFilter( method='filter_device', - name='pk', + field_name='pk', label='Device (ID)', ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - name='interface__virtual_machine', + field_name='interface__virtual_machine', queryset=VirtualMachine.objects.all(), label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( - name='interface__virtual_machine__name', + field_name='interface__virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', label='Virtual machine (name)', @@ -303,9 +323,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): role = django_filters.MultipleChoiceFilter( choices=IPADDRESS_ROLE_CHOICES ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = IPAddress @@ -361,7 +379,7 @@ class VLANGroupFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -373,7 +391,10 @@ class Meta: class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -383,7 +404,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -393,7 +414,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=VLANGroup.objects.all(), to_field_name='slug', label='Group', @@ -403,7 +424,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -413,7 +434,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', label='Role (slug)', @@ -422,9 +443,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=VLAN_STATUS_CHOICES, null_value=None ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = VLAN @@ -451,7 +470,7 @@ class ServiceFilter(django_filters.FilterSet): label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( - name='device__name', + field_name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', @@ -461,14 +480,12 @@ class ServiceFilter(django_filters.FilterSet): label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( - name='virtual_machine__name', + field_name='virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', label='Virtual machine (name)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Service diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index c67c134141c..2909a54b175 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.core.exceptions import ValidationError from netaddr import IPNetwork, AddrFormatError diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 8209b2ffa63..0864223d135 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,9 +1,6 @@ -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 @@ -11,9 +8,9 @@ from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, - CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, - SlugField, add_blank_choice, + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, + CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, SlugField, + StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES ) from virtualization.models import VirtualMachine from .constants import ( @@ -36,11 +33,15 @@ # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = VRF - fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags'] + fields = [ + 'name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags', + ] labels = { 'rd': "RD", } @@ -69,24 +70,48 @@ class Meta: class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + pk = forms.ModelMultipleChoiceField( + queryset=VRF.objects.all(), + widget=forms.MultipleHiddenInput() + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) + ) enforce_unique = forms.NullBooleanField( - required=False, widget=BulkEditNullBooleanSelect, label='Enforce unique space' + required=False, + widget=BulkEditNullBooleanSelect(), + label='Enforce unique space' + ) + description = forms.CharField( + max_length=100, + required=False ) - description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['tenant', 'description'] + nullable_fields = [ + 'tenant', 'description', + ] class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) @@ -99,7 +124,9 @@ class RIRForm(BootstrapMixin, forms.ModelForm): class Meta: model = RIR - fields = ['name', 'slug', 'is_private'] + fields = [ + 'name', 'slug', 'is_private', + ] class RIRCSVForm(forms.ModelForm): @@ -114,11 +141,13 @@ class Meta: class RIRFilterForm(BootstrapMixin, forms.Form): - is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[ - ('', '---------'), - ('True', 'Yes'), - ('False', 'No'), - ])) + is_private = forms.NullBooleanField( + required=False, + label='Private', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) # @@ -126,16 +155,25 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # class AggregateForm(BootstrapMixin, CustomFieldForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Aggregate - fields = ['prefix', 'rir', 'date_added', 'description', 'tags'] + fields = [ + 'prefix', 'rir', 'date_added', 'description', 'tags', + ] help_texts = { 'prefix': "IPv4 or IPv6 network", 'rir': "Regional Internet Registry responsible for this prefix", 'date_added': "Format: YYYY-MM-DD", } + widgets = { + 'rir': APISelect( + api_url="/api/ipam/rirs/" + ) + } class AggregateCSVForm(forms.ModelForm): @@ -154,23 +192,52 @@ class Meta: 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) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Aggregate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + rir = forms.ModelChoiceField( + queryset=RIR.objects.all(), + required=False, + label='RIR', + widget=APISelect( + api_url="/api/ipam/rirs/" + ) + ) + date_added = forms.DateField( + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['date_added', 'description'] + nullable_fields = [ + 'date_added', 'description', + ] class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate - q = forms.CharField(required=False, label='Search') - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') + q = forms.CharField( + required=False, + label='Search' + ) + family = forms.ChoiceField( + required=False, + choices=IP_FAMILY_CHOICES, + label='Address family', + widget=StaticSelect2() + ) rir = FilterChoiceField( - queryset=RIR.objects.annotate(filter_count=Count('aggregates')), + queryset=RIR.objects.all(), to_field_name='slug', - label='RIR' + label='RIR', + widget=APISelectMultiple( + api_url="/api/ipam/rirs/", + value_field="slug", + ) ) @@ -183,7 +250,9 @@ class RoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = Role - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class RoleCSVForm(forms.ModelForm): @@ -206,8 +275,15 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'} + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'vlan_group': 'site_id', + 'vlan': 'site_id', + }, + attrs={ + 'nullable': 'true', + } ) ) vlan_group = ChainedModelChoiceField( @@ -218,8 +294,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, label='VLAN group', widget=APISelect( - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - attrs={'filter-for': 'vlan', 'nullable': 'true'} + api_url='/api/ipam/vlan-groups/', + filter_for={ + 'vlan': 'group_id' + }, + attrs={ + 'nullable': 'true', + } ) ) vlan = ChainedModelChoiceField( @@ -231,7 +312,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, label='VLAN', widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name' + api_url='/api/ipam/vlans/', + display_field='display_name' ) ) tags = TagField(required=False) @@ -242,6 +324,15 @@ class Meta: 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant', 'tags', ] + widgets = { + 'vrf': APISelect( + api_url="/api/ipam/vrfs/" + ), + 'status': StaticSelect2(), + 'role': APISelect( + api_url="/api/ipam/roles/" + ) + } def __init__(self, *args, **kwargs): @@ -252,7 +343,7 @@ def __init__(self, *args, **kwargs): initial['vlan_group'] = instance.vlan.group kwargs['initial'] = initial - super(PrefixForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -313,7 +404,7 @@ class Meta: def clean(self): - super(PrefixCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') vlan_group = self.cleaned_data.get('vlan_group') @@ -347,55 +438,149 @@ def clean(self): 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') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False) - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) - is_pool = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a pool') - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Prefix.objects.all(), + widget=forms.MultipleHiddenInput() + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) + prefix_length = forms.IntegerField( + min_value=1, + max_value=127, + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(PREFIX_STATUS_CHOICES), + required=False, + widget=StaticSelect2() + ) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) + is_pool = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Is a pool' + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description'] + nullable_fields = [ + 'site', 'vrf', 'tenant', 'role', 'description', + ] class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix - q = forms.CharField(required=False, label='Search') - within_include = forms.CharField(required=False, label='Search within', widget=forms.TextInput(attrs={ - 'placeholder': 'Prefix', - })) - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family') - mask_length = forms.ChoiceField(required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label='Mask length') + q = forms.CharField( + required=False, + label='Search' + ) + within_include = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Prefix', + } + ), + label='Search within' + ) + family = forms.ChoiceField( + required=False, + choices=IP_FAMILY_CHOICES, + label='Address family', + widget=StaticSelect2() + ) + mask_length = forms.ChoiceField( + required=False, + choices=PREFIX_MASK_LENGTH_CHOICES, + label='Mask length', + widget=StaticSelect2() + ) vrf = FilterChoiceField( - queryset=VRF.objects.annotate(filter_count=Count('prefixes')), + queryset=VRF.objects.all(), to_field_name='rd', label='VRF', - null_label='-- Global --' + null_label='-- Global --', + widget=APISelectMultiple( + api_url="/api/ipam/vrfs/", + value_field="slug", + null_option=True, + ) ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) - status = AnnotatedMultipleChoiceField( + status = forms.MultipleChoiceField( choices=PREFIX_STATUS_CHOICES, - annotate=Prefix.objects.all(), - annotate_field='status', - required=False + required=False, + widget=StaticSelect2Multiple() ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('prefixes')), + queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + null_option=True, + ) ) role = FilterChoiceField( - queryset=Role.objects.annotate(filter_count=Count('prefixes')), + queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/ipam/roles/", + value_field="slug", + null_option=True, + ) + ) + is_pool = forms.NullBooleanField( + required=False, + label='Is a pool', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + expand = forms.BooleanField( + required=False, + label='Expand prefix hierarchy' ) - expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') # @@ -411,8 +596,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select( - attrs={'filter-for': 'nat_rack'} + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'nat_rack': 'site_id', + 'nat_device': 'site_id' + } ) ) nat_rack = ChainedModelChoiceField( @@ -423,9 +612,14 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) required=False, label='Rack', widget=APISelect( - api_url='/api/dcim/racks/?site_id={{nat_site}}', + api_url='/api/dcim/racks/', display_field='display_name', - attrs={'filter-for': 'nat_device', 'nullable': 'true'} + filter_for={ + 'nat_device': 'rack_id' + }, + attrs={ + 'nullable': 'true' + } ) ) nat_device = ChainedModelChoiceField( @@ -437,9 +631,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) required=False, label='Device', widget=APISelect( - api_url='/api/dcim/devices/?site_id={{nat_site}}&rack_id={{nat_rack}}', + api_url='/api/dcim/devices/', display_field='display_name', - attrs={'filter-for': 'nat_inside'} + filter_for={ + 'nat_inside': 'device_id' + } ) ) nat_inside = ChainedModelChoiceField( @@ -450,22 +646,17 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) required=False, label='IP Address', widget=APISelect( - api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', + api_url='/api/ipam/ip-addresses/', display_field='address' ) ) - livesearch = forms.CharField( + primary_for_parent = forms.BooleanField( required=False, - label='Search', - widget=Livesearch( - query_key='q', - query_url='ipam-api:ipaddress-list', - field_to_update='nat_inside', - obj_label='address' - ) + label='Make this the primary IP for the device/VM' + ) + tags = TagField( + required=False ) - 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 @@ -473,6 +664,13 @@ class Meta: 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', ] + widgets = { + 'status': StaticSelect2(), + 'role': StaticSelect2(), + 'vrf': APISelect( + api_url="/api/ipam/vrfs/" + ) + } def __init__(self, *args, **kwargs): @@ -485,7 +683,7 @@ def __init__(self, *args, **kwargs): initial['nat_device'] = instance.nat_inside.device kwargs['initial'] = initial - super(IPAddressForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -507,7 +705,7 @@ def __init__(self, *args, **kwargs): self.initial['primary_for_parent'] = True def clean(self): - super(IPAddressForm, self).clean() + super().clean() # Primary IP assignment is only available if an interface has been assigned. if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'): @@ -517,7 +715,7 @@ def clean(self): def save(self, *args, **kwargs): - ipaddress = super(IPAddressForm, self).save(*args, **kwargs) + ipaddress = super().save(*args, **kwargs) # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. if self.cleaned_data['primary_for_parent']: @@ -540,17 +738,28 @@ def save(self, *args, **kwargs): class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): - pattern = ExpandableIPAddressField(label='Address pattern') + pattern = ExpandableIPAddressField( + label='Address pattern' + ) class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant'] + fields = [ + 'address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant', + ] + widgets = { + 'status': StaticSelect2(), + 'role': StaticSelect2(), + 'vrf': APISelect( + api_url="/api/ipam/vrfs/" + ) + } def __init__(self, *args, **kwargs): - super(IPAddressBulkAddForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -614,8 +823,7 @@ class Meta: fields = IPAddress.csv_headers def clean(self): - - super(IPAddressCSVForm, self).clean() + super().clean() device = self.cleaned_data.get('device') virtual_machine = self.cleaned_data.get('virtual_machine') @@ -664,7 +872,7 @@ def save(self, *args, **kwargs): name=self.cleaned_data['interface_name'] ) - ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs) + ipaddress = super().save(*args, **kwargs) # Set as primary for device/VM if self.cleaned_data['is_primary']: @@ -679,52 +887,122 @@ def save(self, *args, **kwargs): 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) - status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False) - role = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_ROLE_CHOICES), required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + widget=forms.MultipleHiddenInput() + ) + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) + mask_length = forms.IntegerField( + min_value=1, + max_value=128, + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), + required=False, + widget=StaticSelect2() + ) + role = forms.ChoiceField( + choices=add_blank_choice(IPADDRESS_ROLE_CHOICES), + required=False, + widget=StaticSelect2() + ) + description = forms.CharField( + max_length=100, required=False + ) class Meta: - nullable_fields = ['vrf', 'role', 'tenant', 'description'] + nullable_fields = [ + 'vrf', 'role', 'tenant', 'description', + ] class IPAddressAssignForm(BootstrapMixin, forms.Form): - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global') - address = forms.CharField(label='IP Address') + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + empty_label='Global', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) + address = forms.CharField( + label='IP Address' + ) class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress - q = forms.CharField(required=False, label='Search') - parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={ - 'placeholder': 'Prefix', - })) - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family') - mask_length = forms.ChoiceField(required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, label='Mask length') + q = forms.CharField( + required=False, + label='Search' + ) + parent = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Prefix', + } + ), + label='Parent Prefix' + ) + family = forms.ChoiceField( + required=False, + choices=IP_FAMILY_CHOICES, + label='Address family', + widget=StaticSelect2() + ) + mask_length = forms.ChoiceField( + required=False, + choices=IPADDRESS_MASK_LENGTH_CHOICES, + label='Mask length', + widget=StaticSelect2() + ) vrf = FilterChoiceField( - queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), + queryset=VRF.objects.all(), to_field_name='rd', label='VRF', - null_label='-- Global --' + null_label='-- Global --', + widget=APISelectMultiple( + api_url="/api/ipam/vrfs/", + value_field="slug", + null_option=True, + ) ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) - status = AnnotatedMultipleChoiceField( + status = forms.MultipleChoiceField( choices=IPADDRESS_STATUS_CHOICES, - annotate=IPAddress.objects.all(), - annotate_field='status', - required=False + required=False, + widget=StaticSelect2Multiple() ) - role = AnnotatedMultipleChoiceField( + role = forms.MultipleChoiceField( choices=IPADDRESS_ROLE_CHOICES, - annotate=IPAddress.objects.all(), - annotate_field='role', - required=False + required=False, + widget=StaticSelect2Multiple() ) @@ -737,7 +1015,14 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = VLANGroup - fields = ['site', 'name', 'slug'] + fields = [ + 'site', 'name', 'slug', + ] + widgets = { + 'site': APISelect( + api_url="/api/dcim/sites/" + ) + } class VLANGroupCSVForm(forms.ModelForm): @@ -762,9 +1047,14 @@ class Meta: class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), + queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --' + null_label='-- Global --', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + null_option=True, + ) ) @@ -776,8 +1066,14 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, - widget=forms.Select( - attrs={'filter-for': 'group', 'nullable': 'true'} + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'group': 'site_id' + }, + attrs={ + 'nullable': 'true', + } ) ) group = ChainedModelChoiceField( @@ -788,14 +1084,16 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, label='Group', widget=APISelect( - api_url='/api/ipam/vlan-groups/?site_id={{site}}', + api_url='/api/ipam/vlan-groups/', ) ) tags = TagField(required=False) class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags'] + 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)", @@ -804,6 +1102,12 @@ class Meta: 'status': "Operational status of this VLAN", 'role': "The primary function of this VLAN", } + widgets = { + 'status': StaticSelect2(), + 'role': APISelect( + api_url="/api/ipam/roles/" + ) + } class VLANCSVForm(forms.ModelForm): @@ -852,8 +1156,7 @@ class Meta: } def clean(self): - - super(VLANCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') @@ -864,52 +1167,113 @@ def clean(self): self.instance.group = VLANGroup.objects.get(site=site, name=group_name) except VLANGroup.DoesNotExist: if site: - raise forms.ValidationError("VLAN group {} not found for site {}".format(group_name, site)) + raise forms.ValidationError( + "VLAN group {} not found for site {}".format(group_name, site) + ) else: - raise forms.ValidationError("Global VLAN group {} not found".format(group_name)) + raise forms.ValidationError( + "Global VLAN group {} not found".format(group_name) + ) 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) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(VLAN_STATUS_CHOICES), required=False) - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + widget=forms.MultipleHiddenInput() + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) + group = forms.ModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vlan-groups/" + ) + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(VLAN_STATUS_CHOICES), + required=False, + widget=StaticSelect2() + ) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] + nullable_fields = [ + 'site', 'group', 'tenant', 'role', 'description', + ] class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('vlans')), + queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --' + null_label='-- Global --', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + null_option=True, + ) ) group_id = FilterChoiceField( - queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), + queryset=VLANGroup.objects.all(), label='VLAN group', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/ipam/vlan-groups/", + null_option=True, + ) ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('vlans')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) - status = AnnotatedMultipleChoiceField( + status = forms.MultipleChoiceField( choices=VLAN_STATUS_CHOICES, - annotate=VLAN.objects.all(), - annotate_field='status', - required=False + required=False, + widget=StaticSelect2Multiple() ) role = FilterChoiceField( - queryset=Role.objects.annotate(filter_count=Count('vlans')), + queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/ipam/roles/", + value_field="slug", + null_option=True, + ) ) @@ -918,19 +1282,26 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): # class ServiceForm(BootstrapMixin, CustomFieldForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Service - fields = ['name', 'protocol', 'port', 'ipaddresses', 'description', 'tags'] + 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.", } + widgets = { + 'protocol': StaticSelect2(), + 'ipaddresses': StaticSelect2Multiple(), + } def __init__(self, *args, **kwargs): - - super(ServiceForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit IP address choices to those assigned to interfaces of the parent device/VM if self.instance.device: @@ -954,18 +1325,37 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): ) protocol = forms.ChoiceField( choices=add_blank_choice(IP_PROTOCOL_CHOICES), - required=False + required=False, + widget=StaticSelect2Multiple() ) port = forms.IntegerField( - required=False + 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) + pk = forms.ModelMultipleChoiceField( + queryset=Service.objects.all(), + widget=forms.MultipleHiddenInput() + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(IP_PROTOCOL_CHOICES), + required=False, + widget=StaticSelect2() + ) + 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'] + nullable_fields = [ + 'site', 'tenant', 'role', 'description', + ] diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index 9aca3c03b2d..e1de38a518a 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models import Lookup, Transform, IntegerField from django.db.models import lookups diff --git a/netbox/ipam/migrations/0001_initial.py b/netbox/ipam/migrations/0001_initial.py index f98d049522e..567f991eced 100644 --- a/netbox/ipam/migrations/0001_initial.py +++ b/netbox/ipam/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py b/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py index 373e93d8032..993020a1275 100644 --- a/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py +++ b/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-14 19:34 -from __future__ import unicode_literals - from django.db import migrations, models 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 index c4271ea512f..61d38a69b97 100644 --- 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 @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/ipam/migrations/0003_ipam_add_vlangroups.py b/netbox/ipam/migrations/0003_ipam_add_vlangroups.py index 2e7157fe124..c9092f0f2f2 100644 --- a/netbox/ipam/migrations/0003_ipam_add_vlangroups.py +++ b/netbox/ipam/migrations/0003_ipam_add_vlangroups.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-15 16:22 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py b/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py index fef5ec0b3d0..d8f628c57e8 100644 --- a/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py +++ b/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-15 17:14 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0005_auto_20160725_1842.py b/netbox/ipam/migrations/0005_auto_20160725_1842.py index 17eee6e8c00..726b89259ba 100644 --- a/netbox/ipam/migrations/0005_auto_20160725_1842.py +++ b/netbox/ipam/migrations/0005_auto_20160725_1842.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-25 18:42 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py b/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py index 8d519261def..9352e487290 100644 --- a/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py +++ b/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-27 14:39 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py b/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py index eab3b9a472a..dfe8fbb521e 100644 --- a/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py +++ b/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-28 15:32 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0008_prefix_change_order.py b/netbox/ipam/migrations/0008_prefix_change_order.py index 3ad3eb9e315..ea219da1920 100644 --- a/netbox/ipam/migrations/0008_prefix_change_order.py +++ b/netbox/ipam/migrations/0008_prefix_change_order.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-09-15 16:08 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0009_ipaddress_add_status.py b/netbox/ipam/migrations/0009_ipaddress_add_status.py index ad876c3b6b7..b2859073048 100644 --- a/netbox/ipam/migrations/0009_ipaddress_add_status.py +++ b/netbox/ipam/migrations/0009_ipaddress_add_status.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-10-21 15:44 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0010_ipaddress_help_texts.py b/netbox/ipam/migrations/0010_ipaddress_help_texts.py index a1e05171df9..2a7e0633544 100644 --- a/netbox/ipam/migrations/0010_ipaddress_help_texts.py +++ b/netbox/ipam/migrations/0010_ipaddress_help_texts.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-11-01 17:46 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import ipam.fields diff --git a/netbox/ipam/migrations/0011_rir_add_is_private.py b/netbox/ipam/migrations/0011_rir_add_is_private.py index ad773265328..d8b81d484ad 100644 --- a/netbox/ipam/migrations/0011_rir_add_is_private.py +++ b/netbox/ipam/migrations/0011_rir_add_is_private.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-06 18:27 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0012_services.py b/netbox/ipam/migrations/0012_services.py index bb627440818..12b2cf67390 100644 --- a/netbox/ipam/migrations/0012_services.py +++ b/netbox/ipam/migrations/0012_services.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-15 20:22 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0013_prefix_add_is_pool.py b/netbox/ipam/migrations/0013_prefix_add_is_pool.py index fd149361041..194bcb65130 100644 --- a/netbox/ipam/migrations/0013_prefix_add_is_pool.py +++ b/netbox/ipam/migrations/0013_prefix_add_is_pool.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2016-12-27 19:34 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import ipam.fields diff --git a/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py b/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py index adc8e606c7b..3f5f48437dd 100644 --- a/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py +++ b/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-23 19:10 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0015_global_vlans.py b/netbox/ipam/migrations/0015_global_vlans.py index 18d82cbaf2d..5471e33e277 100644 --- a/netbox/ipam/migrations/0015_global_vlans.py +++ b/netbox/ipam/migrations/0015_global_vlans.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-21 18:45 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0016_unicode_literals.py b/netbox/ipam/migrations/0016_unicode_literals.py index bb29542ad5c..6807bc55519 100644 --- a/netbox/ipam/migrations/0016_unicode_literals.py +++ b/netbox/ipam/migrations/0016_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0017_ipaddress_roles.py b/netbox/ipam/migrations/0017_ipaddress_roles.py index d91c3daa983..11bf372941c 100644 --- a/netbox/ipam/migrations/0017_ipaddress_roles.py +++ b/netbox/ipam/migrations/0017_ipaddress_roles.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-06-16 19:37 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py b/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py index 77e083ef3de..3d318435400 100644 --- a/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py +++ b/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-08-03 19:37 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0019_virtualization.py b/netbox/ipam/migrations/0019_virtualization.py index 955ff8b4ab0..f8ffbca11b4 100644 --- a/netbox/ipam/migrations/0019_virtualization.py +++ b/netbox/ipam/migrations/0019_virtualization.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-31 15:44 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion 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 index c8292bbc07e..e271685a0b0 100644 --- 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 @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py index 9d16be04985..e15c12a3269 100644 --- a/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py +++ b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 20:02 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0021_vrf_ordering.py b/netbox/ipam/migrations/0021_vrf_ordering.py index 878c02d8c3e..7f74115b630 100644 --- a/netbox/ipam/migrations/0021_vrf_ordering.py +++ b/netbox/ipam/migrations/0021_vrf_ordering.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-07 18:37 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0022_tags.py b/netbox/ipam/migrations/0022_tags.py index 14a508317ab..642bccc0577 100644 --- a/netbox/ipam/migrations/0022_tags.py +++ b/netbox/ipam/migrations/0022_tags.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/ipam/migrations/0023_change_logging.py b/netbox/ipam/migrations/0023_change_logging.py index d548fdf15ef..afb732d64fb 100644 --- a/netbox/ipam/migrations/0023_change_logging.py +++ b/netbox/ipam/migrations/0023_change_logging.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f9170cd58a4..a14e1c7ed8f 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation @@ -9,7 +7,6 @@ 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 @@ -20,7 +17,6 @@ from .querysets import PrefixQuerySet -@python_2_unicode_compatible class VRF(ChangeLoggedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -67,7 +63,7 @@ class Meta: verbose_name_plural = 'VRFs' def __str__(self): - return self.display_name or super(VRF, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('ipam:vrf', args=[self.pk]) @@ -88,7 +84,6 @@ def display_name(self): return None -@python_2_unicode_compatible class RIR(ChangeLoggedModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -128,7 +123,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class Aggregate(ChangeLoggedModel, CustomFieldModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -204,7 +198,7 @@ def save(self, *args, **kwargs): if self.prefix: # Infer address family from IPNetwork object self.family = self.prefix.version - super(Aggregate, self).save(*args, **kwargs) + super().save(*args, **kwargs) def to_csv(self): return ( @@ -223,7 +217,6 @@ def get_utilization(self): return int(float(child_prefixes.size) / self.prefix.size * 100) -@python_2_unicode_compatible class Role(ChangeLoggedModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or @@ -256,7 +249,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class Prefix(ChangeLoggedModel, CustomFieldModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -377,7 +369,7 @@ def save(self, *args, **kwargs): self.prefix = self.prefix.cidr # Infer address family from IPNetwork object self.family = self.prefix.version - super(Prefix, self).save(*args, **kwargs) + super().save(*args, **kwargs) def to_csv(self): return ( @@ -393,6 +385,15 @@ def to_csv(self): self.description, ) + def _set_prefix_length(self, value): + """ + Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly, + e.g. for bulk editing. + """ + if self.prefix is not None: + self.prefix.prefixlen = value + prefix_length = property(fset=_set_prefix_length) + def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] @@ -437,12 +438,23 @@ def get_available_ips(self): child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) available_ips = prefix - child_ips - # Remove unusable IPs from non-pool prefixes - if not self.is_pool: - available_ips -= netaddr.IPSet([ - netaddr.IPAddress(self.prefix.first), - netaddr.IPAddress(self.prefix.last), - ]) + # All IP addresses within a pool are considered usable + if self.is_pool: + return available_ips + + # All IP addresses within a point-to-point prefix (IPv4 /31 or IPv6 /127) are considered usable + if ( + self.family == 4 and self.prefix.prefixlen == 31 # RFC 3021 + ) or ( + self.family == 6 and self.prefix.prefixlen == 127 # RFC 6164 + ): + return available_ips + + # Omit first and last IP address from the available set + available_ips -= netaddr.IPSet([ + netaddr.IPAddress(self.prefix.first), + netaddr.IPAddress(self.prefix.last), + ]) return available_ips @@ -492,11 +504,10 @@ def get_queryset(self): then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each IP address as a /32 or /128. """ - qs = super(IPAddressManager, self).get_queryset() + qs = super().get_queryset() return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host') -@python_2_unicode_compatible class IPAddress(ChangeLoggedModel, CustomFieldModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is @@ -596,11 +607,11 @@ def clean(self): if self.address: # Enforce unique IP space (if applicable) - if self.role not in IPADDRESS_ROLES_NONUNIQUE and ( + if self.role not in IPADDRESS_ROLES_NONUNIQUE and (( self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE ) or ( self.vrf and self.vrf.enforce_unique - ): + )): duplicate_ips = self.get_duplicates() if duplicate_ips: raise ValidationError({ @@ -614,7 +625,7 @@ def save(self, *args, **kwargs): if self.address: # Infer address family from IPAddress object self.family = self.address.version - super(IPAddress, self).save(*args, **kwargs) + super().save(*args, **kwargs) def to_csv(self): @@ -639,6 +650,15 @@ def to_csv(self): self.description, ) + def _set_mask_length(self, value): + """ + Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly, + e.g. for bulk editing. + """ + if self.address is not None: + self.address.prefixlen = value + mask_length = property(fset=_set_mask_length) + @property def device(self): if self.interface: @@ -658,7 +678,6 @@ def get_role_class(self): return ROLE_CHOICE_CLASSES[self.role] -@python_2_unicode_compatible class VLANGroup(ChangeLoggedModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. @@ -710,7 +729,6 @@ def get_next_available_vid(self): return None -@python_2_unicode_compatible 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 @@ -784,7 +802,7 @@ class Meta: verbose_name_plural = 'VLANs' def __str__(self): - return self.display_name or super(VLAN, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('ipam:vlan', args=[self.pk]) @@ -823,10 +841,9 @@ def get_members(self): return Interface.objects.filter( Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk) - ) + ).distinct() -@python_2_unicode_compatible 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 diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index f606ab1b4c6..bfb2525f233 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from utilities.sql import NullsFirstQuerySet diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 261c047df9c..026cbc980b2 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor @@ -432,7 +430,7 @@ class Meta(VLANTable.Meta): class VLANMemberTable(BaseTable): parent = tables.LinkColumn(order_by=['device', 'virtual_machine']) - name = tables.Column(verbose_name='Interface') + name = tables.LinkColumn(verbose_name='Interface') untagged = tables.TemplateColumn( template_code=VLAN_MEMBER_UNTAGGED, orderable=False @@ -466,7 +464,7 @@ class Meta(BaseTable.Meta): def __init__(self, interface, *args, **kwargs): self.interface = interface - super(InterfaceVLANTable, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 67b7e123ef0..d57cb728f49 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from netaddr import IPNetwork from rest_framework import status @@ -14,7 +12,7 @@ class VRFTest(APITestCase): def setUp(self): - super(VRFTest, self).setUp() + super().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') @@ -115,7 +113,7 @@ class RIRTest(APITestCase): def setUp(self): - super(RIRTest, self).setUp() + super().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') @@ -216,7 +214,7 @@ class AggregateTest(APITestCase): def setUp(self): - super(AggregateTest, self).setUp() + super().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') @@ -319,7 +317,7 @@ class RoleTest(APITestCase): def setUp(self): - super(RoleTest, self).setUp() + super().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') @@ -420,7 +418,7 @@ class PrefixTest(APITestCase): def setUp(self): - super(PrefixTest, self).setUp() + super().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') @@ -568,7 +566,7 @@ def test_create_single_available_prefix(self): # Try to create one more prefix response = self.client.post(url, {'prefix_length': 30}, **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) def test_create_multiple_available_prefixes(self): @@ -585,7 +583,7 @@ def test_create_multiple_available_prefixes(self): {'prefix_length': 30, 'description': 'Test Prefix 5'}, ] response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) # Verify that no prefixes were created (the entire /28 is still available) @@ -630,7 +628,7 @@ def test_create_single_available_ip(self): # Try to create one more IP response = self.client.post(url, {}, **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) def test_create_multiple_available_ips(self): @@ -641,7 +639,7 @@ def test_create_multiple_available_ips(self): # Try to create nine IPs (only eight are available) data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) # Verify that no IPs were created (eight are still available) @@ -659,7 +657,7 @@ class IPAddressTest(APITestCase): def setUp(self): - super(IPAddressTest, self).setUp() + super().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')) @@ -758,7 +756,7 @@ class VLANGroupTest(APITestCase): def setUp(self): - super(VLANGroupTest, self).setUp() + super().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') @@ -859,7 +857,7 @@ class VLANTest(APITestCase): def setUp(self): - super(VLANTest, self).setUp() + super().setUp() self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1') self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2') @@ -960,7 +958,7 @@ class ServiceTest(APITestCase): def setUp(self): - super(ServiceTest, self).setUp() + super().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/tests/test_models.py b/netbox/ipam/tests/test_models.py index d17e8f5ef09..f7f1705ff1a 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import netaddr from django.core.exceptions import ValidationError from django.test import TestCase, override_settings diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 700d78ae49c..c2f7badd3b7 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,12 +1,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' urlpatterns = [ diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 2e3e0105c04..ffeb2e32399 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import netaddr from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin @@ -338,7 +336,7 @@ def get(self, request, pk): prefix_table.columns.show('pk') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(prefix_table) @@ -459,6 +457,16 @@ def get(self, request, pk): 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' ), pk=pk) + ipnet = netaddr.IPNetwork(prefix.prefix) + prefix.network_address = ipnet.network + prefix.hostmask = ipnet.hostmask + prefix.netmask = ipnet.netmask + prefix.last_network_address = str(netaddr.IPAddress(ipnet.last)) + prefix.last_usable_address = str(netaddr.IPAddress(ipnet.last - 1)) + prefix.first_usable_address = str(netaddr.IPAddress(ipnet.first + 1)) + prefix.hosts_count = int(ipnet.hostmask + 1) + prefix.usable_hosts_count = int(ipnet.hostmask - 1) + try: aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) except Aggregate.DoesNotExist: @@ -514,7 +522,7 @@ def get(self, request, pk): prefix_table.columns.show('pk') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(prefix_table) @@ -553,7 +561,7 @@ def get(self, request, pk): ip_table.columns.show('pk') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(ip_table) @@ -717,7 +725,7 @@ def dispatch(self, request, *args, **kwargs): if 'interface' not in request.GET: return redirect('ipam:ipaddress_add') - return super(IPAddressAssignView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get(self, request): @@ -842,7 +850,7 @@ def get(self, request, pk): vlan_table.columns.hide('group') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(vlan_table) @@ -901,7 +909,7 @@ def get(self, request, pk): members_table = tables.VLANMemberTable(members) paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(members_table) diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 4c7e0f81b5e..61796aabdae 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -1,7 +1,7 @@ from django.conf import settings from django.contrib.admin import AdminSite -from django.contrib.auth.models import Group, User from django.contrib.auth.admin import GroupAdmin, UserAdmin +from django.contrib.auth.models import Group, User from taggit.admin import TagAdmin from taggit.models import Tag @@ -23,8 +23,9 @@ class NetBoxAdminSite(AdminSite): admin_site.register(Tag, TagAdmin) # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK) -try: - import django_rq - admin_site.index_template = 'django_rq/index.html' -except ImportError: - pass +if settings.WEBHOOKS_ENABLED: + try: + import django_rq + admin_site.index_template = 'django_rq/index.html' + except ImportError: + pass diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index a0a9e91465e..60c493be7fb 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings from rest_framework import authentication, exceptions from rest_framework.pagination import LimitOffsetPagination @@ -59,16 +57,15 @@ class TokenPermissions(DjangoModelPermissions): """ def __init__(self): # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. - from django.conf import settings self.authenticated_users_only = settings.LOGIN_REQUIRED - super(TokenPermissions, self).__init__() + super().__init__() def has_permission(self, request, view): # If token authentication is in use, verify that the token allows write operations (for unsafe methods). if request.method not in SAFE_METHODS and isinstance(request.auth, Token): if not request.auth.write_enabled: return False - return super(TokenPermissions, self).has_permission(request, view) + return super().has_permission(request, view) # @@ -84,10 +81,17 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): - try: - self.count = queryset.count() - except (AttributeError, TypeError): + if hasattr(queryset, 'all'): + # TODO: This breaks filtering by annotated values + # Make a clone of the queryset with any annotations stripped (performance hack) + qs = queryset.all() + qs.query.annotations.clear() + self.count = qs.count() + + else: + # We're dealing with an iterable, not a QuerySet self.count = len(queryset) + self.limit = self.get_limit(request) self.offset = self.get_offset(request) self.request = request @@ -128,7 +132,7 @@ def get_next_link(self): if not self.limit: return None - return super(OptionalLimitOffsetPagination, self).get_next_link() + return super().get_next_link() def get_previous_link(self): @@ -136,7 +140,7 @@ def get_previous_link(self): if not self.limit: return None - return super(OptionalLimitOffsetPagination, self).get_previous_link() + return super().get_previous_link() # diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 23d6ba22179..d7a9cf2edcf 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -91,6 +91,10 @@ # are permitted to access most data in NetBox (excluding secrets) but not make any changes. LOGIN_REQUIRED = False +# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to +# re-authenticate. (Default: 1209600 [14 days]) +LOGIN_TIMEOUT = None + # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = False @@ -121,10 +125,6 @@ # 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', @@ -138,9 +138,18 @@ # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' +# By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use +# local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only +# database access.) Note that the user as which NetBox runs must have read and write permissions to this path. +SESSION_FILE_PATH = None + # Time zone (default: UTC) TIME_ZONE = 'UTC' +# The webhooks 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. +WEBHOOKS_ENABLED = False + # Date/time formatting. See the following link for supported formats: # https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date DATE_FORMAT = 'N j, Y' diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index 434377024f5..a2ad1376b7a 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from utilities.forms import BootstrapMixin @@ -17,6 +15,7 @@ ('devicetype', 'Device types'), ('device', 'Devices'), ('virtualchassis', 'Virtual Chassis'), + ('cable', 'Cables'), )), ('IPAM', ( ('vrf', 'VRFs'), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cc393b8332a..ba7cca57be6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -7,6 +7,13 @@ from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured +# Django 2.1 requires Python 3.5+ +if sys.version_info < (3, 5): + raise RuntimeError( + "NetBox requires Python 3.5 or higher (current: Python {})".format(sys.version.split()[0]) + ) + +# Check for configuration file try: from netbox import configuration except ImportError: @@ -14,15 +21,8 @@ "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." ) -# Raise a deprecation warning for Python 2.x -if sys.version_info[0] < 3: - warnings.warn( - "Support for Python 2 will be removed in NetBox v2.5. Please consider migration to Python 3 at your earliest " - "opportunity. Guidance is available in the documentation at http://netbox.readthedocs.io/.", - DeprecationWarning - ) -VERSION = '2.4.6' +VERSION = '2.5.4' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -55,6 +55,7 @@ EMAIL = getattr(configuration, 'EMAIL', {}) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) +LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') @@ -66,6 +67,7 @@ PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REDIS = getattr(configuration, 'REDIS', {}) +SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) 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') @@ -112,6 +114,17 @@ 'default': configuration.DATABASE, } +# Sessions +if LOGIN_TIMEOUT is not None: + if type(LOGIN_TIMEOUT) is not int or LOGIN_TIMEOUT < 0: + raise ImproperlyConfigured( + "LOGIN_TIMEOUT must be a positive integer (value: {})".format(LOGIN_TIMEOUT) + ) + # Django default is 1209600 seconds (14 days) + SESSION_COOKIE_AGE = LOGIN_TIMEOUT +if SESSION_FILE_PATH is not None: + SESSION_ENGINE = 'django.contrib.sessions.backends.file' + # Redis REDIS_HOST = REDIS.get('HOST', 'localhost') REDIS_PORT = REDIS.get('PORT', 6379) @@ -233,9 +246,17 @@ # Secrets SECRETS_MIN_PUBKEY_SIZE = 2048 +# Pagination +PER_PAGE_DEFAULTS = [ + 25, 50, 100, 250, 500, 1000 +] +if PAGINATE_COUNT not in PER_PAGE_DEFAULTS: + PER_PAGE_DEFAULTS.append(PAGINATE_COUNT) + PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS) + # Django filters FILTERS_NULL_CHOICE_LABEL = 'None' -FILTERS_NULL_CHOICE_VALUE = '0' # Must be a string +FILTERS_NULL_CHOICE_VALUE = 'null' # Django REST framework (API) REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version @@ -275,9 +296,12 @@ # drf_yasg settings for Swagger SWAGGER_SETTINGS = { + 'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema', 'DEFAULT_FIELD_INSPECTORS': [ 'utilities.custom_inspectors.NullableBooleanFieldInspector', 'utilities.custom_inspectors.CustomChoiceFieldInspector', + 'utilities.custom_inspectors.TagListFieldInspector', + 'utilities.custom_inspectors.SerializedPKRelatedFieldInspector', 'drf_yasg.inspectors.CamelCaseJSONFilter', 'drf_yasg.inspectors.ReferencingSerializerInspector', 'drf_yasg.inspectors.RelatedFieldInspector', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 9354e24b9ab..45c99beb9c2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,10 +1,8 @@ -from __future__ import unicode_literals - from django.conf import settings from django.conf.urls import include, url from django.views.static import serve -from drf_yasg.views import get_schema_view from drf_yasg import openapi +from drf_yasg.views import get_schema_view from netbox.views import APIRootView, HomeView, SearchView from users.views import LoginView, LogoutView diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index c6814c06848..ff11e38923b 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - from collections import OrderedDict -from django.db.models import Count +from django.db.models import Count, F from django.shortcuts import render from django.views.generic import View from rest_framework.response import Response @@ -13,14 +11,13 @@ from circuits.models import Circuit, Provider from circuits.tables import CircuitTable, ProviderTable from dcim.filters import ( - DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter + CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter ) from dcim.models import ( - ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, RackGroup, Site, - VirtualChassis + Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis ) from dcim.tables import ( - DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable + CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable ) from extras.models import ObjectChange, ReportResult, TopologyMap from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter @@ -91,6 +88,12 @@ 'table': VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), + ('cable', { + 'queryset': Cable.objects.all(), + 'filter': CableFilter, + 'table': CableTable, + 'url': 'dcim:cable_list', + }), # IPAM ('vrf', { 'queryset': VRF.objects.select_related('tenant'), @@ -159,6 +162,18 @@ class HomeView(View): def get(self, request): + connected_consoleports = ConsolePort.objects.filter( + connected_endpoint__isnull=False + ) + connected_powerports = PowerPort.objects.filter( + connected_endpoint__isnull=False + ) + connected_interfaces = Interface.objects.filter( + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') + ) + cables = Cable.objects.all() + stats = { # Organization @@ -168,9 +183,10 @@ def get(self, request): # DCIM 'rack_count': Rack.objects.count(), 'device_count': Device.objects.count(), - 'interface_connections_count': InterfaceConnection.objects.count(), - 'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(), - 'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(), + 'interface_connections_count': connected_interfaces.count(), + 'cable_count': cables.count(), + 'console_connections_count': connected_consoleports.count(), + 'power_connections_count': connected_powerports.count(), # IPAM 'vrf_count': VRF.objects.count(), @@ -197,7 +213,7 @@ def get(self, request): 'stats': stats, 'topology_maps': TopologyMap.objects.filter(site__isnull=True), 'report_results': ReportResult.objects.order_by('-created')[:10], - 'changelog': ObjectChange.objects.select_related('user')[:50] + 'changelog': ObjectChange.objects.select_related('user', 'changed_object_type')[:50] }) diff --git a/netbox/netbox/wsgi.py b/netbox/netbox/wsgi.py index ecfd81d9ad0..137f057c007 100644 --- a/netbox/netbox/wsgi.py +++ b/netbox/netbox/wsgi.py @@ -2,7 +2,6 @@ from django.core.wsgi import get_wsgi_application - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") application = get_wsgi_application() diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 6222a477dbe..ad618b5d126 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -120,6 +120,117 @@ input[name="pk"] { margin-top: 0; } +/* Color Selections */ +.color-selection-aa1409 { + background-color: #aa1409; + color: #ffffff; +} +.color-selection-f44336 { + background-color: #f44336; + color: #ffffff; +} +.color-selection-e91e63 { + background-color: #e91e63; + color: #ffffff; +} +.color-selection-ffe4e1 { + background-color: #ffe4e1; + color: #000000; +} +.color-selection-ff66ff { + background-color: #ff66ff; + color: #ffffff; +} +.color-selection-9c27b0 { + background-color: #9c27b0; + color: #ffffff; +} +.color-selection-673ab7 { + background-color: #673ab7; + color: #ffffff; +} +.color-selection-3f51b5 { + background-color: #3f51b5; + color: #ffffff; +} +.color-selection-2196f3 { + background-color: #2196f3; + color: #ffffff; +} +.color-selection-03a9f4 { + background-color: #03a9f4; + color: #ffffff; +} +.color-selection-00bcd4 { + background-color: #00bcd4; + color: #ffffff; +} +.color-selection-009688 { + background-color: #009688; + color: #ffffff; +} +.color-selection-00ffff { + background-color: #00ffff; + color: #ffffff; +} +.color-selection-2f6a31 { + background-color: #2f6a31; + color: #ffffff; +} +.color-selection-4caf50 { + background-color: #4caf50; + color: #ffffff; +} +.color-selection-8bc34a { + background-color: #8bc34a; + color: #ffffff; +} +.color-selection-cddc39 { + background-color: #cddc39; + color: #000000; +} +.color-selection-ffeb3b { + background-color: #ffeb3b; + color: #000000; +} +.color-selection-ffc107 { + background-color: #ffc107; + color: #000000; +} +.color-selection-ff9800 { + background-color: #ff9800; + color: #ffffff; +} +.color-selection-ff5722 { + background-color: #ff5722; + color: #ffffff; +} +.color-selection-795548 { + background-color: #795548; + color: #ffffff; +} +.color-selection-c0c0c0 { + background-color: #c0c0c0; + color: #000000; +} +.color-selection-9e9e9e { + background-color: #9e9e9e; + color: #ffffff; +} +.color-selection-607d8b { + background-color: #607d8b; + color: #ffffff; +} +.color-selection-111111 { + background-color: #111111; + color: #ffffff; +} +.color-selection-ffffff { + background-color: #ffffff; + color: #000000; +} + + /* Tables */ th.pk, td.pk { padding-bottom: 6px; @@ -140,6 +251,9 @@ table.attr-table td:nth-child(1) { div.paginator { margin-bottom: 20px; } +div.paginator form { + margin-bottom: 6px; +} nav ul.pagination { margin-top: 0; margin-bottom: 8px !important; @@ -390,6 +504,19 @@ table.report th a { top: -51px; } +/* Rendered Markdown */ +.rendered-markdown table { + width: 100%; +} +.rendered-markdown th { + border-bottom: 2px solid #dddddd; + padding: 8px; +} +.rendered-markdown td { + border-top: 1px solid #dddddd; + padding: 8px; +} + /* AJAX loader */ .loading { position: fixed; @@ -416,6 +543,10 @@ table.report th a { } /* Misc */ +.color-block { + display: block; + width: 80px; +} .text-nowrap { white-space: nowrap; } diff --git a/netbox/project-static/js/cabletrace.js b/netbox/project-static/js/cabletrace.js new file mode 100644 index 00000000000..2307cef8724 --- /dev/null +++ b/netbox/project-static/js/cabletrace.js @@ -0,0 +1,24 @@ +$('#cabletrace_modal').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget); + var obj = button.data('obj'); + var url = button.data('url'); + var modal_title = $(this).find('.modal-title'); + var modal_body = $(this).find('.modal-body'); + modal_title.text(obj); + modal_body.empty(); + $.ajax({ + url: url, + dataType: 'json', + success: function(json) { + $.each(json, function(i, segment) { + modal_body.append( + '
' + + '
' + segment[0].device.name + '
' + segment[0].name + '
' + + '
Cable #' + segment[1].id + '
' + + '
' + segment[2].device.name + '
' + segment[2].name + '
' + + '

' + ); + }) + } + }); +}); diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 6cb621071ed..23570576393 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -1,5 +1,10 @@ $(document).ready(function() { + // Pagination + $('select#per_page').change(function() { + this.form.submit(); + }); + // "Toggle" checkbox for object lists (PK column) $('input:checkbox.toggle').click(function() { $(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); @@ -62,119 +67,223 @@ $(document).ready(function() { form.submit(); }); - // API select widget - $('select[filter-for]').change(function() { + // Parse URLs which may contain variable refrences to other field values + function parseURL(url) { + var filter_regex = /\{\{([a-z_]+)\}\}/g; + var match; + var rendered_url = url; + var filter_field; + while (match = filter_regex.exec(url)) { + filter_field = $('#id_' + match[1]); + var custom_attr = $('option:selected', filter_field).attr('api-value'); + if (custom_attr) { + rendered_url = rendered_url.replace(match[0], custom_attr); + } else if (filter_field.val()) { + rendered_url = rendered_url.replace(match[0], filter_field.val()); + } else if (filter_field.attr('nullable') == 'true') { + rendered_url = rendered_url.replace(match[0], 'null'); + } + } + return rendered_url + } - // Resolve child field by ID specified in parent - var child_names = $(this).attr('filter-for'); - var parent = this; + // Assign color picker selection classes + function colorPickerClassCopy(data, container) { + if (data.element) { + $(container).addClass($(data.element).attr("class")); + } + return data.text; + } - // allow more than one child - $.each(child_names.split(" "), function(_, child_name){ + // Color Picker + $('.netbox-select2-color-picker').select2({ + allowClear: true, + placeholder: "---------", + theme: "bootstrap", + templateResult: colorPickerClassCopy, + templateSelection: colorPickerClassCopy + }); - var child_field = $('#id_' + child_name); - var child_selected = child_field.val(); + // Static choice selection + $('.netbox-select2-static').select2({ + allowClear: true, + placeholder: "---------", + theme: "bootstrap" + }); - // Wipe out any existing options within the child field and create a default option - child_field.empty(); - if (!child_field.attr('multiple')) { - child_field.append($("").attr("value", "").text("---------")); - } + // API backed selection + // Includes live search and chained fields + // The `multiple` setting may be controled via a data-* attribute + $('.netbox-select2-api').select2({ + allowClear: true, + placeholder: "---------", + theme: "bootstrap", + ajax: { + delay: 500, - if ($(parent).val() || $(parent).attr('nullable') == 'true') { - var api_url = child_field.attr('api-url') + '&limit=0&brief=1'; - var disabled_indicator = child_field.attr('disabled-indicator'); - var initial_value = child_field.attr('initial'); - var display_field = child_field.attr('display-field') || 'name'; - - // Determine the filter fields needed to make an API call - var filter_regex = /\{\{([a-z_]+)\}\}/g; - var match; - var rendered_url = api_url; - while (match = filter_regex.exec(api_url)) { - var filter_field = $('#id_' + match[1]); - if (filter_field.val()) { - rendered_url = rendered_url.replace(match[0], filter_field.val()); - } else if (filter_field.attr('nullable') == 'true') { - rendered_url = rendered_url.replace(match[0], '0'); - } + url: function(params) { + var element = this[0]; + var url = parseURL(element.getAttribute("data-url")); + + if (url.includes("{{")) { + // URL is not fully rendered yet, abort the request + return false; } + return url; + }, + + data: function(params) { + var element = this[0]; + // Paging. Note that `params.page` indexes at 1 + var offset = (params.page - 1) * 50 || 0; + // Base query params + var parameters = { + q: params.term, + brief: 1, + limit: 50, + offset: offset, + }; + + // filter-for fields from a chain + var attr_name = "data-filter-for-" + $(element).attr("name"); + var form = $(element).closest('form'); + var filter_for_elements = form.find("select[" + attr_name + "]"); + + filter_for_elements.each(function(index, filter_for_element) { + var param_name = $(filter_for_element).attr(attr_name); + var value = $(filter_for_element).val(); + + if (param_name && value) { + parameters[param_name] = value; + } + }); - // If all URL variables have been replaced, make the API call - if (rendered_url.search('{{') < 0) { - console.log(child_name + ": Fetching " + rendered_url); - $.ajax({ - url: rendered_url, - dataType: 'json', - success: function(response, status) { - $.each(response.results, function(index, choice) { - var option = $("").attr("value", choice.id).text(choice[display_field]); - if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) { - option.attr("disabled", "disabled"); - } else if (choice.id == child_selected) { - option.attr("selected", "selected"); - } - child_field.append(option); - }); + // Conditional query params + $.each(element.attributes, function(index, attr){ + if (attr.name.includes("data-conditional-query-param-")){ + var conditional = attr.name.split("data-conditional-query-param-")[1].split("__"); + var field = $("#id_" + conditional[0]); + var field_value = conditional[1]; + + if ($('option:selected', field).attr('api-value') === field_value){ + var _val = attr.value.split("="); + parameters[_val[0]] = _val[1]; } + } + }); + + // Additional query params + $.each(element.attributes, function(index, attr){ + if (attr.name.includes("data-additional-query-param-")){ + var param_name = attr.name.split("data-additional-query-param-")[1] + parameters[param_name] = attr.value; + } + }); + + // This will handle params with multiple values (i.e. for list filter forms) + return $.param(parameters, true); + }, + + processResults: function (data) { + var element = this.$element[0]; + var results = $.map(data.results, function (obj) { + obj.text = obj[element.getAttribute('display-field')] || obj.name; + obj.id = obj[element.getAttribute('value-field')] || obj.id; + + if(element.getAttribute('disabled-indicator') && obj[element.getAttribute('disabled-indicator')]) { + // The disabled-indicator equated to true, so we disable this option + obj.disabled = true; + } + return obj; + }); + + // Handle the null option + if (element.getAttribute('data-null-option')) { + var null_option = $(element).children()[0] + results.unshift({ + id: null_option.value, + text: null_option.text }); } + // Check if there are more results to page + var page = data.next !== null; + return { + results: results, + pagination: { + more: page + } + }; } - - // Trigger change event in case the child field is the parent of another field - child_field.change(); - }); - + } }); - // Auto-complete tags - function split_tags(val) { - return val.split(/,\s*/); + // API backed tags + var tags = $('#id_tags'); + if (tags.length > 0 && tags.val().length > 0){ + tags = $('#id_tags').val().split(/,\s*/); + } else { + tags = []; } - $("#id_tags") - .on("keydown", function(event) { - if (event.keyCode === $.ui.keyCode.TAB && - $(this).autocomplete("instance").menu.active) { - event.preventDefault(); + tag_objs = $.map(tags, function (tag) { + return { + id: tag, + text: tag, + selected: true } - }) - .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; + }); + // Replace the django issued text input with a select element + $('#id_tags').replaceWith(''); + $('#id_tags').select2({ + tags: true, + data: tag_objs, + multiple: true, + allowClear: true, + placeholder: "Tags", + + ajax: { + delay: 250, + url: "/api/extras/tags/", + + data: function(params) { + // Paging. Note that `params.page` indexes at 1 + var offset = (params.page - 1) * 50 || 0; + var parameters = { + q: params.term, + brief: 1, + limit: 50, + offset: offset, + }; + return parameters; + }, + + processResults: function (data) { + var results = $.map(data.results, function (obj) { + return { + id: obj.name, + text: obj.name + } + }); + + // Check if there are more results to page + var page = data.next !== null; + return { + results: results, + pagination: { + more: page + } + }; + } } - }); + }); + $('#id_tags').closest('form').submit(function(event){ + // django-taggit can only accept a single comma seperated string value + var value = $('#id_tags').val(); + if (value.length > 0){ + var final_tags = value.join(', '); + $('#id_tags').val(null).trigger('change'); + var option = new Option(final_tags, final_tags, true, true); + $('#id_tags').append(option).trigger('change'); + } + }); }); diff --git a/netbox/project-static/js/livesearch.js b/netbox/project-static/js/livesearch.js index e00aefbafc7..92902acfd50 100644 --- a/netbox/project-static/js/livesearch.js +++ b/netbox/project-static/js/livesearch.js @@ -24,7 +24,7 @@ $(document).ready(function() { source: function(request, response) { $.ajax({ type: 'GET', - url: search_field.attr('data-source'), + url: search_field.attr('data-source') + '?brief=1', data: search_key + '=' + request.term, success: function(data) { var choices = []; @@ -42,14 +42,14 @@ $(document).ready(function() { event.preventDefault(); search_field.val(ui.item.label); select_fields.val(''); - select_fields.attr('disabled', 'disabled'); real_field.empty(); + select_fields.attr('disabled', 'disabled'); real_field.append($("").attr('value', ui.item.value).text(ui.item.label)); real_field.change(); // Disable parent selection fields // $('select[filter-for="' + real_field.attr('name') + '"]').val(''); }, - minLength: 4, + minLength: 3, delay: 500 }); diff --git a/netbox/project-static/select2-4.0.5/LICENSE.md b/netbox/project-static/select2-4.0.5/LICENSE.md new file mode 100755 index 00000000000..8cb8a2b12cb --- /dev/null +++ b/netbox/project-static/select2-4.0.5/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/netbox/project-static/select2-4.0.5/README.md b/netbox/project-static/select2-4.0.5/README.md new file mode 100755 index 00000000000..6ee975d6ee0 --- /dev/null +++ b/netbox/project-static/select2-4.0.5/README.md @@ -0,0 +1,123 @@ +Select2 +======= +[![Build Status][travis-ci-image]][travis-ci-status] + +Select2 is a jQuery-based replacement for select boxes. It supports searching, +remote data sets, and pagination of results. + +To get started, checkout examples and documentation at +https://select2.org/ + +Use cases +--------- +* Enhancing native selects with search. +* Enhancing native selects with a better multi-select interface. +* Loading data from JavaScript: easily load items via AJAX and have them + searchable. +* Nesting optgroups: native selects only support one level of nesting. Select2 + does not have this restriction. +* Tagging: ability to add new items on the fly. +* Working with large, remote datasets: ability to partially load a dataset based + on the search term. +* Paging of large datasets: easy support for loading more pages when the results + are scrolled to the end. +* Templating: support for custom rendering of results and selections. + +Browser compatibility +--------------------- +* IE 8+ +* Chrome 8+ +* Firefox 10+ +* Safari 3+ +* Opera 10.6+ + +Select2 is automatically tested on the following browsers. + +[![Sauce Labs Test Status][saucelabs-matrix]][saucelabs-status] + +Usage +----- +You can source Select2 directly from a CDN like [JSDliver][jsdelivr] or +[CDNJS][cdnjs], [download it from this GitHub repo][releases], or use one of +the integrations below. + +Integrations +------------ +Third party developers have created plugins for platforms which allow Select2 to be integrated more natively and quickly. For many platforms, additional plugins are not required because Select2 acts as a standard `' + + '' + ); + + this.$searchContainer = $search; + this.$search = $search.find('input'); + + var $rendered = decorated.call(this); + + this._transferTabIndex(); + + return $rendered; + }; + + Search.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('open', function () { + self.$search.trigger('focus'); + }); + + container.on('close', function () { + self.$search.val(''); + self.$search.removeAttr('aria-activedescendant'); + self.$search.trigger('focus'); + }); + + container.on('enable', function () { + self.$search.prop('disabled', false); + + self._transferTabIndex(); + }); + + container.on('disable', function () { + self.$search.prop('disabled', true); + }); + + container.on('focus', function (evt) { + self.$search.trigger('focus'); + }); + + container.on('results:focus', function (params) { + self.$search.attr('aria-activedescendant', params.id); + }); + + this.$selection.on('focusin', '.select2-search--inline', function (evt) { + self.trigger('focus', evt); + }); + + this.$selection.on('focusout', '.select2-search--inline', function (evt) { + self._handleBlur(evt); + }); + + this.$selection.on('keydown', '.select2-search--inline', function (evt) { + evt.stopPropagation(); + + self.trigger('keypress', evt); + + self._keyUpPrevented = evt.isDefaultPrevented(); + + var key = evt.which; + + if (key === KEYS.BACKSPACE && self.$search.val() === '') { + var $previousChoice = self.$searchContainer + .prev('.select2-selection__choice'); + + if ($previousChoice.length > 0) { + var item = $previousChoice.data('data'); + + self.searchRemoveChoice(item); + + evt.preventDefault(); + } + } + }); + + // Try to detect the IE version should the `documentMode` property that + // is stored on the document. This is only implemented in IE and is + // slightly cleaner than doing a user agent check. + // This property is not available in Edge, but Edge also doesn't have + // this bug. + var msie = document.documentMode; + var disableInputEvents = msie && msie <= 11; + + // Workaround for browsers which do not support the `input` event + // This will prevent double-triggering of events for browsers which support + // both the `keyup` and `input` events. + this.$selection.on( + 'input.searchcheck', + '.select2-search--inline', + function (evt) { + // IE will trigger the `input` event when a placeholder is used on a + // search box. To get around this issue, we are forced to ignore all + // `input` events in IE and keep using `keyup`. + if (disableInputEvents) { + self.$selection.off('input.search input.searchcheck'); + return; + } + + // Unbind the duplicated `keyup` event + self.$selection.off('keyup.search'); + } + ); + + this.$selection.on( + 'keyup.search input.search', + '.select2-search--inline', + function (evt) { + // IE will trigger the `input` event when a placeholder is used on a + // search box. To get around this issue, we are forced to ignore all + // `input` events in IE and keep using `keyup`. + if (disableInputEvents && evt.type === 'input') { + self.$selection.off('input.search input.searchcheck'); + return; + } + + var key = evt.which; + + // We can freely ignore events from modifier keys + if (key == KEYS.SHIFT || key == KEYS.CTRL || key == KEYS.ALT) { + return; + } + + // Tabbing will be handled during the `keydown` phase + if (key == KEYS.TAB) { + return; + } + + self.handleSearch(evt); + } + ); + }; + + /** + * This method will transfer the tabindex attribute from the rendered + * selection to the search box. This allows for the search box to be used as + * the primary focus instead of the selection container. + * + * @private + */ + Search.prototype._transferTabIndex = function (decorated) { + this.$search.attr('tabindex', this.$selection.attr('tabindex')); + this.$selection.attr('tabindex', '-1'); + }; + + Search.prototype.createPlaceholder = function (decorated, placeholder) { + this.$search.attr('placeholder', placeholder.text); + }; + + Search.prototype.update = function (decorated, data) { + var searchHadFocus = this.$search[0] == document.activeElement; + + this.$search.attr('placeholder', ''); + + decorated.call(this, data); + + this.$selection.find('.select2-selection__rendered') + .append(this.$searchContainer); + + this.resizeSearch(); + if (searchHadFocus) { + this.$search.focus(); + } + }; + + Search.prototype.handleSearch = function () { + this.resizeSearch(); + + if (!this._keyUpPrevented) { + var input = this.$search.val(); + + this.trigger('query', { + term: input + }); + } + + this._keyUpPrevented = false; + }; + + Search.prototype.searchRemoveChoice = function (decorated, item) { + this.trigger('unselect', { + data: item + }); + + this.$search.val(item.text); + this.handleSearch(); + }; + + Search.prototype.resizeSearch = function () { + this.$search.css('width', '25px'); + + var width = ''; + + if (this.$search.attr('placeholder') !== '') { + width = this.$selection.find('.select2-selection__rendered').innerWidth(); + } else { + var minimumWidth = this.$search.val().length + 1; + + width = (minimumWidth * 0.75) + 'em'; + } + + this.$search.css('width', width); + }; + + return Search; +}); + +S2.define('select2/selection/eventRelay',[ + 'jquery' +], function ($) { + function EventRelay () { } + + EventRelay.prototype.bind = function (decorated, container, $container) { + var self = this; + var relayEvents = [ + 'open', 'opening', + 'close', 'closing', + 'select', 'selecting', + 'unselect', 'unselecting' + ]; + + var preventableEvents = ['opening', 'closing', 'selecting', 'unselecting']; + + decorated.call(this, container, $container); + + container.on('*', function (name, params) { + // Ignore events that should not be relayed + if ($.inArray(name, relayEvents) === -1) { + return; + } + + // The parameters should always be an object + params = params || {}; + + // Generate the jQuery event for the Select2 event + var evt = $.Event('select2:' + name, { + params: params + }); + + self.$element.trigger(evt); + + // Only handle preventable events if it was one + if ($.inArray(name, preventableEvents) === -1) { + return; + } + + params.prevented = evt.isDefaultPrevented(); + }); + }; + + return EventRelay; +}); + +S2.define('select2/translation',[ + 'jquery', + 'require' +], function ($, require) { + function Translation (dict) { + this.dict = dict || {}; + } + + Translation.prototype.all = function () { + return this.dict; + }; + + Translation.prototype.get = function (key) { + return this.dict[key]; + }; + + Translation.prototype.extend = function (translation) { + this.dict = $.extend({}, translation.all(), this.dict); + }; + + // Static functions + + Translation._cache = {}; + + Translation.loadPath = function (path) { + if (!(path in Translation._cache)) { + var translations = require(path); + + Translation._cache[path] = translations; + } + + return new Translation(Translation._cache[path]); + }; + + return Translation; +}); + +S2.define('select2/diacritics',[ + +], function () { + var diacritics = { + '\u24B6': 'A', + '\uFF21': 'A', + '\u00C0': 'A', + '\u00C1': 'A', + '\u00C2': 'A', + '\u1EA6': 'A', + '\u1EA4': 'A', + '\u1EAA': 'A', + '\u1EA8': 'A', + '\u00C3': 'A', + '\u0100': 'A', + '\u0102': 'A', + '\u1EB0': 'A', + '\u1EAE': 'A', + '\u1EB4': 'A', + '\u1EB2': 'A', + '\u0226': 'A', + '\u01E0': 'A', + '\u00C4': 'A', + '\u01DE': 'A', + '\u1EA2': 'A', + '\u00C5': 'A', + '\u01FA': 'A', + '\u01CD': 'A', + '\u0200': 'A', + '\u0202': 'A', + '\u1EA0': 'A', + '\u1EAC': 'A', + '\u1EB6': 'A', + '\u1E00': 'A', + '\u0104': 'A', + '\u023A': 'A', + '\u2C6F': 'A', + '\uA732': 'AA', + '\u00C6': 'AE', + '\u01FC': 'AE', + '\u01E2': 'AE', + '\uA734': 'AO', + '\uA736': 'AU', + '\uA738': 'AV', + '\uA73A': 'AV', + '\uA73C': 'AY', + '\u24B7': 'B', + '\uFF22': 'B', + '\u1E02': 'B', + '\u1E04': 'B', + '\u1E06': 'B', + '\u0243': 'B', + '\u0182': 'B', + '\u0181': 'B', + '\u24B8': 'C', + '\uFF23': 'C', + '\u0106': 'C', + '\u0108': 'C', + '\u010A': 'C', + '\u010C': 'C', + '\u00C7': 'C', + '\u1E08': 'C', + '\u0187': 'C', + '\u023B': 'C', + '\uA73E': 'C', + '\u24B9': 'D', + '\uFF24': 'D', + '\u1E0A': 'D', + '\u010E': 'D', + '\u1E0C': 'D', + '\u1E10': 'D', + '\u1E12': 'D', + '\u1E0E': 'D', + '\u0110': 'D', + '\u018B': 'D', + '\u018A': 'D', + '\u0189': 'D', + '\uA779': 'D', + '\u01F1': 'DZ', + '\u01C4': 'DZ', + '\u01F2': 'Dz', + '\u01C5': 'Dz', + '\u24BA': 'E', + '\uFF25': 'E', + '\u00C8': 'E', + '\u00C9': 'E', + '\u00CA': 'E', + '\u1EC0': 'E', + '\u1EBE': 'E', + '\u1EC4': 'E', + '\u1EC2': 'E', + '\u1EBC': 'E', + '\u0112': 'E', + '\u1E14': 'E', + '\u1E16': 'E', + '\u0114': 'E', + '\u0116': 'E', + '\u00CB': 'E', + '\u1EBA': 'E', + '\u011A': 'E', + '\u0204': 'E', + '\u0206': 'E', + '\u1EB8': 'E', + '\u1EC6': 'E', + '\u0228': 'E', + '\u1E1C': 'E', + '\u0118': 'E', + '\u1E18': 'E', + '\u1E1A': 'E', + '\u0190': 'E', + '\u018E': 'E', + '\u24BB': 'F', + '\uFF26': 'F', + '\u1E1E': 'F', + '\u0191': 'F', + '\uA77B': 'F', + '\u24BC': 'G', + '\uFF27': 'G', + '\u01F4': 'G', + '\u011C': 'G', + '\u1E20': 'G', + '\u011E': 'G', + '\u0120': 'G', + '\u01E6': 'G', + '\u0122': 'G', + '\u01E4': 'G', + '\u0193': 'G', + '\uA7A0': 'G', + '\uA77D': 'G', + '\uA77E': 'G', + '\u24BD': 'H', + '\uFF28': 'H', + '\u0124': 'H', + '\u1E22': 'H', + '\u1E26': 'H', + '\u021E': 'H', + '\u1E24': 'H', + '\u1E28': 'H', + '\u1E2A': 'H', + '\u0126': 'H', + '\u2C67': 'H', + '\u2C75': 'H', + '\uA78D': 'H', + '\u24BE': 'I', + '\uFF29': 'I', + '\u00CC': 'I', + '\u00CD': 'I', + '\u00CE': 'I', + '\u0128': 'I', + '\u012A': 'I', + '\u012C': 'I', + '\u0130': 'I', + '\u00CF': 'I', + '\u1E2E': 'I', + '\u1EC8': 'I', + '\u01CF': 'I', + '\u0208': 'I', + '\u020A': 'I', + '\u1ECA': 'I', + '\u012E': 'I', + '\u1E2C': 'I', + '\u0197': 'I', + '\u24BF': 'J', + '\uFF2A': 'J', + '\u0134': 'J', + '\u0248': 'J', + '\u24C0': 'K', + '\uFF2B': 'K', + '\u1E30': 'K', + '\u01E8': 'K', + '\u1E32': 'K', + '\u0136': 'K', + '\u1E34': 'K', + '\u0198': 'K', + '\u2C69': 'K', + '\uA740': 'K', + '\uA742': 'K', + '\uA744': 'K', + '\uA7A2': 'K', + '\u24C1': 'L', + '\uFF2C': 'L', + '\u013F': 'L', + '\u0139': 'L', + '\u013D': 'L', + '\u1E36': 'L', + '\u1E38': 'L', + '\u013B': 'L', + '\u1E3C': 'L', + '\u1E3A': 'L', + '\u0141': 'L', + '\u023D': 'L', + '\u2C62': 'L', + '\u2C60': 'L', + '\uA748': 'L', + '\uA746': 'L', + '\uA780': 'L', + '\u01C7': 'LJ', + '\u01C8': 'Lj', + '\u24C2': 'M', + '\uFF2D': 'M', + '\u1E3E': 'M', + '\u1E40': 'M', + '\u1E42': 'M', + '\u2C6E': 'M', + '\u019C': 'M', + '\u24C3': 'N', + '\uFF2E': 'N', + '\u01F8': 'N', + '\u0143': 'N', + '\u00D1': 'N', + '\u1E44': 'N', + '\u0147': 'N', + '\u1E46': 'N', + '\u0145': 'N', + '\u1E4A': 'N', + '\u1E48': 'N', + '\u0220': 'N', + '\u019D': 'N', + '\uA790': 'N', + '\uA7A4': 'N', + '\u01CA': 'NJ', + '\u01CB': 'Nj', + '\u24C4': 'O', + '\uFF2F': 'O', + '\u00D2': 'O', + '\u00D3': 'O', + '\u00D4': 'O', + '\u1ED2': 'O', + '\u1ED0': 'O', + '\u1ED6': 'O', + '\u1ED4': 'O', + '\u00D5': 'O', + '\u1E4C': 'O', + '\u022C': 'O', + '\u1E4E': 'O', + '\u014C': 'O', + '\u1E50': 'O', + '\u1E52': 'O', + '\u014E': 'O', + '\u022E': 'O', + '\u0230': 'O', + '\u00D6': 'O', + '\u022A': 'O', + '\u1ECE': 'O', + '\u0150': 'O', + '\u01D1': 'O', + '\u020C': 'O', + '\u020E': 'O', + '\u01A0': 'O', + '\u1EDC': 'O', + '\u1EDA': 'O', + '\u1EE0': 'O', + '\u1EDE': 'O', + '\u1EE2': 'O', + '\u1ECC': 'O', + '\u1ED8': 'O', + '\u01EA': 'O', + '\u01EC': 'O', + '\u00D8': 'O', + '\u01FE': 'O', + '\u0186': 'O', + '\u019F': 'O', + '\uA74A': 'O', + '\uA74C': 'O', + '\u01A2': 'OI', + '\uA74E': 'OO', + '\u0222': 'OU', + '\u24C5': 'P', + '\uFF30': 'P', + '\u1E54': 'P', + '\u1E56': 'P', + '\u01A4': 'P', + '\u2C63': 'P', + '\uA750': 'P', + '\uA752': 'P', + '\uA754': 'P', + '\u24C6': 'Q', + '\uFF31': 'Q', + '\uA756': 'Q', + '\uA758': 'Q', + '\u024A': 'Q', + '\u24C7': 'R', + '\uFF32': 'R', + '\u0154': 'R', + '\u1E58': 'R', + '\u0158': 'R', + '\u0210': 'R', + '\u0212': 'R', + '\u1E5A': 'R', + '\u1E5C': 'R', + '\u0156': 'R', + '\u1E5E': 'R', + '\u024C': 'R', + '\u2C64': 'R', + '\uA75A': 'R', + '\uA7A6': 'R', + '\uA782': 'R', + '\u24C8': 'S', + '\uFF33': 'S', + '\u1E9E': 'S', + '\u015A': 'S', + '\u1E64': 'S', + '\u015C': 'S', + '\u1E60': 'S', + '\u0160': 'S', + '\u1E66': 'S', + '\u1E62': 'S', + '\u1E68': 'S', + '\u0218': 'S', + '\u015E': 'S', + '\u2C7E': 'S', + '\uA7A8': 'S', + '\uA784': 'S', + '\u24C9': 'T', + '\uFF34': 'T', + '\u1E6A': 'T', + '\u0164': 'T', + '\u1E6C': 'T', + '\u021A': 'T', + '\u0162': 'T', + '\u1E70': 'T', + '\u1E6E': 'T', + '\u0166': 'T', + '\u01AC': 'T', + '\u01AE': 'T', + '\u023E': 'T', + '\uA786': 'T', + '\uA728': 'TZ', + '\u24CA': 'U', + '\uFF35': 'U', + '\u00D9': 'U', + '\u00DA': 'U', + '\u00DB': 'U', + '\u0168': 'U', + '\u1E78': 'U', + '\u016A': 'U', + '\u1E7A': 'U', + '\u016C': 'U', + '\u00DC': 'U', + '\u01DB': 'U', + '\u01D7': 'U', + '\u01D5': 'U', + '\u01D9': 'U', + '\u1EE6': 'U', + '\u016E': 'U', + '\u0170': 'U', + '\u01D3': 'U', + '\u0214': 'U', + '\u0216': 'U', + '\u01AF': 'U', + '\u1EEA': 'U', + '\u1EE8': 'U', + '\u1EEE': 'U', + '\u1EEC': 'U', + '\u1EF0': 'U', + '\u1EE4': 'U', + '\u1E72': 'U', + '\u0172': 'U', + '\u1E76': 'U', + '\u1E74': 'U', + '\u0244': 'U', + '\u24CB': 'V', + '\uFF36': 'V', + '\u1E7C': 'V', + '\u1E7E': 'V', + '\u01B2': 'V', + '\uA75E': 'V', + '\u0245': 'V', + '\uA760': 'VY', + '\u24CC': 'W', + '\uFF37': 'W', + '\u1E80': 'W', + '\u1E82': 'W', + '\u0174': 'W', + '\u1E86': 'W', + '\u1E84': 'W', + '\u1E88': 'W', + '\u2C72': 'W', + '\u24CD': 'X', + '\uFF38': 'X', + '\u1E8A': 'X', + '\u1E8C': 'X', + '\u24CE': 'Y', + '\uFF39': 'Y', + '\u1EF2': 'Y', + '\u00DD': 'Y', + '\u0176': 'Y', + '\u1EF8': 'Y', + '\u0232': 'Y', + '\u1E8E': 'Y', + '\u0178': 'Y', + '\u1EF6': 'Y', + '\u1EF4': 'Y', + '\u01B3': 'Y', + '\u024E': 'Y', + '\u1EFE': 'Y', + '\u24CF': 'Z', + '\uFF3A': 'Z', + '\u0179': 'Z', + '\u1E90': 'Z', + '\u017B': 'Z', + '\u017D': 'Z', + '\u1E92': 'Z', + '\u1E94': 'Z', + '\u01B5': 'Z', + '\u0224': 'Z', + '\u2C7F': 'Z', + '\u2C6B': 'Z', + '\uA762': 'Z', + '\u24D0': 'a', + '\uFF41': 'a', + '\u1E9A': 'a', + '\u00E0': 'a', + '\u00E1': 'a', + '\u00E2': 'a', + '\u1EA7': 'a', + '\u1EA5': 'a', + '\u1EAB': 'a', + '\u1EA9': 'a', + '\u00E3': 'a', + '\u0101': 'a', + '\u0103': 'a', + '\u1EB1': 'a', + '\u1EAF': 'a', + '\u1EB5': 'a', + '\u1EB3': 'a', + '\u0227': 'a', + '\u01E1': 'a', + '\u00E4': 'a', + '\u01DF': 'a', + '\u1EA3': 'a', + '\u00E5': 'a', + '\u01FB': 'a', + '\u01CE': 'a', + '\u0201': 'a', + '\u0203': 'a', + '\u1EA1': 'a', + '\u1EAD': 'a', + '\u1EB7': 'a', + '\u1E01': 'a', + '\u0105': 'a', + '\u2C65': 'a', + '\u0250': 'a', + '\uA733': 'aa', + '\u00E6': 'ae', + '\u01FD': 'ae', + '\u01E3': 'ae', + '\uA735': 'ao', + '\uA737': 'au', + '\uA739': 'av', + '\uA73B': 'av', + '\uA73D': 'ay', + '\u24D1': 'b', + '\uFF42': 'b', + '\u1E03': 'b', + '\u1E05': 'b', + '\u1E07': 'b', + '\u0180': 'b', + '\u0183': 'b', + '\u0253': 'b', + '\u24D2': 'c', + '\uFF43': 'c', + '\u0107': 'c', + '\u0109': 'c', + '\u010B': 'c', + '\u010D': 'c', + '\u00E7': 'c', + '\u1E09': 'c', + '\u0188': 'c', + '\u023C': 'c', + '\uA73F': 'c', + '\u2184': 'c', + '\u24D3': 'd', + '\uFF44': 'd', + '\u1E0B': 'd', + '\u010F': 'd', + '\u1E0D': 'd', + '\u1E11': 'd', + '\u1E13': 'd', + '\u1E0F': 'd', + '\u0111': 'd', + '\u018C': 'd', + '\u0256': 'd', + '\u0257': 'd', + '\uA77A': 'd', + '\u01F3': 'dz', + '\u01C6': 'dz', + '\u24D4': 'e', + '\uFF45': 'e', + '\u00E8': 'e', + '\u00E9': 'e', + '\u00EA': 'e', + '\u1EC1': 'e', + '\u1EBF': 'e', + '\u1EC5': 'e', + '\u1EC3': 'e', + '\u1EBD': 'e', + '\u0113': 'e', + '\u1E15': 'e', + '\u1E17': 'e', + '\u0115': 'e', + '\u0117': 'e', + '\u00EB': 'e', + '\u1EBB': 'e', + '\u011B': 'e', + '\u0205': 'e', + '\u0207': 'e', + '\u1EB9': 'e', + '\u1EC7': 'e', + '\u0229': 'e', + '\u1E1D': 'e', + '\u0119': 'e', + '\u1E19': 'e', + '\u1E1B': 'e', + '\u0247': 'e', + '\u025B': 'e', + '\u01DD': 'e', + '\u24D5': 'f', + '\uFF46': 'f', + '\u1E1F': 'f', + '\u0192': 'f', + '\uA77C': 'f', + '\u24D6': 'g', + '\uFF47': 'g', + '\u01F5': 'g', + '\u011D': 'g', + '\u1E21': 'g', + '\u011F': 'g', + '\u0121': 'g', + '\u01E7': 'g', + '\u0123': 'g', + '\u01E5': 'g', + '\u0260': 'g', + '\uA7A1': 'g', + '\u1D79': 'g', + '\uA77F': 'g', + '\u24D7': 'h', + '\uFF48': 'h', + '\u0125': 'h', + '\u1E23': 'h', + '\u1E27': 'h', + '\u021F': 'h', + '\u1E25': 'h', + '\u1E29': 'h', + '\u1E2B': 'h', + '\u1E96': 'h', + '\u0127': 'h', + '\u2C68': 'h', + '\u2C76': 'h', + '\u0265': 'h', + '\u0195': 'hv', + '\u24D8': 'i', + '\uFF49': 'i', + '\u00EC': 'i', + '\u00ED': 'i', + '\u00EE': 'i', + '\u0129': 'i', + '\u012B': 'i', + '\u012D': 'i', + '\u00EF': 'i', + '\u1E2F': 'i', + '\u1EC9': 'i', + '\u01D0': 'i', + '\u0209': 'i', + '\u020B': 'i', + '\u1ECB': 'i', + '\u012F': 'i', + '\u1E2D': 'i', + '\u0268': 'i', + '\u0131': 'i', + '\u24D9': 'j', + '\uFF4A': 'j', + '\u0135': 'j', + '\u01F0': 'j', + '\u0249': 'j', + '\u24DA': 'k', + '\uFF4B': 'k', + '\u1E31': 'k', + '\u01E9': 'k', + '\u1E33': 'k', + '\u0137': 'k', + '\u1E35': 'k', + '\u0199': 'k', + '\u2C6A': 'k', + '\uA741': 'k', + '\uA743': 'k', + '\uA745': 'k', + '\uA7A3': 'k', + '\u24DB': 'l', + '\uFF4C': 'l', + '\u0140': 'l', + '\u013A': 'l', + '\u013E': 'l', + '\u1E37': 'l', + '\u1E39': 'l', + '\u013C': 'l', + '\u1E3D': 'l', + '\u1E3B': 'l', + '\u017F': 'l', + '\u0142': 'l', + '\u019A': 'l', + '\u026B': 'l', + '\u2C61': 'l', + '\uA749': 'l', + '\uA781': 'l', + '\uA747': 'l', + '\u01C9': 'lj', + '\u24DC': 'm', + '\uFF4D': 'm', + '\u1E3F': 'm', + '\u1E41': 'm', + '\u1E43': 'm', + '\u0271': 'm', + '\u026F': 'm', + '\u24DD': 'n', + '\uFF4E': 'n', + '\u01F9': 'n', + '\u0144': 'n', + '\u00F1': 'n', + '\u1E45': 'n', + '\u0148': 'n', + '\u1E47': 'n', + '\u0146': 'n', + '\u1E4B': 'n', + '\u1E49': 'n', + '\u019E': 'n', + '\u0272': 'n', + '\u0149': 'n', + '\uA791': 'n', + '\uA7A5': 'n', + '\u01CC': 'nj', + '\u24DE': 'o', + '\uFF4F': 'o', + '\u00F2': 'o', + '\u00F3': 'o', + '\u00F4': 'o', + '\u1ED3': 'o', + '\u1ED1': 'o', + '\u1ED7': 'o', + '\u1ED5': 'o', + '\u00F5': 'o', + '\u1E4D': 'o', + '\u022D': 'o', + '\u1E4F': 'o', + '\u014D': 'o', + '\u1E51': 'o', + '\u1E53': 'o', + '\u014F': 'o', + '\u022F': 'o', + '\u0231': 'o', + '\u00F6': 'o', + '\u022B': 'o', + '\u1ECF': 'o', + '\u0151': 'o', + '\u01D2': 'o', + '\u020D': 'o', + '\u020F': 'o', + '\u01A1': 'o', + '\u1EDD': 'o', + '\u1EDB': 'o', + '\u1EE1': 'o', + '\u1EDF': 'o', + '\u1EE3': 'o', + '\u1ECD': 'o', + '\u1ED9': 'o', + '\u01EB': 'o', + '\u01ED': 'o', + '\u00F8': 'o', + '\u01FF': 'o', + '\u0254': 'o', + '\uA74B': 'o', + '\uA74D': 'o', + '\u0275': 'o', + '\u01A3': 'oi', + '\u0223': 'ou', + '\uA74F': 'oo', + '\u24DF': 'p', + '\uFF50': 'p', + '\u1E55': 'p', + '\u1E57': 'p', + '\u01A5': 'p', + '\u1D7D': 'p', + '\uA751': 'p', + '\uA753': 'p', + '\uA755': 'p', + '\u24E0': 'q', + '\uFF51': 'q', + '\u024B': 'q', + '\uA757': 'q', + '\uA759': 'q', + '\u24E1': 'r', + '\uFF52': 'r', + '\u0155': 'r', + '\u1E59': 'r', + '\u0159': 'r', + '\u0211': 'r', + '\u0213': 'r', + '\u1E5B': 'r', + '\u1E5D': 'r', + '\u0157': 'r', + '\u1E5F': 'r', + '\u024D': 'r', + '\u027D': 'r', + '\uA75B': 'r', + '\uA7A7': 'r', + '\uA783': 'r', + '\u24E2': 's', + '\uFF53': 's', + '\u00DF': 's', + '\u015B': 's', + '\u1E65': 's', + '\u015D': 's', + '\u1E61': 's', + '\u0161': 's', + '\u1E67': 's', + '\u1E63': 's', + '\u1E69': 's', + '\u0219': 's', + '\u015F': 's', + '\u023F': 's', + '\uA7A9': 's', + '\uA785': 's', + '\u1E9B': 's', + '\u24E3': 't', + '\uFF54': 't', + '\u1E6B': 't', + '\u1E97': 't', + '\u0165': 't', + '\u1E6D': 't', + '\u021B': 't', + '\u0163': 't', + '\u1E71': 't', + '\u1E6F': 't', + '\u0167': 't', + '\u01AD': 't', + '\u0288': 't', + '\u2C66': 't', + '\uA787': 't', + '\uA729': 'tz', + '\u24E4': 'u', + '\uFF55': 'u', + '\u00F9': 'u', + '\u00FA': 'u', + '\u00FB': 'u', + '\u0169': 'u', + '\u1E79': 'u', + '\u016B': 'u', + '\u1E7B': 'u', + '\u016D': 'u', + '\u00FC': 'u', + '\u01DC': 'u', + '\u01D8': 'u', + '\u01D6': 'u', + '\u01DA': 'u', + '\u1EE7': 'u', + '\u016F': 'u', + '\u0171': 'u', + '\u01D4': 'u', + '\u0215': 'u', + '\u0217': 'u', + '\u01B0': 'u', + '\u1EEB': 'u', + '\u1EE9': 'u', + '\u1EEF': 'u', + '\u1EED': 'u', + '\u1EF1': 'u', + '\u1EE5': 'u', + '\u1E73': 'u', + '\u0173': 'u', + '\u1E77': 'u', + '\u1E75': 'u', + '\u0289': 'u', + '\u24E5': 'v', + '\uFF56': 'v', + '\u1E7D': 'v', + '\u1E7F': 'v', + '\u028B': 'v', + '\uA75F': 'v', + '\u028C': 'v', + '\uA761': 'vy', + '\u24E6': 'w', + '\uFF57': 'w', + '\u1E81': 'w', + '\u1E83': 'w', + '\u0175': 'w', + '\u1E87': 'w', + '\u1E85': 'w', + '\u1E98': 'w', + '\u1E89': 'w', + '\u2C73': 'w', + '\u24E7': 'x', + '\uFF58': 'x', + '\u1E8B': 'x', + '\u1E8D': 'x', + '\u24E8': 'y', + '\uFF59': 'y', + '\u1EF3': 'y', + '\u00FD': 'y', + '\u0177': 'y', + '\u1EF9': 'y', + '\u0233': 'y', + '\u1E8F': 'y', + '\u00FF': 'y', + '\u1EF7': 'y', + '\u1E99': 'y', + '\u1EF5': 'y', + '\u01B4': 'y', + '\u024F': 'y', + '\u1EFF': 'y', + '\u24E9': 'z', + '\uFF5A': 'z', + '\u017A': 'z', + '\u1E91': 'z', + '\u017C': 'z', + '\u017E': 'z', + '\u1E93': 'z', + '\u1E95': 'z', + '\u01B6': 'z', + '\u0225': 'z', + '\u0240': 'z', + '\u2C6C': 'z', + '\uA763': 'z', + '\u0386': '\u0391', + '\u0388': '\u0395', + '\u0389': '\u0397', + '\u038A': '\u0399', + '\u03AA': '\u0399', + '\u038C': '\u039F', + '\u038E': '\u03A5', + '\u03AB': '\u03A5', + '\u038F': '\u03A9', + '\u03AC': '\u03B1', + '\u03AD': '\u03B5', + '\u03AE': '\u03B7', + '\u03AF': '\u03B9', + '\u03CA': '\u03B9', + '\u0390': '\u03B9', + '\u03CC': '\u03BF', + '\u03CD': '\u03C5', + '\u03CB': '\u03C5', + '\u03B0': '\u03C5', + '\u03C9': '\u03C9', + '\u03C2': '\u03C3' + }; + + return diacritics; +}); + +S2.define('select2/data/base',[ + '../utils' +], function (Utils) { + function BaseAdapter ($element, options) { + BaseAdapter.__super__.constructor.call(this); + } + + Utils.Extend(BaseAdapter, Utils.Observable); + + BaseAdapter.prototype.current = function (callback) { + throw new Error('The `current` method must be defined in child classes.'); + }; + + BaseAdapter.prototype.query = function (params, callback) { + throw new Error('The `query` method must be defined in child classes.'); + }; + + BaseAdapter.prototype.bind = function (container, $container) { + // Can be implemented in subclasses + }; + + BaseAdapter.prototype.destroy = function () { + // Can be implemented in subclasses + }; + + BaseAdapter.prototype.generateResultId = function (container, data) { + var id = container.id + '-result-'; + + id += Utils.generateChars(4); + + if (data.id != null) { + id += '-' + data.id.toString(); + } else { + id += '-' + Utils.generateChars(4); + } + return id; + }; + + return BaseAdapter; +}); + +S2.define('select2/data/select',[ + './base', + '../utils', + 'jquery' +], function (BaseAdapter, Utils, $) { + function SelectAdapter ($element, options) { + this.$element = $element; + this.options = options; + + SelectAdapter.__super__.constructor.call(this); + } + + Utils.Extend(SelectAdapter, BaseAdapter); + + SelectAdapter.prototype.current = function (callback) { + var data = []; + var self = this; + + this.$element.find(':selected').each(function () { + var $option = $(this); + + var option = self.item($option); + + data.push(option); + }); + + callback(data); + }; + + SelectAdapter.prototype.select = function (data) { + var self = this; + + data.selected = true; + + // If data.element is a DOM node, use it instead + if ($(data.element).is('option')) { + data.element.selected = true; + + this.$element.trigger('change'); + + return; + } + + if (this.$element.prop('multiple')) { + this.current(function (currentData) { + var val = []; + + data = [data]; + data.push.apply(data, currentData); + + for (var d = 0; d < data.length; d++) { + var id = data[d].id; + + if ($.inArray(id, val) === -1) { + val.push(id); + } + } + + self.$element.val(val); + self.$element.trigger('change'); + }); + } else { + var val = data.id; + + this.$element.val(val); + this.$element.trigger('change'); + } + }; + + SelectAdapter.prototype.unselect = function (data) { + var self = this; + + if (!this.$element.prop('multiple')) { + return; + } + + data.selected = false; + + if ($(data.element).is('option')) { + data.element.selected = false; + + this.$element.trigger('change'); + + return; + } + + this.current(function (currentData) { + var val = []; + + for (var d = 0; d < currentData.length; d++) { + var id = currentData[d].id; + + if (id !== data.id && $.inArray(id, val) === -1) { + val.push(id); + } + } + + self.$element.val(val); + + self.$element.trigger('change'); + }); + }; + + SelectAdapter.prototype.bind = function (container, $container) { + var self = this; + + this.container = container; + + container.on('select', function (params) { + self.select(params.data); + }); + + container.on('unselect', function (params) { + self.unselect(params.data); + }); + }; + + SelectAdapter.prototype.destroy = function () { + // Remove anything added to child elements + this.$element.find('*').each(function () { + // Remove any custom data set by Select2 + $.removeData(this, 'data'); + }); + }; + + SelectAdapter.prototype.query = function (params, callback) { + var data = []; + var self = this; + + var $options = this.$element.children(); + + $options.each(function () { + var $option = $(this); + + if (!$option.is('option') && !$option.is('optgroup')) { + return; + } + + var option = self.item($option); + + var matches = self.matches(params, option); + + if (matches !== null) { + data.push(matches); + } + }); + + callback({ + results: data + }); + }; + + SelectAdapter.prototype.addOptions = function ($options) { + Utils.appendMany(this.$element, $options); + }; + + SelectAdapter.prototype.option = function (data) { + var option; + + if (data.children) { + option = document.createElement('optgroup'); + option.label = data.text; + } else { + option = document.createElement('option'); + + if (option.textContent !== undefined) { + option.textContent = data.text; + } else { + option.innerText = data.text; + } + } + + if (data.id !== undefined) { + option.value = data.id; + } + + if (data.disabled) { + option.disabled = true; + } + + if (data.selected) { + option.selected = true; + } + + if (data.title) { + option.title = data.title; + } + + var $option = $(option); + + var normalizedData = this._normalizeItem(data); + normalizedData.element = option; + + // Override the option's data with the combined data + $.data(option, 'data', normalizedData); + + return $option; + }; + + SelectAdapter.prototype.item = function ($option) { + var data = {}; + + data = $.data($option[0], 'data'); + + if (data != null) { + return data; + } + + if ($option.is('option')) { + data = { + id: $option.val(), + text: $option.text(), + disabled: $option.prop('disabled'), + selected: $option.prop('selected'), + title: $option.prop('title') + }; + } else if ($option.is('optgroup')) { + data = { + text: $option.prop('label'), + children: [], + title: $option.prop('title') + }; + + var $children = $option.children('option'); + var children = []; + + for (var c = 0; c < $children.length; c++) { + var $child = $($children[c]); + + var child = this.item($child); + + children.push(child); + } + + data.children = children; + } + + data = this._normalizeItem(data); + data.element = $option[0]; + + $.data($option[0], 'data', data); + + return data; + }; + + SelectAdapter.prototype._normalizeItem = function (item) { + if (!$.isPlainObject(item)) { + item = { + id: item, + text: item + }; + } + + item = $.extend({}, { + text: '' + }, item); + + var defaults = { + selected: false, + disabled: false + }; + + if (item.id != null) { + item.id = item.id.toString(); + } + + if (item.text != null) { + item.text = item.text.toString(); + } + + if (item._resultId == null && item.id && this.container != null) { + item._resultId = this.generateResultId(this.container, item); + } + + return $.extend({}, defaults, item); + }; + + SelectAdapter.prototype.matches = function (params, data) { + var matcher = this.options.get('matcher'); + + return matcher(params, data); + }; + + return SelectAdapter; +}); + +S2.define('select2/data/array',[ + './select', + '../utils', + 'jquery' +], function (SelectAdapter, Utils, $) { + function ArrayAdapter ($element, options) { + var data = options.get('data') || []; + + ArrayAdapter.__super__.constructor.call(this, $element, options); + + this.addOptions(this.convertToOptions(data)); + } + + Utils.Extend(ArrayAdapter, SelectAdapter); + + ArrayAdapter.prototype.select = function (data) { + var $option = this.$element.find('option').filter(function (i, elm) { + return elm.value == data.id.toString(); + }); + + if ($option.length === 0) { + $option = this.option(data); + + this.addOptions($option); + } + + ArrayAdapter.__super__.select.call(this, data); + }; + + ArrayAdapter.prototype.convertToOptions = function (data) { + var self = this; + + var $existing = this.$element.find('option'); + var existingIds = $existing.map(function () { + return self.item($(this)).id; + }).get(); + + var $options = []; + + // Filter out all items except for the one passed in the argument + function onlyItem (item) { + return function () { + return $(this).val() == item.id; + }; + } + + for (var d = 0; d < data.length; d++) { + var item = this._normalizeItem(data[d]); + + // Skip items which were pre-loaded, only merge the data + if ($.inArray(item.id, existingIds) >= 0) { + var $existingOption = $existing.filter(onlyItem(item)); + + var existingData = this.item($existingOption); + var newData = $.extend(true, {}, item, existingData); + + var $newOption = this.option(newData); + + $existingOption.replaceWith($newOption); + + continue; + } + + var $option = this.option(item); + + if (item.children) { + var $children = this.convertToOptions(item.children); + + Utils.appendMany($option, $children); + } + + $options.push($option); + } + + return $options; + }; + + return ArrayAdapter; +}); + +S2.define('select2/data/ajax',[ + './array', + '../utils', + 'jquery' +], function (ArrayAdapter, Utils, $) { + function AjaxAdapter ($element, options) { + this.ajaxOptions = this._applyDefaults(options.get('ajax')); + + if (this.ajaxOptions.processResults != null) { + this.processResults = this.ajaxOptions.processResults; + } + + AjaxAdapter.__super__.constructor.call(this, $element, options); + } + + Utils.Extend(AjaxAdapter, ArrayAdapter); + + AjaxAdapter.prototype._applyDefaults = function (options) { + var defaults = { + data: function (params) { + return $.extend({}, params, { + q: params.term + }); + }, + transport: function (params, success, failure) { + var $request = $.ajax(params); + + $request.then(success); + $request.fail(failure); + + return $request; + } + }; + + return $.extend({}, defaults, options, true); + }; + + AjaxAdapter.prototype.processResults = function (results) { + return results; + }; + + AjaxAdapter.prototype.query = function (params, callback) { + var matches = []; + var self = this; + + if (this._request != null) { + // JSONP requests cannot always be aborted + if ($.isFunction(this._request.abort)) { + this._request.abort(); + } + + this._request = null; + } + + var options = $.extend({ + type: 'GET' + }, this.ajaxOptions); + + if (typeof options.url === 'function') { + options.url = options.url.call(this.$element, params); + } + + if (typeof options.data === 'function') { + options.data = options.data.call(this.$element, params); + } + + function request () { + var $request = options.transport(options, function (data) { + var results = self.processResults(data, params); + + if (self.options.get('debug') && window.console && console.error) { + // Check to make sure that the response included a `results` key. + if (!results || !results.results || !$.isArray(results.results)) { + console.error( + 'Select2: The AJAX results did not return an array in the ' + + '`results` key of the response.' + ); + } + } + + callback(results); + }, function () { + // Attempt to detect if a request was aborted + // Only works if the transport exposes a status property + if ($request.status && $request.status === '0') { + return; + } + + self.trigger('results:message', { + message: 'errorLoading' + }); + }); + + self._request = $request; + } + + if (this.ajaxOptions.delay && params.term != null) { + if (this._queryTimeout) { + window.clearTimeout(this._queryTimeout); + } + + this._queryTimeout = window.setTimeout(request, this.ajaxOptions.delay); + } else { + request(); + } + }; + + return AjaxAdapter; +}); + +S2.define('select2/data/tags',[ + 'jquery' +], function ($) { + function Tags (decorated, $element, options) { + var tags = options.get('tags'); + + var createTag = options.get('createTag'); + + if (createTag !== undefined) { + this.createTag = createTag; + } + + var insertTag = options.get('insertTag'); + + if (insertTag !== undefined) { + this.insertTag = insertTag; + } + + decorated.call(this, $element, options); + + if ($.isArray(tags)) { + for (var t = 0; t < tags.length; t++) { + var tag = tags[t]; + var item = this._normalizeItem(tag); + + var $option = this.option(item); + + this.$element.append($option); + } + } + } + + Tags.prototype.query = function (decorated, params, callback) { + var self = this; + + this._removeOldTags(); + + if (params.term == null || params.page != null) { + decorated.call(this, params, callback); + return; + } + + function wrapper (obj, child) { + var data = obj.results; + + for (var i = 0; i < data.length; i++) { + var option = data[i]; + + var checkChildren = ( + option.children != null && + !wrapper({ + results: option.children + }, true) + ); + + var optionText = (option.text || '').toUpperCase(); + var paramsTerm = (params.term || '').toUpperCase(); + + var checkText = optionText === paramsTerm; + + if (checkText || checkChildren) { + if (child) { + return false; + } + + obj.data = data; + callback(obj); + + return; + } + } + + if (child) { + return true; + } + + var tag = self.createTag(params); + + if (tag != null) { + var $option = self.option(tag); + $option.attr('data-select2-tag', true); + + self.addOptions([$option]); + + self.insertTag(data, tag); + } + + obj.results = data; + + callback(obj); + } + + decorated.call(this, params, wrapper); + }; + + Tags.prototype.createTag = function (decorated, params) { + var term = $.trim(params.term); + + if (term === '') { + return null; + } + + return { + id: term, + text: term + }; + }; + + Tags.prototype.insertTag = function (_, data, tag) { + data.unshift(tag); + }; + + Tags.prototype._removeOldTags = function (_) { + var tag = this._lastTag; + + var $options = this.$element.find('option[data-select2-tag]'); + + $options.each(function () { + if (this.selected) { + return; + } + + $(this).remove(); + }); + }; + + return Tags; +}); + +S2.define('select2/data/tokenizer',[ + 'jquery' +], function ($) { + function Tokenizer (decorated, $element, options) { + var tokenizer = options.get('tokenizer'); + + if (tokenizer !== undefined) { + this.tokenizer = tokenizer; + } + + decorated.call(this, $element, options); + } + + Tokenizer.prototype.bind = function (decorated, container, $container) { + decorated.call(this, container, $container); + + this.$search = container.dropdown.$search || container.selection.$search || + $container.find('.select2-search__field'); + }; + + Tokenizer.prototype.query = function (decorated, params, callback) { + var self = this; + + function createAndSelect (data) { + // Normalize the data object so we can use it for checks + var item = self._normalizeItem(data); + + // Check if the data object already exists as a tag + // Select it if it doesn't + var $existingOptions = self.$element.find('option').filter(function () { + return $(this).val() === item.id; + }); + + // If an existing option wasn't found for it, create the option + if (!$existingOptions.length) { + var $option = self.option(item); + $option.attr('data-select2-tag', true); + + self._removeOldTags(); + self.addOptions([$option]); + } + + // Select the item, now that we know there is an option for it + select(item); + } + + function select (data) { + self.trigger('select', { + data: data + }); + } + + params.term = params.term || ''; + + var tokenData = this.tokenizer(params, this.options, createAndSelect); + + if (tokenData.term !== params.term) { + // Replace the search term if we have the search box + if (this.$search.length) { + this.$search.val(tokenData.term); + this.$search.focus(); + } + + params.term = tokenData.term; + } + + decorated.call(this, params, callback); + }; + + Tokenizer.prototype.tokenizer = function (_, params, options, callback) { + var separators = options.get('tokenSeparators') || []; + var term = params.term; + var i = 0; + + var createTag = this.createTag || function (params) { + return { + id: params.term, + text: params.term + }; + }; + + while (i < term.length) { + var termChar = term[i]; + + if ($.inArray(termChar, separators) === -1) { + i++; + + continue; + } + + var part = term.substr(0, i); + var partParams = $.extend({}, params, { + term: part + }); + + var data = createTag(partParams); + + if (data == null) { + i++; + continue; + } + + callback(data); + + // Reset the term to not include the tokenized portion + term = term.substr(i + 1) || ''; + i = 0; + } + + return { + term: term + }; + }; + + return Tokenizer; +}); + +S2.define('select2/data/minimumInputLength',[ + +], function () { + function MinimumInputLength (decorated, $e, options) { + this.minimumInputLength = options.get('minimumInputLength'); + + decorated.call(this, $e, options); + } + + MinimumInputLength.prototype.query = function (decorated, params, callback) { + params.term = params.term || ''; + + if (params.term.length < this.minimumInputLength) { + this.trigger('results:message', { + message: 'inputTooShort', + args: { + minimum: this.minimumInputLength, + input: params.term, + params: params + } + }); + + return; + } + + decorated.call(this, params, callback); + }; + + return MinimumInputLength; +}); + +S2.define('select2/data/maximumInputLength',[ + +], function () { + function MaximumInputLength (decorated, $e, options) { + this.maximumInputLength = options.get('maximumInputLength'); + + decorated.call(this, $e, options); + } + + MaximumInputLength.prototype.query = function (decorated, params, callback) { + params.term = params.term || ''; + + if (this.maximumInputLength > 0 && + params.term.length > this.maximumInputLength) { + this.trigger('results:message', { + message: 'inputTooLong', + args: { + maximum: this.maximumInputLength, + input: params.term, + params: params + } + }); + + return; + } + + decorated.call(this, params, callback); + }; + + return MaximumInputLength; +}); + +S2.define('select2/data/maximumSelectionLength',[ + +], function (){ + function MaximumSelectionLength (decorated, $e, options) { + this.maximumSelectionLength = options.get('maximumSelectionLength'); + + decorated.call(this, $e, options); + } + + MaximumSelectionLength.prototype.query = + function (decorated, params, callback) { + var self = this; + + this.current(function (currentData) { + var count = currentData != null ? currentData.length : 0; + if (self.maximumSelectionLength > 0 && + count >= self.maximumSelectionLength) { + self.trigger('results:message', { + message: 'maximumSelected', + args: { + maximum: self.maximumSelectionLength + } + }); + return; + } + decorated.call(self, params, callback); + }); + }; + + return MaximumSelectionLength; +}); + +S2.define('select2/dropdown',[ + 'jquery', + './utils' +], function ($, Utils) { + function Dropdown ($element, options) { + this.$element = $element; + this.options = options; + + Dropdown.__super__.constructor.call(this); + } + + Utils.Extend(Dropdown, Utils.Observable); + + Dropdown.prototype.render = function () { + var $dropdown = $( + '' + + '' + + '' + ); + + $dropdown.attr('dir', this.options.get('dir')); + + this.$dropdown = $dropdown; + + return $dropdown; + }; + + Dropdown.prototype.bind = function () { + // Should be implemented in subclasses + }; + + Dropdown.prototype.position = function ($dropdown, $container) { + // Should be implmented in subclasses + }; + + Dropdown.prototype.destroy = function () { + // Remove the dropdown from the DOM + this.$dropdown.remove(); + }; + + return Dropdown; +}); + +S2.define('select2/dropdown/search',[ + 'jquery', + '../utils' +], function ($, Utils) { + function Search () { } + + Search.prototype.render = function (decorated) { + var $rendered = decorated.call(this); + + var $search = $( + '' + + '' + + '' + ); + + this.$searchContainer = $search; + this.$search = $search.find('input'); + + $rendered.prepend($search); + + return $rendered; + }; + + Search.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + this.$search.on('keydown', function (evt) { + self.trigger('keypress', evt); + + self._keyUpPrevented = evt.isDefaultPrevented(); + }); + + // Workaround for browsers which do not support the `input` event + // This will prevent double-triggering of events for browsers which support + // both the `keyup` and `input` events. + this.$search.on('input', function (evt) { + // Unbind the duplicated `keyup` event + $(this).off('keyup'); + }); + + this.$search.on('keyup input', function (evt) { + self.handleSearch(evt); + }); + + container.on('open', function () { + self.$search.attr('tabindex', 0); + + self.$search.focus(); + + window.setTimeout(function () { + self.$search.focus(); + }, 0); + }); + + container.on('close', function () { + self.$search.attr('tabindex', -1); + + self.$search.val(''); + }); + + container.on('focus', function () { + if (!container.isOpen()) { + self.$search.focus(); + } + }); + + container.on('results:all', function (params) { + if (params.query.term == null || params.query.term === '') { + var showSearch = self.showSearch(params); + + if (showSearch) { + self.$searchContainer.removeClass('select2-search--hide'); + } else { + self.$searchContainer.addClass('select2-search--hide'); + } + } + }); + }; + + Search.prototype.handleSearch = function (evt) { + if (!this._keyUpPrevented) { + var input = this.$search.val(); + + this.trigger('query', { + term: input + }); + } + + this._keyUpPrevented = false; + }; + + Search.prototype.showSearch = function (_, params) { + return true; + }; + + return Search; +}); + +S2.define('select2/dropdown/hidePlaceholder',[ + +], function () { + function HidePlaceholder (decorated, $element, options, dataAdapter) { + this.placeholder = this.normalizePlaceholder(options.get('placeholder')); + + decorated.call(this, $element, options, dataAdapter); + } + + HidePlaceholder.prototype.append = function (decorated, data) { + data.results = this.removePlaceholder(data.results); + + decorated.call(this, data); + }; + + HidePlaceholder.prototype.normalizePlaceholder = function (_, placeholder) { + if (typeof placeholder === 'string') { + placeholder = { + id: '', + text: placeholder + }; + } + + return placeholder; + }; + + HidePlaceholder.prototype.removePlaceholder = function (_, data) { + var modifiedData = data.slice(0); + + for (var d = data.length - 1; d >= 0; d--) { + var item = data[d]; + + if (this.placeholder.id === item.id) { + modifiedData.splice(d, 1); + } + } + + return modifiedData; + }; + + return HidePlaceholder; +}); + +S2.define('select2/dropdown/infiniteScroll',[ + 'jquery' +], function ($) { + function InfiniteScroll (decorated, $element, options, dataAdapter) { + this.lastParams = {}; + + decorated.call(this, $element, options, dataAdapter); + + this.$loadingMore = this.createLoadingMore(); + this.loading = false; + } + + InfiniteScroll.prototype.append = function (decorated, data) { + this.$loadingMore.remove(); + this.loading = false; + + decorated.call(this, data); + + if (this.showLoadingMore(data)) { + this.$results.append(this.$loadingMore); + } + }; + + InfiniteScroll.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('query', function (params) { + self.lastParams = params; + self.loading = true; + }); + + container.on('query:append', function (params) { + self.lastParams = params; + self.loading = true; + }); + + this.$results.on('scroll', function () { + var isLoadMoreVisible = $.contains( + document.documentElement, + self.$loadingMore[0] + ); + + if (self.loading || !isLoadMoreVisible) { + return; + } + + var currentOffset = self.$results.offset().top + + self.$results.outerHeight(false); + var loadingMoreOffset = self.$loadingMore.offset().top + + self.$loadingMore.outerHeight(false); + + if (currentOffset + 50 >= loadingMoreOffset) { + self.loadMore(); + } + }); + }; + + InfiniteScroll.prototype.loadMore = function () { + this.loading = true; + + var params = $.extend({}, {page: 1}, this.lastParams); + + params.page++; + + this.trigger('query:append', params); + }; + + InfiniteScroll.prototype.showLoadingMore = function (_, data) { + return data.pagination && data.pagination.more; + }; + + InfiniteScroll.prototype.createLoadingMore = function () { + var $option = $( + '
  • ' + ); + + var message = this.options.get('translations').get('loadingMore'); + + $option.html(message(this.lastParams)); + + return $option; + }; + + return InfiniteScroll; +}); + +S2.define('select2/dropdown/attachBody',[ + 'jquery', + '../utils' +], function ($, Utils) { + function AttachBody (decorated, $element, options) { + this.$dropdownParent = options.get('dropdownParent') || $(document.body); + + decorated.call(this, $element, options); + } + + AttachBody.prototype.bind = function (decorated, container, $container) { + var self = this; + + var setupResultsEvents = false; + + decorated.call(this, container, $container); + + container.on('open', function () { + self._showDropdown(); + self._attachPositioningHandler(container); + + if (!setupResultsEvents) { + setupResultsEvents = true; + + container.on('results:all', function () { + self._positionDropdown(); + self._resizeDropdown(); + }); + + container.on('results:append', function () { + self._positionDropdown(); + self._resizeDropdown(); + }); + } + }); + + container.on('close', function () { + self._hideDropdown(); + self._detachPositioningHandler(container); + }); + + this.$dropdownContainer.on('mousedown', function (evt) { + evt.stopPropagation(); + }); + }; + + AttachBody.prototype.destroy = function (decorated) { + decorated.call(this); + + this.$dropdownContainer.remove(); + }; + + AttachBody.prototype.position = function (decorated, $dropdown, $container) { + // Clone all of the container classes + $dropdown.attr('class', $container.attr('class')); + + $dropdown.removeClass('select2'); + $dropdown.addClass('select2-container--open'); + + $dropdown.css({ + position: 'absolute', + top: -999999 + }); + + this.$container = $container; + }; + + AttachBody.prototype.render = function (decorated) { + var $container = $(''); + + var $dropdown = decorated.call(this); + $container.append($dropdown); + + this.$dropdownContainer = $container; + + return $container; + }; + + AttachBody.prototype._hideDropdown = function (decorated) { + this.$dropdownContainer.detach(); + }; + + AttachBody.prototype._attachPositioningHandler = + function (decorated, container) { + var self = this; + + var scrollEvent = 'scroll.select2.' + container.id; + var resizeEvent = 'resize.select2.' + container.id; + var orientationEvent = 'orientationchange.select2.' + container.id; + + var $watchers = this.$container.parents().filter(Utils.hasScroll); + $watchers.each(function () { + $(this).data('select2-scroll-position', { + x: $(this).scrollLeft(), + y: $(this).scrollTop() + }); + }); + + $watchers.on(scrollEvent, function (ev) { + var position = $(this).data('select2-scroll-position'); + $(this).scrollTop(position.y); + }); + + $(window).on(scrollEvent + ' ' + resizeEvent + ' ' + orientationEvent, + function (e) { + self._positionDropdown(); + self._resizeDropdown(); + }); + }; + + AttachBody.prototype._detachPositioningHandler = + function (decorated, container) { + var scrollEvent = 'scroll.select2.' + container.id; + var resizeEvent = 'resize.select2.' + container.id; + var orientationEvent = 'orientationchange.select2.' + container.id; + + var $watchers = this.$container.parents().filter(Utils.hasScroll); + $watchers.off(scrollEvent); + + $(window).off(scrollEvent + ' ' + resizeEvent + ' ' + orientationEvent); + }; + + AttachBody.prototype._positionDropdown = function () { + var $window = $(window); + + var isCurrentlyAbove = this.$dropdown.hasClass('select2-dropdown--above'); + var isCurrentlyBelow = this.$dropdown.hasClass('select2-dropdown--below'); + + var newDirection = null; + + var offset = this.$container.offset(); + + offset.bottom = offset.top + this.$container.outerHeight(false); + + var container = { + height: this.$container.outerHeight(false) + }; + + container.top = offset.top; + container.bottom = offset.top + container.height; + + var dropdown = { + height: this.$dropdown.outerHeight(false) + }; + + var viewport = { + top: $window.scrollTop(), + bottom: $window.scrollTop() + $window.height() + }; + + var enoughRoomAbove = viewport.top < (offset.top - dropdown.height); + var enoughRoomBelow = viewport.bottom > (offset.bottom + dropdown.height); + + var css = { + left: offset.left, + top: container.bottom + }; + + // Determine what the parent element is to use for calciulating the offset + var $offsetParent = this.$dropdownParent; + + // For statically positoned elements, we need to get the element + // that is determining the offset + if ($offsetParent.css('position') === 'static') { + $offsetParent = $offsetParent.offsetParent(); + } + + var parentOffset = $offsetParent.offset(); + + css.top -= parentOffset.top; + css.left -= parentOffset.left; + + if (!isCurrentlyAbove && !isCurrentlyBelow) { + newDirection = 'below'; + } + + if (!enoughRoomBelow && enoughRoomAbove && !isCurrentlyAbove) { + newDirection = 'above'; + } else if (!enoughRoomAbove && enoughRoomBelow && isCurrentlyAbove) { + newDirection = 'below'; + } + + if (newDirection == 'above' || + (isCurrentlyAbove && newDirection !== 'below')) { + css.top = container.top - parentOffset.top - dropdown.height; + } + + if (newDirection != null) { + this.$dropdown + .removeClass('select2-dropdown--below select2-dropdown--above') + .addClass('select2-dropdown--' + newDirection); + this.$container + .removeClass('select2-container--below select2-container--above') + .addClass('select2-container--' + newDirection); + } + + this.$dropdownContainer.css(css); + }; + + AttachBody.prototype._resizeDropdown = function () { + var css = { + width: this.$container.outerWidth(false) + 'px' + }; + + if (this.options.get('dropdownAutoWidth')) { + css.minWidth = css.width; + css.position = 'relative'; + css.width = 'auto'; + } + + this.$dropdown.css(css); + }; + + AttachBody.prototype._showDropdown = function (decorated) { + this.$dropdownContainer.appendTo(this.$dropdownParent); + + this._positionDropdown(); + this._resizeDropdown(); + }; + + return AttachBody; +}); + +S2.define('select2/dropdown/minimumResultsForSearch',[ + +], function () { + function countResults (data) { + var count = 0; + + for (var d = 0; d < data.length; d++) { + var item = data[d]; + + if (item.children) { + count += countResults(item.children); + } else { + count++; + } + } + + return count; + } + + function MinimumResultsForSearch (decorated, $element, options, dataAdapter) { + this.minimumResultsForSearch = options.get('minimumResultsForSearch'); + + if (this.minimumResultsForSearch < 0) { + this.minimumResultsForSearch = Infinity; + } + + decorated.call(this, $element, options, dataAdapter); + } + + MinimumResultsForSearch.prototype.showSearch = function (decorated, params) { + if (countResults(params.data.results) < this.minimumResultsForSearch) { + return false; + } + + return decorated.call(this, params); + }; + + return MinimumResultsForSearch; +}); + +S2.define('select2/dropdown/selectOnClose',[ + +], function () { + function SelectOnClose () { } + + SelectOnClose.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('close', function (params) { + self._handleSelectOnClose(params); + }); + }; + + SelectOnClose.prototype._handleSelectOnClose = function (_, params) { + if (params && params.originalSelect2Event != null) { + var event = params.originalSelect2Event; + + // Don't select an item if the close event was triggered from a select or + // unselect event + if (event._type === 'select' || event._type === 'unselect') { + return; + } + } + + var $highlightedResults = this.getHighlightedResults(); + + // Only select highlighted results + if ($highlightedResults.length < 1) { + return; + } + + var data = $highlightedResults.data('data'); + + // Don't re-select already selected resulte + if ( + (data.element != null && data.element.selected) || + (data.element == null && data.selected) + ) { + return; + } + + this.trigger('select', { + data: data + }); + }; + + return SelectOnClose; +}); + +S2.define('select2/dropdown/closeOnSelect',[ + +], function () { + function CloseOnSelect () { } + + CloseOnSelect.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('select', function (evt) { + self._selectTriggered(evt); + }); + + container.on('unselect', function (evt) { + self._selectTriggered(evt); + }); + }; + + CloseOnSelect.prototype._selectTriggered = function (_, evt) { + var originalEvent = evt.originalEvent; + + // Don't close if the control key is being held + if (originalEvent && originalEvent.ctrlKey) { + return; + } + + this.trigger('close', { + originalEvent: originalEvent, + originalSelect2Event: evt + }); + }; + + return CloseOnSelect; +}); + +S2.define('select2/i18n/en',[],function () { + // English + return { + errorLoading: function () { + return 'The results could not be loaded.'; + }, + inputTooLong: function (args) { + var overChars = args.input.length - args.maximum; + + var message = 'Please delete ' + overChars + ' character'; + + if (overChars != 1) { + message += 's'; + } + + return message; + }, + inputTooShort: function (args) { + var remainingChars = args.minimum - args.input.length; + + var message = 'Please enter ' + remainingChars + ' or more characters'; + + return message; + }, + loadingMore: function () { + return 'Loading more results…'; + }, + maximumSelected: function (args) { + var message = 'You can only select ' + args.maximum + ' item'; + + if (args.maximum != 1) { + message += 's'; + } + + return message; + }, + noResults: function () { + return 'No results found'; + }, + searching: function () { + return 'Searching…'; + } + }; +}); + +S2.define('select2/defaults',[ + 'jquery', + 'require', + + './results', + + './selection/single', + './selection/multiple', + './selection/placeholder', + './selection/allowClear', + './selection/search', + './selection/eventRelay', + + './utils', + './translation', + './diacritics', + + './data/select', + './data/array', + './data/ajax', + './data/tags', + './data/tokenizer', + './data/minimumInputLength', + './data/maximumInputLength', + './data/maximumSelectionLength', + + './dropdown', + './dropdown/search', + './dropdown/hidePlaceholder', + './dropdown/infiniteScroll', + './dropdown/attachBody', + './dropdown/minimumResultsForSearch', + './dropdown/selectOnClose', + './dropdown/closeOnSelect', + + './i18n/en' +], function ($, require, + + ResultsList, + + SingleSelection, MultipleSelection, Placeholder, AllowClear, + SelectionSearch, EventRelay, + + Utils, Translation, DIACRITICS, + + SelectData, ArrayData, AjaxData, Tags, Tokenizer, + MinimumInputLength, MaximumInputLength, MaximumSelectionLength, + + Dropdown, DropdownSearch, HidePlaceholder, InfiniteScroll, + AttachBody, MinimumResultsForSearch, SelectOnClose, CloseOnSelect, + + EnglishTranslation) { + function Defaults () { + this.reset(); + } + + Defaults.prototype.apply = function (options) { + options = $.extend(true, {}, this.defaults, options); + + if (options.dataAdapter == null) { + if (options.ajax != null) { + options.dataAdapter = AjaxData; + } else if (options.data != null) { + options.dataAdapter = ArrayData; + } else { + options.dataAdapter = SelectData; + } + + if (options.minimumInputLength > 0) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + MinimumInputLength + ); + } + + if (options.maximumInputLength > 0) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + MaximumInputLength + ); + } + + if (options.maximumSelectionLength > 0) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + MaximumSelectionLength + ); + } + + if (options.tags) { + options.dataAdapter = Utils.Decorate(options.dataAdapter, Tags); + } + + if (options.tokenSeparators != null || options.tokenizer != null) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + Tokenizer + ); + } + + if (options.query != null) { + var Query = require(options.amdBase + 'compat/query'); + + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + Query + ); + } + + if (options.initSelection != null) { + var InitSelection = require(options.amdBase + 'compat/initSelection'); + + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + InitSelection + ); + } + } + + if (options.resultsAdapter == null) { + options.resultsAdapter = ResultsList; + + if (options.ajax != null) { + options.resultsAdapter = Utils.Decorate( + options.resultsAdapter, + InfiniteScroll + ); + } + + if (options.placeholder != null) { + options.resultsAdapter = Utils.Decorate( + options.resultsAdapter, + HidePlaceholder + ); + } + + if (options.selectOnClose) { + options.resultsAdapter = Utils.Decorate( + options.resultsAdapter, + SelectOnClose + ); + } + } + + if (options.dropdownAdapter == null) { + if (options.multiple) { + options.dropdownAdapter = Dropdown; + } else { + var SearchableDropdown = Utils.Decorate(Dropdown, DropdownSearch); + + options.dropdownAdapter = SearchableDropdown; + } + + if (options.minimumResultsForSearch !== 0) { + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + MinimumResultsForSearch + ); + } + + if (options.closeOnSelect) { + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + CloseOnSelect + ); + } + + if ( + options.dropdownCssClass != null || + options.dropdownCss != null || + options.adaptDropdownCssClass != null + ) { + var DropdownCSS = require(options.amdBase + 'compat/dropdownCss'); + + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + DropdownCSS + ); + } + + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + AttachBody + ); + } + + if (options.selectionAdapter == null) { + if (options.multiple) { + options.selectionAdapter = MultipleSelection; + } else { + options.selectionAdapter = SingleSelection; + } + + // Add the placeholder mixin if a placeholder was specified + if (options.placeholder != null) { + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + Placeholder + ); + } + + if (options.allowClear) { + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + AllowClear + ); + } + + if (options.multiple) { + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + SelectionSearch + ); + } + + if ( + options.containerCssClass != null || + options.containerCss != null || + options.adaptContainerCssClass != null + ) { + var ContainerCSS = require(options.amdBase + 'compat/containerCss'); + + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + ContainerCSS + ); + } + + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + EventRelay + ); + } + + if (typeof options.language === 'string') { + // Check if the language is specified with a region + if (options.language.indexOf('-') > 0) { + // Extract the region information if it is included + var languageParts = options.language.split('-'); + var baseLanguage = languageParts[0]; + + options.language = [options.language, baseLanguage]; + } else { + options.language = [options.language]; + } + } + + if ($.isArray(options.language)) { + var languages = new Translation(); + options.language.push('en'); + + var languageNames = options.language; + + for (var l = 0; l < languageNames.length; l++) { + var name = languageNames[l]; + var language = {}; + + try { + // Try to load it with the original name + language = Translation.loadPath(name); + } catch (e) { + try { + // If we couldn't load it, check if it wasn't the full path + name = this.defaults.amdLanguageBase + name; + language = Translation.loadPath(name); + } catch (ex) { + // The translation could not be loaded at all. Sometimes this is + // because of a configuration problem, other times this can be + // because of how Select2 helps load all possible translation files. + if (options.debug && window.console && console.warn) { + console.warn( + 'Select2: The language file for "' + name + '" could not be ' + + 'automatically loaded. A fallback will be used instead.' + ); + } + + continue; + } + } + + languages.extend(language); + } + + options.translations = languages; + } else { + var baseTranslation = Translation.loadPath( + this.defaults.amdLanguageBase + 'en' + ); + var customTranslation = new Translation(options.language); + + customTranslation.extend(baseTranslation); + + options.translations = customTranslation; + } + + return options; + }; + + Defaults.prototype.reset = function () { + function stripDiacritics (text) { + // Used 'uni range + named function' from http://jsperf.com/diacritics/18 + function match(a) { + return DIACRITICS[a] || a; + } + + return text.replace(/[^\u0000-\u007E]/g, match); + } + + function matcher (params, data) { + // Always return the object if there is nothing to compare + if ($.trim(params.term) === '') { + return data; + } + + // Do a recursive check for options with children + if (data.children && data.children.length > 0) { + // Clone the data object if there are children + // This is required as we modify the object to remove any non-matches + var match = $.extend(true, {}, data); + + // Check each child of the option + for (var c = data.children.length - 1; c >= 0; c--) { + var child = data.children[c]; + + var matches = matcher(params, child); + + // If there wasn't a match, remove the object in the array + if (matches == null) { + match.children.splice(c, 1); + } + } + + // If any children matched, return the new object + if (match.children.length > 0) { + return match; + } + + // If there were no matching children, check just the plain object + return matcher(params, match); + } + + var original = stripDiacritics(data.text).toUpperCase(); + var term = stripDiacritics(params.term).toUpperCase(); + + // Check if the text contains the term + if (original.indexOf(term) > -1) { + return data; + } + + // If it doesn't contain the term, don't return anything + return null; + } + + this.defaults = { + amdBase: './', + amdLanguageBase: './i18n/', + closeOnSelect: true, + debug: false, + dropdownAutoWidth: false, + escapeMarkup: Utils.escapeMarkup, + language: EnglishTranslation, + matcher: matcher, + minimumInputLength: 0, + maximumInputLength: 0, + maximumSelectionLength: 0, + minimumResultsForSearch: 0, + selectOnClose: false, + sorter: function (data) { + return data; + }, + templateResult: function (result) { + return result.text; + }, + templateSelection: function (selection) { + return selection.text; + }, + theme: 'default', + width: 'resolve' + }; + }; + + Defaults.prototype.set = function (key, value) { + var camelKey = $.camelCase(key); + + var data = {}; + data[camelKey] = value; + + var convertedData = Utils._convertData(data); + + $.extend(this.defaults, convertedData); + }; + + var defaults = new Defaults(); + + return defaults; +}); + +S2.define('select2/options',[ + 'require', + 'jquery', + './defaults', + './utils' +], function (require, $, Defaults, Utils) { + function Options (options, $element) { + this.options = options; + + if ($element != null) { + this.fromElement($element); + } + + this.options = Defaults.apply(this.options); + + if ($element && $element.is('input')) { + var InputCompat = require(this.get('amdBase') + 'compat/inputData'); + + this.options.dataAdapter = Utils.Decorate( + this.options.dataAdapter, + InputCompat + ); + } + } + + Options.prototype.fromElement = function ($e) { + var excludedData = ['select2']; + + if (this.options.multiple == null) { + this.options.multiple = $e.prop('multiple'); + } + + if (this.options.disabled == null) { + this.options.disabled = $e.prop('disabled'); + } + + if (this.options.language == null) { + if ($e.prop('lang')) { + this.options.language = $e.prop('lang').toLowerCase(); + } else if ($e.closest('[lang]').prop('lang')) { + this.options.language = $e.closest('[lang]').prop('lang'); + } + } + + if (this.options.dir == null) { + if ($e.prop('dir')) { + this.options.dir = $e.prop('dir'); + } else if ($e.closest('[dir]').prop('dir')) { + this.options.dir = $e.closest('[dir]').prop('dir'); + } else { + this.options.dir = 'ltr'; + } + } + + $e.prop('disabled', this.options.disabled); + $e.prop('multiple', this.options.multiple); + + if ($e.data('select2Tags')) { + if (this.options.debug && window.console && console.warn) { + console.warn( + 'Select2: The `data-select2-tags` attribute has been changed to ' + + 'use the `data-data` and `data-tags="true"` attributes and will be ' + + 'removed in future versions of Select2.' + ); + } + + $e.data('data', $e.data('select2Tags')); + $e.data('tags', true); + } + + if ($e.data('ajaxUrl')) { + if (this.options.debug && window.console && console.warn) { + console.warn( + 'Select2: The `data-ajax-url` attribute has been changed to ' + + '`data-ajax--url` and support for the old attribute will be removed' + + ' in future versions of Select2.' + ); + } + + $e.attr('ajax--url', $e.data('ajaxUrl')); + $e.data('ajax--url', $e.data('ajaxUrl')); + } + + var dataset = {}; + + // Prefer the element's `dataset` attribute if it exists + // jQuery 1.x does not correctly handle data attributes with multiple dashes + if ($.fn.jquery && $.fn.jquery.substr(0, 2) == '1.' && $e[0].dataset) { + dataset = $.extend(true, {}, $e[0].dataset, $e.data()); + } else { + dataset = $e.data(); + } + + var data = $.extend(true, {}, dataset); + + data = Utils._convertData(data); + + for (var key in data) { + if ($.inArray(key, excludedData) > -1) { + continue; + } + + if ($.isPlainObject(this.options[key])) { + $.extend(this.options[key], data[key]); + } else { + this.options[key] = data[key]; + } + } + + return this; + }; + + Options.prototype.get = function (key) { + return this.options[key]; + }; + + Options.prototype.set = function (key, val) { + this.options[key] = val; + }; + + return Options; +}); + +S2.define('select2/core',[ + 'jquery', + './options', + './utils', + './keys' +], function ($, Options, Utils, KEYS) { + var Select2 = function ($element, options) { + if ($element.data('select2') != null) { + $element.data('select2').destroy(); + } + + this.$element = $element; + + this.id = this._generateId($element); + + options = options || {}; + + this.options = new Options(options, $element); + + Select2.__super__.constructor.call(this); + + // Set up the tabindex + + var tabindex = $element.attr('tabindex') || 0; + $element.data('old-tabindex', tabindex); + $element.attr('tabindex', '-1'); + + // Set up containers and adapters + + var DataAdapter = this.options.get('dataAdapter'); + this.dataAdapter = new DataAdapter($element, this.options); + + var $container = this.render(); + + this._placeContainer($container); + + var SelectionAdapter = this.options.get('selectionAdapter'); + this.selection = new SelectionAdapter($element, this.options); + this.$selection = this.selection.render(); + + this.selection.position(this.$selection, $container); + + var DropdownAdapter = this.options.get('dropdownAdapter'); + this.dropdown = new DropdownAdapter($element, this.options); + this.$dropdown = this.dropdown.render(); + + this.dropdown.position(this.$dropdown, $container); + + var ResultsAdapter = this.options.get('resultsAdapter'); + this.results = new ResultsAdapter($element, this.options, this.dataAdapter); + this.$results = this.results.render(); + + this.results.position(this.$results, this.$dropdown); + + // Bind events + + var self = this; + + // Bind the container to all of the adapters + this._bindAdapters(); + + // Register any DOM event handlers + this._registerDomEvents(); + + // Register any internal event handlers + this._registerDataEvents(); + this._registerSelectionEvents(); + this._registerDropdownEvents(); + this._registerResultsEvents(); + this._registerEvents(); + + // Set the initial state + this.dataAdapter.current(function (initialData) { + self.trigger('selection:update', { + data: initialData + }); + }); + + // Hide the original select + $element.addClass('select2-hidden-accessible'); + $element.attr('aria-hidden', 'true'); + + // Synchronize any monitored attributes + this._syncAttributes(); + + $element.data('select2', this); + }; + + Utils.Extend(Select2, Utils.Observable); + + Select2.prototype._generateId = function ($element) { + var id = ''; + + if ($element.attr('id') != null) { + id = $element.attr('id'); + } else if ($element.attr('name') != null) { + id = $element.attr('name') + '-' + Utils.generateChars(2); + } else { + id = Utils.generateChars(4); + } + + id = id.replace(/(:|\.|\[|\]|,)/g, ''); + id = 'select2-' + id; + + return id; + }; + + Select2.prototype._placeContainer = function ($container) { + $container.insertAfter(this.$element); + + var width = this._resolveWidth(this.$element, this.options.get('width')); + + if (width != null) { + $container.css('width', width); + } + }; + + Select2.prototype._resolveWidth = function ($element, method) { + var WIDTH = /^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i; + + if (method == 'resolve') { + var styleWidth = this._resolveWidth($element, 'style'); + + if (styleWidth != null) { + return styleWidth; + } + + return this._resolveWidth($element, 'element'); + } + + if (method == 'element') { + var elementWidth = $element.outerWidth(false); + + if (elementWidth <= 0) { + return 'auto'; + } + + return elementWidth + 'px'; + } + + if (method == 'style') { + var style = $element.attr('style'); + + if (typeof(style) !== 'string') { + return null; + } + + var attrs = style.split(';'); + + for (var i = 0, l = attrs.length; i < l; i = i + 1) { + var attr = attrs[i].replace(/\s/g, ''); + var matches = attr.match(WIDTH); + + if (matches !== null && matches.length >= 1) { + return matches[1]; + } + } + + return null; + } + + return method; + }; + + Select2.prototype._bindAdapters = function () { + this.dataAdapter.bind(this, this.$container); + this.selection.bind(this, this.$container); + + this.dropdown.bind(this, this.$container); + this.results.bind(this, this.$container); + }; + + Select2.prototype._registerDomEvents = function () { + var self = this; + + this.$element.on('change.select2', function () { + self.dataAdapter.current(function (data) { + self.trigger('selection:update', { + data: data + }); + }); + }); + + this.$element.on('focus.select2', function (evt) { + self.trigger('focus', evt); + }); + + this._syncA = Utils.bind(this._syncAttributes, this); + this._syncS = Utils.bind(this._syncSubtree, this); + + if (this.$element[0].attachEvent) { + this.$element[0].attachEvent('onpropertychange', this._syncA); + } + + var observer = window.MutationObserver || + window.WebKitMutationObserver || + window.MozMutationObserver + ; + + if (observer != null) { + this._observer = new observer(function (mutations) { + $.each(mutations, self._syncA); + $.each(mutations, self._syncS); + }); + this._observer.observe(this.$element[0], { + attributes: true, + childList: true, + subtree: false + }); + } else if (this.$element[0].addEventListener) { + this.$element[0].addEventListener( + 'DOMAttrModified', + self._syncA, + false + ); + this.$element[0].addEventListener( + 'DOMNodeInserted', + self._syncS, + false + ); + this.$element[0].addEventListener( + 'DOMNodeRemoved', + self._syncS, + false + ); + } + }; + + Select2.prototype._registerDataEvents = function () { + var self = this; + + this.dataAdapter.on('*', function (name, params) { + self.trigger(name, params); + }); + }; + + Select2.prototype._registerSelectionEvents = function () { + var self = this; + var nonRelayEvents = ['toggle', 'focus']; + + this.selection.on('toggle', function () { + self.toggleDropdown(); + }); + + this.selection.on('focus', function (params) { + self.focus(params); + }); + + this.selection.on('*', function (name, params) { + if ($.inArray(name, nonRelayEvents) !== -1) { + return; + } + + self.trigger(name, params); + }); + }; + + Select2.prototype._registerDropdownEvents = function () { + var self = this; + + this.dropdown.on('*', function (name, params) { + self.trigger(name, params); + }); + }; + + Select2.prototype._registerResultsEvents = function () { + var self = this; + + this.results.on('*', function (name, params) { + self.trigger(name, params); + }); + }; + + Select2.prototype._registerEvents = function () { + var self = this; + + this.on('open', function () { + self.$container.addClass('select2-container--open'); + }); + + this.on('close', function () { + self.$container.removeClass('select2-container--open'); + }); + + this.on('enable', function () { + self.$container.removeClass('select2-container--disabled'); + }); + + this.on('disable', function () { + self.$container.addClass('select2-container--disabled'); + }); + + this.on('blur', function () { + self.$container.removeClass('select2-container--focus'); + }); + + this.on('query', function (params) { + if (!self.isOpen()) { + self.trigger('open', {}); + } + + this.dataAdapter.query(params, function (data) { + self.trigger('results:all', { + data: data, + query: params + }); + }); + }); + + this.on('query:append', function (params) { + this.dataAdapter.query(params, function (data) { + self.trigger('results:append', { + data: data, + query: params + }); + }); + }); + + this.on('keypress', function (evt) { + var key = evt.which; + + if (self.isOpen()) { + if (key === KEYS.ESC || key === KEYS.TAB || + (key === KEYS.UP && evt.altKey)) { + self.close(); + + evt.preventDefault(); + } else if (key === KEYS.ENTER) { + self.trigger('results:select', {}); + + evt.preventDefault(); + } else if ((key === KEYS.SPACE && evt.ctrlKey)) { + self.trigger('results:toggle', {}); + + evt.preventDefault(); + } else if (key === KEYS.UP) { + self.trigger('results:previous', {}); + + evt.preventDefault(); + } else if (key === KEYS.DOWN) { + self.trigger('results:next', {}); + + evt.preventDefault(); + } + } else { + if (key === KEYS.ENTER || key === KEYS.SPACE || + (key === KEYS.DOWN && evt.altKey)) { + self.open(); + + evt.preventDefault(); + } + } + }); + }; + + Select2.prototype._syncAttributes = function () { + this.options.set('disabled', this.$element.prop('disabled')); + + if (this.options.get('disabled')) { + if (this.isOpen()) { + this.close(); + } + + this.trigger('disable', {}); + } else { + this.trigger('enable', {}); + } + }; + + Select2.prototype._syncSubtree = function (evt, mutations) { + var changed = false; + var self = this; + + // Ignore any mutation events raised for elements that aren't options or + // optgroups. This handles the case when the select element is destroyed + if ( + evt && evt.target && ( + evt.target.nodeName !== 'OPTION' && evt.target.nodeName !== 'OPTGROUP' + ) + ) { + return; + } + + if (!mutations) { + // If mutation events aren't supported, then we can only assume that the + // change affected the selections + changed = true; + } else if (mutations.addedNodes && mutations.addedNodes.length > 0) { + for (var n = 0; n < mutations.addedNodes.length; n++) { + var node = mutations.addedNodes[n]; + + if (node.selected) { + changed = true; + } + } + } else if (mutations.removedNodes && mutations.removedNodes.length > 0) { + changed = true; + } + + // Only re-pull the data if we think there is a change + if (changed) { + this.dataAdapter.current(function (currentData) { + self.trigger('selection:update', { + data: currentData + }); + }); + } + }; + + /** + * Override the trigger method to automatically trigger pre-events when + * there are events that can be prevented. + */ + Select2.prototype.trigger = function (name, args) { + var actualTrigger = Select2.__super__.trigger; + var preTriggerMap = { + 'open': 'opening', + 'close': 'closing', + 'select': 'selecting', + 'unselect': 'unselecting' + }; + + if (args === undefined) { + args = {}; + } + + if (name in preTriggerMap) { + var preTriggerName = preTriggerMap[name]; + var preTriggerArgs = { + prevented: false, + name: name, + args: args + }; + + actualTrigger.call(this, preTriggerName, preTriggerArgs); + + if (preTriggerArgs.prevented) { + args.prevented = true; + + return; + } + } + + actualTrigger.call(this, name, args); + }; + + Select2.prototype.toggleDropdown = function () { + if (this.options.get('disabled')) { + return; + } + + if (this.isOpen()) { + this.close(); + } else { + this.open(); + } + }; + + Select2.prototype.open = function () { + if (this.isOpen()) { + return; + } + + this.trigger('query', {}); + }; + + Select2.prototype.close = function () { + if (!this.isOpen()) { + return; + } + + this.trigger('close', {}); + }; + + Select2.prototype.isOpen = function () { + return this.$container.hasClass('select2-container--open'); + }; + + Select2.prototype.hasFocus = function () { + return this.$container.hasClass('select2-container--focus'); + }; + + Select2.prototype.focus = function (data) { + // No need to re-trigger focus events if we are already focused + if (this.hasFocus()) { + return; + } + + this.$container.addClass('select2-container--focus'); + this.trigger('focus', {}); + }; + + Select2.prototype.enable = function (args) { + if (this.options.get('debug') && window.console && console.warn) { + console.warn( + 'Select2: The `select2("enable")` method has been deprecated and will' + + ' be removed in later Select2 versions. Use $element.prop("disabled")' + + ' instead.' + ); + } + + if (args == null || args.length === 0) { + args = [true]; + } + + var disabled = !args[0]; + + this.$element.prop('disabled', disabled); + }; + + Select2.prototype.data = function () { + if (this.options.get('debug') && + arguments.length > 0 && window.console && console.warn) { + console.warn( + 'Select2: Data can no longer be set using `select2("data")`. You ' + + 'should consider setting the value instead using `$element.val()`.' + ); + } + + var data = []; + + this.dataAdapter.current(function (currentData) { + data = currentData; + }); + + return data; + }; + + Select2.prototype.val = function (args) { + if (this.options.get('debug') && window.console && console.warn) { + console.warn( + 'Select2: The `select2("val")` method has been deprecated and will be' + + ' removed in later Select2 versions. Use $element.val() instead.' + ); + } + + if (args == null || args.length === 0) { + return this.$element.val(); + } + + var newVal = args[0]; + + if ($.isArray(newVal)) { + newVal = $.map(newVal, function (obj) { + return obj.toString(); + }); + } + + this.$element.val(newVal).trigger('change'); + }; + + Select2.prototype.destroy = function () { + this.$container.remove(); + + if (this.$element[0].detachEvent) { + this.$element[0].detachEvent('onpropertychange', this._syncA); + } + + if (this._observer != null) { + this._observer.disconnect(); + this._observer = null; + } else if (this.$element[0].removeEventListener) { + this.$element[0] + .removeEventListener('DOMAttrModified', this._syncA, false); + this.$element[0] + .removeEventListener('DOMNodeInserted', this._syncS, false); + this.$element[0] + .removeEventListener('DOMNodeRemoved', this._syncS, false); + } + + this._syncA = null; + this._syncS = null; + + this.$element.off('.select2'); + this.$element.attr('tabindex', this.$element.data('old-tabindex')); + + this.$element.removeClass('select2-hidden-accessible'); + this.$element.attr('aria-hidden', 'false'); + this.$element.removeData('select2'); + + this.dataAdapter.destroy(); + this.selection.destroy(); + this.dropdown.destroy(); + this.results.destroy(); + + this.dataAdapter = null; + this.selection = null; + this.dropdown = null; + this.results = null; + }; + + Select2.prototype.render = function () { + var $container = $( + '' + + '' + + '' + + '' + ); + + $container.attr('dir', this.options.get('dir')); + + this.$container = $container; + + this.$container.addClass('select2-container--' + this.options.get('theme')); + + $container.data('element', this.$element); + + return $container; + }; + + return Select2; +}); + +S2.define('select2/compat/utils',[ + 'jquery' +], function ($) { + function syncCssClasses ($dest, $src, adapter) { + var classes, replacements = [], adapted; + + classes = $.trim($dest.attr('class')); + + if (classes) { + classes = '' + classes; // for IE which returns object + + $(classes.split(/\s+/)).each(function () { + // Save all Select2 classes + if (this.indexOf('select2-') === 0) { + replacements.push(this); + } + }); + } + + classes = $.trim($src.attr('class')); + + if (classes) { + classes = '' + classes; // for IE which returns object + + $(classes.split(/\s+/)).each(function () { + // Only adapt non-Select2 classes + if (this.indexOf('select2-') !== 0) { + adapted = adapter(this); + + if (adapted != null) { + replacements.push(adapted); + } + } + }); + } + + $dest.attr('class', replacements.join(' ')); + } + + return { + syncCssClasses: syncCssClasses + }; +}); + +S2.define('select2/compat/containerCss',[ + 'jquery', + './utils' +], function ($, CompatUtils) { + // No-op CSS adapter that discards all classes by default + function _containerAdapter (clazz) { + return null; + } + + function ContainerCSS () { } + + ContainerCSS.prototype.render = function (decorated) { + var $container = decorated.call(this); + + var containerCssClass = this.options.get('containerCssClass') || ''; + + if ($.isFunction(containerCssClass)) { + containerCssClass = containerCssClass(this.$element); + } + + var containerCssAdapter = this.options.get('adaptContainerCssClass'); + containerCssAdapter = containerCssAdapter || _containerAdapter; + + if (containerCssClass.indexOf(':all:') !== -1) { + containerCssClass = containerCssClass.replace(':all:', ''); + + var _cssAdapter = containerCssAdapter; + + containerCssAdapter = function (clazz) { + var adapted = _cssAdapter(clazz); + + if (adapted != null) { + // Append the old one along with the adapted one + return adapted + ' ' + clazz; + } + + return clazz; + }; + } + + var containerCss = this.options.get('containerCss') || {}; + + if ($.isFunction(containerCss)) { + containerCss = containerCss(this.$element); + } + + CompatUtils.syncCssClasses($container, this.$element, containerCssAdapter); + + $container.css(containerCss); + $container.addClass(containerCssClass); + + return $container; + }; + + return ContainerCSS; +}); + +S2.define('select2/compat/dropdownCss',[ + 'jquery', + './utils' +], function ($, CompatUtils) { + // No-op CSS adapter that discards all classes by default + function _dropdownAdapter (clazz) { + return null; + } + + function DropdownCSS () { } + + DropdownCSS.prototype.render = function (decorated) { + var $dropdown = decorated.call(this); + + var dropdownCssClass = this.options.get('dropdownCssClass') || ''; + + if ($.isFunction(dropdownCssClass)) { + dropdownCssClass = dropdownCssClass(this.$element); + } + + var dropdownCssAdapter = this.options.get('adaptDropdownCssClass'); + dropdownCssAdapter = dropdownCssAdapter || _dropdownAdapter; + + if (dropdownCssClass.indexOf(':all:') !== -1) { + dropdownCssClass = dropdownCssClass.replace(':all:', ''); + + var _cssAdapter = dropdownCssAdapter; + + dropdownCssAdapter = function (clazz) { + var adapted = _cssAdapter(clazz); + + if (adapted != null) { + // Append the old one along with the adapted one + return adapted + ' ' + clazz; + } + + return clazz; + }; + } + + var dropdownCss = this.options.get('dropdownCss') || {}; + + if ($.isFunction(dropdownCss)) { + dropdownCss = dropdownCss(this.$element); + } + + CompatUtils.syncCssClasses($dropdown, this.$element, dropdownCssAdapter); + + $dropdown.css(dropdownCss); + $dropdown.addClass(dropdownCssClass); + + return $dropdown; + }; + + return DropdownCSS; +}); + +S2.define('select2/compat/initSelection',[ + 'jquery' +], function ($) { + function InitSelection (decorated, $element, options) { + if (options.get('debug') && window.console && console.warn) { + console.warn( + 'Select2: The `initSelection` option has been deprecated in favor' + + ' of a custom data adapter that overrides the `current` method. ' + + 'This method is now called multiple times instead of a single ' + + 'time when the instance is initialized. Support will be removed ' + + 'for the `initSelection` option in future versions of Select2' + ); + } + + this.initSelection = options.get('initSelection'); + this._isInitialized = false; + + decorated.call(this, $element, options); + } + + InitSelection.prototype.current = function (decorated, callback) { + var self = this; + + if (this._isInitialized) { + decorated.call(this, callback); + + return; + } + + this.initSelection.call(null, this.$element, function (data) { + self._isInitialized = true; + + if (!$.isArray(data)) { + data = [data]; + } + + callback(data); + }); + }; + + return InitSelection; +}); + +S2.define('select2/compat/inputData',[ + 'jquery' +], function ($) { + function InputData (decorated, $element, options) { + this._currentData = []; + this._valueSeparator = options.get('valueSeparator') || ','; + + if ($element.prop('type') === 'hidden') { + if (options.get('debug') && console && console.warn) { + console.warn( + 'Select2: Using a hidden input with Select2 is no longer ' + + 'supported and may stop working in the future. It is recommended ' + + 'to use a `');this.$searchContainer=c,this.$search=c.find("input");var d=b.call(this);return this._transferTabIndex(),d},d.prototype.bind=function(a,b,d){var e=this;a.call(this,b,d),b.on("open",function(){e.$search.trigger("focus")}),b.on("close",function(){e.$search.val(""),e.$search.removeAttr("aria-activedescendant"),e.$search.trigger("focus")}),b.on("enable",function(){e.$search.prop("disabled",!1),e._transferTabIndex()}),b.on("disable",function(){e.$search.prop("disabled",!0)}),b.on("focus",function(a){e.$search.trigger("focus")}),b.on("results:focus",function(a){e.$search.attr("aria-activedescendant",a.id)}),this.$selection.on("focusin",".select2-search--inline",function(a){e.trigger("focus",a)}),this.$selection.on("focusout",".select2-search--inline",function(a){e._handleBlur(a)}),this.$selection.on("keydown",".select2-search--inline",function(a){if(a.stopPropagation(),e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented(),a.which===c.BACKSPACE&&""===e.$search.val()){var b=e.$searchContainer.prev(".select2-selection__choice");if(b.length>0){var d=b.data("data");e.searchRemoveChoice(d),a.preventDefault()}}});var f=document.documentMode,g=f&&f<=11;this.$selection.on("input.searchcheck",".select2-search--inline",function(a){if(g)return void e.$selection.off("input.search input.searchcheck");e.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(a){if(g&&"input"===a.type)return void e.$selection.off("input.search input.searchcheck");var b=a.which;b!=c.SHIFT&&b!=c.CTRL&&b!=c.ALT&&b!=c.TAB&&e.handleSearch(a)})},d.prototype._transferTabIndex=function(a){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},d.prototype.createPlaceholder=function(a,b){this.$search.attr("placeholder",b.text)},d.prototype.update=function(a,b){var c=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),a.call(this,b),this.$selection.find(".select2-selection__rendered").append(this.$searchContainer),this.resizeSearch(),c&&this.$search.focus()},d.prototype.handleSearch=function(){if(this.resizeSearch(),!this._keyUpPrevented){var a=this.$search.val();this.trigger("query",{term:a})}this._keyUpPrevented=!1},d.prototype.searchRemoveChoice=function(a,b){this.trigger("unselect",{data:b}),this.$search.val(b.text),this.handleSearch()},d.prototype.resizeSearch=function(){this.$search.css("width","25px");var a="";if(""!==this.$search.attr("placeholder"))a=this.$selection.find(".select2-selection__rendered").innerWidth();else{a=.75*(this.$search.val().length+1)+"em"}this.$search.css("width",a)},d}),b.define("select2/selection/eventRelay",["jquery"],function(a){function b(){}return b.prototype.bind=function(b,c,d){var e=this,f=["open","opening","close","closing","select","selecting","unselect","unselecting"],g=["opening","closing","selecting","unselecting"];b.call(this,c,d),c.on("*",function(b,c){if(-1!==a.inArray(b,f)){c=c||{};var d=a.Event("select2:"+b,{params:c});e.$element.trigger(d),-1!==a.inArray(b,g)&&(c.prevented=d.isDefaultPrevented())}})},b}),b.define("select2/translation",["jquery","require"],function(a,b){function c(a){this.dict=a||{}}return c.prototype.all=function(){return this.dict},c.prototype.get=function(a){return this.dict[a]},c.prototype.extend=function(b){this.dict=a.extend({},b.all(),this.dict)},c._cache={},c.loadPath=function(a){if(!(a in c._cache)){var d=b(a);c._cache[a]=d}return new c(c._cache[a])},c}),b.define("select2/diacritics",[],function(){return{"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ω":"ω","ς":"σ"}}),b.define("select2/data/base",["../utils"],function(a){function b(a,c){b.__super__.constructor.call(this)}return a.Extend(b,a.Observable),b.prototype.current=function(a){throw new Error("The `current` method must be defined in child classes.")},b.prototype.query=function(a,b){throw new Error("The `query` method must be defined in child classes.")},b.prototype.bind=function(a,b){},b.prototype.destroy=function(){},b.prototype.generateResultId=function(b,c){var d=b.id+"-result-";return d+=a.generateChars(4),null!=c.id?d+="-"+c.id.toString():d+="-"+a.generateChars(4),d},b}),b.define("select2/data/select",["./base","../utils","jquery"],function(a,b,c){function d(a,b){this.$element=a,this.options=b,d.__super__.constructor.call(this)}return b.Extend(d,a),d.prototype.current=function(a){var b=[],d=this;this.$element.find(":selected").each(function(){var a=c(this),e=d.item(a);b.push(e)}),a(b)},d.prototype.select=function(a){var b=this;if(a.selected=!0,c(a.element).is("option"))return a.element.selected=!0,void this.$element.trigger("change");if(this.$element.prop("multiple"))this.current(function(d){var e=[];a=[a],a.push.apply(a,d);for(var f=0;f=0){var k=f.filter(d(j)),l=this.item(k),m=c.extend(!0,{},j,l),n=this.option(m);k.replaceWith(n)}else{var o=this.option(j);if(j.children){var p=this.convertToOptions(j.children);b.appendMany(o,p)}h.push(o)}}return h},d}),b.define("select2/data/ajax",["./array","../utils","jquery"],function(a,b,c){function d(a,b){this.ajaxOptions=this._applyDefaults(b.get("ajax")),null!=this.ajaxOptions.processResults&&(this.processResults=this.ajaxOptions.processResults),d.__super__.constructor.call(this,a,b)}return b.Extend(d,a),d.prototype._applyDefaults=function(a){var b={data:function(a){return c.extend({},a,{q:a.term})},transport:function(a,b,d){var e=c.ajax(a);return e.then(b),e.fail(d),e}};return c.extend({},b,a,!0)},d.prototype.processResults=function(a){return a},d.prototype.query=function(a,b){function d(){var d=f.transport(f,function(d){var f=e.processResults(d,a);e.options.get("debug")&&window.console&&console.error&&(f&&f.results&&c.isArray(f.results)||console.error("Select2: The AJAX results did not return an array in the `results` key of the response.")),b(f)},function(){d.status&&"0"===d.status||e.trigger("results:message",{message:"errorLoading"})});e._request=d}var e=this;null!=this._request&&(c.isFunction(this._request.abort)&&this._request.abort(),this._request=null);var f=c.extend({type:"GET"},this.ajaxOptions);"function"==typeof f.url&&(f.url=f.url.call(this.$element,a)),"function"==typeof f.data&&(f.data=f.data.call(this.$element,a)),this.ajaxOptions.delay&&null!=a.term?(this._queryTimeout&&window.clearTimeout(this._queryTimeout),this._queryTimeout=window.setTimeout(d,this.ajaxOptions.delay)):d()},d}),b.define("select2/data/tags",["jquery"],function(a){function b(b,c,d){var e=d.get("tags"),f=d.get("createTag");void 0!==f&&(this.createTag=f);var g=d.get("insertTag");if(void 0!==g&&(this.insertTag=g),b.call(this,c,d),a.isArray(e))for(var h=0;h0&&b.term.length>this.maximumInputLength)return void this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:b.term,params:b}});a.call(this,b,c)},a}),b.define("select2/data/maximumSelectionLength",[],function(){function a(a,b,c){this.maximumSelectionLength=c.get("maximumSelectionLength"),a.call(this,b,c)}return a.prototype.query=function(a,b,c){var d=this;this.current(function(e){var f=null!=e?e.length:0;if(d.maximumSelectionLength>0&&f>=d.maximumSelectionLength)return void d.trigger("results:message",{message:"maximumSelected",args:{maximum:d.maximumSelectionLength}});a.call(d,b,c)})},a}),b.define("select2/dropdown",["jquery","./utils"],function(a,b){function c(a,b){this.$element=a,this.options=b,c.__super__.constructor.call(this)}return b.Extend(c,b.Observable),c.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$dropdown=b,b},c.prototype.bind=function(){},c.prototype.position=function(a,b){},c.prototype.destroy=function(){this.$dropdown.remove()},c}),b.define("select2/dropdown/search",["jquery","../utils"],function(a,b){function c(){}return c.prototype.render=function(b){var c=b.call(this),d=a('');return this.$searchContainer=d,this.$search=d.find("input"),c.prepend(d),c},c.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),this.$search.on("keydown",function(a){e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented()}),this.$search.on("input",function(b){a(this).off("keyup")}),this.$search.on("keyup input",function(a){e.handleSearch(a)}),c.on("open",function(){e.$search.attr("tabindex",0),e.$search.focus(),window.setTimeout(function(){e.$search.focus()},0)}),c.on("close",function(){e.$search.attr("tabindex",-1),e.$search.val("")}),c.on("focus",function(){c.isOpen()||e.$search.focus()}),c.on("results:all",function(a){if(null==a.query.term||""===a.query.term){e.showSearch(a)?e.$searchContainer.removeClass("select2-search--hide"):e.$searchContainer.addClass("select2-search--hide")}})},c.prototype.handleSearch=function(a){if(!this._keyUpPrevented){var b=this.$search.val();this.trigger("query",{term:b})}this._keyUpPrevented=!1},c.prototype.showSearch=function(a,b){return!0},c}),b.define("select2/dropdown/hidePlaceholder",[],function(){function a(a,b,c,d){this.placeholder=this.normalizePlaceholder(c.get("placeholder")),a.call(this,b,c,d)}return a.prototype.append=function(a,b){b.results=this.removePlaceholder(b.results),a.call(this,b)},a.prototype.normalizePlaceholder=function(a,b){return"string"==typeof b&&(b={id:"",text:b}),b},a.prototype.removePlaceholder=function(a,b){for(var c=b.slice(0),d=b.length-1;d>=0;d--){var e=b[d];this.placeholder.id===e.id&&c.splice(d,1)}return c},a}),b.define("select2/dropdown/infiniteScroll",["jquery"],function(a){function b(a,b,c,d){this.lastParams={},a.call(this,b,c,d),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return b.prototype.append=function(a,b){this.$loadingMore.remove(),this.loading=!1,a.call(this,b),this.showLoadingMore(b)&&this.$results.append(this.$loadingMore)},b.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),c.on("query",function(a){e.lastParams=a,e.loading=!0}),c.on("query:append",function(a){e.lastParams=a,e.loading=!0}),this.$results.on("scroll",function(){var b=a.contains(document.documentElement,e.$loadingMore[0]);if(!e.loading&&b){e.$results.offset().top+e.$results.outerHeight(!1)+50>=e.$loadingMore.offset().top+e.$loadingMore.outerHeight(!1)&&e.loadMore()}})},b.prototype.loadMore=function(){this.loading=!0;var b=a.extend({},{page:1},this.lastParams);b.page++,this.trigger("query:append",b)},b.prototype.showLoadingMore=function(a,b){return b.pagination&&b.pagination.more},b.prototype.createLoadingMore=function(){var b=a('
  • '),c=this.options.get("translations").get("loadingMore");return b.html(c(this.lastParams)),b},b}),b.define("select2/dropdown/attachBody",["jquery","../utils"],function(a,b){function c(b,c,d){this.$dropdownParent=d.get("dropdownParent")||a(document.body),b.call(this,c,d)}return c.prototype.bind=function(a,b,c){var d=this,e=!1;a.call(this,b,c),b.on("open",function(){d._showDropdown(),d._attachPositioningHandler(b),e||(e=!0,b.on("results:all",function(){d._positionDropdown(),d._resizeDropdown()}),b.on("results:append",function(){d._positionDropdown(),d._resizeDropdown()}))}),b.on("close",function(){d._hideDropdown(),d._detachPositioningHandler(b)}),this.$dropdownContainer.on("mousedown",function(a){a.stopPropagation()})},c.prototype.destroy=function(a){a.call(this),this.$dropdownContainer.remove()},c.prototype.position=function(a,b,c){b.attr("class",c.attr("class")),b.removeClass("select2"),b.addClass("select2-container--open"),b.css({position:"absolute",top:-999999}),this.$container=c},c.prototype.render=function(b){var c=a(""),d=b.call(this);return c.append(d),this.$dropdownContainer=c,c},c.prototype._hideDropdown=function(a){this.$dropdownContainer.detach()},c.prototype._attachPositioningHandler=function(c,d){var e=this,f="scroll.select2."+d.id,g="resize.select2."+d.id,h="orientationchange.select2."+d.id,i=this.$container.parents().filter(b.hasScroll);i.each(function(){a(this).data("select2-scroll-position",{x:a(this).scrollLeft(),y:a(this).scrollTop()})}),i.on(f,function(b){var c=a(this).data("select2-scroll-position");a(this).scrollTop(c.y)}),a(window).on(f+" "+g+" "+h,function(a){e._positionDropdown(),e._resizeDropdown()})},c.prototype._detachPositioningHandler=function(c,d){var e="scroll.select2."+d.id,f="resize.select2."+d.id,g="orientationchange.select2."+d.id;this.$container.parents().filter(b.hasScroll).off(e),a(window).off(e+" "+f+" "+g)},c.prototype._positionDropdown=function(){var b=a(window),c=this.$dropdown.hasClass("select2-dropdown--above"),d=this.$dropdown.hasClass("select2-dropdown--below"),e=null,f=this.$container.offset();f.bottom=f.top+this.$container.outerHeight(!1);var g={height:this.$container.outerHeight(!1)};g.top=f.top,g.bottom=f.top+g.height;var h={height:this.$dropdown.outerHeight(!1)},i={top:b.scrollTop(),bottom:b.scrollTop()+b.height()},j=i.topf.bottom+h.height,l={left:f.left,top:g.bottom},m=this.$dropdownParent;"static"===m.css("position")&&(m=m.offsetParent());var n=m.offset();l.top-=n.top,l.left-=n.left,c||d||(e="below"),k||!j||c?!j&&k&&c&&(e="below"):e="above",("above"==e||c&&"below"!==e)&&(l.top=g.top-n.top-h.height),null!=e&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+e),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+e)),this.$dropdownContainer.css(l)},c.prototype._resizeDropdown=function(){var a={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(a.minWidth=a.width,a.position="relative",a.width="auto"),this.$dropdown.css(a)},c.prototype._showDropdown=function(a){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},c}),b.define("select2/dropdown/minimumResultsForSearch",[],function(){function a(b){for(var c=0,d=0;d0&&(l.dataAdapter=j.Decorate(l.dataAdapter,r)),l.maximumInputLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,s)),l.maximumSelectionLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,t)),l.tags&&(l.dataAdapter=j.Decorate(l.dataAdapter,p)),null==l.tokenSeparators&&null==l.tokenizer||(l.dataAdapter=j.Decorate(l.dataAdapter,q)),null!=l.query){var C=b(l.amdBase+"compat/query");l.dataAdapter=j.Decorate(l.dataAdapter,C)}if(null!=l.initSelection){var D=b(l.amdBase+"compat/initSelection");l.dataAdapter=j.Decorate(l.dataAdapter,D)}}if(null==l.resultsAdapter&&(l.resultsAdapter=c,null!=l.ajax&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,x)),null!=l.placeholder&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,w)),l.selectOnClose&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,A))),null==l.dropdownAdapter){if(l.multiple)l.dropdownAdapter=u;else{var E=j.Decorate(u,v);l.dropdownAdapter=E}if(0!==l.minimumResultsForSearch&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,z)),l.closeOnSelect&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,B)),null!=l.dropdownCssClass||null!=l.dropdownCss||null!=l.adaptDropdownCssClass){var F=b(l.amdBase+"compat/dropdownCss");l.dropdownAdapter=j.Decorate(l.dropdownAdapter,F)}l.dropdownAdapter=j.Decorate(l.dropdownAdapter,y)}if(null==l.selectionAdapter){if(l.multiple?l.selectionAdapter=e:l.selectionAdapter=d,null!=l.placeholder&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,f)),l.allowClear&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,g)),l.multiple&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,h)),null!=l.containerCssClass||null!=l.containerCss||null!=l.adaptContainerCssClass){var G=b(l.amdBase+"compat/containerCss");l.selectionAdapter=j.Decorate(l.selectionAdapter,G)}l.selectionAdapter=j.Decorate(l.selectionAdapter,i)}if("string"==typeof l.language)if(l.language.indexOf("-")>0){var H=l.language.split("-"),I=H[0];l.language=[l.language,I]}else l.language=[l.language];if(a.isArray(l.language)){var J=new k;l.language.push("en");for(var K=l.language,L=0;L0){for(var f=a.extend(!0,{},e),g=e.children.length-1;g>=0;g--){null==c(d,e.children[g])&&f.children.splice(g,1)}return f.children.length>0?f:c(d,f)}var h=b(e.text).toUpperCase(),i=b(d.term).toUpperCase();return h.indexOf(i)>-1?e:null}this.defaults={amdBase:"./",amdLanguageBase:"./i18n/",closeOnSelect:!0,debug:!1,dropdownAutoWidth:!1,escapeMarkup:j.escapeMarkup,language:C,matcher:c,minimumInputLength:0,maximumInputLength:0,maximumSelectionLength:0,minimumResultsForSearch:0,selectOnClose:!1,sorter:function(a){return a},templateResult:function(a){return a.text},templateSelection:function(a){return a.text},theme:"default",width:"resolve"}},D.prototype.set=function(b,c){var d=a.camelCase(b),e={};e[d]=c;var f=j._convertData(e);a.extend(this.defaults,f)},new D}),b.define("select2/options",["require","jquery","./defaults","./utils"],function(a,b,c,d){function e(b,e){if(this.options=b,null!=e&&this.fromElement(e),this.options=c.apply(this.options),e&&e.is("input")){var f=a(this.get("amdBase")+"compat/inputData");this.options.dataAdapter=d.Decorate(this.options.dataAdapter,f)}}return e.prototype.fromElement=function(a){var c=["select2"];null==this.options.multiple&&(this.options.multiple=a.prop("multiple")),null==this.options.disabled&&(this.options.disabled=a.prop("disabled")),null==this.options.language&&(a.prop("lang")?this.options.language=a.prop("lang").toLowerCase():a.closest("[lang]").prop("lang")&&(this.options.language=a.closest("[lang]").prop("lang"))),null==this.options.dir&&(a.prop("dir")?this.options.dir=a.prop("dir"):a.closest("[dir]").prop("dir")?this.options.dir=a.closest("[dir]").prop("dir"):this.options.dir="ltr"),a.prop("disabled",this.options.disabled),a.prop("multiple",this.options.multiple),a.data("select2Tags")&&(this.options.debug&&window.console&&console.warn&&console.warn('Select2: The `data-select2-tags` attribute has been changed to use the `data-data` and `data-tags="true"` attributes and will be removed in future versions of Select2.'),a.data("data",a.data("select2Tags")),a.data("tags",!0)),a.data("ajaxUrl")&&(this.options.debug&&window.console&&console.warn&&console.warn("Select2: The `data-ajax-url` attribute has been changed to `data-ajax--url` and support for the old attribute will be removed in future versions of Select2."),a.attr("ajax--url",a.data("ajaxUrl")),a.data("ajax--url",a.data("ajaxUrl")));var e={};e=b.fn.jquery&&"1."==b.fn.jquery.substr(0,2)&&a[0].dataset?b.extend(!0,{},a[0].dataset,a.data()):a.data();var f=b.extend(!0,{},e);f=d._convertData(f);for(var g in f)b.inArray(g,c)>-1||(b.isPlainObject(this.options[g])?b.extend(this.options[g],f[g]):this.options[g]=f[g]);return this},e.prototype.get=function(a){return this.options[a]},e.prototype.set=function(a,b){this.options[a]=b},e}),b.define("select2/core",["jquery","./options","./utils","./keys"],function(a,b,c,d){var e=function(a,c){null!=a.data("select2")&&a.data("select2").destroy(),this.$element=a,this.id=this._generateId(a),c=c||{},this.options=new b(c,a),e.__super__.constructor.call(this);var d=a.attr("tabindex")||0;a.data("old-tabindex",d),a.attr("tabindex","-1");var f=this.options.get("dataAdapter");this.dataAdapter=new f(a,this.options);var g=this.render();this._placeContainer(g);var h=this.options.get("selectionAdapter");this.selection=new h(a,this.options),this.$selection=this.selection.render(),this.selection.position(this.$selection,g);var i=this.options.get("dropdownAdapter");this.dropdown=new i(a,this.options),this.$dropdown=this.dropdown.render(),this.dropdown.position(this.$dropdown,g);var j=this.options.get("resultsAdapter");this.results=new j(a,this.options,this.dataAdapter),this.$results=this.results.render(),this.results.position(this.$results,this.$dropdown);var k=this;this._bindAdapters(),this._registerDomEvents(),this._registerDataEvents(),this._registerSelectionEvents(),this._registerDropdownEvents(),this._registerResultsEvents(),this._registerEvents(),this.dataAdapter.current(function(a){k.trigger("selection:update",{data:a})}),a.addClass("select2-hidden-accessible"),a.attr("aria-hidden","true"),this._syncAttributes(),a.data("select2",this)};return c.Extend(e,c.Observable),e.prototype._generateId=function(a){var b="";return b=null!=a.attr("id")?a.attr("id"):null!=a.attr("name")?a.attr("name")+"-"+c.generateChars(2):c.generateChars(4),b=b.replace(/(:|\.|\[|\]|,)/g,""),b="select2-"+b},e.prototype._placeContainer=function(a){a.insertAfter(this.$element);var b=this._resolveWidth(this.$element,this.options.get("width"));null!=b&&a.css("width",b)},e.prototype._resolveWidth=function(a,b){var c=/^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i;if("resolve"==b){var d=this._resolveWidth(a,"style");return null!=d?d:this._resolveWidth(a,"element")}if("element"==b){var e=a.outerWidth(!1);return e<=0?"auto":e+"px"}if("style"==b){var f=a.attr("style");if("string"!=typeof f)return null;for(var g=f.split(";"),h=0,i=g.length;h=1)return k[1]}return null}return b},e.prototype._bindAdapters=function(){this.dataAdapter.bind(this,this.$container),this.selection.bind(this,this.$container),this.dropdown.bind(this,this.$container),this.results.bind(this,this.$container)},e.prototype._registerDomEvents=function(){var b=this;this.$element.on("change.select2",function(){b.dataAdapter.current(function(a){b.trigger("selection:update",{data:a})})}),this.$element.on("focus.select2",function(a){b.trigger("focus",a)}),this._syncA=c.bind(this._syncAttributes,this),this._syncS=c.bind(this._syncSubtree,this),this.$element[0].attachEvent&&this.$element[0].attachEvent("onpropertychange",this._syncA);var d=window.MutationObserver||window.WebKitMutationObserver||window.MozMutationObserver;null!=d?(this._observer=new d(function(c){a.each(c,b._syncA),a.each(c,b._syncS)}),this._observer.observe(this.$element[0],{attributes:!0,childList:!0,subtree:!1})):this.$element[0].addEventListener&&(this.$element[0].addEventListener("DOMAttrModified",b._syncA,!1),this.$element[0].addEventListener("DOMNodeInserted",b._syncS,!1),this.$element[0].addEventListener("DOMNodeRemoved",b._syncS,!1))},e.prototype._registerDataEvents=function(){var a=this;this.dataAdapter.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerSelectionEvents=function(){var b=this,c=["toggle","focus"];this.selection.on("toggle",function(){b.toggleDropdown()}),this.selection.on("focus",function(a){b.focus(a)}),this.selection.on("*",function(d,e){-1===a.inArray(d,c)&&b.trigger(d,e)})},e.prototype._registerDropdownEvents=function(){var a=this;this.dropdown.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerResultsEvents=function(){var a=this;this.results.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerEvents=function(){var a=this;this.on("open",function(){a.$container.addClass("select2-container--open")}),this.on("close",function(){a.$container.removeClass("select2-container--open")}),this.on("enable",function(){a.$container.removeClass("select2-container--disabled")}),this.on("disable",function(){a.$container.addClass("select2-container--disabled")}),this.on("blur",function(){a.$container.removeClass("select2-container--focus")}),this.on("query",function(b){a.isOpen()||a.trigger("open",{}),this.dataAdapter.query(b,function(c){a.trigger("results:all",{data:c,query:b})})}),this.on("query:append",function(b){this.dataAdapter.query(b,function(c){a.trigger("results:append",{data:c,query:b})})}),this.on("keypress",function(b){var c=b.which;a.isOpen()?c===d.ESC||c===d.TAB||c===d.UP&&b.altKey?(a.close(),b.preventDefault()):c===d.ENTER?(a.trigger("results:select",{}),b.preventDefault()):c===d.SPACE&&b.ctrlKey?(a.trigger("results:toggle",{}),b.preventDefault()):c===d.UP?(a.trigger("results:previous",{}),b.preventDefault()):c===d.DOWN&&(a.trigger("results:next",{}),b.preventDefault()):(c===d.ENTER||c===d.SPACE||c===d.DOWN&&b.altKey)&&(a.open(),b.preventDefault())})},e.prototype._syncAttributes=function(){this.options.set("disabled",this.$element.prop("disabled")),this.options.get("disabled")?(this.isOpen()&&this.close(),this.trigger("disable",{})):this.trigger("enable",{})},e.prototype._syncSubtree=function(a,b){var c=!1,d=this;if(!a||!a.target||"OPTION"===a.target.nodeName||"OPTGROUP"===a.target.nodeName){if(b)if(b.addedNodes&&b.addedNodes.length>0)for(var e=0;e0&&(c=!0);else c=!0;c&&this.dataAdapter.current(function(a){d.trigger("selection:update",{data:a})})}},e.prototype.trigger=function(a,b){var c=e.__super__.trigger,d={open:"opening",close:"closing",select:"selecting",unselect:"unselecting"};if(void 0===b&&(b={}),a in d){var f=d[a],g={prevented:!1,name:a,args:b};if(c.call(this,f,g),g.prevented)return void(b.prevented=!0)}c.call(this,a,b)},e.prototype.toggleDropdown=function(){this.options.get("disabled")||(this.isOpen()?this.close():this.open())},e.prototype.open=function(){this.isOpen()||this.trigger("query",{})},e.prototype.close=function(){this.isOpen()&&this.trigger("close",{})},e.prototype.isOpen=function(){return this.$container.hasClass("select2-container--open")},e.prototype.hasFocus=function(){return this.$container.hasClass("select2-container--focus")},e.prototype.focus=function(a){this.hasFocus()||(this.$container.addClass("select2-container--focus"),this.trigger("focus",{}))},e.prototype.enable=function(a){this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("enable")` method has been deprecated and will be removed in later Select2 versions. Use $element.prop("disabled") instead.'),null!=a&&0!==a.length||(a=[!0]);var b=!a[0];this.$element.prop("disabled",b)},e.prototype.data=function(){this.options.get("debug")&&arguments.length>0&&window.console&&console.warn&&console.warn('Select2: Data can no longer be set using `select2("data")`. You should consider setting the value instead using `$element.val()`.');var a=[];return this.dataAdapter.current(function(b){a=b}),a},e.prototype.val=function(b){if(this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("val")` method has been deprecated and will be removed in later Select2 versions. Use $element.val() instead.'),null==b||0===b.length)return this.$element.val();var c=b[0];a.isArray(c)&&(c=a.map(c,function(a){return a.toString()})),this.$element.val(c).trigger("change")},e.prototype.destroy=function(){this.$container.remove(),this.$element[0].detachEvent&&this.$element[0].detachEvent("onpropertychange",this._syncA),null!=this._observer?(this._observer.disconnect(),this._observer=null):this.$element[0].removeEventListener&&(this.$element[0].removeEventListener("DOMAttrModified",this._syncA,!1),this.$element[0].removeEventListener("DOMNodeInserted",this._syncS,!1),this.$element[0].removeEventListener("DOMNodeRemoved",this._syncS,!1)),this._syncA=null,this._syncS=null,this.$element.off(".select2"),this.$element.attr("tabindex",this.$element.data("old-tabindex")),this.$element.removeClass("select2-hidden-accessible"),this.$element.attr("aria-hidden","false"),this.$element.removeData("select2"),this.dataAdapter.destroy(),this.selection.destroy(),this.dropdown.destroy(),this.results.destroy(),this.dataAdapter=null,this.selection=null,this.dropdown=null,this.results=null},e.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$container=b,this.$container.addClass("select2-container--"+this.options.get("theme")),b.data("element",this.$element),b},e}),b.define("select2/compat/utils",["jquery"],function(a){function b(b,c,d){var e,f,g=[];e=a.trim(b.attr("class")),e&&(e=""+e,a(e.split(/\s+/)).each(function(){0===this.indexOf("select2-")&&g.push(this)})),e=a.trim(c.attr("class")),e&&(e=""+e,a(e.split(/\s+/)).each(function(){0!==this.indexOf("select2-")&&null!=(f=d(this))&&g.push(f)})),b.attr("class",g.join(" "))}return{syncCssClasses:b}}),b.define("select2/compat/containerCss",["jquery","./utils"],function(a,b){function c(a){return null}function d(){}return d.prototype.render=function(d){var e=d.call(this),f=this.options.get("containerCssClass")||"";a.isFunction(f)&&(f=f(this.$element));var g=this.options.get("adaptContainerCssClass");if(g=g||c,-1!==f.indexOf(":all:")){f=f.replace(":all:","");var h=g;g=function(a){var b=h(a);return null!=b?b+" "+a:a}}var i=this.options.get("containerCss")||{};return a.isFunction(i)&&(i=i(this.$element)),b.syncCssClasses(e,this.$element,g),e.css(i),e.addClass(f),e},d}),b.define("select2/compat/dropdownCss",["jquery","./utils"],function(a,b){function c(a){return null}function d(){}return d.prototype.render=function(d){var e=d.call(this),f=this.options.get("dropdownCssClass")||"";a.isFunction(f)&&(f=f(this.$element));var g=this.options.get("adaptDropdownCssClass");if(g=g||c,-1!==f.indexOf(":all:")){f=f.replace(":all:","");var h=g;g=function(a){var b=h(a);return null!=b?b+" "+a:a}}var i=this.options.get("dropdownCss")||{};return a.isFunction(i)&&(i=i(this.$element)),b.syncCssClasses(e,this.$element,g),e.css(i),e.addClass(f),e},d}),b.define("select2/compat/initSelection",["jquery"],function(a){function b(a,b,c){c.get("debug")&&window.console&&console.warn&&console.warn("Select2: The `initSelection` option has been deprecated in favor of a custom data adapter that overrides the `current` method. This method is now called multiple times instead of a single time when the instance is initialized. Support will be removed for the `initSelection` option in future versions of Select2"),this.initSelection=c.get("initSelection"),this._isInitialized=!1,a.call(this,b,c)}return b.prototype.current=function(b,c){var d=this;if(this._isInitialized)return void b.call(this,c);this.initSelection.call(null,this.$element,function(b){d._isInitialized=!0,a.isArray(b)||(b=[b]),c(b)})},b}),b.define("select2/compat/inputData",["jquery"],function(a){function b(a,b,c){this._currentData=[],this._valueSeparator=c.get("valueSeparator")||",","hidden"===b.prop("type")&&c.get("debug")&&console&&console.warn&&console.warn("Select2: Using a hidden input with Select2 is no longer supported and may stop working in the future. It is recommended to use a `' + + '' + ); + + this.$searchContainer = $search; + this.$search = $search.find('input'); + + var $rendered = decorated.call(this); + + this._transferTabIndex(); + + return $rendered; + }; + + Search.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('open', function () { + self.$search.trigger('focus'); + }); + + container.on('close', function () { + self.$search.val(''); + self.$search.removeAttr('aria-activedescendant'); + self.$search.trigger('focus'); + }); + + container.on('enable', function () { + self.$search.prop('disabled', false); + + self._transferTabIndex(); + }); + + container.on('disable', function () { + self.$search.prop('disabled', true); + }); + + container.on('focus', function (evt) { + self.$search.trigger('focus'); + }); + + container.on('results:focus', function (params) { + self.$search.attr('aria-activedescendant', params.id); + }); + + this.$selection.on('focusin', '.select2-search--inline', function (evt) { + self.trigger('focus', evt); + }); + + this.$selection.on('focusout', '.select2-search--inline', function (evt) { + self._handleBlur(evt); + }); + + this.$selection.on('keydown', '.select2-search--inline', function (evt) { + evt.stopPropagation(); + + self.trigger('keypress', evt); + + self._keyUpPrevented = evt.isDefaultPrevented(); + + var key = evt.which; + + if (key === KEYS.BACKSPACE && self.$search.val() === '') { + var $previousChoice = self.$searchContainer + .prev('.select2-selection__choice'); + + if ($previousChoice.length > 0) { + var item = $previousChoice.data('data'); + + self.searchRemoveChoice(item); + + evt.preventDefault(); + } + } + }); + + // Try to detect the IE version should the `documentMode` property that + // is stored on the document. This is only implemented in IE and is + // slightly cleaner than doing a user agent check. + // This property is not available in Edge, but Edge also doesn't have + // this bug. + var msie = document.documentMode; + var disableInputEvents = msie && msie <= 11; + + // Workaround for browsers which do not support the `input` event + // This will prevent double-triggering of events for browsers which support + // both the `keyup` and `input` events. + this.$selection.on( + 'input.searchcheck', + '.select2-search--inline', + function (evt) { + // IE will trigger the `input` event when a placeholder is used on a + // search box. To get around this issue, we are forced to ignore all + // `input` events in IE and keep using `keyup`. + if (disableInputEvents) { + self.$selection.off('input.search input.searchcheck'); + return; + } + + // Unbind the duplicated `keyup` event + self.$selection.off('keyup.search'); + } + ); + + this.$selection.on( + 'keyup.search input.search', + '.select2-search--inline', + function (evt) { + // IE will trigger the `input` event when a placeholder is used on a + // search box. To get around this issue, we are forced to ignore all + // `input` events in IE and keep using `keyup`. + if (disableInputEvents && evt.type === 'input') { + self.$selection.off('input.search input.searchcheck'); + return; + } + + var key = evt.which; + + // We can freely ignore events from modifier keys + if (key == KEYS.SHIFT || key == KEYS.CTRL || key == KEYS.ALT) { + return; + } + + // Tabbing will be handled during the `keydown` phase + if (key == KEYS.TAB) { + return; + } + + self.handleSearch(evt); + } + ); + }; + + /** + * This method will transfer the tabindex attribute from the rendered + * selection to the search box. This allows for the search box to be used as + * the primary focus instead of the selection container. + * + * @private + */ + Search.prototype._transferTabIndex = function (decorated) { + this.$search.attr('tabindex', this.$selection.attr('tabindex')); + this.$selection.attr('tabindex', '-1'); + }; + + Search.prototype.createPlaceholder = function (decorated, placeholder) { + this.$search.attr('placeholder', placeholder.text); + }; + + Search.prototype.update = function (decorated, data) { + var searchHadFocus = this.$search[0] == document.activeElement; + + this.$search.attr('placeholder', ''); + + decorated.call(this, data); + + this.$selection.find('.select2-selection__rendered') + .append(this.$searchContainer); + + this.resizeSearch(); + if (searchHadFocus) { + this.$search.focus(); + } + }; + + Search.prototype.handleSearch = function () { + this.resizeSearch(); + + if (!this._keyUpPrevented) { + var input = this.$search.val(); + + this.trigger('query', { + term: input + }); + } + + this._keyUpPrevented = false; + }; + + Search.prototype.searchRemoveChoice = function (decorated, item) { + this.trigger('unselect', { + data: item + }); + + this.$search.val(item.text); + this.handleSearch(); + }; + + Search.prototype.resizeSearch = function () { + this.$search.css('width', '25px'); + + var width = ''; + + if (this.$search.attr('placeholder') !== '') { + width = this.$selection.find('.select2-selection__rendered').innerWidth(); + } else { + var minimumWidth = this.$search.val().length + 1; + + width = (minimumWidth * 0.75) + 'em'; + } + + this.$search.css('width', width); + }; + + return Search; +}); + +S2.define('select2/selection/eventRelay',[ + 'jquery' +], function ($) { + function EventRelay () { } + + EventRelay.prototype.bind = function (decorated, container, $container) { + var self = this; + var relayEvents = [ + 'open', 'opening', + 'close', 'closing', + 'select', 'selecting', + 'unselect', 'unselecting' + ]; + + var preventableEvents = ['opening', 'closing', 'selecting', 'unselecting']; + + decorated.call(this, container, $container); + + container.on('*', function (name, params) { + // Ignore events that should not be relayed + if ($.inArray(name, relayEvents) === -1) { + return; + } + + // The parameters should always be an object + params = params || {}; + + // Generate the jQuery event for the Select2 event + var evt = $.Event('select2:' + name, { + params: params + }); + + self.$element.trigger(evt); + + // Only handle preventable events if it was one + if ($.inArray(name, preventableEvents) === -1) { + return; + } + + params.prevented = evt.isDefaultPrevented(); + }); + }; + + return EventRelay; +}); + +S2.define('select2/translation',[ + 'jquery', + 'require' +], function ($, require) { + function Translation (dict) { + this.dict = dict || {}; + } + + Translation.prototype.all = function () { + return this.dict; + }; + + Translation.prototype.get = function (key) { + return this.dict[key]; + }; + + Translation.prototype.extend = function (translation) { + this.dict = $.extend({}, translation.all(), this.dict); + }; + + // Static functions + + Translation._cache = {}; + + Translation.loadPath = function (path) { + if (!(path in Translation._cache)) { + var translations = require(path); + + Translation._cache[path] = translations; + } + + return new Translation(Translation._cache[path]); + }; + + return Translation; +}); + +S2.define('select2/diacritics',[ + +], function () { + var diacritics = { + '\u24B6': 'A', + '\uFF21': 'A', + '\u00C0': 'A', + '\u00C1': 'A', + '\u00C2': 'A', + '\u1EA6': 'A', + '\u1EA4': 'A', + '\u1EAA': 'A', + '\u1EA8': 'A', + '\u00C3': 'A', + '\u0100': 'A', + '\u0102': 'A', + '\u1EB0': 'A', + '\u1EAE': 'A', + '\u1EB4': 'A', + '\u1EB2': 'A', + '\u0226': 'A', + '\u01E0': 'A', + '\u00C4': 'A', + '\u01DE': 'A', + '\u1EA2': 'A', + '\u00C5': 'A', + '\u01FA': 'A', + '\u01CD': 'A', + '\u0200': 'A', + '\u0202': 'A', + '\u1EA0': 'A', + '\u1EAC': 'A', + '\u1EB6': 'A', + '\u1E00': 'A', + '\u0104': 'A', + '\u023A': 'A', + '\u2C6F': 'A', + '\uA732': 'AA', + '\u00C6': 'AE', + '\u01FC': 'AE', + '\u01E2': 'AE', + '\uA734': 'AO', + '\uA736': 'AU', + '\uA738': 'AV', + '\uA73A': 'AV', + '\uA73C': 'AY', + '\u24B7': 'B', + '\uFF22': 'B', + '\u1E02': 'B', + '\u1E04': 'B', + '\u1E06': 'B', + '\u0243': 'B', + '\u0182': 'B', + '\u0181': 'B', + '\u24B8': 'C', + '\uFF23': 'C', + '\u0106': 'C', + '\u0108': 'C', + '\u010A': 'C', + '\u010C': 'C', + '\u00C7': 'C', + '\u1E08': 'C', + '\u0187': 'C', + '\u023B': 'C', + '\uA73E': 'C', + '\u24B9': 'D', + '\uFF24': 'D', + '\u1E0A': 'D', + '\u010E': 'D', + '\u1E0C': 'D', + '\u1E10': 'D', + '\u1E12': 'D', + '\u1E0E': 'D', + '\u0110': 'D', + '\u018B': 'D', + '\u018A': 'D', + '\u0189': 'D', + '\uA779': 'D', + '\u01F1': 'DZ', + '\u01C4': 'DZ', + '\u01F2': 'Dz', + '\u01C5': 'Dz', + '\u24BA': 'E', + '\uFF25': 'E', + '\u00C8': 'E', + '\u00C9': 'E', + '\u00CA': 'E', + '\u1EC0': 'E', + '\u1EBE': 'E', + '\u1EC4': 'E', + '\u1EC2': 'E', + '\u1EBC': 'E', + '\u0112': 'E', + '\u1E14': 'E', + '\u1E16': 'E', + '\u0114': 'E', + '\u0116': 'E', + '\u00CB': 'E', + '\u1EBA': 'E', + '\u011A': 'E', + '\u0204': 'E', + '\u0206': 'E', + '\u1EB8': 'E', + '\u1EC6': 'E', + '\u0228': 'E', + '\u1E1C': 'E', + '\u0118': 'E', + '\u1E18': 'E', + '\u1E1A': 'E', + '\u0190': 'E', + '\u018E': 'E', + '\u24BB': 'F', + '\uFF26': 'F', + '\u1E1E': 'F', + '\u0191': 'F', + '\uA77B': 'F', + '\u24BC': 'G', + '\uFF27': 'G', + '\u01F4': 'G', + '\u011C': 'G', + '\u1E20': 'G', + '\u011E': 'G', + '\u0120': 'G', + '\u01E6': 'G', + '\u0122': 'G', + '\u01E4': 'G', + '\u0193': 'G', + '\uA7A0': 'G', + '\uA77D': 'G', + '\uA77E': 'G', + '\u24BD': 'H', + '\uFF28': 'H', + '\u0124': 'H', + '\u1E22': 'H', + '\u1E26': 'H', + '\u021E': 'H', + '\u1E24': 'H', + '\u1E28': 'H', + '\u1E2A': 'H', + '\u0126': 'H', + '\u2C67': 'H', + '\u2C75': 'H', + '\uA78D': 'H', + '\u24BE': 'I', + '\uFF29': 'I', + '\u00CC': 'I', + '\u00CD': 'I', + '\u00CE': 'I', + '\u0128': 'I', + '\u012A': 'I', + '\u012C': 'I', + '\u0130': 'I', + '\u00CF': 'I', + '\u1E2E': 'I', + '\u1EC8': 'I', + '\u01CF': 'I', + '\u0208': 'I', + '\u020A': 'I', + '\u1ECA': 'I', + '\u012E': 'I', + '\u1E2C': 'I', + '\u0197': 'I', + '\u24BF': 'J', + '\uFF2A': 'J', + '\u0134': 'J', + '\u0248': 'J', + '\u24C0': 'K', + '\uFF2B': 'K', + '\u1E30': 'K', + '\u01E8': 'K', + '\u1E32': 'K', + '\u0136': 'K', + '\u1E34': 'K', + '\u0198': 'K', + '\u2C69': 'K', + '\uA740': 'K', + '\uA742': 'K', + '\uA744': 'K', + '\uA7A2': 'K', + '\u24C1': 'L', + '\uFF2C': 'L', + '\u013F': 'L', + '\u0139': 'L', + '\u013D': 'L', + '\u1E36': 'L', + '\u1E38': 'L', + '\u013B': 'L', + '\u1E3C': 'L', + '\u1E3A': 'L', + '\u0141': 'L', + '\u023D': 'L', + '\u2C62': 'L', + '\u2C60': 'L', + '\uA748': 'L', + '\uA746': 'L', + '\uA780': 'L', + '\u01C7': 'LJ', + '\u01C8': 'Lj', + '\u24C2': 'M', + '\uFF2D': 'M', + '\u1E3E': 'M', + '\u1E40': 'M', + '\u1E42': 'M', + '\u2C6E': 'M', + '\u019C': 'M', + '\u24C3': 'N', + '\uFF2E': 'N', + '\u01F8': 'N', + '\u0143': 'N', + '\u00D1': 'N', + '\u1E44': 'N', + '\u0147': 'N', + '\u1E46': 'N', + '\u0145': 'N', + '\u1E4A': 'N', + '\u1E48': 'N', + '\u0220': 'N', + '\u019D': 'N', + '\uA790': 'N', + '\uA7A4': 'N', + '\u01CA': 'NJ', + '\u01CB': 'Nj', + '\u24C4': 'O', + '\uFF2F': 'O', + '\u00D2': 'O', + '\u00D3': 'O', + '\u00D4': 'O', + '\u1ED2': 'O', + '\u1ED0': 'O', + '\u1ED6': 'O', + '\u1ED4': 'O', + '\u00D5': 'O', + '\u1E4C': 'O', + '\u022C': 'O', + '\u1E4E': 'O', + '\u014C': 'O', + '\u1E50': 'O', + '\u1E52': 'O', + '\u014E': 'O', + '\u022E': 'O', + '\u0230': 'O', + '\u00D6': 'O', + '\u022A': 'O', + '\u1ECE': 'O', + '\u0150': 'O', + '\u01D1': 'O', + '\u020C': 'O', + '\u020E': 'O', + '\u01A0': 'O', + '\u1EDC': 'O', + '\u1EDA': 'O', + '\u1EE0': 'O', + '\u1EDE': 'O', + '\u1EE2': 'O', + '\u1ECC': 'O', + '\u1ED8': 'O', + '\u01EA': 'O', + '\u01EC': 'O', + '\u00D8': 'O', + '\u01FE': 'O', + '\u0186': 'O', + '\u019F': 'O', + '\uA74A': 'O', + '\uA74C': 'O', + '\u01A2': 'OI', + '\uA74E': 'OO', + '\u0222': 'OU', + '\u24C5': 'P', + '\uFF30': 'P', + '\u1E54': 'P', + '\u1E56': 'P', + '\u01A4': 'P', + '\u2C63': 'P', + '\uA750': 'P', + '\uA752': 'P', + '\uA754': 'P', + '\u24C6': 'Q', + '\uFF31': 'Q', + '\uA756': 'Q', + '\uA758': 'Q', + '\u024A': 'Q', + '\u24C7': 'R', + '\uFF32': 'R', + '\u0154': 'R', + '\u1E58': 'R', + '\u0158': 'R', + '\u0210': 'R', + '\u0212': 'R', + '\u1E5A': 'R', + '\u1E5C': 'R', + '\u0156': 'R', + '\u1E5E': 'R', + '\u024C': 'R', + '\u2C64': 'R', + '\uA75A': 'R', + '\uA7A6': 'R', + '\uA782': 'R', + '\u24C8': 'S', + '\uFF33': 'S', + '\u1E9E': 'S', + '\u015A': 'S', + '\u1E64': 'S', + '\u015C': 'S', + '\u1E60': 'S', + '\u0160': 'S', + '\u1E66': 'S', + '\u1E62': 'S', + '\u1E68': 'S', + '\u0218': 'S', + '\u015E': 'S', + '\u2C7E': 'S', + '\uA7A8': 'S', + '\uA784': 'S', + '\u24C9': 'T', + '\uFF34': 'T', + '\u1E6A': 'T', + '\u0164': 'T', + '\u1E6C': 'T', + '\u021A': 'T', + '\u0162': 'T', + '\u1E70': 'T', + '\u1E6E': 'T', + '\u0166': 'T', + '\u01AC': 'T', + '\u01AE': 'T', + '\u023E': 'T', + '\uA786': 'T', + '\uA728': 'TZ', + '\u24CA': 'U', + '\uFF35': 'U', + '\u00D9': 'U', + '\u00DA': 'U', + '\u00DB': 'U', + '\u0168': 'U', + '\u1E78': 'U', + '\u016A': 'U', + '\u1E7A': 'U', + '\u016C': 'U', + '\u00DC': 'U', + '\u01DB': 'U', + '\u01D7': 'U', + '\u01D5': 'U', + '\u01D9': 'U', + '\u1EE6': 'U', + '\u016E': 'U', + '\u0170': 'U', + '\u01D3': 'U', + '\u0214': 'U', + '\u0216': 'U', + '\u01AF': 'U', + '\u1EEA': 'U', + '\u1EE8': 'U', + '\u1EEE': 'U', + '\u1EEC': 'U', + '\u1EF0': 'U', + '\u1EE4': 'U', + '\u1E72': 'U', + '\u0172': 'U', + '\u1E76': 'U', + '\u1E74': 'U', + '\u0244': 'U', + '\u24CB': 'V', + '\uFF36': 'V', + '\u1E7C': 'V', + '\u1E7E': 'V', + '\u01B2': 'V', + '\uA75E': 'V', + '\u0245': 'V', + '\uA760': 'VY', + '\u24CC': 'W', + '\uFF37': 'W', + '\u1E80': 'W', + '\u1E82': 'W', + '\u0174': 'W', + '\u1E86': 'W', + '\u1E84': 'W', + '\u1E88': 'W', + '\u2C72': 'W', + '\u24CD': 'X', + '\uFF38': 'X', + '\u1E8A': 'X', + '\u1E8C': 'X', + '\u24CE': 'Y', + '\uFF39': 'Y', + '\u1EF2': 'Y', + '\u00DD': 'Y', + '\u0176': 'Y', + '\u1EF8': 'Y', + '\u0232': 'Y', + '\u1E8E': 'Y', + '\u0178': 'Y', + '\u1EF6': 'Y', + '\u1EF4': 'Y', + '\u01B3': 'Y', + '\u024E': 'Y', + '\u1EFE': 'Y', + '\u24CF': 'Z', + '\uFF3A': 'Z', + '\u0179': 'Z', + '\u1E90': 'Z', + '\u017B': 'Z', + '\u017D': 'Z', + '\u1E92': 'Z', + '\u1E94': 'Z', + '\u01B5': 'Z', + '\u0224': 'Z', + '\u2C7F': 'Z', + '\u2C6B': 'Z', + '\uA762': 'Z', + '\u24D0': 'a', + '\uFF41': 'a', + '\u1E9A': 'a', + '\u00E0': 'a', + '\u00E1': 'a', + '\u00E2': 'a', + '\u1EA7': 'a', + '\u1EA5': 'a', + '\u1EAB': 'a', + '\u1EA9': 'a', + '\u00E3': 'a', + '\u0101': 'a', + '\u0103': 'a', + '\u1EB1': 'a', + '\u1EAF': 'a', + '\u1EB5': 'a', + '\u1EB3': 'a', + '\u0227': 'a', + '\u01E1': 'a', + '\u00E4': 'a', + '\u01DF': 'a', + '\u1EA3': 'a', + '\u00E5': 'a', + '\u01FB': 'a', + '\u01CE': 'a', + '\u0201': 'a', + '\u0203': 'a', + '\u1EA1': 'a', + '\u1EAD': 'a', + '\u1EB7': 'a', + '\u1E01': 'a', + '\u0105': 'a', + '\u2C65': 'a', + '\u0250': 'a', + '\uA733': 'aa', + '\u00E6': 'ae', + '\u01FD': 'ae', + '\u01E3': 'ae', + '\uA735': 'ao', + '\uA737': 'au', + '\uA739': 'av', + '\uA73B': 'av', + '\uA73D': 'ay', + '\u24D1': 'b', + '\uFF42': 'b', + '\u1E03': 'b', + '\u1E05': 'b', + '\u1E07': 'b', + '\u0180': 'b', + '\u0183': 'b', + '\u0253': 'b', + '\u24D2': 'c', + '\uFF43': 'c', + '\u0107': 'c', + '\u0109': 'c', + '\u010B': 'c', + '\u010D': 'c', + '\u00E7': 'c', + '\u1E09': 'c', + '\u0188': 'c', + '\u023C': 'c', + '\uA73F': 'c', + '\u2184': 'c', + '\u24D3': 'd', + '\uFF44': 'd', + '\u1E0B': 'd', + '\u010F': 'd', + '\u1E0D': 'd', + '\u1E11': 'd', + '\u1E13': 'd', + '\u1E0F': 'd', + '\u0111': 'd', + '\u018C': 'd', + '\u0256': 'd', + '\u0257': 'd', + '\uA77A': 'd', + '\u01F3': 'dz', + '\u01C6': 'dz', + '\u24D4': 'e', + '\uFF45': 'e', + '\u00E8': 'e', + '\u00E9': 'e', + '\u00EA': 'e', + '\u1EC1': 'e', + '\u1EBF': 'e', + '\u1EC5': 'e', + '\u1EC3': 'e', + '\u1EBD': 'e', + '\u0113': 'e', + '\u1E15': 'e', + '\u1E17': 'e', + '\u0115': 'e', + '\u0117': 'e', + '\u00EB': 'e', + '\u1EBB': 'e', + '\u011B': 'e', + '\u0205': 'e', + '\u0207': 'e', + '\u1EB9': 'e', + '\u1EC7': 'e', + '\u0229': 'e', + '\u1E1D': 'e', + '\u0119': 'e', + '\u1E19': 'e', + '\u1E1B': 'e', + '\u0247': 'e', + '\u025B': 'e', + '\u01DD': 'e', + '\u24D5': 'f', + '\uFF46': 'f', + '\u1E1F': 'f', + '\u0192': 'f', + '\uA77C': 'f', + '\u24D6': 'g', + '\uFF47': 'g', + '\u01F5': 'g', + '\u011D': 'g', + '\u1E21': 'g', + '\u011F': 'g', + '\u0121': 'g', + '\u01E7': 'g', + '\u0123': 'g', + '\u01E5': 'g', + '\u0260': 'g', + '\uA7A1': 'g', + '\u1D79': 'g', + '\uA77F': 'g', + '\u24D7': 'h', + '\uFF48': 'h', + '\u0125': 'h', + '\u1E23': 'h', + '\u1E27': 'h', + '\u021F': 'h', + '\u1E25': 'h', + '\u1E29': 'h', + '\u1E2B': 'h', + '\u1E96': 'h', + '\u0127': 'h', + '\u2C68': 'h', + '\u2C76': 'h', + '\u0265': 'h', + '\u0195': 'hv', + '\u24D8': 'i', + '\uFF49': 'i', + '\u00EC': 'i', + '\u00ED': 'i', + '\u00EE': 'i', + '\u0129': 'i', + '\u012B': 'i', + '\u012D': 'i', + '\u00EF': 'i', + '\u1E2F': 'i', + '\u1EC9': 'i', + '\u01D0': 'i', + '\u0209': 'i', + '\u020B': 'i', + '\u1ECB': 'i', + '\u012F': 'i', + '\u1E2D': 'i', + '\u0268': 'i', + '\u0131': 'i', + '\u24D9': 'j', + '\uFF4A': 'j', + '\u0135': 'j', + '\u01F0': 'j', + '\u0249': 'j', + '\u24DA': 'k', + '\uFF4B': 'k', + '\u1E31': 'k', + '\u01E9': 'k', + '\u1E33': 'k', + '\u0137': 'k', + '\u1E35': 'k', + '\u0199': 'k', + '\u2C6A': 'k', + '\uA741': 'k', + '\uA743': 'k', + '\uA745': 'k', + '\uA7A3': 'k', + '\u24DB': 'l', + '\uFF4C': 'l', + '\u0140': 'l', + '\u013A': 'l', + '\u013E': 'l', + '\u1E37': 'l', + '\u1E39': 'l', + '\u013C': 'l', + '\u1E3D': 'l', + '\u1E3B': 'l', + '\u017F': 'l', + '\u0142': 'l', + '\u019A': 'l', + '\u026B': 'l', + '\u2C61': 'l', + '\uA749': 'l', + '\uA781': 'l', + '\uA747': 'l', + '\u01C9': 'lj', + '\u24DC': 'm', + '\uFF4D': 'm', + '\u1E3F': 'm', + '\u1E41': 'm', + '\u1E43': 'm', + '\u0271': 'm', + '\u026F': 'm', + '\u24DD': 'n', + '\uFF4E': 'n', + '\u01F9': 'n', + '\u0144': 'n', + '\u00F1': 'n', + '\u1E45': 'n', + '\u0148': 'n', + '\u1E47': 'n', + '\u0146': 'n', + '\u1E4B': 'n', + '\u1E49': 'n', + '\u019E': 'n', + '\u0272': 'n', + '\u0149': 'n', + '\uA791': 'n', + '\uA7A5': 'n', + '\u01CC': 'nj', + '\u24DE': 'o', + '\uFF4F': 'o', + '\u00F2': 'o', + '\u00F3': 'o', + '\u00F4': 'o', + '\u1ED3': 'o', + '\u1ED1': 'o', + '\u1ED7': 'o', + '\u1ED5': 'o', + '\u00F5': 'o', + '\u1E4D': 'o', + '\u022D': 'o', + '\u1E4F': 'o', + '\u014D': 'o', + '\u1E51': 'o', + '\u1E53': 'o', + '\u014F': 'o', + '\u022F': 'o', + '\u0231': 'o', + '\u00F6': 'o', + '\u022B': 'o', + '\u1ECF': 'o', + '\u0151': 'o', + '\u01D2': 'o', + '\u020D': 'o', + '\u020F': 'o', + '\u01A1': 'o', + '\u1EDD': 'o', + '\u1EDB': 'o', + '\u1EE1': 'o', + '\u1EDF': 'o', + '\u1EE3': 'o', + '\u1ECD': 'o', + '\u1ED9': 'o', + '\u01EB': 'o', + '\u01ED': 'o', + '\u00F8': 'o', + '\u01FF': 'o', + '\u0254': 'o', + '\uA74B': 'o', + '\uA74D': 'o', + '\u0275': 'o', + '\u01A3': 'oi', + '\u0223': 'ou', + '\uA74F': 'oo', + '\u24DF': 'p', + '\uFF50': 'p', + '\u1E55': 'p', + '\u1E57': 'p', + '\u01A5': 'p', + '\u1D7D': 'p', + '\uA751': 'p', + '\uA753': 'p', + '\uA755': 'p', + '\u24E0': 'q', + '\uFF51': 'q', + '\u024B': 'q', + '\uA757': 'q', + '\uA759': 'q', + '\u24E1': 'r', + '\uFF52': 'r', + '\u0155': 'r', + '\u1E59': 'r', + '\u0159': 'r', + '\u0211': 'r', + '\u0213': 'r', + '\u1E5B': 'r', + '\u1E5D': 'r', + '\u0157': 'r', + '\u1E5F': 'r', + '\u024D': 'r', + '\u027D': 'r', + '\uA75B': 'r', + '\uA7A7': 'r', + '\uA783': 'r', + '\u24E2': 's', + '\uFF53': 's', + '\u00DF': 's', + '\u015B': 's', + '\u1E65': 's', + '\u015D': 's', + '\u1E61': 's', + '\u0161': 's', + '\u1E67': 's', + '\u1E63': 's', + '\u1E69': 's', + '\u0219': 's', + '\u015F': 's', + '\u023F': 's', + '\uA7A9': 's', + '\uA785': 's', + '\u1E9B': 's', + '\u24E3': 't', + '\uFF54': 't', + '\u1E6B': 't', + '\u1E97': 't', + '\u0165': 't', + '\u1E6D': 't', + '\u021B': 't', + '\u0163': 't', + '\u1E71': 't', + '\u1E6F': 't', + '\u0167': 't', + '\u01AD': 't', + '\u0288': 't', + '\u2C66': 't', + '\uA787': 't', + '\uA729': 'tz', + '\u24E4': 'u', + '\uFF55': 'u', + '\u00F9': 'u', + '\u00FA': 'u', + '\u00FB': 'u', + '\u0169': 'u', + '\u1E79': 'u', + '\u016B': 'u', + '\u1E7B': 'u', + '\u016D': 'u', + '\u00FC': 'u', + '\u01DC': 'u', + '\u01D8': 'u', + '\u01D6': 'u', + '\u01DA': 'u', + '\u1EE7': 'u', + '\u016F': 'u', + '\u0171': 'u', + '\u01D4': 'u', + '\u0215': 'u', + '\u0217': 'u', + '\u01B0': 'u', + '\u1EEB': 'u', + '\u1EE9': 'u', + '\u1EEF': 'u', + '\u1EED': 'u', + '\u1EF1': 'u', + '\u1EE5': 'u', + '\u1E73': 'u', + '\u0173': 'u', + '\u1E77': 'u', + '\u1E75': 'u', + '\u0289': 'u', + '\u24E5': 'v', + '\uFF56': 'v', + '\u1E7D': 'v', + '\u1E7F': 'v', + '\u028B': 'v', + '\uA75F': 'v', + '\u028C': 'v', + '\uA761': 'vy', + '\u24E6': 'w', + '\uFF57': 'w', + '\u1E81': 'w', + '\u1E83': 'w', + '\u0175': 'w', + '\u1E87': 'w', + '\u1E85': 'w', + '\u1E98': 'w', + '\u1E89': 'w', + '\u2C73': 'w', + '\u24E7': 'x', + '\uFF58': 'x', + '\u1E8B': 'x', + '\u1E8D': 'x', + '\u24E8': 'y', + '\uFF59': 'y', + '\u1EF3': 'y', + '\u00FD': 'y', + '\u0177': 'y', + '\u1EF9': 'y', + '\u0233': 'y', + '\u1E8F': 'y', + '\u00FF': 'y', + '\u1EF7': 'y', + '\u1E99': 'y', + '\u1EF5': 'y', + '\u01B4': 'y', + '\u024F': 'y', + '\u1EFF': 'y', + '\u24E9': 'z', + '\uFF5A': 'z', + '\u017A': 'z', + '\u1E91': 'z', + '\u017C': 'z', + '\u017E': 'z', + '\u1E93': 'z', + '\u1E95': 'z', + '\u01B6': 'z', + '\u0225': 'z', + '\u0240': 'z', + '\u2C6C': 'z', + '\uA763': 'z', + '\u0386': '\u0391', + '\u0388': '\u0395', + '\u0389': '\u0397', + '\u038A': '\u0399', + '\u03AA': '\u0399', + '\u038C': '\u039F', + '\u038E': '\u03A5', + '\u03AB': '\u03A5', + '\u038F': '\u03A9', + '\u03AC': '\u03B1', + '\u03AD': '\u03B5', + '\u03AE': '\u03B7', + '\u03AF': '\u03B9', + '\u03CA': '\u03B9', + '\u0390': '\u03B9', + '\u03CC': '\u03BF', + '\u03CD': '\u03C5', + '\u03CB': '\u03C5', + '\u03B0': '\u03C5', + '\u03C9': '\u03C9', + '\u03C2': '\u03C3' + }; + + return diacritics; +}); + +S2.define('select2/data/base',[ + '../utils' +], function (Utils) { + function BaseAdapter ($element, options) { + BaseAdapter.__super__.constructor.call(this); + } + + Utils.Extend(BaseAdapter, Utils.Observable); + + BaseAdapter.prototype.current = function (callback) { + throw new Error('The `current` method must be defined in child classes.'); + }; + + BaseAdapter.prototype.query = function (params, callback) { + throw new Error('The `query` method must be defined in child classes.'); + }; + + BaseAdapter.prototype.bind = function (container, $container) { + // Can be implemented in subclasses + }; + + BaseAdapter.prototype.destroy = function () { + // Can be implemented in subclasses + }; + + BaseAdapter.prototype.generateResultId = function (container, data) { + var id = container.id + '-result-'; + + id += Utils.generateChars(4); + + if (data.id != null) { + id += '-' + data.id.toString(); + } else { + id += '-' + Utils.generateChars(4); + } + return id; + }; + + return BaseAdapter; +}); + +S2.define('select2/data/select',[ + './base', + '../utils', + 'jquery' +], function (BaseAdapter, Utils, $) { + function SelectAdapter ($element, options) { + this.$element = $element; + this.options = options; + + SelectAdapter.__super__.constructor.call(this); + } + + Utils.Extend(SelectAdapter, BaseAdapter); + + SelectAdapter.prototype.current = function (callback) { + var data = []; + var self = this; + + this.$element.find(':selected').each(function () { + var $option = $(this); + + var option = self.item($option); + + data.push(option); + }); + + callback(data); + }; + + SelectAdapter.prototype.select = function (data) { + var self = this; + + data.selected = true; + + // If data.element is a DOM node, use it instead + if ($(data.element).is('option')) { + data.element.selected = true; + + this.$element.trigger('change'); + + return; + } + + if (this.$element.prop('multiple')) { + this.current(function (currentData) { + var val = []; + + data = [data]; + data.push.apply(data, currentData); + + for (var d = 0; d < data.length; d++) { + var id = data[d].id; + + if ($.inArray(id, val) === -1) { + val.push(id); + } + } + + self.$element.val(val); + self.$element.trigger('change'); + }); + } else { + var val = data.id; + + this.$element.val(val); + this.$element.trigger('change'); + } + }; + + SelectAdapter.prototype.unselect = function (data) { + var self = this; + + if (!this.$element.prop('multiple')) { + return; + } + + data.selected = false; + + if ($(data.element).is('option')) { + data.element.selected = false; + + this.$element.trigger('change'); + + return; + } + + this.current(function (currentData) { + var val = []; + + for (var d = 0; d < currentData.length; d++) { + var id = currentData[d].id; + + if (id !== data.id && $.inArray(id, val) === -1) { + val.push(id); + } + } + + self.$element.val(val); + + self.$element.trigger('change'); + }); + }; + + SelectAdapter.prototype.bind = function (container, $container) { + var self = this; + + this.container = container; + + container.on('select', function (params) { + self.select(params.data); + }); + + container.on('unselect', function (params) { + self.unselect(params.data); + }); + }; + + SelectAdapter.prototype.destroy = function () { + // Remove anything added to child elements + this.$element.find('*').each(function () { + // Remove any custom data set by Select2 + $.removeData(this, 'data'); + }); + }; + + SelectAdapter.prototype.query = function (params, callback) { + var data = []; + var self = this; + + var $options = this.$element.children(); + + $options.each(function () { + var $option = $(this); + + if (!$option.is('option') && !$option.is('optgroup')) { + return; + } + + var option = self.item($option); + + var matches = self.matches(params, option); + + if (matches !== null) { + data.push(matches); + } + }); + + callback({ + results: data + }); + }; + + SelectAdapter.prototype.addOptions = function ($options) { + Utils.appendMany(this.$element, $options); + }; + + SelectAdapter.prototype.option = function (data) { + var option; + + if (data.children) { + option = document.createElement('optgroup'); + option.label = data.text; + } else { + option = document.createElement('option'); + + if (option.textContent !== undefined) { + option.textContent = data.text; + } else { + option.innerText = data.text; + } + } + + if (data.id !== undefined) { + option.value = data.id; + } + + if (data.disabled) { + option.disabled = true; + } + + if (data.selected) { + option.selected = true; + } + + if (data.title) { + option.title = data.title; + } + + var $option = $(option); + + var normalizedData = this._normalizeItem(data); + normalizedData.element = option; + + // Override the option's data with the combined data + $.data(option, 'data', normalizedData); + + return $option; + }; + + SelectAdapter.prototype.item = function ($option) { + var data = {}; + + data = $.data($option[0], 'data'); + + if (data != null) { + return data; + } + + if ($option.is('option')) { + data = { + id: $option.val(), + text: $option.text(), + disabled: $option.prop('disabled'), + selected: $option.prop('selected'), + title: $option.prop('title') + }; + } else if ($option.is('optgroup')) { + data = { + text: $option.prop('label'), + children: [], + title: $option.prop('title') + }; + + var $children = $option.children('option'); + var children = []; + + for (var c = 0; c < $children.length; c++) { + var $child = $($children[c]); + + var child = this.item($child); + + children.push(child); + } + + data.children = children; + } + + data = this._normalizeItem(data); + data.element = $option[0]; + + $.data($option[0], 'data', data); + + return data; + }; + + SelectAdapter.prototype._normalizeItem = function (item) { + if (!$.isPlainObject(item)) { + item = { + id: item, + text: item + }; + } + + item = $.extend({}, { + text: '' + }, item); + + var defaults = { + selected: false, + disabled: false + }; + + if (item.id != null) { + item.id = item.id.toString(); + } + + if (item.text != null) { + item.text = item.text.toString(); + } + + if (item._resultId == null && item.id && this.container != null) { + item._resultId = this.generateResultId(this.container, item); + } + + return $.extend({}, defaults, item); + }; + + SelectAdapter.prototype.matches = function (params, data) { + var matcher = this.options.get('matcher'); + + return matcher(params, data); + }; + + return SelectAdapter; +}); + +S2.define('select2/data/array',[ + './select', + '../utils', + 'jquery' +], function (SelectAdapter, Utils, $) { + function ArrayAdapter ($element, options) { + var data = options.get('data') || []; + + ArrayAdapter.__super__.constructor.call(this, $element, options); + + this.addOptions(this.convertToOptions(data)); + } + + Utils.Extend(ArrayAdapter, SelectAdapter); + + ArrayAdapter.prototype.select = function (data) { + var $option = this.$element.find('option').filter(function (i, elm) { + return elm.value == data.id.toString(); + }); + + if ($option.length === 0) { + $option = this.option(data); + + this.addOptions($option); + } + + ArrayAdapter.__super__.select.call(this, data); + }; + + ArrayAdapter.prototype.convertToOptions = function (data) { + var self = this; + + var $existing = this.$element.find('option'); + var existingIds = $existing.map(function () { + return self.item($(this)).id; + }).get(); + + var $options = []; + + // Filter out all items except for the one passed in the argument + function onlyItem (item) { + return function () { + return $(this).val() == item.id; + }; + } + + for (var d = 0; d < data.length; d++) { + var item = this._normalizeItem(data[d]); + + // Skip items which were pre-loaded, only merge the data + if ($.inArray(item.id, existingIds) >= 0) { + var $existingOption = $existing.filter(onlyItem(item)); + + var existingData = this.item($existingOption); + var newData = $.extend(true, {}, item, existingData); + + var $newOption = this.option(newData); + + $existingOption.replaceWith($newOption); + + continue; + } + + var $option = this.option(item); + + if (item.children) { + var $children = this.convertToOptions(item.children); + + Utils.appendMany($option, $children); + } + + $options.push($option); + } + + return $options; + }; + + return ArrayAdapter; +}); + +S2.define('select2/data/ajax',[ + './array', + '../utils', + 'jquery' +], function (ArrayAdapter, Utils, $) { + function AjaxAdapter ($element, options) { + this.ajaxOptions = this._applyDefaults(options.get('ajax')); + + if (this.ajaxOptions.processResults != null) { + this.processResults = this.ajaxOptions.processResults; + } + + AjaxAdapter.__super__.constructor.call(this, $element, options); + } + + Utils.Extend(AjaxAdapter, ArrayAdapter); + + AjaxAdapter.prototype._applyDefaults = function (options) { + var defaults = { + data: function (params) { + return $.extend({}, params, { + q: params.term + }); + }, + transport: function (params, success, failure) { + var $request = $.ajax(params); + + $request.then(success); + $request.fail(failure); + + return $request; + } + }; + + return $.extend({}, defaults, options, true); + }; + + AjaxAdapter.prototype.processResults = function (results) { + return results; + }; + + AjaxAdapter.prototype.query = function (params, callback) { + var matches = []; + var self = this; + + if (this._request != null) { + // JSONP requests cannot always be aborted + if ($.isFunction(this._request.abort)) { + this._request.abort(); + } + + this._request = null; + } + + var options = $.extend({ + type: 'GET' + }, this.ajaxOptions); + + if (typeof options.url === 'function') { + options.url = options.url.call(this.$element, params); + } + + if (typeof options.data === 'function') { + options.data = options.data.call(this.$element, params); + } + + function request () { + var $request = options.transport(options, function (data) { + var results = self.processResults(data, params); + + if (self.options.get('debug') && window.console && console.error) { + // Check to make sure that the response included a `results` key. + if (!results || !results.results || !$.isArray(results.results)) { + console.error( + 'Select2: The AJAX results did not return an array in the ' + + '`results` key of the response.' + ); + } + } + + callback(results); + }, function () { + // Attempt to detect if a request was aborted + // Only works if the transport exposes a status property + if ($request.status && $request.status === '0') { + return; + } + + self.trigger('results:message', { + message: 'errorLoading' + }); + }); + + self._request = $request; + } + + if (this.ajaxOptions.delay && params.term != null) { + if (this._queryTimeout) { + window.clearTimeout(this._queryTimeout); + } + + this._queryTimeout = window.setTimeout(request, this.ajaxOptions.delay); + } else { + request(); + } + }; + + return AjaxAdapter; +}); + +S2.define('select2/data/tags',[ + 'jquery' +], function ($) { + function Tags (decorated, $element, options) { + var tags = options.get('tags'); + + var createTag = options.get('createTag'); + + if (createTag !== undefined) { + this.createTag = createTag; + } + + var insertTag = options.get('insertTag'); + + if (insertTag !== undefined) { + this.insertTag = insertTag; + } + + decorated.call(this, $element, options); + + if ($.isArray(tags)) { + for (var t = 0; t < tags.length; t++) { + var tag = tags[t]; + var item = this._normalizeItem(tag); + + var $option = this.option(item); + + this.$element.append($option); + } + } + } + + Tags.prototype.query = function (decorated, params, callback) { + var self = this; + + this._removeOldTags(); + + if (params.term == null || params.page != null) { + decorated.call(this, params, callback); + return; + } + + function wrapper (obj, child) { + var data = obj.results; + + for (var i = 0; i < data.length; i++) { + var option = data[i]; + + var checkChildren = ( + option.children != null && + !wrapper({ + results: option.children + }, true) + ); + + var optionText = (option.text || '').toUpperCase(); + var paramsTerm = (params.term || '').toUpperCase(); + + var checkText = optionText === paramsTerm; + + if (checkText || checkChildren) { + if (child) { + return false; + } + + obj.data = data; + callback(obj); + + return; + } + } + + if (child) { + return true; + } + + var tag = self.createTag(params); + + if (tag != null) { + var $option = self.option(tag); + $option.attr('data-select2-tag', true); + + self.addOptions([$option]); + + self.insertTag(data, tag); + } + + obj.results = data; + + callback(obj); + } + + decorated.call(this, params, wrapper); + }; + + Tags.prototype.createTag = function (decorated, params) { + var term = $.trim(params.term); + + if (term === '') { + return null; + } + + return { + id: term, + text: term + }; + }; + + Tags.prototype.insertTag = function (_, data, tag) { + data.unshift(tag); + }; + + Tags.prototype._removeOldTags = function (_) { + var tag = this._lastTag; + + var $options = this.$element.find('option[data-select2-tag]'); + + $options.each(function () { + if (this.selected) { + return; + } + + $(this).remove(); + }); + }; + + return Tags; +}); + +S2.define('select2/data/tokenizer',[ + 'jquery' +], function ($) { + function Tokenizer (decorated, $element, options) { + var tokenizer = options.get('tokenizer'); + + if (tokenizer !== undefined) { + this.tokenizer = tokenizer; + } + + decorated.call(this, $element, options); + } + + Tokenizer.prototype.bind = function (decorated, container, $container) { + decorated.call(this, container, $container); + + this.$search = container.dropdown.$search || container.selection.$search || + $container.find('.select2-search__field'); + }; + + Tokenizer.prototype.query = function (decorated, params, callback) { + var self = this; + + function createAndSelect (data) { + // Normalize the data object so we can use it for checks + var item = self._normalizeItem(data); + + // Check if the data object already exists as a tag + // Select it if it doesn't + var $existingOptions = self.$element.find('option').filter(function () { + return $(this).val() === item.id; + }); + + // If an existing option wasn't found for it, create the option + if (!$existingOptions.length) { + var $option = self.option(item); + $option.attr('data-select2-tag', true); + + self._removeOldTags(); + self.addOptions([$option]); + } + + // Select the item, now that we know there is an option for it + select(item); + } + + function select (data) { + self.trigger('select', { + data: data + }); + } + + params.term = params.term || ''; + + var tokenData = this.tokenizer(params, this.options, createAndSelect); + + if (tokenData.term !== params.term) { + // Replace the search term if we have the search box + if (this.$search.length) { + this.$search.val(tokenData.term); + this.$search.focus(); + } + + params.term = tokenData.term; + } + + decorated.call(this, params, callback); + }; + + Tokenizer.prototype.tokenizer = function (_, params, options, callback) { + var separators = options.get('tokenSeparators') || []; + var term = params.term; + var i = 0; + + var createTag = this.createTag || function (params) { + return { + id: params.term, + text: params.term + }; + }; + + while (i < term.length) { + var termChar = term[i]; + + if ($.inArray(termChar, separators) === -1) { + i++; + + continue; + } + + var part = term.substr(0, i); + var partParams = $.extend({}, params, { + term: part + }); + + var data = createTag(partParams); + + if (data == null) { + i++; + continue; + } + + callback(data); + + // Reset the term to not include the tokenized portion + term = term.substr(i + 1) || ''; + i = 0; + } + + return { + term: term + }; + }; + + return Tokenizer; +}); + +S2.define('select2/data/minimumInputLength',[ + +], function () { + function MinimumInputLength (decorated, $e, options) { + this.minimumInputLength = options.get('minimumInputLength'); + + decorated.call(this, $e, options); + } + + MinimumInputLength.prototype.query = function (decorated, params, callback) { + params.term = params.term || ''; + + if (params.term.length < this.minimumInputLength) { + this.trigger('results:message', { + message: 'inputTooShort', + args: { + minimum: this.minimumInputLength, + input: params.term, + params: params + } + }); + + return; + } + + decorated.call(this, params, callback); + }; + + return MinimumInputLength; +}); + +S2.define('select2/data/maximumInputLength',[ + +], function () { + function MaximumInputLength (decorated, $e, options) { + this.maximumInputLength = options.get('maximumInputLength'); + + decorated.call(this, $e, options); + } + + MaximumInputLength.prototype.query = function (decorated, params, callback) { + params.term = params.term || ''; + + if (this.maximumInputLength > 0 && + params.term.length > this.maximumInputLength) { + this.trigger('results:message', { + message: 'inputTooLong', + args: { + maximum: this.maximumInputLength, + input: params.term, + params: params + } + }); + + return; + } + + decorated.call(this, params, callback); + }; + + return MaximumInputLength; +}); + +S2.define('select2/data/maximumSelectionLength',[ + +], function (){ + function MaximumSelectionLength (decorated, $e, options) { + this.maximumSelectionLength = options.get('maximumSelectionLength'); + + decorated.call(this, $e, options); + } + + MaximumSelectionLength.prototype.query = + function (decorated, params, callback) { + var self = this; + + this.current(function (currentData) { + var count = currentData != null ? currentData.length : 0; + if (self.maximumSelectionLength > 0 && + count >= self.maximumSelectionLength) { + self.trigger('results:message', { + message: 'maximumSelected', + args: { + maximum: self.maximumSelectionLength + } + }); + return; + } + decorated.call(self, params, callback); + }); + }; + + return MaximumSelectionLength; +}); + +S2.define('select2/dropdown',[ + 'jquery', + './utils' +], function ($, Utils) { + function Dropdown ($element, options) { + this.$element = $element; + this.options = options; + + Dropdown.__super__.constructor.call(this); + } + + Utils.Extend(Dropdown, Utils.Observable); + + Dropdown.prototype.render = function () { + var $dropdown = $( + '' + + '' + + '' + ); + + $dropdown.attr('dir', this.options.get('dir')); + + this.$dropdown = $dropdown; + + return $dropdown; + }; + + Dropdown.prototype.bind = function () { + // Should be implemented in subclasses + }; + + Dropdown.prototype.position = function ($dropdown, $container) { + // Should be implmented in subclasses + }; + + Dropdown.prototype.destroy = function () { + // Remove the dropdown from the DOM + this.$dropdown.remove(); + }; + + return Dropdown; +}); + +S2.define('select2/dropdown/search',[ + 'jquery', + '../utils' +], function ($, Utils) { + function Search () { } + + Search.prototype.render = function (decorated) { + var $rendered = decorated.call(this); + + var $search = $( + '' + + '' + + '' + ); + + this.$searchContainer = $search; + this.$search = $search.find('input'); + + $rendered.prepend($search); + + return $rendered; + }; + + Search.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + this.$search.on('keydown', function (evt) { + self.trigger('keypress', evt); + + self._keyUpPrevented = evt.isDefaultPrevented(); + }); + + // Workaround for browsers which do not support the `input` event + // This will prevent double-triggering of events for browsers which support + // both the `keyup` and `input` events. + this.$search.on('input', function (evt) { + // Unbind the duplicated `keyup` event + $(this).off('keyup'); + }); + + this.$search.on('keyup input', function (evt) { + self.handleSearch(evt); + }); + + container.on('open', function () { + self.$search.attr('tabindex', 0); + + self.$search.focus(); + + window.setTimeout(function () { + self.$search.focus(); + }, 0); + }); + + container.on('close', function () { + self.$search.attr('tabindex', -1); + + self.$search.val(''); + }); + + container.on('focus', function () { + if (!container.isOpen()) { + self.$search.focus(); + } + }); + + container.on('results:all', function (params) { + if (params.query.term == null || params.query.term === '') { + var showSearch = self.showSearch(params); + + if (showSearch) { + self.$searchContainer.removeClass('select2-search--hide'); + } else { + self.$searchContainer.addClass('select2-search--hide'); + } + } + }); + }; + + Search.prototype.handleSearch = function (evt) { + if (!this._keyUpPrevented) { + var input = this.$search.val(); + + this.trigger('query', { + term: input + }); + } + + this._keyUpPrevented = false; + }; + + Search.prototype.showSearch = function (_, params) { + return true; + }; + + return Search; +}); + +S2.define('select2/dropdown/hidePlaceholder',[ + +], function () { + function HidePlaceholder (decorated, $element, options, dataAdapter) { + this.placeholder = this.normalizePlaceholder(options.get('placeholder')); + + decorated.call(this, $element, options, dataAdapter); + } + + HidePlaceholder.prototype.append = function (decorated, data) { + data.results = this.removePlaceholder(data.results); + + decorated.call(this, data); + }; + + HidePlaceholder.prototype.normalizePlaceholder = function (_, placeholder) { + if (typeof placeholder === 'string') { + placeholder = { + id: '', + text: placeholder + }; + } + + return placeholder; + }; + + HidePlaceholder.prototype.removePlaceholder = function (_, data) { + var modifiedData = data.slice(0); + + for (var d = data.length - 1; d >= 0; d--) { + var item = data[d]; + + if (this.placeholder.id === item.id) { + modifiedData.splice(d, 1); + } + } + + return modifiedData; + }; + + return HidePlaceholder; +}); + +S2.define('select2/dropdown/infiniteScroll',[ + 'jquery' +], function ($) { + function InfiniteScroll (decorated, $element, options, dataAdapter) { + this.lastParams = {}; + + decorated.call(this, $element, options, dataAdapter); + + this.$loadingMore = this.createLoadingMore(); + this.loading = false; + } + + InfiniteScroll.prototype.append = function (decorated, data) { + this.$loadingMore.remove(); + this.loading = false; + + decorated.call(this, data); + + if (this.showLoadingMore(data)) { + this.$results.append(this.$loadingMore); + } + }; + + InfiniteScroll.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('query', function (params) { + self.lastParams = params; + self.loading = true; + }); + + container.on('query:append', function (params) { + self.lastParams = params; + self.loading = true; + }); + + this.$results.on('scroll', function () { + var isLoadMoreVisible = $.contains( + document.documentElement, + self.$loadingMore[0] + ); + + if (self.loading || !isLoadMoreVisible) { + return; + } + + var currentOffset = self.$results.offset().top + + self.$results.outerHeight(false); + var loadingMoreOffset = self.$loadingMore.offset().top + + self.$loadingMore.outerHeight(false); + + if (currentOffset + 50 >= loadingMoreOffset) { + self.loadMore(); + } + }); + }; + + InfiniteScroll.prototype.loadMore = function () { + this.loading = true; + + var params = $.extend({}, {page: 1}, this.lastParams); + + params.page++; + + this.trigger('query:append', params); + }; + + InfiniteScroll.prototype.showLoadingMore = function (_, data) { + return data.pagination && data.pagination.more; + }; + + InfiniteScroll.prototype.createLoadingMore = function () { + var $option = $( + '
  • ' + ); + + var message = this.options.get('translations').get('loadingMore'); + + $option.html(message(this.lastParams)); + + return $option; + }; + + return InfiniteScroll; +}); + +S2.define('select2/dropdown/attachBody',[ + 'jquery', + '../utils' +], function ($, Utils) { + function AttachBody (decorated, $element, options) { + this.$dropdownParent = options.get('dropdownParent') || $(document.body); + + decorated.call(this, $element, options); + } + + AttachBody.prototype.bind = function (decorated, container, $container) { + var self = this; + + var setupResultsEvents = false; + + decorated.call(this, container, $container); + + container.on('open', function () { + self._showDropdown(); + self._attachPositioningHandler(container); + + if (!setupResultsEvents) { + setupResultsEvents = true; + + container.on('results:all', function () { + self._positionDropdown(); + self._resizeDropdown(); + }); + + container.on('results:append', function () { + self._positionDropdown(); + self._resizeDropdown(); + }); + } + }); + + container.on('close', function () { + self._hideDropdown(); + self._detachPositioningHandler(container); + }); + + this.$dropdownContainer.on('mousedown', function (evt) { + evt.stopPropagation(); + }); + }; + + AttachBody.prototype.destroy = function (decorated) { + decorated.call(this); + + this.$dropdownContainer.remove(); + }; + + AttachBody.prototype.position = function (decorated, $dropdown, $container) { + // Clone all of the container classes + $dropdown.attr('class', $container.attr('class')); + + $dropdown.removeClass('select2'); + $dropdown.addClass('select2-container--open'); + + $dropdown.css({ + position: 'absolute', + top: -999999 + }); + + this.$container = $container; + }; + + AttachBody.prototype.render = function (decorated) { + var $container = $(''); + + var $dropdown = decorated.call(this); + $container.append($dropdown); + + this.$dropdownContainer = $container; + + return $container; + }; + + AttachBody.prototype._hideDropdown = function (decorated) { + this.$dropdownContainer.detach(); + }; + + AttachBody.prototype._attachPositioningHandler = + function (decorated, container) { + var self = this; + + var scrollEvent = 'scroll.select2.' + container.id; + var resizeEvent = 'resize.select2.' + container.id; + var orientationEvent = 'orientationchange.select2.' + container.id; + + var $watchers = this.$container.parents().filter(Utils.hasScroll); + $watchers.each(function () { + $(this).data('select2-scroll-position', { + x: $(this).scrollLeft(), + y: $(this).scrollTop() + }); + }); + + $watchers.on(scrollEvent, function (ev) { + var position = $(this).data('select2-scroll-position'); + $(this).scrollTop(position.y); + }); + + $(window).on(scrollEvent + ' ' + resizeEvent + ' ' + orientationEvent, + function (e) { + self._positionDropdown(); + self._resizeDropdown(); + }); + }; + + AttachBody.prototype._detachPositioningHandler = + function (decorated, container) { + var scrollEvent = 'scroll.select2.' + container.id; + var resizeEvent = 'resize.select2.' + container.id; + var orientationEvent = 'orientationchange.select2.' + container.id; + + var $watchers = this.$container.parents().filter(Utils.hasScroll); + $watchers.off(scrollEvent); + + $(window).off(scrollEvent + ' ' + resizeEvent + ' ' + orientationEvent); + }; + + AttachBody.prototype._positionDropdown = function () { + var $window = $(window); + + var isCurrentlyAbove = this.$dropdown.hasClass('select2-dropdown--above'); + var isCurrentlyBelow = this.$dropdown.hasClass('select2-dropdown--below'); + + var newDirection = null; + + var offset = this.$container.offset(); + + offset.bottom = offset.top + this.$container.outerHeight(false); + + var container = { + height: this.$container.outerHeight(false) + }; + + container.top = offset.top; + container.bottom = offset.top + container.height; + + var dropdown = { + height: this.$dropdown.outerHeight(false) + }; + + var viewport = { + top: $window.scrollTop(), + bottom: $window.scrollTop() + $window.height() + }; + + var enoughRoomAbove = viewport.top < (offset.top - dropdown.height); + var enoughRoomBelow = viewport.bottom > (offset.bottom + dropdown.height); + + var css = { + left: offset.left, + top: container.bottom + }; + + // Determine what the parent element is to use for calciulating the offset + var $offsetParent = this.$dropdownParent; + + // For statically positoned elements, we need to get the element + // that is determining the offset + if ($offsetParent.css('position') === 'static') { + $offsetParent = $offsetParent.offsetParent(); + } + + var parentOffset = $offsetParent.offset(); + + css.top -= parentOffset.top; + css.left -= parentOffset.left; + + if (!isCurrentlyAbove && !isCurrentlyBelow) { + newDirection = 'below'; + } + + if (!enoughRoomBelow && enoughRoomAbove && !isCurrentlyAbove) { + newDirection = 'above'; + } else if (!enoughRoomAbove && enoughRoomBelow && isCurrentlyAbove) { + newDirection = 'below'; + } + + if (newDirection == 'above' || + (isCurrentlyAbove && newDirection !== 'below')) { + css.top = container.top - parentOffset.top - dropdown.height; + } + + if (newDirection != null) { + this.$dropdown + .removeClass('select2-dropdown--below select2-dropdown--above') + .addClass('select2-dropdown--' + newDirection); + this.$container + .removeClass('select2-container--below select2-container--above') + .addClass('select2-container--' + newDirection); + } + + this.$dropdownContainer.css(css); + }; + + AttachBody.prototype._resizeDropdown = function () { + var css = { + width: this.$container.outerWidth(false) + 'px' + }; + + if (this.options.get('dropdownAutoWidth')) { + css.minWidth = css.width; + css.position = 'relative'; + css.width = 'auto'; + } + + this.$dropdown.css(css); + }; + + AttachBody.prototype._showDropdown = function (decorated) { + this.$dropdownContainer.appendTo(this.$dropdownParent); + + this._positionDropdown(); + this._resizeDropdown(); + }; + + return AttachBody; +}); + +S2.define('select2/dropdown/minimumResultsForSearch',[ + +], function () { + function countResults (data) { + var count = 0; + + for (var d = 0; d < data.length; d++) { + var item = data[d]; + + if (item.children) { + count += countResults(item.children); + } else { + count++; + } + } + + return count; + } + + function MinimumResultsForSearch (decorated, $element, options, dataAdapter) { + this.minimumResultsForSearch = options.get('minimumResultsForSearch'); + + if (this.minimumResultsForSearch < 0) { + this.minimumResultsForSearch = Infinity; + } + + decorated.call(this, $element, options, dataAdapter); + } + + MinimumResultsForSearch.prototype.showSearch = function (decorated, params) { + if (countResults(params.data.results) < this.minimumResultsForSearch) { + return false; + } + + return decorated.call(this, params); + }; + + return MinimumResultsForSearch; +}); + +S2.define('select2/dropdown/selectOnClose',[ + +], function () { + function SelectOnClose () { } + + SelectOnClose.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('close', function (params) { + self._handleSelectOnClose(params); + }); + }; + + SelectOnClose.prototype._handleSelectOnClose = function (_, params) { + if (params && params.originalSelect2Event != null) { + var event = params.originalSelect2Event; + + // Don't select an item if the close event was triggered from a select or + // unselect event + if (event._type === 'select' || event._type === 'unselect') { + return; + } + } + + var $highlightedResults = this.getHighlightedResults(); + + // Only select highlighted results + if ($highlightedResults.length < 1) { + return; + } + + var data = $highlightedResults.data('data'); + + // Don't re-select already selected resulte + if ( + (data.element != null && data.element.selected) || + (data.element == null && data.selected) + ) { + return; + } + + this.trigger('select', { + data: data + }); + }; + + return SelectOnClose; +}); + +S2.define('select2/dropdown/closeOnSelect',[ + +], function () { + function CloseOnSelect () { } + + CloseOnSelect.prototype.bind = function (decorated, container, $container) { + var self = this; + + decorated.call(this, container, $container); + + container.on('select', function (evt) { + self._selectTriggered(evt); + }); + + container.on('unselect', function (evt) { + self._selectTriggered(evt); + }); + }; + + CloseOnSelect.prototype._selectTriggered = function (_, evt) { + var originalEvent = evt.originalEvent; + + // Don't close if the control key is being held + if (originalEvent && originalEvent.ctrlKey) { + return; + } + + this.trigger('close', { + originalEvent: originalEvent, + originalSelect2Event: evt + }); + }; + + return CloseOnSelect; +}); + +S2.define('select2/i18n/en',[],function () { + // English + return { + errorLoading: function () { + return 'The results could not be loaded.'; + }, + inputTooLong: function (args) { + var overChars = args.input.length - args.maximum; + + var message = 'Please delete ' + overChars + ' character'; + + if (overChars != 1) { + message += 's'; + } + + return message; + }, + inputTooShort: function (args) { + var remainingChars = args.minimum - args.input.length; + + var message = 'Please enter ' + remainingChars + ' or more characters'; + + return message; + }, + loadingMore: function () { + return 'Loading more results…'; + }, + maximumSelected: function (args) { + var message = 'You can only select ' + args.maximum + ' item'; + + if (args.maximum != 1) { + message += 's'; + } + + return message; + }, + noResults: function () { + return 'No results found'; + }, + searching: function () { + return 'Searching…'; + } + }; +}); + +S2.define('select2/defaults',[ + 'jquery', + 'require', + + './results', + + './selection/single', + './selection/multiple', + './selection/placeholder', + './selection/allowClear', + './selection/search', + './selection/eventRelay', + + './utils', + './translation', + './diacritics', + + './data/select', + './data/array', + './data/ajax', + './data/tags', + './data/tokenizer', + './data/minimumInputLength', + './data/maximumInputLength', + './data/maximumSelectionLength', + + './dropdown', + './dropdown/search', + './dropdown/hidePlaceholder', + './dropdown/infiniteScroll', + './dropdown/attachBody', + './dropdown/minimumResultsForSearch', + './dropdown/selectOnClose', + './dropdown/closeOnSelect', + + './i18n/en' +], function ($, require, + + ResultsList, + + SingleSelection, MultipleSelection, Placeholder, AllowClear, + SelectionSearch, EventRelay, + + Utils, Translation, DIACRITICS, + + SelectData, ArrayData, AjaxData, Tags, Tokenizer, + MinimumInputLength, MaximumInputLength, MaximumSelectionLength, + + Dropdown, DropdownSearch, HidePlaceholder, InfiniteScroll, + AttachBody, MinimumResultsForSearch, SelectOnClose, CloseOnSelect, + + EnglishTranslation) { + function Defaults () { + this.reset(); + } + + Defaults.prototype.apply = function (options) { + options = $.extend(true, {}, this.defaults, options); + + if (options.dataAdapter == null) { + if (options.ajax != null) { + options.dataAdapter = AjaxData; + } else if (options.data != null) { + options.dataAdapter = ArrayData; + } else { + options.dataAdapter = SelectData; + } + + if (options.minimumInputLength > 0) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + MinimumInputLength + ); + } + + if (options.maximumInputLength > 0) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + MaximumInputLength + ); + } + + if (options.maximumSelectionLength > 0) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + MaximumSelectionLength + ); + } + + if (options.tags) { + options.dataAdapter = Utils.Decorate(options.dataAdapter, Tags); + } + + if (options.tokenSeparators != null || options.tokenizer != null) { + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + Tokenizer + ); + } + + if (options.query != null) { + var Query = require(options.amdBase + 'compat/query'); + + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + Query + ); + } + + if (options.initSelection != null) { + var InitSelection = require(options.amdBase + 'compat/initSelection'); + + options.dataAdapter = Utils.Decorate( + options.dataAdapter, + InitSelection + ); + } + } + + if (options.resultsAdapter == null) { + options.resultsAdapter = ResultsList; + + if (options.ajax != null) { + options.resultsAdapter = Utils.Decorate( + options.resultsAdapter, + InfiniteScroll + ); + } + + if (options.placeholder != null) { + options.resultsAdapter = Utils.Decorate( + options.resultsAdapter, + HidePlaceholder + ); + } + + if (options.selectOnClose) { + options.resultsAdapter = Utils.Decorate( + options.resultsAdapter, + SelectOnClose + ); + } + } + + if (options.dropdownAdapter == null) { + if (options.multiple) { + options.dropdownAdapter = Dropdown; + } else { + var SearchableDropdown = Utils.Decorate(Dropdown, DropdownSearch); + + options.dropdownAdapter = SearchableDropdown; + } + + if (options.minimumResultsForSearch !== 0) { + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + MinimumResultsForSearch + ); + } + + if (options.closeOnSelect) { + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + CloseOnSelect + ); + } + + if ( + options.dropdownCssClass != null || + options.dropdownCss != null || + options.adaptDropdownCssClass != null + ) { + var DropdownCSS = require(options.amdBase + 'compat/dropdownCss'); + + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + DropdownCSS + ); + } + + options.dropdownAdapter = Utils.Decorate( + options.dropdownAdapter, + AttachBody + ); + } + + if (options.selectionAdapter == null) { + if (options.multiple) { + options.selectionAdapter = MultipleSelection; + } else { + options.selectionAdapter = SingleSelection; + } + + // Add the placeholder mixin if a placeholder was specified + if (options.placeholder != null) { + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + Placeholder + ); + } + + if (options.allowClear) { + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + AllowClear + ); + } + + if (options.multiple) { + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + SelectionSearch + ); + } + + if ( + options.containerCssClass != null || + options.containerCss != null || + options.adaptContainerCssClass != null + ) { + var ContainerCSS = require(options.amdBase + 'compat/containerCss'); + + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + ContainerCSS + ); + } + + options.selectionAdapter = Utils.Decorate( + options.selectionAdapter, + EventRelay + ); + } + + if (typeof options.language === 'string') { + // Check if the language is specified with a region + if (options.language.indexOf('-') > 0) { + // Extract the region information if it is included + var languageParts = options.language.split('-'); + var baseLanguage = languageParts[0]; + + options.language = [options.language, baseLanguage]; + } else { + options.language = [options.language]; + } + } + + if ($.isArray(options.language)) { + var languages = new Translation(); + options.language.push('en'); + + var languageNames = options.language; + + for (var l = 0; l < languageNames.length; l++) { + var name = languageNames[l]; + var language = {}; + + try { + // Try to load it with the original name + language = Translation.loadPath(name); + } catch (e) { + try { + // If we couldn't load it, check if it wasn't the full path + name = this.defaults.amdLanguageBase + name; + language = Translation.loadPath(name); + } catch (ex) { + // The translation could not be loaded at all. Sometimes this is + // because of a configuration problem, other times this can be + // because of how Select2 helps load all possible translation files. + if (options.debug && window.console && console.warn) { + console.warn( + 'Select2: The language file for "' + name + '" could not be ' + + 'automatically loaded. A fallback will be used instead.' + ); + } + + continue; + } + } + + languages.extend(language); + } + + options.translations = languages; + } else { + var baseTranslation = Translation.loadPath( + this.defaults.amdLanguageBase + 'en' + ); + var customTranslation = new Translation(options.language); + + customTranslation.extend(baseTranslation); + + options.translations = customTranslation; + } + + return options; + }; + + Defaults.prototype.reset = function () { + function stripDiacritics (text) { + // Used 'uni range + named function' from http://jsperf.com/diacritics/18 + function match(a) { + return DIACRITICS[a] || a; + } + + return text.replace(/[^\u0000-\u007E]/g, match); + } + + function matcher (params, data) { + // Always return the object if there is nothing to compare + if ($.trim(params.term) === '') { + return data; + } + + // Do a recursive check for options with children + if (data.children && data.children.length > 0) { + // Clone the data object if there are children + // This is required as we modify the object to remove any non-matches + var match = $.extend(true, {}, data); + + // Check each child of the option + for (var c = data.children.length - 1; c >= 0; c--) { + var child = data.children[c]; + + var matches = matcher(params, child); + + // If there wasn't a match, remove the object in the array + if (matches == null) { + match.children.splice(c, 1); + } + } + + // If any children matched, return the new object + if (match.children.length > 0) { + return match; + } + + // If there were no matching children, check just the plain object + return matcher(params, match); + } + + var original = stripDiacritics(data.text).toUpperCase(); + var term = stripDiacritics(params.term).toUpperCase(); + + // Check if the text contains the term + if (original.indexOf(term) > -1) { + return data; + } + + // If it doesn't contain the term, don't return anything + return null; + } + + this.defaults = { + amdBase: './', + amdLanguageBase: './i18n/', + closeOnSelect: true, + debug: false, + dropdownAutoWidth: false, + escapeMarkup: Utils.escapeMarkup, + language: EnglishTranslation, + matcher: matcher, + minimumInputLength: 0, + maximumInputLength: 0, + maximumSelectionLength: 0, + minimumResultsForSearch: 0, + selectOnClose: false, + sorter: function (data) { + return data; + }, + templateResult: function (result) { + return result.text; + }, + templateSelection: function (selection) { + return selection.text; + }, + theme: 'default', + width: 'resolve' + }; + }; + + Defaults.prototype.set = function (key, value) { + var camelKey = $.camelCase(key); + + var data = {}; + data[camelKey] = value; + + var convertedData = Utils._convertData(data); + + $.extend(this.defaults, convertedData); + }; + + var defaults = new Defaults(); + + return defaults; +}); + +S2.define('select2/options',[ + 'require', + 'jquery', + './defaults', + './utils' +], function (require, $, Defaults, Utils) { + function Options (options, $element) { + this.options = options; + + if ($element != null) { + this.fromElement($element); + } + + this.options = Defaults.apply(this.options); + + if ($element && $element.is('input')) { + var InputCompat = require(this.get('amdBase') + 'compat/inputData'); + + this.options.dataAdapter = Utils.Decorate( + this.options.dataAdapter, + InputCompat + ); + } + } + + Options.prototype.fromElement = function ($e) { + var excludedData = ['select2']; + + if (this.options.multiple == null) { + this.options.multiple = $e.prop('multiple'); + } + + if (this.options.disabled == null) { + this.options.disabled = $e.prop('disabled'); + } + + if (this.options.language == null) { + if ($e.prop('lang')) { + this.options.language = $e.prop('lang').toLowerCase(); + } else if ($e.closest('[lang]').prop('lang')) { + this.options.language = $e.closest('[lang]').prop('lang'); + } + } + + if (this.options.dir == null) { + if ($e.prop('dir')) { + this.options.dir = $e.prop('dir'); + } else if ($e.closest('[dir]').prop('dir')) { + this.options.dir = $e.closest('[dir]').prop('dir'); + } else { + this.options.dir = 'ltr'; + } + } + + $e.prop('disabled', this.options.disabled); + $e.prop('multiple', this.options.multiple); + + if ($e.data('select2Tags')) { + if (this.options.debug && window.console && console.warn) { + console.warn( + 'Select2: The `data-select2-tags` attribute has been changed to ' + + 'use the `data-data` and `data-tags="true"` attributes and will be ' + + 'removed in future versions of Select2.' + ); + } + + $e.data('data', $e.data('select2Tags')); + $e.data('tags', true); + } + + if ($e.data('ajaxUrl')) { + if (this.options.debug && window.console && console.warn) { + console.warn( + 'Select2: The `data-ajax-url` attribute has been changed to ' + + '`data-ajax--url` and support for the old attribute will be removed' + + ' in future versions of Select2.' + ); + } + + $e.attr('ajax--url', $e.data('ajaxUrl')); + $e.data('ajax--url', $e.data('ajaxUrl')); + } + + var dataset = {}; + + // Prefer the element's `dataset` attribute if it exists + // jQuery 1.x does not correctly handle data attributes with multiple dashes + if ($.fn.jquery && $.fn.jquery.substr(0, 2) == '1.' && $e[0].dataset) { + dataset = $.extend(true, {}, $e[0].dataset, $e.data()); + } else { + dataset = $e.data(); + } + + var data = $.extend(true, {}, dataset); + + data = Utils._convertData(data); + + for (var key in data) { + if ($.inArray(key, excludedData) > -1) { + continue; + } + + if ($.isPlainObject(this.options[key])) { + $.extend(this.options[key], data[key]); + } else { + this.options[key] = data[key]; + } + } + + return this; + }; + + Options.prototype.get = function (key) { + return this.options[key]; + }; + + Options.prototype.set = function (key, val) { + this.options[key] = val; + }; + + return Options; +}); + +S2.define('select2/core',[ + 'jquery', + './options', + './utils', + './keys' +], function ($, Options, Utils, KEYS) { + var Select2 = function ($element, options) { + if ($element.data('select2') != null) { + $element.data('select2').destroy(); + } + + this.$element = $element; + + this.id = this._generateId($element); + + options = options || {}; + + this.options = new Options(options, $element); + + Select2.__super__.constructor.call(this); + + // Set up the tabindex + + var tabindex = $element.attr('tabindex') || 0; + $element.data('old-tabindex', tabindex); + $element.attr('tabindex', '-1'); + + // Set up containers and adapters + + var DataAdapter = this.options.get('dataAdapter'); + this.dataAdapter = new DataAdapter($element, this.options); + + var $container = this.render(); + + this._placeContainer($container); + + var SelectionAdapter = this.options.get('selectionAdapter'); + this.selection = new SelectionAdapter($element, this.options); + this.$selection = this.selection.render(); + + this.selection.position(this.$selection, $container); + + var DropdownAdapter = this.options.get('dropdownAdapter'); + this.dropdown = new DropdownAdapter($element, this.options); + this.$dropdown = this.dropdown.render(); + + this.dropdown.position(this.$dropdown, $container); + + var ResultsAdapter = this.options.get('resultsAdapter'); + this.results = new ResultsAdapter($element, this.options, this.dataAdapter); + this.$results = this.results.render(); + + this.results.position(this.$results, this.$dropdown); + + // Bind events + + var self = this; + + // Bind the container to all of the adapters + this._bindAdapters(); + + // Register any DOM event handlers + this._registerDomEvents(); + + // Register any internal event handlers + this._registerDataEvents(); + this._registerSelectionEvents(); + this._registerDropdownEvents(); + this._registerResultsEvents(); + this._registerEvents(); + + // Set the initial state + this.dataAdapter.current(function (initialData) { + self.trigger('selection:update', { + data: initialData + }); + }); + + // Hide the original select + $element.addClass('select2-hidden-accessible'); + $element.attr('aria-hidden', 'true'); + + // Synchronize any monitored attributes + this._syncAttributes(); + + $element.data('select2', this); + }; + + Utils.Extend(Select2, Utils.Observable); + + Select2.prototype._generateId = function ($element) { + var id = ''; + + if ($element.attr('id') != null) { + id = $element.attr('id'); + } else if ($element.attr('name') != null) { + id = $element.attr('name') + '-' + Utils.generateChars(2); + } else { + id = Utils.generateChars(4); + } + + id = id.replace(/(:|\.|\[|\]|,)/g, ''); + id = 'select2-' + id; + + return id; + }; + + Select2.prototype._placeContainer = function ($container) { + $container.insertAfter(this.$element); + + var width = this._resolveWidth(this.$element, this.options.get('width')); + + if (width != null) { + $container.css('width', width); + } + }; + + Select2.prototype._resolveWidth = function ($element, method) { + var WIDTH = /^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i; + + if (method == 'resolve') { + var styleWidth = this._resolveWidth($element, 'style'); + + if (styleWidth != null) { + return styleWidth; + } + + return this._resolveWidth($element, 'element'); + } + + if (method == 'element') { + var elementWidth = $element.outerWidth(false); + + if (elementWidth <= 0) { + return 'auto'; + } + + return elementWidth + 'px'; + } + + if (method == 'style') { + var style = $element.attr('style'); + + if (typeof(style) !== 'string') { + return null; + } + + var attrs = style.split(';'); + + for (var i = 0, l = attrs.length; i < l; i = i + 1) { + var attr = attrs[i].replace(/\s/g, ''); + var matches = attr.match(WIDTH); + + if (matches !== null && matches.length >= 1) { + return matches[1]; + } + } + + return null; + } + + return method; + }; + + Select2.prototype._bindAdapters = function () { + this.dataAdapter.bind(this, this.$container); + this.selection.bind(this, this.$container); + + this.dropdown.bind(this, this.$container); + this.results.bind(this, this.$container); + }; + + Select2.prototype._registerDomEvents = function () { + var self = this; + + this.$element.on('change.select2', function () { + self.dataAdapter.current(function (data) { + self.trigger('selection:update', { + data: data + }); + }); + }); + + this.$element.on('focus.select2', function (evt) { + self.trigger('focus', evt); + }); + + this._syncA = Utils.bind(this._syncAttributes, this); + this._syncS = Utils.bind(this._syncSubtree, this); + + if (this.$element[0].attachEvent) { + this.$element[0].attachEvent('onpropertychange', this._syncA); + } + + var observer = window.MutationObserver || + window.WebKitMutationObserver || + window.MozMutationObserver + ; + + if (observer != null) { + this._observer = new observer(function (mutations) { + $.each(mutations, self._syncA); + $.each(mutations, self._syncS); + }); + this._observer.observe(this.$element[0], { + attributes: true, + childList: true, + subtree: false + }); + } else if (this.$element[0].addEventListener) { + this.$element[0].addEventListener( + 'DOMAttrModified', + self._syncA, + false + ); + this.$element[0].addEventListener( + 'DOMNodeInserted', + self._syncS, + false + ); + this.$element[0].addEventListener( + 'DOMNodeRemoved', + self._syncS, + false + ); + } + }; + + Select2.prototype._registerDataEvents = function () { + var self = this; + + this.dataAdapter.on('*', function (name, params) { + self.trigger(name, params); + }); + }; + + Select2.prototype._registerSelectionEvents = function () { + var self = this; + var nonRelayEvents = ['toggle', 'focus']; + + this.selection.on('toggle', function () { + self.toggleDropdown(); + }); + + this.selection.on('focus', function (params) { + self.focus(params); + }); + + this.selection.on('*', function (name, params) { + if ($.inArray(name, nonRelayEvents) !== -1) { + return; + } + + self.trigger(name, params); + }); + }; + + Select2.prototype._registerDropdownEvents = function () { + var self = this; + + this.dropdown.on('*', function (name, params) { + self.trigger(name, params); + }); + }; + + Select2.prototype._registerResultsEvents = function () { + var self = this; + + this.results.on('*', function (name, params) { + self.trigger(name, params); + }); + }; + + Select2.prototype._registerEvents = function () { + var self = this; + + this.on('open', function () { + self.$container.addClass('select2-container--open'); + }); + + this.on('close', function () { + self.$container.removeClass('select2-container--open'); + }); + + this.on('enable', function () { + self.$container.removeClass('select2-container--disabled'); + }); + + this.on('disable', function () { + self.$container.addClass('select2-container--disabled'); + }); + + this.on('blur', function () { + self.$container.removeClass('select2-container--focus'); + }); + + this.on('query', function (params) { + if (!self.isOpen()) { + self.trigger('open', {}); + } + + this.dataAdapter.query(params, function (data) { + self.trigger('results:all', { + data: data, + query: params + }); + }); + }); + + this.on('query:append', function (params) { + this.dataAdapter.query(params, function (data) { + self.trigger('results:append', { + data: data, + query: params + }); + }); + }); + + this.on('keypress', function (evt) { + var key = evt.which; + + if (self.isOpen()) { + if (key === KEYS.ESC || key === KEYS.TAB || + (key === KEYS.UP && evt.altKey)) { + self.close(); + + evt.preventDefault(); + } else if (key === KEYS.ENTER) { + self.trigger('results:select', {}); + + evt.preventDefault(); + } else if ((key === KEYS.SPACE && evt.ctrlKey)) { + self.trigger('results:toggle', {}); + + evt.preventDefault(); + } else if (key === KEYS.UP) { + self.trigger('results:previous', {}); + + evt.preventDefault(); + } else if (key === KEYS.DOWN) { + self.trigger('results:next', {}); + + evt.preventDefault(); + } + } else { + if (key === KEYS.ENTER || key === KEYS.SPACE || + (key === KEYS.DOWN && evt.altKey)) { + self.open(); + + evt.preventDefault(); + } + } + }); + }; + + Select2.prototype._syncAttributes = function () { + this.options.set('disabled', this.$element.prop('disabled')); + + if (this.options.get('disabled')) { + if (this.isOpen()) { + this.close(); + } + + this.trigger('disable', {}); + } else { + this.trigger('enable', {}); + } + }; + + Select2.prototype._syncSubtree = function (evt, mutations) { + var changed = false; + var self = this; + + // Ignore any mutation events raised for elements that aren't options or + // optgroups. This handles the case when the select element is destroyed + if ( + evt && evt.target && ( + evt.target.nodeName !== 'OPTION' && evt.target.nodeName !== 'OPTGROUP' + ) + ) { + return; + } + + if (!mutations) { + // If mutation events aren't supported, then we can only assume that the + // change affected the selections + changed = true; + } else if (mutations.addedNodes && mutations.addedNodes.length > 0) { + for (var n = 0; n < mutations.addedNodes.length; n++) { + var node = mutations.addedNodes[n]; + + if (node.selected) { + changed = true; + } + } + } else if (mutations.removedNodes && mutations.removedNodes.length > 0) { + changed = true; + } + + // Only re-pull the data if we think there is a change + if (changed) { + this.dataAdapter.current(function (currentData) { + self.trigger('selection:update', { + data: currentData + }); + }); + } + }; + + /** + * Override the trigger method to automatically trigger pre-events when + * there are events that can be prevented. + */ + Select2.prototype.trigger = function (name, args) { + var actualTrigger = Select2.__super__.trigger; + var preTriggerMap = { + 'open': 'opening', + 'close': 'closing', + 'select': 'selecting', + 'unselect': 'unselecting' + }; + + if (args === undefined) { + args = {}; + } + + if (name in preTriggerMap) { + var preTriggerName = preTriggerMap[name]; + var preTriggerArgs = { + prevented: false, + name: name, + args: args + }; + + actualTrigger.call(this, preTriggerName, preTriggerArgs); + + if (preTriggerArgs.prevented) { + args.prevented = true; + + return; + } + } + + actualTrigger.call(this, name, args); + }; + + Select2.prototype.toggleDropdown = function () { + if (this.options.get('disabled')) { + return; + } + + if (this.isOpen()) { + this.close(); + } else { + this.open(); + } + }; + + Select2.prototype.open = function () { + if (this.isOpen()) { + return; + } + + this.trigger('query', {}); + }; + + Select2.prototype.close = function () { + if (!this.isOpen()) { + return; + } + + this.trigger('close', {}); + }; + + Select2.prototype.isOpen = function () { + return this.$container.hasClass('select2-container--open'); + }; + + Select2.prototype.hasFocus = function () { + return this.$container.hasClass('select2-container--focus'); + }; + + Select2.prototype.focus = function (data) { + // No need to re-trigger focus events if we are already focused + if (this.hasFocus()) { + return; + } + + this.$container.addClass('select2-container--focus'); + this.trigger('focus', {}); + }; + + Select2.prototype.enable = function (args) { + if (this.options.get('debug') && window.console && console.warn) { + console.warn( + 'Select2: The `select2("enable")` method has been deprecated and will' + + ' be removed in later Select2 versions. Use $element.prop("disabled")' + + ' instead.' + ); + } + + if (args == null || args.length === 0) { + args = [true]; + } + + var disabled = !args[0]; + + this.$element.prop('disabled', disabled); + }; + + Select2.prototype.data = function () { + if (this.options.get('debug') && + arguments.length > 0 && window.console && console.warn) { + console.warn( + 'Select2: Data can no longer be set using `select2("data")`. You ' + + 'should consider setting the value instead using `$element.val()`.' + ); + } + + var data = []; + + this.dataAdapter.current(function (currentData) { + data = currentData; + }); + + return data; + }; + + Select2.prototype.val = function (args) { + if (this.options.get('debug') && window.console && console.warn) { + console.warn( + 'Select2: The `select2("val")` method has been deprecated and will be' + + ' removed in later Select2 versions. Use $element.val() instead.' + ); + } + + if (args == null || args.length === 0) { + return this.$element.val(); + } + + var newVal = args[0]; + + if ($.isArray(newVal)) { + newVal = $.map(newVal, function (obj) { + return obj.toString(); + }); + } + + this.$element.val(newVal).trigger('change'); + }; + + Select2.prototype.destroy = function () { + this.$container.remove(); + + if (this.$element[0].detachEvent) { + this.$element[0].detachEvent('onpropertychange', this._syncA); + } + + if (this._observer != null) { + this._observer.disconnect(); + this._observer = null; + } else if (this.$element[0].removeEventListener) { + this.$element[0] + .removeEventListener('DOMAttrModified', this._syncA, false); + this.$element[0] + .removeEventListener('DOMNodeInserted', this._syncS, false); + this.$element[0] + .removeEventListener('DOMNodeRemoved', this._syncS, false); + } + + this._syncA = null; + this._syncS = null; + + this.$element.off('.select2'); + this.$element.attr('tabindex', this.$element.data('old-tabindex')); + + this.$element.removeClass('select2-hidden-accessible'); + this.$element.attr('aria-hidden', 'false'); + this.$element.removeData('select2'); + + this.dataAdapter.destroy(); + this.selection.destroy(); + this.dropdown.destroy(); + this.results.destroy(); + + this.dataAdapter = null; + this.selection = null; + this.dropdown = null; + this.results = null; + }; + + Select2.prototype.render = function () { + var $container = $( + '' + + '' + + '' + + '' + ); + + $container.attr('dir', this.options.get('dir')); + + this.$container = $container; + + this.$container.addClass('select2-container--' + this.options.get('theme')); + + $container.data('element', this.$element); + + return $container; + }; + + return Select2; +}); + +S2.define('jquery-mousewheel',[ + 'jquery' +], function ($) { + // Used to shim jQuery.mousewheel for non-full builds. + return $; +}); + +S2.define('jquery.select2',[ + 'jquery', + 'jquery-mousewheel', + + './select2/core', + './select2/defaults' +], function ($, _, Select2, Defaults) { + if ($.fn.select2 == null) { + // All methods that should return the element + var thisMethods = ['open', 'close', 'destroy']; + + $.fn.select2 = function (options) { + options = options || {}; + + if (typeof options === 'object') { + this.each(function () { + var instanceOptions = $.extend(true, {}, options); + + var instance = new Select2($(this), instanceOptions); + }); + + return this; + } else if (typeof options === 'string') { + var ret; + var args = Array.prototype.slice.call(arguments, 1); + + this.each(function () { + var instance = $(this).data('select2'); + + if (instance == null && window.console && console.error) { + console.error( + 'The select2(\'' + options + '\') method was called on an ' + + 'element that is not using Select2.' + ); + } + + ret = instance[options].apply(instance, args); + }); + + // Check if we should be returning `this` + if ($.inArray(options, thisMethods) > -1) { + return this; + } + + return ret; + } else { + throw new Error('Invalid arguments for Select2: ' + options); + } + }; + } + + if ($.fn.select2.defaults == null) { + $.fn.select2.defaults = Defaults; + } + + return Select2; +}); + + // Return the AMD loader configuration so it can be used outside of this file + return { + define: S2.define, + require: S2.require + }; +}()); + + // Autoload the jQuery bindings + // We know that all of the modules exist above this, so we're safe + var select2 = S2.require('jquery.select2'); + + // Hold the AMD module references on the jQuery function that was just loaded + // This allows Select2 to use the internal loader outside of this file, such + // as in the language files. + jQuery.fn.select2.amd = S2; + + // Return the Select2 instance for anyone who is importing it. + return select2; +})); diff --git a/netbox/project-static/select2-4.0.5/js/select2.min.js b/netbox/project-static/select2-4.0.5/js/select2.min.js new file mode 100755 index 00000000000..7ef2fda809e --- /dev/null +++ b/netbox/project-static/select2-4.0.5/js/select2.min.js @@ -0,0 +1 @@ +/*! Select2 4.0.5 | https://github.com/select2/select2/blob/master/LICENSE.md */!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof module&&module.exports?module.exports=function(b,c){return void 0===c&&(c="undefined"!=typeof window?require("jquery"):require("jquery")(b)),a(c),c}:a(jQuery)}(function(a){var b=function(){if(a&&a.fn&&a.fn.select2&&a.fn.select2.amd)var b=a.fn.select2.amd;var b;return function(){if(!b||!b.requirejs){b?c=b:b={};var a,c,d;!function(b){function e(a,b){return v.call(a,b)}function f(a,b){var c,d,e,f,g,h,i,j,k,l,m,n,o=b&&b.split("/"),p=t.map,q=p&&p["*"]||{};if(a){for(a=a.split("/"),g=a.length-1,t.nodeIdCompat&&x.test(a[g])&&(a[g]=a[g].replace(x,"")),"."===a[0].charAt(0)&&o&&(n=o.slice(0,o.length-1),a=n.concat(a)),k=0;k0&&(a.splice(k-1,2),k-=2)}a=a.join("/")}if((o||q)&&p){for(c=a.split("/"),k=c.length;k>0;k-=1){if(d=c.slice(0,k).join("/"),o)for(l=o.length;l>0;l-=1)if((e=p[o.slice(0,l).join("/")])&&(e=e[d])){f=e,h=k;break}if(f)break;!i&&q&&q[d]&&(i=q[d],j=k)}!f&&i&&(f=i,h=j),f&&(c.splice(0,h,f),a=c.join("/"))}return a}function g(a,c){return function(){var d=w.call(arguments,0);return"string"!=typeof d[0]&&1===d.length&&d.push(null),o.apply(b,d.concat([a,c]))}}function h(a){return function(b){return f(b,a)}}function i(a){return function(b){r[a]=b}}function j(a){if(e(s,a)){var c=s[a];delete s[a],u[a]=!0,n.apply(b,c)}if(!e(r,a)&&!e(u,a))throw new Error("No "+a);return r[a]}function k(a){var b,c=a?a.indexOf("!"):-1;return c>-1&&(b=a.substring(0,c),a=a.substring(c+1,a.length)),[b,a]}function l(a){return a?k(a):[]}function m(a){return function(){return t&&t.config&&t.config[a]||{}}}var n,o,p,q,r={},s={},t={},u={},v=Object.prototype.hasOwnProperty,w=[].slice,x=/\.js$/;p=function(a,b){var c,d=k(a),e=d[0],g=b[1];return a=d[1],e&&(e=f(e,g),c=j(e)),e?a=c&&c.normalize?c.normalize(a,h(g)):f(a,g):(a=f(a,g),d=k(a),e=d[0],a=d[1],e&&(c=j(e))),{f:e?e+"!"+a:a,n:a,pr:e,p:c}},q={require:function(a){return g(a)},exports:function(a){var b=r[a];return void 0!==b?b:r[a]={}},module:function(a){return{id:a,uri:"",exports:r[a],config:m(a)}}},n=function(a,c,d,f){var h,k,m,n,o,t,v,w=[],x=typeof d;if(f=f||a,t=l(f),"undefined"===x||"function"===x){for(c=!c.length&&d.length?["require","exports","module"]:c,o=0;o0&&(b.call(arguments,a.prototype.constructor),e=c.prototype.constructor),e.apply(this,arguments)}function e(){this.constructor=d}var f=b(c),g=b(a);c.displayName=a.displayName,d.prototype=new e;for(var h=0;h":">",'"':""","'":"'","/":"/"};return"string"!=typeof a?a:String(a).replace(/[&<>"'\/\\]/g,function(a){return b[a]})},c.appendMany=function(b,c){if("1.7"===a.fn.jquery.substr(0,3)){var d=a();a.map(c,function(a){d=d.add(a)}),c=d}b.append(c)},c}),b.define("select2/results",["jquery","./utils"],function(a,b){function c(a,b,d){this.$element=a,this.data=d,this.options=b,c.__super__.constructor.call(this)}return b.Extend(c,b.Observable),c.prototype.render=function(){var b=a('
      ');return this.options.get("multiple")&&b.attr("aria-multiselectable","true"),this.$results=b,b},c.prototype.clear=function(){this.$results.empty()},c.prototype.displayMessage=function(b){var c=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var d=a('
    • '),e=this.options.get("translations").get(b.message);d.append(c(e(b.args))),d[0].className+=" select2-results__message",this.$results.append(d)},c.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},c.prototype.append=function(a){this.hideLoading();var b=[];if(null==a.results||0===a.results.length)return void(0===this.$results.children().length&&this.trigger("results:message",{message:"noResults"}));a.results=this.sort(a.results);for(var c=0;c0?b.first().trigger("mouseenter"):a.first().trigger("mouseenter"),this.ensureHighlightVisible()},c.prototype.setClasses=function(){var b=this;this.data.current(function(c){var d=a.map(c,function(a){return a.id.toString()});b.$results.find(".select2-results__option[aria-selected]").each(function(){var b=a(this),c=a.data(this,"data"),e=""+c.id;null!=c.element&&c.element.selected||null==c.element&&a.inArray(e,d)>-1?b.attr("aria-selected","true"):b.attr("aria-selected","false")})})},c.prototype.showLoading=function(a){this.hideLoading();var b=this.options.get("translations").get("searching"),c={disabled:!0,loading:!0,text:b(a)},d=this.option(c);d.className+=" loading-results",this.$results.prepend(d)},c.prototype.hideLoading=function(){this.$results.find(".loading-results").remove()},c.prototype.option=function(b){var c=document.createElement("li");c.className="select2-results__option";var d={role:"treeitem","aria-selected":"false"};b.disabled&&(delete d["aria-selected"],d["aria-disabled"]="true"),null==b.id&&delete d["aria-selected"],null!=b._resultId&&(c.id=b._resultId),b.title&&(c.title=b.title),b.children&&(d.role="group",d["aria-label"]=b.text,delete d["aria-selected"]);for(var e in d){var f=d[e];c.setAttribute(e,f)}if(b.children){var g=a(c),h=document.createElement("strong");h.className="select2-results__group";a(h);this.template(b,h);for(var i=[],j=0;j",{class:"select2-results__options select2-results__options--nested"});m.append(i),g.append(h),g.append(m)}else this.template(b,c);return a.data(c,"data",b),c},c.prototype.bind=function(b,c){var d=this,e=b.id+"-results";this.$results.attr("id",e),b.on("results:all",function(a){d.clear(),d.append(a.data),b.isOpen()&&(d.setClasses(),d.highlightFirstItem())}),b.on("results:append",function(a){d.append(a.data),b.isOpen()&&d.setClasses()}),b.on("query",function(a){d.hideMessages(),d.showLoading(a)}),b.on("select",function(){b.isOpen()&&(d.setClasses(),d.highlightFirstItem())}),b.on("unselect",function(){b.isOpen()&&(d.setClasses(),d.highlightFirstItem())}),b.on("open",function(){d.$results.attr("aria-expanded","true"),d.$results.attr("aria-hidden","false"),d.setClasses(),d.ensureHighlightVisible()}),b.on("close",function(){d.$results.attr("aria-expanded","false"),d.$results.attr("aria-hidden","true"),d.$results.removeAttr("aria-activedescendant")}),b.on("results:toggle",function(){var a=d.getHighlightedResults();0!==a.length&&a.trigger("mouseup")}),b.on("results:select",function(){var a=d.getHighlightedResults();if(0!==a.length){var b=a.data("data");"true"==a.attr("aria-selected")?d.trigger("close",{}):d.trigger("select",{data:b})}}),b.on("results:previous",function(){var a=d.getHighlightedResults(),b=d.$results.find("[aria-selected]"),c=b.index(a);if(0!==c){var e=c-1;0===a.length&&(e=0);var f=b.eq(e);f.trigger("mouseenter");var g=d.$results.offset().top,h=f.offset().top,i=d.$results.scrollTop()+(h-g);0===e?d.$results.scrollTop(0):h-g<0&&d.$results.scrollTop(i)}}),b.on("results:next",function(){var a=d.getHighlightedResults(),b=d.$results.find("[aria-selected]"),c=b.index(a),e=c+1;if(!(e>=b.length)){var f=b.eq(e);f.trigger("mouseenter");var g=d.$results.offset().top+d.$results.outerHeight(!1),h=f.offset().top+f.outerHeight(!1),i=d.$results.scrollTop()+h-g;0===e?d.$results.scrollTop(0):h>g&&d.$results.scrollTop(i)}}),b.on("results:focus",function(a){a.element.addClass("select2-results__option--highlighted")}),b.on("results:message",function(a){d.displayMessage(a)}),a.fn.mousewheel&&this.$results.on("mousewheel",function(a){var b=d.$results.scrollTop(),c=d.$results.get(0).scrollHeight-b+a.deltaY,e=a.deltaY>0&&b-a.deltaY<=0,f=a.deltaY<0&&c<=d.$results.height();e?(d.$results.scrollTop(0),a.preventDefault(),a.stopPropagation()):f&&(d.$results.scrollTop(d.$results.get(0).scrollHeight-d.$results.height()),a.preventDefault(),a.stopPropagation())}),this.$results.on("mouseup",".select2-results__option[aria-selected]",function(b){var c=a(this),e=c.data("data");if("true"===c.attr("aria-selected"))return void(d.options.get("multiple")?d.trigger("unselect",{originalEvent:b,data:e}):d.trigger("close",{}));d.trigger("select",{originalEvent:b,data:e})}),this.$results.on("mouseenter",".select2-results__option[aria-selected]",function(b){var c=a(this).data("data");d.getHighlightedResults().removeClass("select2-results__option--highlighted"),d.trigger("results:focus",{data:c,element:a(this)})})},c.prototype.getHighlightedResults=function(){return this.$results.find(".select2-results__option--highlighted")},c.prototype.destroy=function(){this.$results.remove()},c.prototype.ensureHighlightVisible=function(){var a=this.getHighlightedResults();if(0!==a.length){var b=this.$results.find("[aria-selected]"),c=b.index(a),d=this.$results.offset().top,e=a.offset().top,f=this.$results.scrollTop()+(e-d),g=e-d;f-=2*a.outerHeight(!1),c<=2?this.$results.scrollTop(0):(g>this.$results.outerHeight()||g<0)&&this.$results.scrollTop(f)}},c.prototype.template=function(b,c){var d=this.options.get("templateResult"),e=this.options.get("escapeMarkup"),f=d(b,c);null==f?c.style.display="none":"string"==typeof f?c.innerHTML=e(f):a(c).append(f)},c}),b.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),b.define("select2/selection/base",["jquery","../utils","../keys"],function(a,b,c){function d(a,b){this.$element=a,this.options=b,d.__super__.constructor.call(this)}return b.Extend(d,b.Observable),d.prototype.render=function(){var b=a('');return this._tabindex=0,null!=this.$element.data("old-tabindex")?this._tabindex=this.$element.data("old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),b.attr("title",this.$element.attr("title")),b.attr("tabindex",this._tabindex),this.$selection=b,b},d.prototype.bind=function(a,b){var d=this,e=(a.id,a.id+"-results");this.container=a,this.$selection.on("focus",function(a){d.trigger("focus",a)}),this.$selection.on("blur",function(a){d._handleBlur(a)}),this.$selection.on("keydown",function(a){d.trigger("keypress",a),a.which===c.SPACE&&a.preventDefault()}),a.on("results:focus",function(a){d.$selection.attr("aria-activedescendant",a.data._resultId)}),a.on("selection:update",function(a){d.update(a.data)}),a.on("open",function(){d.$selection.attr("aria-expanded","true"),d.$selection.attr("aria-owns",e),d._attachCloseHandler(a)}),a.on("close",function(){d.$selection.attr("aria-expanded","false"),d.$selection.removeAttr("aria-activedescendant"),d.$selection.removeAttr("aria-owns"),d.$selection.focus(),d._detachCloseHandler(a)}),a.on("enable",function(){d.$selection.attr("tabindex",d._tabindex)}),a.on("disable",function(){d.$selection.attr("tabindex","-1")})},d.prototype._handleBlur=function(b){var c=this;window.setTimeout(function(){document.activeElement==c.$selection[0]||a.contains(c.$selection[0],document.activeElement)||c.trigger("blur",b)},1)},d.prototype._attachCloseHandler=function(b){a(document.body).on("mousedown.select2."+b.id,function(b){var c=a(b.target),d=c.closest(".select2");a(".select2.select2-container--open").each(function(){var b=a(this);this!=d[0]&&b.data("element").select2("close")})})},d.prototype._detachCloseHandler=function(b){a(document.body).off("mousedown.select2."+b.id)},d.prototype.position=function(a,b){b.find(".selection").append(a)},d.prototype.destroy=function(){this._detachCloseHandler(this.container)},d.prototype.update=function(a){throw new Error("The `update` method must be defined in child classes.")},d}),b.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(a,b,c,d){function e(){e.__super__.constructor.apply(this,arguments)}return c.Extend(e,b),e.prototype.render=function(){var a=e.__super__.render.call(this);return a.addClass("select2-selection--single"),a.html(''),a},e.prototype.bind=function(a,b){var c=this;e.__super__.bind.apply(this,arguments);var d=a.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",d),this.$selection.attr("aria-labelledby",d),this.$selection.on("mousedown",function(a){1===a.which&&c.trigger("toggle",{originalEvent:a})}),this.$selection.on("focus",function(a){}),this.$selection.on("blur",function(a){}),a.on("focus",function(b){a.isOpen()||c.$selection.focus()}),a.on("selection:update",function(a){c.update(a.data)})},e.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},e.prototype.display=function(a,b){var c=this.options.get("templateSelection");return this.options.get("escapeMarkup")(c(a,b))},e.prototype.selectionContainer=function(){return a("")},e.prototype.update=function(a){if(0===a.length)return void this.clear();var b=a[0],c=this.$selection.find(".select2-selection__rendered"),d=this.display(b,c);c.empty().append(d),c.prop("title",b.title||b.text)},e}),b.define("select2/selection/multiple",["jquery","./base","../utils"],function(a,b,c){function d(a,b){d.__super__.constructor.apply(this,arguments)}return c.Extend(d,b),d.prototype.render=function(){var a=d.__super__.render.call(this);return a.addClass("select2-selection--multiple"),a.html('
        '),a},d.prototype.bind=function(b,c){var e=this;d.__super__.bind.apply(this,arguments),this.$selection.on("click",function(a){e.trigger("toggle",{originalEvent:a})}),this.$selection.on("click",".select2-selection__choice__remove",function(b){if(!e.options.get("disabled")){var c=a(this),d=c.parent(),f=d.data("data");e.trigger("unselect",{originalEvent:b,data:f})}})},d.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},d.prototype.display=function(a,b){var c=this.options.get("templateSelection");return this.options.get("escapeMarkup")(c(a,b))},d.prototype.selectionContainer=function(){return a('
      • ×
      • ')},d.prototype.update=function(a){if(this.clear(),0!==a.length){for(var b=[],d=0;d1||c)return a.call(this,b);this.clear();var d=this.createPlaceholder(this.placeholder);this.$selection.find(".select2-selection__rendered").append(d)},b}),b.define("select2/selection/allowClear",["jquery","../keys"],function(a,b){function c(){}return c.prototype.bind=function(a,b,c){var d=this;a.call(this,b,c),null==this.placeholder&&this.options.get("debug")&&window.console&&console.error&&console.error("Select2: The `allowClear` option should be used in combination with the `placeholder` option."),this.$selection.on("mousedown",".select2-selection__clear",function(a){d._handleClear(a)}),b.on("keypress",function(a){d._handleKeyboardClear(a,b)})},c.prototype._handleClear=function(a,b){if(!this.options.get("disabled")){var c=this.$selection.find(".select2-selection__clear");if(0!==c.length){b.stopPropagation();for(var d=c.data("data"),e=0;e0||0===c.length)){var d=a('×');d.data("data",c),this.$selection.find(".select2-selection__rendered").prepend(d)}},c}),b.define("select2/selection/search",["jquery","../utils","../keys"],function(a,b,c){function d(a,b,c){a.call(this,b,c)}return d.prototype.render=function(b){var c=a('');this.$searchContainer=c,this.$search=c.find("input");var d=b.call(this);return this._transferTabIndex(),d},d.prototype.bind=function(a,b,d){var e=this;a.call(this,b,d),b.on("open",function(){e.$search.trigger("focus")}),b.on("close",function(){e.$search.val(""),e.$search.removeAttr("aria-activedescendant"),e.$search.trigger("focus")}),b.on("enable",function(){e.$search.prop("disabled",!1),e._transferTabIndex()}),b.on("disable",function(){e.$search.prop("disabled",!0)}),b.on("focus",function(a){e.$search.trigger("focus")}),b.on("results:focus",function(a){e.$search.attr("aria-activedescendant",a.id)}),this.$selection.on("focusin",".select2-search--inline",function(a){e.trigger("focus",a)}),this.$selection.on("focusout",".select2-search--inline",function(a){e._handleBlur(a)}),this.$selection.on("keydown",".select2-search--inline",function(a){if(a.stopPropagation(),e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented(),a.which===c.BACKSPACE&&""===e.$search.val()){var b=e.$searchContainer.prev(".select2-selection__choice");if(b.length>0){var d=b.data("data");e.searchRemoveChoice(d),a.preventDefault()}}});var f=document.documentMode,g=f&&f<=11;this.$selection.on("input.searchcheck",".select2-search--inline",function(a){if(g)return void e.$selection.off("input.search input.searchcheck");e.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(a){if(g&&"input"===a.type)return void e.$selection.off("input.search input.searchcheck");var b=a.which;b!=c.SHIFT&&b!=c.CTRL&&b!=c.ALT&&b!=c.TAB&&e.handleSearch(a)})},d.prototype._transferTabIndex=function(a){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},d.prototype.createPlaceholder=function(a,b){this.$search.attr("placeholder",b.text)},d.prototype.update=function(a,b){var c=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),a.call(this,b),this.$selection.find(".select2-selection__rendered").append(this.$searchContainer),this.resizeSearch(),c&&this.$search.focus()},d.prototype.handleSearch=function(){if(this.resizeSearch(),!this._keyUpPrevented){var a=this.$search.val();this.trigger("query",{term:a})}this._keyUpPrevented=!1},d.prototype.searchRemoveChoice=function(a,b){this.trigger("unselect",{data:b}),this.$search.val(b.text),this.handleSearch()},d.prototype.resizeSearch=function(){this.$search.css("width","25px");var a="";if(""!==this.$search.attr("placeholder"))a=this.$selection.find(".select2-selection__rendered").innerWidth();else{a=.75*(this.$search.val().length+1)+"em"}this.$search.css("width",a)},d}),b.define("select2/selection/eventRelay",["jquery"],function(a){function b(){}return b.prototype.bind=function(b,c,d){var e=this,f=["open","opening","close","closing","select","selecting","unselect","unselecting"],g=["opening","closing","selecting","unselecting"];b.call(this,c,d),c.on("*",function(b,c){if(-1!==a.inArray(b,f)){c=c||{};var d=a.Event("select2:"+b,{params:c});e.$element.trigger(d),-1!==a.inArray(b,g)&&(c.prevented=d.isDefaultPrevented())}})},b}),b.define("select2/translation",["jquery","require"],function(a,b){function c(a){this.dict=a||{}}return c.prototype.all=function(){return this.dict},c.prototype.get=function(a){return this.dict[a]},c.prototype.extend=function(b){this.dict=a.extend({},b.all(),this.dict)},c._cache={},c.loadPath=function(a){if(!(a in c._cache)){var d=b(a);c._cache[a]=d}return new c(c._cache[a])},c}),b.define("select2/diacritics",[],function(){return{"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ω":"ω","ς":"σ"}}),b.define("select2/data/base",["../utils"],function(a){function b(a,c){b.__super__.constructor.call(this)}return a.Extend(b,a.Observable),b.prototype.current=function(a){throw new Error("The `current` method must be defined in child classes.")},b.prototype.query=function(a,b){throw new Error("The `query` method must be defined in child classes.")},b.prototype.bind=function(a,b){},b.prototype.destroy=function(){},b.prototype.generateResultId=function(b,c){var d=b.id+"-result-";return d+=a.generateChars(4),null!=c.id?d+="-"+c.id.toString():d+="-"+a.generateChars(4),d},b}),b.define("select2/data/select",["./base","../utils","jquery"],function(a,b,c){function d(a,b){this.$element=a,this.options=b,d.__super__.constructor.call(this)}return b.Extend(d,a),d.prototype.current=function(a){var b=[],d=this;this.$element.find(":selected").each(function(){var a=c(this),e=d.item(a);b.push(e)}),a(b)},d.prototype.select=function(a){var b=this;if(a.selected=!0,c(a.element).is("option"))return a.element.selected=!0,void this.$element.trigger("change");if(this.$element.prop("multiple"))this.current(function(d){var e=[];a=[a],a.push.apply(a,d);for(var f=0;f=0){var k=f.filter(d(j)),l=this.item(k),m=c.extend(!0,{},j,l),n=this.option(m);k.replaceWith(n)}else{var o=this.option(j);if(j.children){var p=this.convertToOptions(j.children);b.appendMany(o,p)}h.push(o)}}return h},d}),b.define("select2/data/ajax",["./array","../utils","jquery"],function(a,b,c){function d(a,b){this.ajaxOptions=this._applyDefaults(b.get("ajax")),null!=this.ajaxOptions.processResults&&(this.processResults=this.ajaxOptions.processResults),d.__super__.constructor.call(this,a,b)}return b.Extend(d,a),d.prototype._applyDefaults=function(a){var b={data:function(a){return c.extend({},a,{q:a.term})},transport:function(a,b,d){var e=c.ajax(a);return e.then(b),e.fail(d),e}};return c.extend({},b,a,!0)},d.prototype.processResults=function(a){return a},d.prototype.query=function(a,b){function d(){var d=f.transport(f,function(d){var f=e.processResults(d,a);e.options.get("debug")&&window.console&&console.error&&(f&&f.results&&c.isArray(f.results)||console.error("Select2: The AJAX results did not return an array in the `results` key of the response.")),b(f)},function(){d.status&&"0"===d.status||e.trigger("results:message",{message:"errorLoading"})});e._request=d}var e=this;null!=this._request&&(c.isFunction(this._request.abort)&&this._request.abort(),this._request=null);var f=c.extend({type:"GET"},this.ajaxOptions);"function"==typeof f.url&&(f.url=f.url.call(this.$element,a)),"function"==typeof f.data&&(f.data=f.data.call(this.$element,a)),this.ajaxOptions.delay&&null!=a.term?(this._queryTimeout&&window.clearTimeout(this._queryTimeout),this._queryTimeout=window.setTimeout(d,this.ajaxOptions.delay)):d()},d}),b.define("select2/data/tags",["jquery"],function(a){function b(b,c,d){var e=d.get("tags"),f=d.get("createTag");void 0!==f&&(this.createTag=f);var g=d.get("insertTag");if(void 0!==g&&(this.insertTag=g),b.call(this,c,d),a.isArray(e))for(var h=0;h0&&b.term.length>this.maximumInputLength)return void this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:b.term,params:b}});a.call(this,b,c)},a}),b.define("select2/data/maximumSelectionLength",[],function(){function a(a,b,c){this.maximumSelectionLength=c.get("maximumSelectionLength"),a.call(this,b,c)}return a.prototype.query=function(a,b,c){var d=this;this.current(function(e){var f=null!=e?e.length:0;if(d.maximumSelectionLength>0&&f>=d.maximumSelectionLength)return void d.trigger("results:message",{message:"maximumSelected",args:{maximum:d.maximumSelectionLength}});a.call(d,b,c)})},a}),b.define("select2/dropdown",["jquery","./utils"],function(a,b){function c(a,b){this.$element=a,this.options=b,c.__super__.constructor.call(this)}return b.Extend(c,b.Observable),c.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$dropdown=b,b},c.prototype.bind=function(){},c.prototype.position=function(a,b){},c.prototype.destroy=function(){this.$dropdown.remove()},c}),b.define("select2/dropdown/search",["jquery","../utils"],function(a,b){function c(){}return c.prototype.render=function(b){var c=b.call(this),d=a('');return this.$searchContainer=d,this.$search=d.find("input"),c.prepend(d),c},c.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),this.$search.on("keydown",function(a){e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented()}),this.$search.on("input",function(b){a(this).off("keyup")}),this.$search.on("keyup input",function(a){e.handleSearch(a)}),c.on("open",function(){e.$search.attr("tabindex",0),e.$search.focus(),window.setTimeout(function(){e.$search.focus()},0)}),c.on("close",function(){e.$search.attr("tabindex",-1),e.$search.val("")}),c.on("focus",function(){c.isOpen()||e.$search.focus()}),c.on("results:all",function(a){if(null==a.query.term||""===a.query.term){e.showSearch(a)?e.$searchContainer.removeClass("select2-search--hide"):e.$searchContainer.addClass("select2-search--hide")}})},c.prototype.handleSearch=function(a){if(!this._keyUpPrevented){var b=this.$search.val();this.trigger("query",{term:b})}this._keyUpPrevented=!1},c.prototype.showSearch=function(a,b){return!0},c}),b.define("select2/dropdown/hidePlaceholder",[],function(){function a(a,b,c,d){this.placeholder=this.normalizePlaceholder(c.get("placeholder")),a.call(this,b,c,d)}return a.prototype.append=function(a,b){b.results=this.removePlaceholder(b.results),a.call(this,b)},a.prototype.normalizePlaceholder=function(a,b){return"string"==typeof b&&(b={id:"",text:b}),b},a.prototype.removePlaceholder=function(a,b){for(var c=b.slice(0),d=b.length-1;d>=0;d--){var e=b[d];this.placeholder.id===e.id&&c.splice(d,1)}return c},a}),b.define("select2/dropdown/infiniteScroll",["jquery"],function(a){function b(a,b,c,d){this.lastParams={},a.call(this,b,c,d),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return b.prototype.append=function(a,b){this.$loadingMore.remove(),this.loading=!1,a.call(this,b),this.showLoadingMore(b)&&this.$results.append(this.$loadingMore)},b.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),c.on("query",function(a){e.lastParams=a,e.loading=!0}),c.on("query:append",function(a){e.lastParams=a,e.loading=!0}),this.$results.on("scroll",function(){var b=a.contains(document.documentElement,e.$loadingMore[0]);if(!e.loading&&b){e.$results.offset().top+e.$results.outerHeight(!1)+50>=e.$loadingMore.offset().top+e.$loadingMore.outerHeight(!1)&&e.loadMore()}})},b.prototype.loadMore=function(){this.loading=!0;var b=a.extend({},{page:1},this.lastParams);b.page++,this.trigger("query:append",b)},b.prototype.showLoadingMore=function(a,b){return b.pagination&&b.pagination.more},b.prototype.createLoadingMore=function(){var b=a('
      • '),c=this.options.get("translations").get("loadingMore");return b.html(c(this.lastParams)),b},b}),b.define("select2/dropdown/attachBody",["jquery","../utils"],function(a,b){function c(b,c,d){this.$dropdownParent=d.get("dropdownParent")||a(document.body),b.call(this,c,d)}return c.prototype.bind=function(a,b,c){var d=this,e=!1;a.call(this,b,c),b.on("open",function(){d._showDropdown(),d._attachPositioningHandler(b),e||(e=!0,b.on("results:all",function(){d._positionDropdown(),d._resizeDropdown()}),b.on("results:append",function(){d._positionDropdown(),d._resizeDropdown()}))}),b.on("close",function(){d._hideDropdown(),d._detachPositioningHandler(b)}),this.$dropdownContainer.on("mousedown",function(a){a.stopPropagation()})},c.prototype.destroy=function(a){a.call(this),this.$dropdownContainer.remove()},c.prototype.position=function(a,b,c){b.attr("class",c.attr("class")),b.removeClass("select2"),b.addClass("select2-container--open"),b.css({position:"absolute",top:-999999}),this.$container=c},c.prototype.render=function(b){var c=a(""),d=b.call(this);return c.append(d),this.$dropdownContainer=c,c},c.prototype._hideDropdown=function(a){this.$dropdownContainer.detach()},c.prototype._attachPositioningHandler=function(c,d){var e=this,f="scroll.select2."+d.id,g="resize.select2."+d.id,h="orientationchange.select2."+d.id,i=this.$container.parents().filter(b.hasScroll);i.each(function(){a(this).data("select2-scroll-position",{x:a(this).scrollLeft(),y:a(this).scrollTop()})}),i.on(f,function(b){var c=a(this).data("select2-scroll-position");a(this).scrollTop(c.y)}),a(window).on(f+" "+g+" "+h,function(a){e._positionDropdown(),e._resizeDropdown()})},c.prototype._detachPositioningHandler=function(c,d){var e="scroll.select2."+d.id,f="resize.select2."+d.id,g="orientationchange.select2."+d.id;this.$container.parents().filter(b.hasScroll).off(e),a(window).off(e+" "+f+" "+g)},c.prototype._positionDropdown=function(){var b=a(window),c=this.$dropdown.hasClass("select2-dropdown--above"),d=this.$dropdown.hasClass("select2-dropdown--below"),e=null,f=this.$container.offset();f.bottom=f.top+this.$container.outerHeight(!1);var g={height:this.$container.outerHeight(!1)};g.top=f.top,g.bottom=f.top+g.height;var h={height:this.$dropdown.outerHeight(!1)},i={top:b.scrollTop(),bottom:b.scrollTop()+b.height()},j=i.topf.bottom+h.height,l={left:f.left,top:g.bottom},m=this.$dropdownParent;"static"===m.css("position")&&(m=m.offsetParent());var n=m.offset();l.top-=n.top,l.left-=n.left,c||d||(e="below"),k||!j||c?!j&&k&&c&&(e="below"):e="above",("above"==e||c&&"below"!==e)&&(l.top=g.top-n.top-h.height),null!=e&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+e),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+e)),this.$dropdownContainer.css(l)},c.prototype._resizeDropdown=function(){var a={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(a.minWidth=a.width,a.position="relative",a.width="auto"),this.$dropdown.css(a)},c.prototype._showDropdown=function(a){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},c}),b.define("select2/dropdown/minimumResultsForSearch",[],function(){function a(b){for(var c=0,d=0;d0&&(l.dataAdapter=j.Decorate(l.dataAdapter,r)),l.maximumInputLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,s)),l.maximumSelectionLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,t)),l.tags&&(l.dataAdapter=j.Decorate(l.dataAdapter,p)),null==l.tokenSeparators&&null==l.tokenizer||(l.dataAdapter=j.Decorate(l.dataAdapter,q)),null!=l.query){var C=b(l.amdBase+"compat/query");l.dataAdapter=j.Decorate(l.dataAdapter,C)}if(null!=l.initSelection){var D=b(l.amdBase+"compat/initSelection");l.dataAdapter=j.Decorate(l.dataAdapter,D)}}if(null==l.resultsAdapter&&(l.resultsAdapter=c,null!=l.ajax&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,x)),null!=l.placeholder&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,w)),l.selectOnClose&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,A))),null==l.dropdownAdapter){if(l.multiple)l.dropdownAdapter=u;else{var E=j.Decorate(u,v);l.dropdownAdapter=E}if(0!==l.minimumResultsForSearch&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,z)),l.closeOnSelect&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,B)),null!=l.dropdownCssClass||null!=l.dropdownCss||null!=l.adaptDropdownCssClass){var F=b(l.amdBase+"compat/dropdownCss");l.dropdownAdapter=j.Decorate(l.dropdownAdapter,F)}l.dropdownAdapter=j.Decorate(l.dropdownAdapter,y)}if(null==l.selectionAdapter){if(l.multiple?l.selectionAdapter=e:l.selectionAdapter=d,null!=l.placeholder&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,f)),l.allowClear&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,g)),l.multiple&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,h)),null!=l.containerCssClass||null!=l.containerCss||null!=l.adaptContainerCssClass){var G=b(l.amdBase+"compat/containerCss");l.selectionAdapter=j.Decorate(l.selectionAdapter,G)}l.selectionAdapter=j.Decorate(l.selectionAdapter,i)}if("string"==typeof l.language)if(l.language.indexOf("-")>0){var H=l.language.split("-"),I=H[0];l.language=[l.language,I]}else l.language=[l.language];if(a.isArray(l.language)){var J=new k;l.language.push("en");for(var K=l.language,L=0;L0){for(var f=a.extend(!0,{},e),g=e.children.length-1;g>=0;g--){null==c(d,e.children[g])&&f.children.splice(g,1)}return f.children.length>0?f:c(d,f)}var h=b(e.text).toUpperCase(),i=b(d.term).toUpperCase();return h.indexOf(i)>-1?e:null}this.defaults={amdBase:"./",amdLanguageBase:"./i18n/",closeOnSelect:!0,debug:!1,dropdownAutoWidth:!1,escapeMarkup:j.escapeMarkup,language:C,matcher:c,minimumInputLength:0,maximumInputLength:0,maximumSelectionLength:0,minimumResultsForSearch:0,selectOnClose:!1,sorter:function(a){return a},templateResult:function(a){return a.text},templateSelection:function(a){return a.text},theme:"default",width:"resolve"}},D.prototype.set=function(b,c){var d=a.camelCase(b),e={};e[d]=c;var f=j._convertData(e);a.extend(this.defaults,f)},new D}),b.define("select2/options",["require","jquery","./defaults","./utils"],function(a,b,c,d){function e(b,e){if(this.options=b,null!=e&&this.fromElement(e),this.options=c.apply(this.options),e&&e.is("input")){var f=a(this.get("amdBase")+"compat/inputData");this.options.dataAdapter=d.Decorate(this.options.dataAdapter,f)}}return e.prototype.fromElement=function(a){var c=["select2"];null==this.options.multiple&&(this.options.multiple=a.prop("multiple")),null==this.options.disabled&&(this.options.disabled=a.prop("disabled")),null==this.options.language&&(a.prop("lang")?this.options.language=a.prop("lang").toLowerCase():a.closest("[lang]").prop("lang")&&(this.options.language=a.closest("[lang]").prop("lang"))),null==this.options.dir&&(a.prop("dir")?this.options.dir=a.prop("dir"):a.closest("[dir]").prop("dir")?this.options.dir=a.closest("[dir]").prop("dir"):this.options.dir="ltr"),a.prop("disabled",this.options.disabled),a.prop("multiple",this.options.multiple),a.data("select2Tags")&&(this.options.debug&&window.console&&console.warn&&console.warn('Select2: The `data-select2-tags` attribute has been changed to use the `data-data` and `data-tags="true"` attributes and will be removed in future versions of Select2.'),a.data("data",a.data("select2Tags")),a.data("tags",!0)),a.data("ajaxUrl")&&(this.options.debug&&window.console&&console.warn&&console.warn("Select2: The `data-ajax-url` attribute has been changed to `data-ajax--url` and support for the old attribute will be removed in future versions of Select2."),a.attr("ajax--url",a.data("ajaxUrl")),a.data("ajax--url",a.data("ajaxUrl")));var e={};e=b.fn.jquery&&"1."==b.fn.jquery.substr(0,2)&&a[0].dataset?b.extend(!0,{},a[0].dataset,a.data()):a.data();var f=b.extend(!0,{},e);f=d._convertData(f);for(var g in f)b.inArray(g,c)>-1||(b.isPlainObject(this.options[g])?b.extend(this.options[g],f[g]):this.options[g]=f[g]);return this},e.prototype.get=function(a){return this.options[a]},e.prototype.set=function(a,b){this.options[a]=b},e}),b.define("select2/core",["jquery","./options","./utils","./keys"],function(a,b,c,d){var e=function(a,c){null!=a.data("select2")&&a.data("select2").destroy(),this.$element=a,this.id=this._generateId(a),c=c||{},this.options=new b(c,a),e.__super__.constructor.call(this);var d=a.attr("tabindex")||0;a.data("old-tabindex",d),a.attr("tabindex","-1");var f=this.options.get("dataAdapter");this.dataAdapter=new f(a,this.options);var g=this.render();this._placeContainer(g);var h=this.options.get("selectionAdapter");this.selection=new h(a,this.options),this.$selection=this.selection.render(),this.selection.position(this.$selection,g);var i=this.options.get("dropdownAdapter");this.dropdown=new i(a,this.options),this.$dropdown=this.dropdown.render(),this.dropdown.position(this.$dropdown,g);var j=this.options.get("resultsAdapter");this.results=new j(a,this.options,this.dataAdapter),this.$results=this.results.render(),this.results.position(this.$results,this.$dropdown);var k=this;this._bindAdapters(),this._registerDomEvents(),this._registerDataEvents(),this._registerSelectionEvents(),this._registerDropdownEvents(),this._registerResultsEvents(),this._registerEvents(),this.dataAdapter.current(function(a){k.trigger("selection:update",{data:a})}),a.addClass("select2-hidden-accessible"),a.attr("aria-hidden","true"),this._syncAttributes(),a.data("select2",this)};return c.Extend(e,c.Observable),e.prototype._generateId=function(a){var b="";return b=null!=a.attr("id")?a.attr("id"):null!=a.attr("name")?a.attr("name")+"-"+c.generateChars(2):c.generateChars(4),b=b.replace(/(:|\.|\[|\]|,)/g,""),b="select2-"+b},e.prototype._placeContainer=function(a){a.insertAfter(this.$element);var b=this._resolveWidth(this.$element,this.options.get("width"));null!=b&&a.css("width",b)},e.prototype._resolveWidth=function(a,b){var c=/^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i;if("resolve"==b){var d=this._resolveWidth(a,"style");return null!=d?d:this._resolveWidth(a,"element")}if("element"==b){var e=a.outerWidth(!1);return e<=0?"auto":e+"px"}if("style"==b){var f=a.attr("style");if("string"!=typeof f)return null;for(var g=f.split(";"),h=0,i=g.length;h=1)return k[1]}return null}return b},e.prototype._bindAdapters=function(){this.dataAdapter.bind(this,this.$container),this.selection.bind(this,this.$container),this.dropdown.bind(this,this.$container),this.results.bind(this,this.$container)},e.prototype._registerDomEvents=function(){var b=this;this.$element.on("change.select2",function(){b.dataAdapter.current(function(a){b.trigger("selection:update",{data:a})})}),this.$element.on("focus.select2",function(a){b.trigger("focus",a)}),this._syncA=c.bind(this._syncAttributes,this),this._syncS=c.bind(this._syncSubtree,this),this.$element[0].attachEvent&&this.$element[0].attachEvent("onpropertychange",this._syncA);var d=window.MutationObserver||window.WebKitMutationObserver||window.MozMutationObserver;null!=d?(this._observer=new d(function(c){a.each(c,b._syncA),a.each(c,b._syncS)}),this._observer.observe(this.$element[0],{attributes:!0,childList:!0,subtree:!1})):this.$element[0].addEventListener&&(this.$element[0].addEventListener("DOMAttrModified",b._syncA,!1),this.$element[0].addEventListener("DOMNodeInserted",b._syncS,!1),this.$element[0].addEventListener("DOMNodeRemoved",b._syncS,!1))},e.prototype._registerDataEvents=function(){var a=this;this.dataAdapter.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerSelectionEvents=function(){var b=this,c=["toggle","focus"];this.selection.on("toggle",function(){b.toggleDropdown()}),this.selection.on("focus",function(a){b.focus(a)}),this.selection.on("*",function(d,e){-1===a.inArray(d,c)&&b.trigger(d,e)})},e.prototype._registerDropdownEvents=function(){var a=this;this.dropdown.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerResultsEvents=function(){var a=this;this.results.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerEvents=function(){var a=this;this.on("open",function(){a.$container.addClass("select2-container--open")}),this.on("close",function(){a.$container.removeClass("select2-container--open")}),this.on("enable",function(){a.$container.removeClass("select2-container--disabled")}),this.on("disable",function(){a.$container.addClass("select2-container--disabled")}),this.on("blur",function(){a.$container.removeClass("select2-container--focus")}),this.on("query",function(b){a.isOpen()||a.trigger("open",{}),this.dataAdapter.query(b,function(c){a.trigger("results:all",{data:c,query:b})})}),this.on("query:append",function(b){this.dataAdapter.query(b,function(c){a.trigger("results:append",{data:c,query:b})})}),this.on("keypress",function(b){var c=b.which;a.isOpen()?c===d.ESC||c===d.TAB||c===d.UP&&b.altKey?(a.close(),b.preventDefault()):c===d.ENTER?(a.trigger("results:select",{}),b.preventDefault()):c===d.SPACE&&b.ctrlKey?(a.trigger("results:toggle",{}),b.preventDefault()):c===d.UP?(a.trigger("results:previous",{}),b.preventDefault()):c===d.DOWN&&(a.trigger("results:next",{}),b.preventDefault()):(c===d.ENTER||c===d.SPACE||c===d.DOWN&&b.altKey)&&(a.open(),b.preventDefault())})},e.prototype._syncAttributes=function(){this.options.set("disabled",this.$element.prop("disabled")),this.options.get("disabled")?(this.isOpen()&&this.close(),this.trigger("disable",{})):this.trigger("enable",{})},e.prototype._syncSubtree=function(a,b){var c=!1,d=this;if(!a||!a.target||"OPTION"===a.target.nodeName||"OPTGROUP"===a.target.nodeName){if(b)if(b.addedNodes&&b.addedNodes.length>0)for(var e=0;e0&&(c=!0);else c=!0;c&&this.dataAdapter.current(function(a){d.trigger("selection:update",{data:a})})}},e.prototype.trigger=function(a,b){var c=e.__super__.trigger,d={open:"opening",close:"closing",select:"selecting",unselect:"unselecting"};if(void 0===b&&(b={}),a in d){var f=d[a],g={prevented:!1,name:a,args:b};if(c.call(this,f,g),g.prevented)return void(b.prevented=!0)}c.call(this,a,b)},e.prototype.toggleDropdown=function(){this.options.get("disabled")||(this.isOpen()?this.close():this.open())},e.prototype.open=function(){this.isOpen()||this.trigger("query",{})},e.prototype.close=function(){this.isOpen()&&this.trigger("close",{})},e.prototype.isOpen=function(){return this.$container.hasClass("select2-container--open")},e.prototype.hasFocus=function(){return this.$container.hasClass("select2-container--focus")},e.prototype.focus=function(a){this.hasFocus()||(this.$container.addClass("select2-container--focus"),this.trigger("focus",{}))},e.prototype.enable=function(a){this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("enable")` method has been deprecated and will be removed in later Select2 versions. Use $element.prop("disabled") instead.'),null!=a&&0!==a.length||(a=[!0]);var b=!a[0];this.$element.prop("disabled",b)},e.prototype.data=function(){this.options.get("debug")&&arguments.length>0&&window.console&&console.warn&&console.warn('Select2: Data can no longer be set using `select2("data")`. You should consider setting the value instead using `$element.val()`.');var a=[];return this.dataAdapter.current(function(b){a=b}),a},e.prototype.val=function(b){if(this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("val")` method has been deprecated and will be removed in later Select2 versions. Use $element.val() instead.'),null==b||0===b.length)return this.$element.val();var c=b[0];a.isArray(c)&&(c=a.map(c,function(a){return a.toString()})),this.$element.val(c).trigger("change")},e.prototype.destroy=function(){this.$container.remove(),this.$element[0].detachEvent&&this.$element[0].detachEvent("onpropertychange",this._syncA),null!=this._observer?(this._observer.disconnect(),this._observer=null):this.$element[0].removeEventListener&&(this.$element[0].removeEventListener("DOMAttrModified",this._syncA,!1),this.$element[0].removeEventListener("DOMNodeInserted",this._syncS,!1),this.$element[0].removeEventListener("DOMNodeRemoved",this._syncS,!1)),this._syncA=null,this._syncS=null,this.$element.off(".select2"),this.$element.attr("tabindex",this.$element.data("old-tabindex")),this.$element.removeClass("select2-hidden-accessible"),this.$element.attr("aria-hidden","false"),this.$element.removeData("select2"),this.dataAdapter.destroy(),this.selection.destroy(),this.dropdown.destroy(),this.results.destroy(),this.dataAdapter=null,this.selection=null,this.dropdown=null,this.results=null},e.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$container=b,this.$container.addClass("select2-container--"+this.options.get("theme")),b.data("element",this.$element),b},e}),b.define("jquery-mousewheel",["jquery"],function(a){return a}),b.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults"],function(a,b,c,d){if(null==a.fn.select2){var e=["open","close","destroy"];a.fn.select2=function(b){if("object"==typeof(b=b||{}))return this.each(function(){var d=a.extend(!0,{},b);new c(a(this),d)}),this;if("string"==typeof b){var d,f=Array.prototype.slice.call(arguments,1);return this.each(function(){var c=a(this).data("select2");null==c&&window.console&&console.error&&console.error("The select2('"+b+"') method was called on an element that is not using Select2."),d=c[b].apply(c,f)}),a.inArray(b,e)>-1?this:d}throw new Error("Invalid arguments for Select2: "+b)}}return null==a.fn.select2.defaults&&(a.fn.select2.defaults=d),c}),{define:b.define,require:b.require}}(),c=b.require("jquery.select2");return a.fn.select2.amd=b,c}); \ No newline at end of file diff --git a/netbox/project-static/select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css b/netbox/project-static/select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css new file mode 100644 index 00000000000..4810faa61bf --- /dev/null +++ b/netbox/project-static/select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css @@ -0,0 +1,7 @@ +/*! + * Select2 Bootstrap Theme v0.1.0-beta.10 (https://select2.github.io/select2-bootstrap-theme) + * Copyright 2015-2017 Florian Kissling and contributors (https://github.com/select2/select2-bootstrap-theme/graphs/contributors) + * Licensed under MIT (https://github.com/select2/select2-bootstrap-theme/blob/master/LICENSE) + */ + +.select2-container--bootstrap{display:block}.select2-container--bootstrap .select2-selection{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);background-color:#fff;border:1px solid #ccc;border-radius:4px;color:#555;font-size:14px;outline:0}.select2-container--bootstrap .select2-selection.form-control{border-radius:4px}.select2-container--bootstrap .select2-search--dropdown .select2-search__field{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);background-color:#fff;border:1px solid #ccc;border-radius:4px;color:#555;font-size:14px}.select2-container--bootstrap .select2-search__field{outline:0}.select2-container--bootstrap .select2-search__field::-webkit-input-placeholder{color:#999}.select2-container--bootstrap .select2-search__field:-moz-placeholder{color:#999}.select2-container--bootstrap .select2-search__field::-moz-placeholder{color:#999;opacity:1}.select2-container--bootstrap .select2-search__field:-ms-input-placeholder{color:#999}.select2-container--bootstrap .select2-results__option{padding:6px 12px}.select2-container--bootstrap .select2-results__option[role=group]{padding:0}.select2-container--bootstrap .select2-results__option[aria-disabled=true]{color:#777;cursor:not-allowed}.select2-container--bootstrap .select2-results__option[aria-selected=true]{background-color:#f5f5f5;color:#262626}.select2-container--bootstrap .select2-results__option--highlighted[aria-selected]{background-color:#337ab7;color:#fff}.select2-container--bootstrap .select2-results__option .select2-results__option{padding:6px 12px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option{margin-left:-12px;padding-left:24px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-24px;padding-left:36px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-36px;padding-left:48px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-48px;padding-left:60px}.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-60px;padding-left:72px}.select2-container--bootstrap .select2-results__group{color:#777;display:block;padding:6px 12px;font-size:12px;line-height:1.42857143;white-space:nowrap}.select2-container--bootstrap.select2-container--focus .select2-selection,.select2-container--bootstrap.select2-container--open .select2-selection{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;border-color:#66afe9}.select2-container--bootstrap.select2-container--open .select2-selection .select2-selection__arrow b{border-color:transparent transparent #999;border-width:0 4px 4px}.select2-container--bootstrap.select2-container--open.select2-container--below .select2-selection{border-bottom-right-radius:0;border-bottom-left-radius:0;border-bottom-color:transparent}.select2-container--bootstrap.select2-container--open.select2-container--above .select2-selection{border-top-right-radius:0;border-top-left-radius:0;border-top-color:transparent}.select2-container--bootstrap .select2-selection__clear{color:#999;cursor:pointer;float:right;font-weight:700;margin-right:10px}.select2-container--bootstrap .select2-selection__clear:hover{color:#333}.select2-container--bootstrap.select2-container--disabled .select2-selection{border-color:#ccc;-webkit-box-shadow:none;box-shadow:none}.select2-container--bootstrap.select2-container--disabled .select2-search__field,.select2-container--bootstrap.select2-container--disabled .select2-selection{cursor:not-allowed}.select2-container--bootstrap.select2-container--disabled .select2-selection,.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice{background-color:#eee}.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice__remove,.select2-container--bootstrap.select2-container--disabled .select2-selection__clear{display:none}.select2-container--bootstrap .select2-dropdown{-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175);border-color:#66afe9;overflow-x:hidden;margin-top:-1px}.select2-container--bootstrap .select2-dropdown--above{-webkit-box-shadow:0 -6px 12px rgba(0,0,0,.175);box-shadow:0 -6px 12px rgba(0,0,0,.175);margin-top:1px}.select2-container--bootstrap .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--bootstrap .select2-selection--single{height:34px;line-height:1.42857143;padding:6px 24px 6px 12px}.select2-container--bootstrap .select2-selection--single .select2-selection__arrow{position:absolute;bottom:0;right:12px;top:0;width:4px}.select2-container--bootstrap .select2-selection--single .select2-selection__arrow b{border-color:#999 transparent transparent;border-style:solid;border-width:4px 4px 0;height:0;left:0;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--bootstrap .select2-selection--single .select2-selection__rendered{color:#555;padding:0}.select2-container--bootstrap .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--bootstrap .select2-selection--multiple{min-height:34px;padding:0;height:auto}.select2-container--bootstrap .select2-selection--multiple .select2-selection__rendered{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;display:block;line-height:1.42857143;list-style:none;margin:0;overflow:hidden;padding:0;width:100%;text-overflow:ellipsis;white-space:nowrap}.select2-container--bootstrap .select2-selection--multiple .select2-selection__placeholder{color:#999;float:left;margin-top:5px}.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice{color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;cursor:default;float:left;margin:5px 0 0 6px;padding:0 6px}.select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field{background:0 0;padding:0 12px;height:32px;line-height:1.42857143;margin-top:0;min-width:5em}.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:700;margin-right:3px}.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--bootstrap .select2-selection--multiple .select2-selection__clear{margin-top:6px}.form-group-sm .select2-container--bootstrap .select2-selection--single,.input-group-sm .select2-container--bootstrap .select2-selection--single,.select2-container--bootstrap .select2-selection--single.input-sm{border-radius:3px;font-size:12px;height:30px;line-height:1.5;padding:5px 22px 5px 10px}.form-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.input-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.select2-container--bootstrap .select2-selection--single.input-sm .select2-selection__arrow b{margin-left:-5px}.form-group-sm .select2-container--bootstrap .select2-selection--multiple,.input-group-sm .select2-container--bootstrap .select2-selection--multiple,.select2-container--bootstrap .select2-selection--multiple.input-sm{min-height:30px;border-radius:3px}.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__choice{font-size:12px;line-height:1.5;margin:4px 0 0 5px;padding:0 5px}.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-search--inline .select2-search__field{padding:0 10px;font-size:12px;height:28px;line-height:1.5}.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__clear{margin-top:5px}.form-group-lg .select2-container--bootstrap .select2-selection--single,.input-group-lg .select2-container--bootstrap .select2-selection--single,.select2-container--bootstrap .select2-selection--single.input-lg{border-radius:6px;font-size:18px;height:46px;line-height:1.3333333;padding:10px 31px 10px 16px}.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow,.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow,.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow{width:5px}.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow b{border-width:5px 5px 0;margin-left:-10px;margin-top:-2.5px}.form-group-lg .select2-container--bootstrap .select2-selection--multiple,.input-group-lg .select2-container--bootstrap .select2-selection--multiple,.select2-container--bootstrap .select2-selection--multiple.input-lg{min-height:46px;border-radius:6px}.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__choice{font-size:18px;line-height:1.3333333;border-radius:4px;margin:9px 0 0 8px;padding:0 10px}.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-search--inline .select2-search__field{padding:0 16px;font-size:18px;height:44px;line-height:1.3333333}.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__clear{margin-top:10px}.input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single .select2-selection__arrow b,.select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #999;border-width:0 5px 5px}.select2-container--bootstrap[dir=rtl] .select2-selection--single{padding-left:24px;padding-right:12px}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__rendered{padding-right:0;padding-left:0;text-align:right}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__clear{float:left}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__arrow{left:12px;right:auto}.select2-container--bootstrap[dir=rtl] .select2-selection--single .select2-selection__arrow b{margin-left:0}.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-search--inline,.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__choice,.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__placeholder{float:right}.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__choice{margin-left:0;margin-right:6px}.select2-container--bootstrap[dir=rtl] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.has-warning .select2-dropdown,.has-warning .select2-selection{border-color:#8a6d3b}.has-warning .select2-container--focus .select2-selection,.has-warning .select2-container--open .select2-selection{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;border-color:#66512c}.has-warning.select2-drop-active{border-color:#66512c}.has-warning.select2-drop-active.select2-drop.select2-drop-above{border-top-color:#66512c}.has-error .select2-dropdown,.has-error .select2-selection{border-color:#a94442}.has-error .select2-container--focus .select2-selection,.has-error .select2-container--open .select2-selection{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;border-color:#843534}.has-error.select2-drop-active{border-color:#843534}.has-error.select2-drop-active.select2-drop.select2-drop-above{border-top-color:#843534}.has-success .select2-dropdown,.has-success .select2-selection{border-color:#3c763d}.has-success .select2-container--focus .select2-selection,.has-success .select2-container--open .select2-selection{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;border-color:#2b542c}.has-success.select2-drop-active{border-color:#2b542c}.has-success.select2-drop-active.select2-drop.select2-drop-above{border-top-color:#2b542c}.input-group>.select2-hidden-accessible:first-child+.select2-container--bootstrap>.selection>.select2-selection,.input-group>.select2-hidden-accessible:first-child+.select2-container--bootstrap>.selection>.select2-selection.form-control{border-bottom-right-radius:0;border-top-right-radius:0}.input-group>.select2-hidden-accessible:not(:first-child)+.select2-container--bootstrap:not(:last-child)>.selection>.select2-selection,.input-group>.select2-hidden-accessible:not(:first-child)+.select2-container--bootstrap:not(:last-child)>.selection>.select2-selection.form-control{border-radius:0}.input-group>.select2-hidden-accessible:not(:first-child):not(:last-child)+.select2-container--bootstrap:last-child>.selection>.select2-selection,.input-group>.select2-hidden-accessible:not(:first-child):not(:last-child)+.select2-container--bootstrap:last-child>.selection>.select2-selection.form-control{border-bottom-left-radius:0;border-top-left-radius:0}.input-group>.select2-container--bootstrap{display:table;table-layout:fixed;position:relative;z-index:2;width:100%;margin-bottom:0}.input-group>.select2-container--bootstrap>.selection>.select2-selection.form-control{float:none}.input-group>.select2-container--bootstrap.select2-container--focus,.input-group>.select2-container--bootstrap.select2-container--open{z-index:3}.input-group>.select2-container--bootstrap,.input-group>.select2-container--bootstrap .input-group-btn,.input-group>.select2-container--bootstrap .input-group-btn .btn{vertical-align:top}.form-control.select2-hidden-accessible{position:absolute!important;width:1px!important}@media (min-width:768px){.form-inline .select2-container--bootstrap{display:inline-block}} diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index 4eeac519ca6..94ede4545a8 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import admin, messages from django.shortcuts import redirect, render @@ -23,7 +21,7 @@ def get_readonly_fields(self, request, obj=None): def get_actions(self, request): # Bulk deletion is disabled at the manager level, so remove the action from the admin site for this model. - actions = super(UserKeyAdmin, self).get_actions(request) + actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] if not request.user.has_perm('secrets.activate_userkey'): diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py new file mode 100644 index 00000000000..819546c63cb --- /dev/null +++ b/netbox/secrets/api/nested_serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from secrets.models import SecretRole +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedSecretRoleSerializer' +] + + +class NestedSecretRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') + + class Meta: + model = SecretRole + fields = ['id', 'url', 'name', 'slug'] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index ee7217b635c..1faf85dcf0f 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,17 +1,16 @@ -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 dcim.api.nested_serializers import NestedDeviceSerializer from extras.api.customfields import CustomFieldModelSerializer from secrets.models import Secret, SecretRole -from utilities.api import ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ValidatedModelSerializer +from .nested_serializers import * # -# SecretRoles +# Secrets # class SecretRoleSerializer(ValidatedModelSerializer): @@ -21,18 +20,6 @@ class Meta: fields = ['id', 'name', 'slug'] -class NestedSecretRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') - - class Meta: - model = SecretRole - fields = ['id', 'url', 'name', 'slug'] - - -# -# Secrets -# - class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() @@ -62,6 +49,6 @@ def validate(self, data): validator(data) # Enforce model validation - super(SecretSerializer, self).validate(data) + super().validate(data) return data diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 2a24c445a99..def87b3a1ac 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,15 +15,15 @@ def get_view_name(self): router.APIRootView = SecretsRootView # Field choices -router.register(r'_choices', views.SecretsFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.SecretsFieldChoicesViewSet, basename='field-choice') # Secrets router.register(r'secret-roles', views.SecretRoleViewSet) router.register(r'secrets', views.SecretViewSet) # Miscellaneous -router.register(r'get-session-key', views.GetSessionKeyViewSet, base_name='get-session-key') -router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, base_name='generate-rsa-key-pair') +router.register(r'get-session-key', views.GetSessionKeyViewSet, basename='get-session-key') +router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair') app_name = 'secrets-api' urlpatterns = router.urls diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 01567be8b59..0c164de07da 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import base64 from Crypto.PublicKey import RSA @@ -37,7 +35,7 @@ class SecretRoleViewSet(ModelViewSet): queryset = SecretRole.objects.all() serializer_class = serializers.SecretRoleSerializer permission_classes = [IsAuthenticated] - filter_class = filters.SecretRoleFilter + filterset_class = filters.SecretRoleFilter # @@ -51,21 +49,21 @@ class SecretViewSet(ModelViewSet): 'role__users', 'role__groups', 'tags', ) serializer_class = serializers.SecretSerializer - filter_class = filters.SecretFilter + filterset_class = filters.SecretFilter master_key = None def get_serializer_context(self): # Make the master key available to the serializer for encrypting plaintext values - context = super(SecretViewSet, self).get_serializer_context() + context = super().get_serializer_context() context['master_key'] = self.master_key return context def initial(self, request, *args, **kwargs): - super(SecretViewSet, self).initial(request, *args, **kwargs) + super().initial(request, *args, **kwargs) if request.user.is_authenticated: diff --git a/netbox/secrets/apps.py b/netbox/secrets/apps.py index bc3714966b4..eec54bd7f46 100644 --- a/netbox/secrets/apps.py +++ b/netbox/secrets/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py index 0b9ebc16e40..e2f44ac90f0 100644 --- a/netbox/secrets/decorators.py +++ b/netbox/secrets/decorators.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.shortcuts import redirect diff --git a/netbox/secrets/exceptions.py b/netbox/secrets/exceptions.py index f014d8a14d6..11433d41e1f 100644 --- a/netbox/secrets/exceptions.py +++ b/netbox/secrets/exceptions.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - class InvalidKey(Exception): """ diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index f43a82b2233..5880fb9f900 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -1,11 +1,9 @@ -from __future__ import unicode_literals - 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 utilities.filters import NumericInFilter, TagFilter from .models import Secret, SecretRole @@ -17,7 +15,10 @@ class Meta: class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -27,7 +28,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=SecretRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -37,14 +38,12 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( - name='device__name', + field_name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Secret diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 59e637a18c7..6c13ca243dc 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -1,14 +1,14 @@ -from __future__ import unicode_literals - 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 extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm -from utilities.forms import BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField +from utilities.forms import ( + APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, + StaticSelect2Multiple +) from .models import Secret, SecretRole, UserKey @@ -41,7 +41,13 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = SecretRole - fields = ['name', 'slug', 'users', 'groups'] + fields = [ + 'name', 'slug', 'users', 'groups', + ] + widgets = { + 'users': StaticSelect2Multiple(), + 'groups': StaticSelect2Multiple(), + } class SecretRoleCSVForm(forms.ModelForm): @@ -64,7 +70,11 @@ class SecretForm(BootstrapMixin, CustomFieldForm): max_length=65535, required=False, label='Plaintext', - widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}) + widget=forms.PasswordInput( + attrs={ + 'class': 'requires-session-key', + } + ) ) plaintext2 = forms.CharField( max_length=65535, @@ -72,15 +82,23 @@ class SecretForm(BootstrapMixin, CustomFieldForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Secret - fields = ['role', 'name', 'plaintext', 'plaintext2', 'tags'] + fields = [ + 'role', 'name', 'plaintext', 'plaintext2', 'tags', + ] + widgets = { + 'role': APISelect( + api_url="/api/secrets/secret-roles/" + ) + } def __init__(self, *args, **kwargs): - - super(SecretForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # A plaintext value is required when creating a new Secret if not self.instance.pk: @@ -124,26 +142,47 @@ class Meta: } def save(self, *args, **kwargs): - s = super(SecretCSVForm, self).save(*args, **kwargs) + s = super().save(*args, **kwargs) s.plaintext = str(self.cleaned_data['plaintext']) return s 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) + pk = forms.ModelMultipleChoiceField( + queryset=Secret.objects.all(), + widget=forms.MultipleHiddenInput() + ) + role = forms.ModelChoiceField( + queryset=SecretRole.objects.all(), + required=False, + widget=APISelect( + api_url="/api/secrets/secret-roles/" + ) + ) + name = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['name'] + nullable_fields = [ + 'name', + ] class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Secret - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) role = FilterChoiceField( - queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), - to_field_name='slug' + queryset=SecretRole.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/secrets/secret-roles/", + value_field="slug", + ) ) @@ -171,5 +210,15 @@ def clean_public_key(self): class ActivateUserKeyForm(forms.Form): - _selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys') - secret_key = forms.CharField(label='Your private key', widget=forms.Textarea(attrs={'class': 'vLargeTextField'})) + _selected_action = forms.ModelMultipleChoiceField( + queryset=UserKey.objects.all(), + label='User Keys' + ) + secret_key = forms.CharField( + widget=forms.Textarea( + attrs={ + 'class': 'vLargeTextField', + } + ), + label='Your private key' + ) diff --git a/netbox/secrets/hashers.py b/netbox/secrets/hashers.py index 49da1605dab..fc5066fc642 100644 --- a/netbox/secrets/hashers.py +++ b/netbox/secrets/hashers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.auth.hashers import PBKDF2PasswordHasher diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py index 8dc0d54c6fd..1281a266a87 100644 --- a/netbox/secrets/migrations/0001_initial.py +++ b/netbox/secrets/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py index fb7d374319d..04db89e7cbe 100644 --- a/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py +++ b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/secrets/migrations/0002_userkey_add_session_key.py b/netbox/secrets/migrations/0002_userkey_add_session_key.py index 4cd885cfbd2..03abfb70e5a 100644 --- a/netbox/secrets/migrations/0002_userkey_add_session_key.py +++ b/netbox/secrets/migrations/0002_userkey_add_session_key.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-27 15:26 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/secrets/migrations/0003_unicode_literals.py b/netbox/secrets/migrations/0003_unicode_literals.py index b8b7956d84f..48be221c5bc 100644 --- a/netbox/secrets/migrations/0003_unicode_literals.py +++ b/netbox/secrets/migrations/0003_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/secrets/migrations/0004_tags.py b/netbox/secrets/migrations/0004_tags.py index ac952dc9206..bdba7980427 100644 --- a/netbox/secrets/migrations/0004_tags.py +++ b/netbox/secrets/migrations/0004_tags.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/secrets/migrations/0005_change_logging.py b/netbox/secrets/migrations/0005_change_logging.py index 94708793455..d920e6fb2e3 100644 --- a/netbox/secrets/migrations/0005_change_logging.py +++ b/netbox/secrets/migrations/0005_change_logging.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 8bbf3d14da6..8190cd1dd94 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,6 +1,5 @@ -from __future__ import unicode_literals - import os +import sys from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.PublicKey import RSA @@ -12,7 +11,7 @@ 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 django.utils.encoding import force_bytes from taggit.managers import TaggableManager from extras.models import CustomFieldModel @@ -49,7 +48,6 @@ def decrypt_master_key(master_key_cipher, private_key): return cipher.decrypt(master_key_cipher) -@python_2_unicode_compatible class UserKey(models.Model): """ A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted @@ -87,7 +85,7 @@ class Meta: ) def __init__(self, *args, **kwargs): - super(UserKey, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Store the initial public_key and master_key_cipher to check for changes on save(). self.__initial_public_key = self.public_key @@ -127,7 +125,7 @@ def clean(self, *args, **kwargs): ) }) - super(UserKey, self).clean() + super().clean() def save(self, *args, **kwargs): @@ -140,7 +138,7 @@ def save(self, *args, **kwargs): master_key = generate_random_key() self.master_key_cipher = encrypt_master_key(master_key, self.public_key) - super(UserKey, self).save(*args, **kwargs) + super().save(*args, **kwargs) def delete(self, *args, **kwargs): @@ -150,7 +148,7 @@ def delete(self, *args, **kwargs): raise Exception("Cannot delete the last active UserKey when Secrets exist! This would render all secrets " "inaccessible.") - super(UserKey, self).delete(*args, **kwargs) + super().delete(*args, **kwargs) def is_filled(self): """ @@ -187,7 +185,6 @@ def activate(self, master_key): self.save() -@python_2_unicode_compatible class SessionKey(models.Model): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. @@ -233,7 +230,7 @@ def save(self, master_key=None, *args, **kwargs): # Encrypt master key using the session key self.cipher = strxor.strxor(self.key, master_key) - super(SessionKey, self).save(*args, **kwargs) + super().save(*args, **kwargs) def get_master_key(self, session_key): @@ -258,7 +255,6 @@ def get_session_key(self, master_key): return session_key -@python_2_unicode_compatible class SecretRole(ChangeLoggedModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles @@ -311,7 +307,6 @@ def has_member(self, user): return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists() -@python_2_unicode_compatible class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible @@ -361,7 +356,7 @@ class Meta: def __init__(self, *args, **kwargs): self.plaintext = kwargs.pop('plaintext', None) - super(Secret, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def __str__(self): if self.role and self.device and self.name: @@ -392,6 +387,7 @@ def _pad(self, s): s = s.encode('utf8') if len(s) > 65535: raise ValueError("Maximum plaintext size is 65535 bytes.") + # Minimum ciphertext size is 64 bytes to conceal the length of short secrets. if len(s) <= 62: pad_length = 62 - len(s) @@ -399,12 +395,14 @@ def _pad(self, s): pad_length = 16 - ((len(s) + 2) % 16) else: pad_length = 0 - return ( - chr(len(s) >> 8).encode() + - chr(len(s) % 256).encode() + - s + - os.urandom(pad_length) - ) + + # Python 2 compatibility + if sys.version_info[0] < 3: + header = chr(len(s) >> 8) + chr(len(s) % 256) + else: + header = bytes([len(s) >> 8]) + bytes([len(s) % 256]) + + return header + s + os.urandom(pad_length) def _unpad(self, s): """ diff --git a/netbox/secrets/querysets.py b/netbox/secrets/querysets.py index c5595e1d3b3..c9732c5fe2d 100644 --- a/netbox/secrets/querysets.py +++ b/netbox/secrets/querysets.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models import QuerySet diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 4cfb1a6ea91..39d260a6de8 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from utilities.tables import BaseTable, ToggleColumn diff --git a/netbox/secrets/templatetags/secret_helpers.py b/netbox/secrets/templatetags/secret_helpers.py index 0e1ff554c60..142c0d2cba8 100644 --- a/netbox/secrets/templatetags/secret_helpers.py +++ b/netbox/secrets/templatetags/secret_helpers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import template diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index d8d156ef311..c260f1a482f 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import base64 from django.urls import reverse @@ -53,7 +51,7 @@ class SecretRoleTest(APITestCase): def setUp(self): - super(SecretRoleTest, self).setUp() + super().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') @@ -154,7 +152,7 @@ class SecretTest(APITestCase): def setUp(self): - super(SecretTest, self).setUp() + super().setUp() userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() @@ -296,7 +294,7 @@ class GetSessionKeyTest(APITestCase): def setUp(self): - super(GetSessionKeyTest, self).setUp() + super().setUp() userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() diff --git a/netbox/secrets/tests/test_models.py b/netbox/secrets/tests/test_models.py index 887c048bf52..b3ba0cee10b 100644 --- a/netbox/secrets/tests/test_models.py +++ b/netbox/secrets/tests/test_models.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +import string from Crypto.PublicKey import RSA from django.conf import settings @@ -88,7 +88,7 @@ def test_01_encrypt_decrypt(self): """ Test basic encryption and decryption functionality using a random master key. """ - plaintext = "FooBar123" + plaintext = string.printable * 2 secret_key = generate_random_key() s = Secret(plaintext=plaintext) s.encrypt(secret_key) diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 952725b5400..e1ce2b8f223 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index d15c9cbc25c..91d8caf0da9 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import base64 from django.contrib import messages @@ -230,7 +228,7 @@ def post(self, request): messages.error(request, "No session key found for this user.") if self.master_key is not None: - return super(SecretBulkImportView, self).post(request) + return super().post(request) else: messages.error(request, "Invalid private key! Unable to encrypt secret data.") diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 1da608a4860..c09061c108b 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -1,4 +1,4 @@ -{% load static from staticfiles %} +{% load static %} diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 27ebb052dca..46db2d8ba2f 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -1,4 +1,4 @@ -{% load static from staticfiles %} +{% load static %} {% load helpers %} @@ -7,6 +7,8 @@ + + @@ -54,7 +56,7 @@

        Maintenance Mode

        - Docs · + Docs · API · Code · Help @@ -66,6 +68,7 @@

        Maintenance Mode

        + +{% endblock %} diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html new file mode 100644 index 00000000000..17403a07d41 --- /dev/null +++ b/netbox/templates/dcim/cable_edit.html @@ -0,0 +1,23 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
        +
        Cable
        +
        + {% render_field form.type %} + {% render_field form.status %} + {% render_field form.label %} + {% render_field form.color %} +
        + +
        + {{ form.length }} +
        +
        + {{ form.length_unit }} +
        +
        +
        +
        +{% endblock %} diff --git a/netbox/templates/dcim/cable_list.html b/netbox/templates/dcim/cable_list.html new file mode 100644 index 00000000000..07336e78cc8 --- /dev/null +++ b/netbox/templates/dcim/cable_list.html @@ -0,0 +1,20 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
        + {% if perms.dcim.add_cable %} + {% import_button 'dcim:cable_import' %} + {% endif %} + {% export_button content_type %} +
        +

        {% block title %}Cables{% endblock %}

        +
        +
        + {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %} +
        +
        + {% include 'inc/search_panel.html' %} +
        +
        +{% endblock %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html new file mode 100644 index 00000000000..7ef88543eab --- /dev/null +++ b/netbox/templates/dcim/cable_trace.html @@ -0,0 +1,48 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +

        {% block title %}Cable Trace for {{ obj }}{% endblock %}

        +{% endblock %} + +{% block content %} +
        +
        +

        Near End

        +
        +
        +

        Far End

        +
        +
        + {% for near_end, cable, far_end in trace %} +
        +
        +

        {{ forloop.counter }}

        +
        +
        + {% include 'dcim/inc/cable_trace_end.html' with end=near_end %} +
        +
        + {% if cable %} +

        + + {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} + +

        +

        {{ cable.get_status_display }}

        +

        {{ cable.get_type_display|default:"" }}

        + {% if cable.length %}- {{ cable.length }}{{ cable.get_length_unit_display }}{% endif %} +   + {% else %} +

        No Cable

        + {% endif %} +
        +
        + {% if far_end %} + {% include 'dcim/inc/cable_trace_end.html' with end=far_end %} + {% endif %} +
        +
        + {% if not forloop.last %}
        {% endif %} + {% endfor %} +{% endblock %} diff --git a/netbox/templates/dcim/console_connections_list.html b/netbox/templates/dcim/console_connections_list.html index 89eb0822dca..8b7827f8a3b 100644 --- a/netbox/templates/dcim/console_connections_list.html +++ b/netbox/templates/dcim/console_connections_list.html @@ -3,15 +3,13 @@ {% block content %}
        - {% if perms.dcim.change_consoleport %} - {% import_button 'dcim:console_connections_import' %} - {% endif %} {% export_button content_type %}

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

        {% include 'responsive_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
        {% include 'inc/search_panel.html' %} diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html deleted file mode 100644 index 679540960c8..00000000000 --- a/netbox/templates/dcim/consoleport_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
        - {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
        -
        - {% if form.non_field_errors %} -
        -
        Errors
        -
        - {{ form.non_field_errors }} -
        -
        - {% endif %} -
        -
        {% block title %}Connect {{ consoleport.device }} {{ consoleport }}{% endblock %}
        -
        - -
        - -
        - {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.console_server %} -
        -
        - {% render_field form.cs_port %} - {% render_field form.connection_status %} -
        -
        -
        -
        - - Cancel -
        -
        -
        -
        -
        -{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/consoleport_disconnect.html b/netbox/templates/dcim/consoleport_disconnect.html deleted file mode 100644 index dfd5cf2e7fb..00000000000 --- a/netbox/templates/dcim/consoleport_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect console port {{ consoleport }}?{% endblock %} - -{% block message %} -

        Are you sure you want to disconnect this console port from {{ consoleport.cs_port.device }} {{ consoleport.cs_port }}?

        -{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html deleted file mode 100644 index 98910432913..00000000000 --- a/netbox/templates/dcim/consoleserverport_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
        - {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
        -
        - {% if form.non_field_errors %} -
        -
        Errors
        -
        - {{ form.non_field_errors }} -
        -
        - {% endif %} -
        -
        {% block title %}Connect {{ consoleserverport.device }} {{ consoleserverport }}{% endblock %}
        -
        - -
        - -
        - {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.device %} -
        -
        - {% render_field form.port %} - {% render_field form.connection_status %} -
        -
        -
        -
        - - Cancel -
        -
        -
        -
        -
        -{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_disconnect.html b/netbox/templates/dcim/consoleserverport_disconnect.html deleted file mode 100644 index 5c059446405..00000000000 --- a/netbox/templates/dcim/consoleserverport_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect {{ consoleserverport.device }} {{ consoleserverport }}?{% endblock %} - -{% block message %} -

        Are you sure you want to disconnect {{ consoleserverport.connected_console.device }} {{ consoleserverport.connected_console }} from this port?

        -{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 7b56269b12b..860a3aa4431 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load helpers %} {% block title %}{{ device }}{% endblock %} @@ -35,6 +35,21 @@
        {% if perms.dcim.change_device %} +
        + + +
        Edit this device @@ -127,7 +142,7 @@

        {{ device }}

        {% elif device.rack and device.device_type.u_height %} Not racked {% else %} - N/A + {% endif %} @@ -153,23 +168,11 @@

        {{ device }}

        Serial Number - - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} - + {{ device.serial|placeholder }} Asset Tag - - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} - + {{ device.asset_tag|placeholder }}
        @@ -251,7 +254,7 @@

        {{ device }}

        (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -266,7 +269,7 @@

        {{ device }}

        (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -290,7 +293,7 @@

        {{ device }}

        Comments
        -
        +
        {% if device.comments %} {{ device.comments|gfm }} {% else %} @@ -300,55 +303,35 @@

        {{ device }}

        -
        -
        - 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 %} {% if request.user.is_authenticated %}
        @@ -501,7 +484,7 @@

        {{ device }}

        {% endif %} {% endif %} - {% if interfaces or device.device_type.is_network_device %} + {% if interfaces %} {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
        {% csrf_token %} @@ -525,7 +508,9 @@

        {{ device }}

        Name LAG Description + MTU Mode + Cable Connection @@ -533,10 +518,6 @@

        {{ device }}

        {% for iface in interfaces %} {% include 'dcim/inc/interface.html' %} - {% empty %} - - — No interfaces defined — - {% endfor %} @@ -549,8 +530,8 @@

        {{ device }}

        Edit {% endif %} - {% if interfaces and perms.dcim.delete_interfaceconnection %} - {% endif %} @@ -573,7 +554,7 @@

        {{ device }}

        {% endif %} {% endif %} - {% if cs_ports or device.device_type.is_console_server %} + {% if consoleserverports %} {% if perms.dcim.delete_consoleserverport %}
        {% csrf_token %} @@ -589,30 +570,27 @@

        {{ device }}

        {% endif %} Name + Cable Connection - {% for csp in cs_ports %} + {% for csp in consoleserverports %} {% include 'dcim/inc/consoleserverport.html' %} - {% empty %} - - — No console server ports defined — - {% endfor %}
        -{% include 'inc/graphs_modal.html' %} +{% include 'inc/modal.html' with modal_name='graphs' %} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} {% block javascript %} -{% endblock %} diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index d4fbcbc79a2..d2bba95628a 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -22,13 +22,18 @@ {% for iface in interfaces %} {{ iface }} - {% if iface.connection %} - {% with iface.connected_interface as connected_iface %} - - {{ connected_iface.device }} - - - {{ connected_iface }} + {% if iface.connected_endpoint.device %} + + {{ iface.connected_endpoint.device }} + + + {{ iface.connected_endpoint }} + + {% elif iface.connected_endpoint.circuit %} + {% with circuit=iface.connected_endpoint.circuit %} + + + {{ circuit.provider }} {{ circuit }} {% endwith %} {% else %} diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index 7743cc6359e..5d7f0770a57 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device_status.html @@ -1,5 +1,5 @@ {% extends 'dcim/device.html' %} -{% load staticfiles %} +{% load static %} {% block title %}{{ device }} - Status{% endblock %} @@ -73,53 +73,58 @@ url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_facts&method=get_environment", dataType: 'json', success: function(json) { - $('#hostname').html(json['get_facts']['hostname']); - $('#fqdn').html(json['get_facts']['fqdn']); - $('#vendor').html(json['get_facts']['vendor']); - $('#model').html(json['get_facts']['model']); - $('#serial_number').html(json['get_facts']['serial_number']); - $('#os_version').html(json['get_facts']['os_version']); - // Calculate uptime - var uptime = json['get_facts']['uptime']; - console.log(uptime); - var uptime_days = Math.floor(uptime / 86400); - var uptime_hours = Math.floor(uptime % 86400 / 3600); - var uptime_minutes = Math.floor(uptime % 3600 / 60); - $('#uptime').html(uptime_days + "d " + uptime_hours + "h " + uptime_minutes + "m"); - $.each(json['get_environment']['cpu'], function(name, obj) { - var row="" + name + "" + obj['%usage'] + "%"; - $("#cpu").after(row) - }); - $('#memory').after("Used" + json['get_environment']['memory']['used_ram'] + ""); - $('#memory').after("Available" + json['get_environment']['memory']['available_ram'] + ""); - $.each(json['get_environment']['temperature'], function(name, obj) { - var style = "success"; - if (obj['is_alert']) { - style = "warning"; - } else if (obj['is_critical']) { - style = "danger"; - } - var row="" + name + "" + obj['temperature'] + "°C"; - $("#temperature").after(row) - }); - $.each(json['get_environment']['fans'], function(name, obj) { - var row; - if (obj['status']) { - row="" + name + ""; - } else { - row="" + name + ""; - } - $("#fans").after(row) - }); - $.each(json['get_environment']['power'], function(name, obj) { - var row; - if (obj['status']) { - row="" + name + ""; - } else { - row="" + name + ""; - } - $("#power").after(row) - }); + if (!json['get_facts']['error']) { + $('#hostname').html(json['get_facts']['hostname']); + $('#fqdn').html(json['get_facts']['fqdn']); + $('#vendor').html(json['get_facts']['vendor']); + $('#model').html(json['get_facts']['model']); + $('#serial_number').html(json['get_facts']['serial_number']); + $('#os_version').html(json['get_facts']['os_version']); + // Calculate uptime + var uptime = json['get_facts']['uptime']; + console.log(uptime); + var uptime_days = Math.floor(uptime / 86400); + var uptime_hours = Math.floor(uptime % 86400 / 3600); + var uptime_minutes = Math.floor(uptime % 3600 / 60); + $('#uptime').html(uptime_days + "d " + uptime_hours + "h " + uptime_minutes + "m"); + } + + if (!json['get_environment']['error']) { + $.each(json['get_environment']['cpu'], function(name, obj) { + var row="" + name + "" + obj['%usage'] + "%"; + $("#cpu").after(row) + }); + $('#memory').after("Used" + json['get_environment']['memory']['used_ram'] + ""); + $('#memory').after("Available" + json['get_environment']['memory']['available_ram'] + ""); + $.each(json['get_environment']['temperature'], function(name, obj) { + var style = "success"; + if (obj['is_alert']) { + style = "warning"; + } else if (obj['is_critical']) { + style = "danger"; + } + var row="" + name + "" + obj['temperature'] + "°C"; + $("#temperature").after(row) + }); + $.each(json['get_environment']['fans'], function(name, obj) { + var row; + if (obj['status']) { + row="" + name + ""; + } else { + row="" + name + ""; + } + $("#fans").after(row) + }); + $.each(json['get_environment']['power'], function(name, obj) { + var row; + if (obj['status']) { + row="" + name + ""; + } else { + row="" + name + ""; + } + $("#power").after(row) + }); + } }, error: function(xhr) { alert(xhr.responseText); diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 652c291e651..cf3576d4af9 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -15,12 +15,27 @@
        {% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
        - {% if perms.dcim.change_devicetype %} + {% if perms.dcim.change_devicetype %} +
        + + +
        - - Edit this device type + + Edit this device type - {% endif %} + {% endif %} {% if perms.dcim.delete_devicetype %} @@ -43,7 +58,7 @@

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

        {% block content %}
        -
        +
        -
        -
        - Function -
        - - - - - - - - - - - + - - - - + + + +
        - {% if devicetype.is_console_server %} - - {% else %} - - {% endif %} - - Console Server
        - This device {% if devicetype.is_console_server %}has{% else %}does not have{% endif %} console server ports -
        - {% if devicetype.is_pdu %} - - {% else %} - - {% endif %} - - PDU
        - This device {% if devicetype.is_pdu %}has{% else %}does not have{% endif %} power outlets -
        - {% if devicetype.is_network_device %} - - {% else %} - - {% endif %} - Parent/Child - Network Device
        - This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} network interfaces -
        {% if devicetype.subdevice_role == True %} {% elif devicetype.subdevice_role == False %} {% else %} - - {% endif %} - - Parent/Child
        - {% if devicetype.subdevice_role == True %} - This device has device bays for mounting child devices - {% elif devicetype.subdevice_role == False %} - This device can only be mounted in a parent device - {% else %} - This device does not have device bays + {% endif %}
        Instances{{ devicetype.instances.count }}
        +
        +
        {% 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
        -
        +
        {% if devicetype.comments %} {{ devicetype.comments|gfm }} {% else %} @@ -173,21 +128,53 @@

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

        -
        - {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %} - {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %} - {% if devicetype.is_parent_device or devicebay_table.rows %} +
        +{% if devicetype.consoleport_templates.exists or devicetype.powerport_templates.exists %} +
        +
        + {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %} +
        +
        + {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %} +
        +
        +{% endif %} +{% if devicetype.is_parent_device or devicebay_table.rows %} +
        +
        {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %} - {% endif %} - {% if devicetype.is_network_device or interface_table.rows %} - {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %} - {% endif %} - {% if devicetype.is_console_server or consoleserverport_table.rows %} +
        +
        +{% endif %} +{% if devicetype.consoleserverport_templates.exists %} +
        +
        {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %} - {% endif %} - {% if devicetype.is_pdu or poweroutlet_table.rows %} +
        +
        +{% endif %} +{% if devicetype.poweroutlet_templates.exists %} +
        +
        {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %} - {% endif %} +
        -
        +{% endif %} +{% if devicetype.interface_templates.exists %} +
        +
        + {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %} +
        +
        +{% endif %} +{% if devicetype.frontport_templates.exists or devicetype.rearport_templates.exists %} +
        +
        + {% include 'dcim/inc/devicetype_component_table.html' with table=front_port_table title='Front Ports' add_url='dcim:devicetype_add_frontport' delete_url='dcim:devicetype_delete_frontport' %} +
        +
        + {% include 'dcim/inc/devicetype_component_table.html' with table=rear_port_table title='Rear Ports' add_url='dcim:devicetype_add_rearport' delete_url='dcim:devicetype_delete_rearport' %} +
        +
        +{% endif %} {% endblock %} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index d0ed2c20435..3c22eb9cde6 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -11,15 +11,6 @@ {% render_field form.part_number %} {% render_field form.u_height %} {% render_field form.is_full_depth %} - {% render_field form.interface_ordering %} -
        -
        -
        -
        Function
        -
        - {% render_field form.is_console_server %} - {% render_field form.is_pdu %} - {% render_field form.is_network_device %} {% render_field form.subdevice_role %}
        diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html new file mode 100644 index 00000000000..48af97a0b8b --- /dev/null +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -0,0 +1,36 @@ +{% load helpers %} + + {% if termination.device %} + {# Device component #} + + + + + + + + + + + + + {% else %} + {# Circuit termination #} + + + + + + + + + {% endif %} +
        Device + {{ termination.device }} +
        Type + {{ termination|model_name|capfirst }} +
        Component{{ termination }}
        Provider + {{ termination.circuit.provider }} +
        Circuit + {{ termination.circuit }} ({{ termination }}) +
        diff --git a/netbox/templates/dcim/inc/cable_toggle_buttons.html b/netbox/templates/dcim/inc/cable_toggle_buttons.html new file mode 100644 index 00000000000..3e0209e01ec --- /dev/null +++ b/netbox/templates/dcim/inc/cable_toggle_buttons.html @@ -0,0 +1,16 @@ +{% if perms.dcim.change_cable %} + {% if cable.status %} + + + + {% else %} + + + + {% endif %} +{% endif %} +{% if perms.dcim.delete_cable %} + + + +{% endif %} diff --git a/netbox/templates/dcim/inc/cable_trace_end.html b/netbox/templates/dcim/inc/cable_trace_end.html new file mode 100644 index 00000000000..c4c41e4bad3 --- /dev/null +++ b/netbox/templates/dcim/inc/cable_trace_end.html @@ -0,0 +1,34 @@ +{% load helpers %} + +
        +
        + {% if end.device %} + {{ end.device }}
        + + {{ end.device.site }} + {% if end.device.rack %} + / {{ end.device.rack }} + {% endif %} + + {% else %} + {{ end.circuit.provider }} + {% endif %} +
        +
        + {% if end.device %} + {# Device component #} + {% with model=end|model_name %} + {{ model|bettertitle }} {{ end }}
        + {% if model == 'interface' %} + {{ end.get_form_factor_display }} + {% elif model == 'front port' or model == 'rear port' %} + {{ end.get_type_display }} + {% endif %} + {% endwith %} + {% else %} + {# Circuit termination #} + {{ end.circuit }}
        + {{ end }} + {% endif %} +
        +
        diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 4d75cc65b15..e78ab7ea7de 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -1,45 +1,49 @@ - + + + {# Name #} {{ cp }} - {% if cp.cs_port %} + + {# Cable #} + + {% if cp.cable %} + {{ cp.cable }} + {% else %} + — + {% endif %} + + + {# Connection #} + {% if cp.connected_endpoint %} - {{ cp.cs_port.device }} + {{ cp.connected_endpoint.device }} - {{ cp.cs_port }} + {{ cp.connected_endpoint }} {% else %} Not connected {% endif %} + + {# Actions #} + {% if cp.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %} + {% elif perms.dcim.add_cable %} + + + + {% endif %} {% if perms.dcim.change_consoleport %} - {% if cp.cs_port %} - {% if cp.connection_status %} - - - - {% else %} - - - - {% endif %} - - - - {% else %} - - - - {% endif %} {% endif %} {% if perms.dcim.delete_consoleport %} - {% if cp.cs_port %} + {% if cp.connected_endpoint %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 673f51388da..212846b0074 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -1,50 +1,56 @@ - + + + {# Checkbox #} {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} {% endif %} + + {# Name #} {{ csp }} - {% if csp.connected_console %} + + {# Cable #} + + {% if csp.cable %} + {{ csp.cable }} + {% else %} + — + {% endif %} + + + {# Connection #} + {% if csp.connected_endpoint %} - {{ csp.connected_console.device }} + {{ csp.connected_endpoint.device }} - {{ csp.connected_console }} + {{ csp.connected_endpoint }} {% else %} Not connected {% endif %} + + {# Actions #} + {% if csp.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %} + {% elif perms.dcim.add_cable %} + + + + {% endif %} {% if perms.dcim.change_consoleserverport %} - {% if csp.connected_console %} - {% if csp.connected_console.connection_status %} - - - - {% else %} - - - - {% endif %} - - - - {% else %} - - - - {% endif %} {% endif %} {% if perms.dcim.delete_consoleserverport %} - {% if csp.connected_console %} + {% if csp.connected_endpoint %} diff --git a/netbox/templates/dcim/inc/filter_rack_group.html b/netbox/templates/dcim/inc/filter_rack_group.html deleted file mode 100644 index 9c5582f874f..00000000000 --- a/netbox/templates/dcim/inc/filter_rack_group.html +++ /dev/null @@ -1,29 +0,0 @@ - diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html new file mode 100644 index 00000000000..568eaa38e62 --- /dev/null +++ b/netbox/templates/dcim/inc/frontport.html @@ -0,0 +1,55 @@ +{% load helpers %} + + + {# Checkbox #} + {% if perms.dcim.change_frontport or perms.dcim.delete_frontport %} + + + + {% endif %} + + {# Name #} + + {{ frontport }} + + + {# Type #} + {{ frontport.get_type_display }} + + {# Rear port #} + {{ frontport.rear_port }} + {{ frontport.rear_port_position }} + + {# Description #} + {{ frontport.description|placeholder }} + + {# Cable #} + + {% if frontport.cable %} + {{ frontport.cable }} + {% else %} + Not connected + {% endif %} + + + {# Actions #} + + {% if frontport.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %} + {% elif perms.dcim.add_cable %} + + + + {% endif %} + {% if perms.dcim.change_frontport %} + + + + {% endif %} + {% if perms.dcim.delete_frontport %} + + + + {% endif %} + + diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 229f6f2eb81..3d805720857 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,4 +1,5 @@ - +{% load helpers %} + {# Checkbox #} {% if perms.dcim.change_interface or perms.dcim.delete_interface %} @@ -8,60 +9,123 @@ {% endif %} {# Icon and name #} - + {{ iface }} + {% if iface.mac_address %} +
        {{ iface.mac_address }} + {% endif %} {# LAG #} {% if iface.lag %} - {{ iface.lag }} + {{ iface.lag }} + {% endif %} + + + {# Description/tags #} + + {% if iface.description %} + {{ iface.description }}
        {% endif %} + {% for tag in iface.tags.all %} + {% tag tag %} + {% empty %} + {% if not iface.description %}—{% endif %} + {% endfor %} - {# Description #} - {{ iface.description|default:"—" }} + {# MTU #} + {{ iface.mtu|default:"—" }} {# 802.1Q mode #} - {{ iface.get_mode_display }} + {{ iface.get_mode_display|default:"—" }} + + {# Cable #} + + {% if iface.cable %} + {{ iface.cable }} + + + + {% else %} + — + {% endif %} + {# Connection or type #} {% if iface.is_lag %} LAG interface
        - {{ iface.member_interfaces.all|join:", "|default:"No members" }} + + {% for member in iface.member_interfaces.all %} + {{ member }}{% if not forloop.last %}, {% endif %} + {% empty %} + No members + {% endfor %} + {% elif iface.is_virtual %} Virtual interface {% elif iface.is_wireless %} Wireless interface - {% elif iface.connection %} - {% with iface.connected_interface as connected_iface %} - - {{ connected_iface.device }} - - - {{ connected_iface }} - - {% endwith %} - {% elif iface.circuit_termination %} - {% with iface.circuit_termination.get_peer_termination as peer_termination %} - - - {% if peer_termination %} - {% if peer_termination.interface %} - {{ peer_termination.interface.device }} - ({{ peer_termination.site }}) - {% else %} - {{ peer_termination.site }} - {% endif %} - via + {% elif iface.connected_endpoint.name %} + {# Connected to an Interface #} + + + {{ iface.connected_endpoint.device }} + + + + + + {{ iface.connected_endpoint }} + + + + {% elif iface.connected_endpoint.term_side %} + {# Connected to a CircuitTermination #} + {% with iface.connected_endpoint.get_peer_termination as peer_termination %} + {% if peer_termination %} + {% if peer_termination.connected_endpoint %} + + + {{ peer_termination.connected_endpoint.device }} +
        + via + + {{ iface.connected_endpoint.circuit.provider }} + {{ iface.connected_endpoint.circuit }} + + + + + {{ peer_termination.connected_endpoint }} + + {% else %} + + + {{ peer_termination.site }} + + via + + {{ iface.connected_endpoint.circuit.provider }} + {{ iface.connected_endpoint.circuit }} + + {% endif %} - {{ iface.circuit_termination.circuit }} - + {% else %} + + + + {{ iface.connected_endpoint.circuit.provider }} + {{ iface.connected_endpoint.circuit }} + + + {% endif %} {% endwith %} {% else %} @@ -72,7 +136,7 @@ {# Buttons #} {% if show_graphs %} - {% if iface.circuit_termination or iface.connection %} + {% if iface.connected_endpoint %} @@ -84,32 +148,12 @@ {% endif %} {% if perms.dcim.change_interface %} - {% if not iface.is_virtual %} - {% if iface.connection %} - {% if iface.connection.connection_status %} - - - - {% else %} - - - - {% endif %} - - - - {% elif iface.circuit_termination and perms.circuits.change_circuittermination %} - - - - - {% else %} - - - - {% endif %} + {% if iface.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %} + {% elif not iface.is_virtual and perms.dcim.add_cable %} + + + {% endif %} @@ -138,7 +182,7 @@ {% endif %} {# IP addresses table #} - + diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 18cfb7f2cff..54ce414adca 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -1,50 +1,56 @@ - + + + {# Checkbox #} {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} {% endif %} + + {# Name #} - {% if po.connected_port %} + + {# Cable #} + + + {# Connection #} + {% if po.connected_endpoint %} {% else %} {% endif %} + + {# Actions #} + + + {# Name #} - {% if pp.power_outlet %} + + {# Cable #} + + + {# Connection #} + {% if pp.connected_endpoint %} {% else %} {% endif %} + + {# Actions #} + + {# Checkbox #} + {% if perms.dcim.change_rearport or perms.dcim.delete_rearport %} + + {% endif %} + + {# Name #} + + + {# Type #} + + + {# Positions #} + + + {# Description #} + + + {# Cable #} + + + {# Actions #} + + diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 47278d468d3..8625852e7ab 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -82,33 +82,15 @@

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

        - + - + - + @@ -122,120 +104,105 @@

        {% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %} {% if interface.is_connectable %}
        - Connected Interface + Connection
        - {% if connected_interface %} + {% if interface.cable %}

        {{ po }} + {% if po.cable %} + {{ po.cable }} + {% else %} + — + {% endif %} + - {{ po.connected_port.device }} + {{ po.connected_endpoint.device }} - {{ po.connected_port }} + {{ po.connected_endpoint }} Not connected + {% if po.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %} + {% elif perms.dcim.add_cable %} + + + + {% endif %} {% if perms.dcim.change_poweroutlet %} - {% if po.connected_port %} - {% if po.connected_port.connection_status %} - - - - {% else %} - - - - {% endif %} - - - - {% else %} - - - - {% endif %} {% endif %} {% if perms.dcim.delete_poweroutlet %} - {% if po.connected_port %} + {% if po.connected_endpoint %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 32e7f20fd4a..4bfbdadcfe4 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -1,45 +1,49 @@ -
        {{ pp }} + {% if pp.cable %} + {{ pp.cable }} + {% else %} + — + {% endif %} + - {{ pp.power_outlet.device }} + {{ pp.connected_endpoint.device }} - {{ pp.power_outlet }} + {{ pp.connected_endpoint }} Not connected - {% if perms.dcim.change_powerport %} - {% if pp.power_outlet %} - {% if pp.connection_status %} - - - - {% else %} - - - - {% endif %} - - + {% if pp.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %} + {% elif perms.dcim.add_cable %} + + - {% else %} - - - - {% endif %} + {% endif %} + {% if perms.dcim.change_powerport %} {% endif %} {% if perms.dcim.delete_powerport %} - {% if pp.power_outlet %} + {% if pp.connected_endpoint %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 46fe01c8deb..e7beeb9ba3a 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -27,13 +27,13 @@ {% ifequal u.device.face face_id %} - {{ u.device.name|default:u.device.device_role }} + {{ u.device }} {% if u.device.devicebay_count %} ({{ u.device.get_children.count }}/{{ u.device.devicebay_count }}) {% endif %} {% else %} - {{ u.device.name|default:u.device.device_role }} + {{ u.device }} {% endifequal %} {% else %} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html new file mode 100644 index 00000000000..77ddbb78a46 --- /dev/null +++ b/netbox/templates/dcim/inc/rearport.html @@ -0,0 +1,54 @@ +{% load helpers %} +
        + + + {{ rearport }} + {{ rearport.get_type_display }}{{ rearport.positions }}{{ rearport.description|placeholder }} + {% if rearport.cable %} + {{ rearport.cable }} + {% else %} + Not connected + {% endif %} + + {% if rearport.cable %} + {% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %} + {% elif perms.dcim.add_cable %} + + + + {% endif %} + {% if perms.dcim.change_rearport %} + + + + {% endif %} + {% if perms.dcim.delete_rearport %} + + + + {% endif %} +
        Description - {% if interface.description %} - {{ interface.description }} - {% else %} - N/A - {% endif %} - {{ interface.description|placeholder }}
        MTU - {% if interface.mtu %} - {{ interface.mtu }} - {% else %} - N/A - {% endif %} - {{ interface.mtu|placeholder }}
        MAC Address - {% if interface.mac_address %} - {{ interface.mac_address }} - {% else %} - N/A - {% endif %} - {{ interface.mac_address|placeholder }}
        802.1Q Mode
        + {% if connected_interface %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% elif connected_circuittermination %} + {% with ct=connected_circuittermination %} + + + + + + + + + + + + + {% endwith %} + {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - + - + - - - - - {% if interface.connection %} - - - - - {% endif %}
        Device + {{ connected_interface.device }} +
        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{{ connected_interface.description|placeholder }}
        MTU{{ connected_interface.mtu|placeholder }}
        MAC Address{{ connected_interface.mac_address|placeholder }}
        802.1Q Mode{{ connected_interface.get_mode_display }}
        Provider{{ ct.circuit.provider }}
        Circuit{{ ct.circuit }}
        Side{{ ct.term_side }}
        {% 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 %} -
        MTUCable - {% if connected_interface.mtu %} - {{ connected_interface.mtu }} - {% else %} - N/A - {% endif %} + {{ interface.cable }} + + +
        MAC AddressConnection Status - {% if connected_interface.mac_address %} - {{ connected_interface.mac_address }} + {% if interface.connection_status %} + {{ interface.get_connection_status_display }} {% else %} - N/A + {{ interface.get_connection_status_display }} {% 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 + Not connected
        {% 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 %}
        diff --git a/netbox/templates/dcim/interface_connections_list.html b/netbox/templates/dcim/interface_connections_list.html index 950eb2f0b7c..27a4368a072 100644 --- a/netbox/templates/dcim/interface_connections_list.html +++ b/netbox/templates/dcim/interface_connections_list.html @@ -3,15 +3,13 @@ {% block content %}
        - {% if perms.dcim.add_interfaceconnection %} - {% import_button 'dcim:interface_connections_import' %} - {% endif %} {% export_button content_type %}

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

        {% include 'responsive_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
        {% include 'inc/search_panel.html' %} diff --git a/netbox/templates/dcim/interfaceconnection_delete.html b/netbox/templates/dcim/interfaceconnection_delete.html deleted file mode 100644 index 8cb08e1ad43..00000000000 --- a/netbox/templates/dcim/interfaceconnection_delete.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Delete interface connection?{% endblock %} - -{% block message %} -

        Are you sure you want to delete the connection between these two interfaces?

        -
          -
        • {{ interfaceconnection.interface_a.device }}: {{ interfaceconnection.interface_a }}
        • -
        • {{ interfaceconnection.interface_b.device }}: {{ interfaceconnection.interface_b }}
        • -
        -{% endblock %} diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html deleted file mode 100644 index 488c114728e..00000000000 --- a/netbox/templates/dcim/interfaceconnection_edit.html +++ /dev/null @@ -1,97 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block title %}Connect Interfaces{% endblock %} - -{% block content %} -

        Connect Interfaces

        -
        - {% csrf_token %} -
        -
        - {% if form.non_field_errors %} -
        -
        Errors
        -
        - {{ form.non_field_errors }} -
        -
        - {% endif %} -
        -
        -
        -
        -
        -
        - A Side -
        -
        -
        - -
        -

        {{ device.site }}

        -
        -
        -
        - -
        -

        {{ device.rack|default:"None" }}

        -
        -
        -
        - -
        -

        {{ device }}

        -
        -
        - {% render_field form.interface_a %} -
        -
        -
        -
        - -
        -
        -
        -
        - B Side -
        -
        - -
        - -
        - {% render_field form.site_b %} - {% render_field form.rack_b %} - {% render_field form.device_b %} -
        -
        - {% render_field form.interface_b %} -
        -
        -
        -
        -
        -
        - {% render_field form.connection_status %} -
        -
        -
        -
        - - - Cancel -
        -
        -
        -{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/power_connections_list.html b/netbox/templates/dcim/power_connections_list.html index 4e351eb6a90..4982b5702ab 100644 --- a/netbox/templates/dcim/power_connections_list.html +++ b/netbox/templates/dcim/power_connections_list.html @@ -3,15 +3,13 @@ {% block content %}
        - {% if perms.dcim.change_powerport %} - {% import_button 'dcim:power_connections_import' %} - {% endif %} {% export_button content_type %}

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

        {% include 'responsive_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
        {% include 'inc/search_panel.html' %} diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html deleted file mode 100644 index 6c7cef4498f..00000000000 --- a/netbox/templates/dcim/poweroutlet_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
        - {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
        -
        - {% if form.non_field_errors %} -
        -
        Errors
        -
        - {{ form.non_field_errors }} -
        -
        - {% endif %} -
        -
        {% block title %}Connect {{ poweroutlet.device }} {{ poweroutlet }}{% endblock %}
        -
        - -
        - -
        - {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.device %} -
        -
        - {% render_field form.port %} - {% render_field form.connection_status %} -
        -
        -
        -
        - - Cancel -
        -
        -
        -
        -
        -{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_disconnect.html b/netbox/templates/dcim/poweroutlet_disconnect.html deleted file mode 100644 index 81372033b57..00000000000 --- a/netbox/templates/dcim/poweroutlet_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect {{ poweroutlet.device }} {{ poweroutlet }}?{% endblock %} - -{% block message %} -

        Are you sure you want to disconnect {{ poweroutlet.connected_port.device }} {{ poweroutlet.connected_port }} from this port?

        -{% endblock %} diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html deleted file mode 100644 index 1ffa6de2890..00000000000 --- a/netbox/templates/dcim/powerport_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
        - {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
        -
        - {% if form.non_field_errors %} -
        -
        Errors
        -
        - {{ form.non_field_errors }} -
        -
        - {% endif %} -
        -
        {% block title %}Connect {{ powerport.device }} {{ powerport }}{% endblock %}
        -
        - -
        - -
        - {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.pdu %} -
        -
        - {% render_field form.power_outlet %} - {% render_field form.connection_status %} -
        -
        -
        -
        - - Cancel -
        -
        -
        -
        -
        -{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/powerport_disconnect.html b/netbox/templates/dcim/powerport_disconnect.html deleted file mode 100644 index f98694d9fd8..00000000000 --- a/netbox/templates/dcim/powerport_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect power port {{ powerport }}?{% endblock %} - -{% block message %} -

        Are you sure you want to disconnect this power port from {{ powerport.power_outlet.device }} {{ powerport.power_outlet }}?

        -{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index aaebe02da4c..1d92aac2240 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -83,13 +83,7 @@

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

        Facility ID - - {% if rack.facility_id %} - {{ rack.facility_id }} - {% else %} - N/A - {% endif %} - + {{ rack.facility_id|placeholder }} Tenant @@ -105,6 +99,12 @@

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

        {% endif %} + + Status + + {{ rack.get_status_display }} + + Role @@ -117,14 +117,12 @@

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

        Serial Number - - {% if rack.serial %} - {{ rack.serial }} - {% else %} - N/A - {% endif %} - + {{ rack.serial|placeholder }} + + Asset Tag + {{ rack.asset_tag|placeholder }} + Devices @@ -156,6 +154,26 @@

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

        Height {{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %}) + + Outer Width + + {% if rack.outer_width %} + {{ rack.outer_width }} {{ rack.get_outer_unit_display }} + {% else %} + + {% endif %} + + + + Outer Depth + + {% if rack.outer_depth %} + {{ rack.outer_depth }} {{ rack.get_outer_unit_display }} + {% else %} + + {% endif %} + +
        {% include 'inc/custom_fields_panel.html' with obj=rack %} @@ -164,7 +182,7 @@

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

        Comments
        -
        +
        {% if rack.comments %} {{ rack.comments|gfm }} {% else %} @@ -195,7 +213,7 @@

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

        {% if device.parent_bay %} {{ device.parent_bay }} {% else %} - N/A + {% endif %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index d500a195424..cd1192c19cf 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -9,8 +9,10 @@ {% render_field form.name %} {% render_field form.facility_id %} {% render_field form.group %} + {% render_field form.status %} {% render_field form.role %} {% render_field form.serial %} + {% render_field form.asset_tag %}
        @@ -26,6 +28,18 @@ {% render_field form.type %} {% render_field form.width %} {% render_field form.u_height %} +
        + +
        + {{ form.outer_width }} +
        +
        + {{ form.outer_depth }} +
        +
        + {{ form.outer_unit }} +
        +
        {% render_field form.desc_units %}
        diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html index e61f4eadf2d..049c5097177 100644 --- a/netbox/templates/dcim/rack_list.html +++ b/netbox/templates/dcim/rack_list.html @@ -20,8 +20,3 @@

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

        {% endblock %} - -{% block javascript %} - {% include 'dcim/inc/filter_rack_group.html' %} -{% endblock %} - diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index f4623b57b28..a78cba43d51 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load tz %} {% load helpers %} @@ -106,23 +106,11 @@

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

        Facility - - {% if site.facility %} - {{ site.facility }} - {% else %} - N/A - {% endif %} - + {{ site.facility|placeholder }} AS Number - - {% if site.asn %} - {{ site.asn }} - {% else %} - N/A - {% endif %} - + {{ site.asn|placeholder }} Time Zone @@ -131,19 +119,13 @@

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

        {{ site.time_zone }} (UTC {{ site.time_zone|tzoffset }})
        Site time: {% timezone site.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %} {% else %} - N/A + {% endif %} Description - - {% if site.description %} - {{ site.description }} - {% else %} - N/A - {% endif %} - + {{ site.description|placeholder }}
        @@ -157,25 +139,19 @@

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

        {% if site.physical_address %} {{ site.physical_address|linebreaksbr }} {% else %} - N/A + {% endif %} Shipping Address - - {% if site.shipping_address %} - {{ site.shipping_address|linebreaksbr }} - {% else %} - N/A - {% endif %} - + {{ site.shipping_address|linebreaksbr|placeholder }} GPS Coordinates @@ -188,19 +164,13 @@

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

        {{ site.latitude }}, {{ site.longitude }} {% else %} - N/A + {% endif %} Contact Name - - {% if site.contact_name %} - {{ site.contact_name }} - {% else %} - N/A - {% endif %} - + {{ site.contact_name|placeholder }} Contact Phone @@ -208,7 +178,7 @@

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

        {% if site.contact_phone %} {{ site.contact_phone }} {% else %} - N/A + {% endif %} @@ -218,7 +188,7 @@

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

        {% if site.contact_email %} {{ site.contact_email }} {% else %} - N/A + {% endif %} @@ -230,7 +200,7 @@

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

        Comments
        -
        +
        {% if site.comments %} {{ site.comments|gfm }} {% else %} @@ -330,7 +300,7 @@

        N/A - {% endif %} - - - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A + {% endif %} + {{ device.serial|placeholder }} {{ form.vc_position }} {% if form.vc_position.errors %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index c987daf3327..6c14b7b16c5 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -55,13 +55,7 @@

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

        Description - - {% if configcontext.description %} - {{ configcontext.description }} - {% else %} - N/A - {% endif %} - + {{ configcontext.description|placeholder }} Active diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html index c35ba76ff09..16a1dc22035 100644 --- a/netbox/templates/extras/configcontext_list.html +++ b/netbox/templates/extras/configcontext_list.html @@ -10,7 +10,7 @@

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

        - {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %} + {% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
        {% include 'inc/search_panel.html' %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 7d2f8a2e2cf..609e8acc920 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -38,7 +38,7 @@

        {{ module|bettertitle }}

        {{ method }} - + diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html new file mode 100644 index 00000000000..748cad0bf5f --- /dev/null +++ b/netbox/templates/extras/tag.html @@ -0,0 +1,70 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
        +
        + +
        +
        +
        +
        + + + + +
        +
        +
        +
        +
        + {% if perms.taggit.change_tag %} + + + Edit this tag + + {% endif %} +
        +

        {% block title %}Tag: {{ tag }}{% endblock %}

        +{% endblock %} + +{% block content %} +
        +
        +
        +
        + Tag +
        + + + + + + + + + + + + + +
        Name + {{ tag.name }} +
        Slug + {{ tag.slug }} +
        Tagged Items + {{ items_count }} +
        +
        +
        +
        + {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} + {% include 'inc/paginator.html' with paginator=items_table.paginator page=items_table.page %} +
        +
        +{% endblock %} diff --git a/netbox/templates/extras/tag_list.html b/netbox/templates/extras/tag_list.html index 3136991a0d4..8178e55385c 100644 --- a/netbox/templates/extras/tag_list.html +++ b/netbox/templates/extras/tag_list.html @@ -4,8 +4,11 @@ {% block content %}

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

        -
        +
        {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
        +
        + {% include 'inc/search_panel.html' %} +
        {% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index d6af5645849..76d90bad71d 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -39,6 +39,8 @@

        Devic

        Connections

        + {{ stats.cable_count }} +

        Cables

        {{ stats.interface_connections_count }}

        Interfaces

        {{ stats.console_connections_count }} diff --git a/netbox/templates/inc/ajax_loader.html b/netbox/templates/inc/ajax_loader.html index b5b3ee2c1db..f6982bd6520 100644 --- a/netbox/templates/inc/ajax_loader.html +++ b/netbox/templates/inc/ajax_loader.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %}
        diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index e469048af0a..52d9c2d6ee3 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -20,7 +20,7 @@ {% elif field.required %} Not defined {% else %} - N/A + {% endif %} diff --git a/netbox/templates/inc/graphs_modal.html b/netbox/templates/inc/modal.html similarity index 68% rename from netbox/templates/inc/graphs_modal.html rename to netbox/templates/inc/modal.html index 29eaf18bf5d..b70b9115fd5 100644 --- a/netbox/templates/inc/graphs_modal.html +++ b/netbox/templates/inc/modal.html @@ -1,9 +1,9 @@ - diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 73e87149b63..96e3a5e1044 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -129,13 +129,7 @@

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

        Description - - {% if vlan.description %} - {{ vlan.description }} - {% else %} - N/A - {% endif %} - + {{ vlan.description|placeholder }}
        diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index a69ccd0f574..f8921280427 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -82,13 +82,7 @@

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

        Description - - {% if vrf.description %} - {{ vrf.description }} - {% else %} - N/A - {% endif %} - + {{ vrf.description|placeholder }}
        diff --git a/netbox/templates/panel_table.html b/netbox/templates/panel_table.html index 26e623675d6..18958512dbe 100644 --- a/netbox/templates/panel_table.html +++ b/netbox/templates/panel_table.html @@ -12,9 +12,3 @@
        None
        {% endif %}
        - -{% if table.rows and not hide_paginator %} - {% with paginator=table.paginator page=table.page %} - {% include 'inc/paginator.html' %} - {% endwith %} -{% endif %} diff --git a/netbox/templates/responsive_table.html b/netbox/templates/responsive_table.html index 81d9126fda2..a6aaf5a6f07 100644 --- a/netbox/templates/responsive_table.html +++ b/netbox/templates/responsive_table.html @@ -3,6 +3,3 @@
        {% render_table table 'inc/table.html' %}
        -{% with paginator=table.paginator page=table.page %} - {% include 'inc/paginator.html' %} -{% endwith %} diff --git a/netbox/templates/search.html b/netbox/templates/search.html index 36c0fb5adcd..6388cc02224 100644 --- a/netbox/templates/search.html +++ b/netbox/templates/search.html @@ -12,7 +12,7 @@ diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 2d2fc464407..be196aa57f7 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load form_helpers %} {% block content %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index a460f80e813..169f16b118c 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -1,5 +1,5 @@ {% extends 'utilities/obj_import.html' %} -{% load static from staticfiles %} +{% load static %} {% block content %} {{ block.super }} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 6f2131a512e..91d3ce98675 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -71,13 +71,7 @@

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

        Description - - {% if tenant.description %} - {{ tenant.description }} - {% else %} - N/A - {% endif %} - + {{ tenant.description|placeholder }}

        @@ -87,7 +81,7 @@

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

        Comments
        -
        +
        diff --git a/netbox/templates/users/recent_activity.html b/netbox/templates/users/recent_activity.html deleted file mode 100644 index 92933d78bc6..00000000000 --- a/netbox/templates/users/recent_activity.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'users/_user.html' %} - -{% block title %}Recent Activity{% endblock %} - -{% block usercontent %} - - - - - - - - - {% for action in recent_activity %} - - - - - {% endfor %} - -
        TimeAction
        {{ action.time|date:'SHORT_DATETIME_FORMAT' }}{{ action.icon }} {{ action.message|safe }}
        -{% endblock %} diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html index c590f442341..40c3715b03b 100644 --- a/netbox/templates/users/userkey_edit.html +++ b/netbox/templates/users/userkey_edit.html @@ -1,5 +1,5 @@ {% extends 'users/_user.html' %} -{% load static from staticfiles %} +{% load static %} {% load form_helpers %} {% block title %}User Key{% endblock %} diff --git a/netbox/templates/utilities/obj_bulk_add_component.html b/netbox/templates/utilities/obj_bulk_add_component.html index 93c7060a4ce..fb9fb0418b7 100644 --- a/netbox/templates/utilities/obj_bulk_add_component.html +++ b/netbox/templates/utilities/obj_bulk_add_component.html @@ -2,7 +2,8 @@ {% load form_helpers %} {% block content %} -

        Add {{ component_name|title }}

        +

        {% block title %}Add {{ model_name|title }}{% endblock %}

        +

        {{ table.rows|length }} {{ parent_model_name }} selected

        {% csrf_token %} {% if request.POST.return_url %} @@ -27,7 +28,7 @@

        Add {{ component_name|title }}

        {% endif %}
        -
        {{ component_name|title }} to Add
        +
        {{ model_name|title }} to Add
        {% for field in form.visible_fields %} {% render_field field %} diff --git a/netbox/templates/utilities/obj_table.html b/netbox/templates/utilities/obj_table.html index 058c7ef0725..8c5e3b8bef3 100644 --- a/netbox/templates/utilities/obj_table.html +++ b/netbox/templates/utilities/obj_table.html @@ -28,19 +28,22 @@
        {% endif %} {% include table_template|default:'responsive_table.html' %} - {% block extra_actions %}{% endblock %} - {% if bulk_edit_url and permissions.change %} - - {% endif %} - {% if bulk_delete_url and permissions.delete %} - - {% endif %} +
        + {% block extra_actions %}{% endblock %} + {% if bulk_edit_url and permissions.change %} + + {% endif %} + {% if bulk_delete_url and permissions.delete %} + + {% endif %} +
        {% else %} {% include table_template|default:'responsive_table.html' %} {% endif %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
        diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 69ed4e212bc..d3606e62420 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -99,7 +99,7 @@

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

        Comments
        -
        +
        {% if cluster.comments %} {{ cluster.comments|gfm }} {% else %} diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 0a792e0c4f7..cdb946c57bc 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load form_helpers %} {% block content %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 552aaa9970b..1556c5af089 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -116,7 +116,7 @@

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

        (NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -131,7 +131,7 @@

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

        (NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -143,7 +143,7 @@

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

        Comments
        -
        +
        {% if virtualmachine.comments %} {{ virtualmachine.comments|gfm }} {% else %} @@ -181,13 +181,7 @@

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

        - + @@ -195,7 +189,7 @@

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

        {% if virtualmachine.memory %} {{ virtualmachine.memory }} MB {% else %} - N/A + {% endif %} @@ -205,7 +199,7 @@

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

        {% if virtualmachine.disk %} {{ virtualmachine.disk }} GB {% else %} - N/A + {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index bf2961fd823..0ca54edd102 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -20,32 +20,3 @@

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

        {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py new file mode 100644 index 00000000000..d26ac4675dc --- /dev/null +++ b/netbox/tenancy/api/nested_serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from tenancy.models import Tenant, TenantGroup +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedTenantGroupSerializer', + 'NestedTenantSerializer', +] + + +# +# Tenants +# + +class NestedTenantGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') + + class Meta: + model = TenantGroup + fields = ['id', 'url', 'name', 'slug'] + + +class NestedTenantSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') + + class Meta: + model = Tenant + fields = ['id', 'url', 'name', 'slug'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 592e35a6ebb..80f3b948d06 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,15 +1,13 @@ -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, WritableNestedSerializer +from utilities.api import ValidatedModelSerializer +from .nested_serializers import * # -# Tenant groups +# Tenants # class TenantGroupSerializer(ValidatedModelSerializer): @@ -19,18 +17,6 @@ class Meta: fields = ['id', 'name', 'slug'] -class NestedTenantGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') - - class Meta: - model = TenantGroup - fields = ['id', 'url', 'name', 'slug'] - - -# -# Tenants -# - class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): group = NestedTenantGroupSerializer(required=False) tags = TagListSerializerField(required=False) @@ -41,11 +27,3 @@ class Meta: 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - - -class NestedTenantSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') - - class Meta: - model = Tenant - fields = ['id', 'url', 'name', 'slug'] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index a36a1ec3d15..3da0e0f8217 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ def get_view_name(self): router.APIRootView = TenancyRootView # Field choices -router.register(r'_choices', views.TenancyFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.TenancyFieldChoicesViewSet, basename='field-choice') # Tenants router.register(r'tenant-groups', views.TenantGroupViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 48cd76163da..af3e318fc54 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from extras.api.views import CustomFieldModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup @@ -22,7 +20,7 @@ class TenancyFieldChoicesViewSet(FieldChoicesViewSet): class TenantGroupViewSet(ModelViewSet): queryset = TenantGroup.objects.all() serializer_class = serializers.TenantGroupSerializer - filter_class = filters.TenantGroupFilter + filterset_class = filters.TenantGroupFilter # @@ -32,4 +30,4 @@ class TenantGroupViewSet(ModelViewSet): class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group').prefetch_related('tags') serializer_class = serializers.TenantSerializer - filter_class = filters.TenantFilter + filterset_class = filters.TenantFilter diff --git a/netbox/tenancy/apps.py b/netbox/tenancy/apps.py index df2cd2fbb07..53cb9a056c9 100644 --- a/netbox/tenancy/apps.py +++ b/netbox/tenancy/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 7eccff5d3bc..5b3ec30d4c8 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -1,10 +1,8 @@ -from __future__ import unicode_literals - import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet -from utilities.filters import NumericInFilter +from utilities.filters import NumericInFilter, TagFilter from .models import Tenant, TenantGroup @@ -16,7 +14,10 @@ class Meta: class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -26,14 +27,12 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', label='Group (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Tenant diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index b909349231e..3c97eb801e6 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,12 +1,11 @@ -from __future__ import unicode_literals - from django import forms from django.db.models import Count from taggit.forms import TagField from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import ( - APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField, + APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, + FilterChoiceField, SlugField, ) from .models import Tenant, TenantGroup @@ -20,7 +19,9 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = TenantGroup - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class TenantGroupCSVForm(forms.ModelForm): @@ -41,11 +42,20 @@ class Meta: class TenantForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments', 'tags'] + fields = [ + 'name', 'slug', 'group', 'description', 'comments', 'tags', + ] + widgets = { + 'group': APISelect( + api_url="/api/tenancy/tenant-groups/" + ) + } class TenantCSVForm(forms.ModelForm): @@ -70,20 +80,39 @@ class Meta: class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) - group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Tenant.objects.all(), + widget=forms.MultipleHiddenInput() + ) + group = forms.ModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenant-groups/" + ) + ) class Meta: - nullable_fields = ['group'] + nullable_fields = [ + 'group', + ] class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) group = FilterChoiceField( - queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')), + queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/", + value_field="slug", + null_option=True, + ) ) @@ -95,8 +124,14 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): tenant_group = forms.ModelChoiceField( queryset=TenantGroup.objects.all(), required=False, - widget=forms.Select( - attrs={'filter-for': 'tenant', 'nullable': 'true'} + widget=APISelect( + api_url="/api/tenancy/tenant-groups/", + filter_for={ + 'tenant': 'group_id', + }, + attrs={ + 'nullable': 'true', + } ) ) tenant = ChainedModelChoiceField( @@ -106,7 +141,7 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): ), required=False, widget=APISelect( - api_url='/api/tenancy/tenants/?group_id={{tenant_group}}' + api_url='/api/tenancy/tenants/' ) ) @@ -119,4 +154,4 @@ def __init__(self, *args, **kwargs): initial['tenant_group'] = instance.tenant.group kwargs['initial'] = initial - super(TenancyForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) diff --git a/netbox/tenancy/migrations/0001_initial.py b/netbox/tenancy/migrations/0001_initial.py index ed2f800ef53..fcad19413db 100644 --- a/netbox/tenancy/migrations/0001_initial.py +++ b/netbox/tenancy/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 21:58 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/tenancy/migrations/0002_tenant_group_optional.py b/netbox/tenancy/migrations/0002_tenant_group_optional.py index 95b1138ac51..3d91b76ecd9 100644 --- a/netbox/tenancy/migrations/0002_tenant_group_optional.py +++ b/netbox/tenancy/migrations/0002_tenant_group_optional.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-02 19:54 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion 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 index d4258f4dcf1..77dc55975fe 100644 --- 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 @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/tenancy/migrations/0003_unicode_literals.py b/netbox/tenancy/migrations/0003_unicode_literals.py index ed547c51098..24cc7f969f1 100644 --- a/netbox/tenancy/migrations/0003_unicode_literals.py +++ b/netbox/tenancy/migrations/0003_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/tenancy/migrations/0004_tags.py b/netbox/tenancy/migrations/0004_tags.py index 5cb9398b5b3..dbea49cd0ff 100644 --- a/netbox/tenancy/migrations/0004_tags.py +++ b/netbox/tenancy/migrations/0004_tags.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/tenancy/migrations/0005_change_logging.py b/netbox/tenancy/migrations/0005_change_logging.py index 7712e9d02e8..eb097936672 100644 --- a/netbox/tenancy/migrations/0005_change_logging.py +++ b/netbox/tenancy/migrations/0005_change_logging.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 5a22143d325..045679b90ef 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,16 +1,12 @@ -from __future__ import unicode_literals - 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 from utilities.models import ChangeLoggedModel -@python_2_unicode_compatible class TenantGroup(ChangeLoggedModel): """ An arbitrary collection of Tenants. @@ -41,7 +37,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 2e763591abd..91122df7a67 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from utilities.tables import BaseTable, ToggleColumn diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 78b907d2044..69db73ac607 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from rest_framework import status @@ -11,7 +9,7 @@ class TenantGroupTest(APITestCase): def setUp(self): - super(TenantGroupTest, self).setUp() + super().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') @@ -112,7 +110,7 @@ class TenantTest(APITestCase): def setUp(self): - super(TenantTest, self).setUp() + super().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 2da03b7f5e1..19522e6c757 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index fdb453665b8..97334c9f0cb 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,7 +1,5 @@ -from __future__ import unicode_literals - from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db.models import Count, Q +from django.db.models import Count from django.shortcuts import get_object_or_404, render from django.views.generic import View diff --git a/netbox/users/admin.py b/netbox/users/admin.py index ba7a0f912a1..fdc02d62e5c 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,21 +1,39 @@ -from __future__ import unicode_literals - from django import forms from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as UserAdmin_ +from django.contrib.auth.models import User from netbox.admin import admin_site from .models import Token +# Unregister the built-in UserAdmin so that we can use our custom admin view below +admin_site.unregister(User) + + +@admin.register(User, site=admin_site) +class UserAdmin(UserAdmin_): + list_display = [ + 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' + ] + + class TokenAdminForm(forms.ModelForm): - key = forms.CharField(required=False, help_text="If no key is provided, one will be generated automatically.") + key = forms.CharField( + required=False, + help_text="If no key is provided, one will be generated automatically." + ) class Meta: - fields = ['user', 'key', 'write_enabled', 'expires', 'description'] + fields = [ + 'user', 'key', 'write_enabled', 'expires', 'description' + ] model = Token @admin.register(Token, site=admin_site) class TokenAdmin(admin.ModelAdmin): form = TokenAdminForm - list_display = ['key', 'user', 'created', 'expires', 'write_enabled', 'description'] + list_display = [ + 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + ] diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py new file mode 100644 index 00000000000..d1b64971360 --- /dev/null +++ b/netbox/users/api/nested_serializers.py @@ -0,0 +1,18 @@ +from django.contrib.auth.models import User + +from utilities.api import WritableNestedSerializer + +_all_ = [ + 'NestedUserSerializer', +] + + +# +# Users +# + +class NestedUserSerializer(WritableNestedSerializer): + + class Meta: + model = User + fields = ['id', 'username'] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 861bdade9a4..86d350e691b 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,12 +1,4 @@ -from __future__ import unicode_literals +from .nested_serializers import * -from django.contrib.auth.models import User -from utilities.api import WritableNestedSerializer - - -class NestedUserSerializer(WritableNestedSerializer): - - class Meta: - model = User - fields = ['id', 'username'] +# Placeholder for future serializers diff --git a/netbox/users/forms.py b/netbox/users/forms.py index d25e128e6e5..641a1f3e89b 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm @@ -10,7 +8,7 @@ class LoginForm(BootstrapMixin, AuthenticationForm): def __init__(self, *args, **kwargs): - super(LoginForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['username'].widget.attrs['placeholder'] = '' self.fields['password'].widget.attrs['placeholder'] = '' @@ -21,11 +19,16 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): class TokenForm(BootstrapMixin, forms.ModelForm): - key = forms.CharField(required=False, help_text="If no key is provided, one will be generated automatically.") + key = forms.CharField( + required=False, + help_text="If no key is provided, one will be generated automatically." + ) class Meta: model = Token - fields = ['key', 'write_enabled', 'expires', 'description'] + fields = [ + 'key', 'write_enabled', 'expires', 'description', + ] help_texts = { 'expires': 'YYYY-MM-DD [HH:MM:SS]' } diff --git a/netbox/users/migrations/0001_api_tokens.py b/netbox/users/migrations/0001_api_tokens.py index d766b2ef00d..3e2ea274e21 100644 --- a/netbox/users/migrations/0001_api_tokens.py +++ b/netbox/users/migrations/0001_api_tokens.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-03-08 15:32 -from __future__ import unicode_literals - from django.conf import settings import django.core.validators from django.db import migrations, models 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 index 54a6078a051..1c82a092d65 100644 --- a/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py +++ b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/users/migrations/0002_unicode_literals.py b/netbox/users/migrations/0002_unicode_literals.py index 8a7f96bbd1a..d0cf75fd8a9 100644 --- a/netbox/users/migrations/0002_unicode_literals.py +++ b/netbox/users/migrations/0002_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/users/models.py b/netbox/users/models.py index 15f4f46f45e..2956a977887 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import binascii import os @@ -7,10 +5,8 @@ from django.core.validators import MinLengthValidator from django.db import models from django.utils import timezone -from django.utils.encoding import python_2_unicode_compatible -@python_2_unicode_compatible class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. @@ -52,7 +48,7 @@ def __str__(self): def save(self, *args, **kwargs): if not self.key: self.key = self.generate_key() - return super(Token, self).save(*args, **kwargs) + return super().save(*args, **kwargs) def generate_key(self): # Generate a random 160-bit key expressed in hexadecimal. diff --git a/netbox/users/urls.py b/netbox/users/urls.py index aad89e10462..a45f859e71f 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from . import views @@ -16,6 +14,5 @@ url(r'^user-key/$', views.UserKeyView.as_view(), name='userkey'), url(r'^user-key/edit/$', views.UserKeyEditView.as_view(), name='userkey_edit'), url(r'^session-key/delete/$', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'), - url(r'^recent-activity/$', views.RecentActivityView.as_view(), name='recent_activity'), ] diff --git a/netbox/users/views.py b/netbox/users/views.py index bc8263202c0..171d444b98d 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth.decorators import login_required @@ -38,7 +36,7 @@ def post(self, request): # Determine where to direct user after successful login redirect_to = request.POST.get('next', '') - if not is_safe_url(url=redirect_to, host=request.get_host()): + if not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): redirect_to = reverse('home') # Authenticate user @@ -134,7 +132,7 @@ def dispatch(self, request, *args, **kwargs): except UserKey.DoesNotExist: self.userkey = UserKey(user=request.user) - return super(UserKeyEditView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get(self, request): form = UserKeyForm(instance=self.userkey) @@ -198,18 +196,6 @@ def post(self, request): }) -@method_decorator(login_required, name='dispatch') -class RecentActivityView(View): - template_name = 'users/recent_activity.html' - - def get(self, request): - - return render(request, self.template_name, { - 'recent_activity': request.user.actions.all()[:50], - 'active_tab': 'recent_activity', - }) - - # # API tokens # diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 9b9dabef5ec..530372fb95b 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - from collections import OrderedDict -import pytz +import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist @@ -68,18 +66,38 @@ def __init__(self, choices, **kwargs): self._choices[k2] = v2 else: self._choices[k] = v - super(ChoiceField, self).__init__(**kwargs) + super().__init__(**kwargs) def to_representation(self, obj): - return {'value': obj, 'label': self._choices[obj]} + if obj is '': + return None + data = OrderedDict([ + ('value', obj), + ('label', self._choices[obj]) + ]) + return data def to_internal_value(self, data): - # Hotwiring boolean values + + # Provide an explicit error message if the request is trying to write a dict + if type(data) is dict: + raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary.') + + # Check for string representations of boolean/integer values if hasattr(data, 'lower'): if data.lower() == 'true': - return True - if data.lower() == 'false': - return False + data = True + elif data.lower() == 'false': + data = False + else: + try: + data = int(data) + except ValueError: + pass + + if data not in self._choices: + raise ValidationError("{} is not a valid choice.".format(data)) + return data @@ -121,7 +139,7 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField): def __init__(self, serializer, **kwargs): self.serializer = serializer self.pk_field = kwargs.pop('pk_field', None) - super(SerializedPKRelatedField, self).__init__(**kwargs) + super().__init__(**kwargs) def to_representation(self, value): return self.serializer(value, context={'request': self.context['request']}).data @@ -165,6 +183,13 @@ class WritableNestedSerializer(ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. """ + def run_validators(self, value): + # DRF v3.8.2: Skip running validators on the data, since we only accept an integer PK instead of a dict. For + # more context, see: + # https://github.com/encode/django-rest-framework/pull/5922/commits/2227bc47f8b287b66775948ffb60b2d9378ac84f + # https://github.com/encode/django-rest-framework/issues/6053 + return + def to_internal_value(self, data): if data is None: return None @@ -190,7 +215,7 @@ def get_serializer(self, *args, **kwargs): if isinstance(kwargs.get('data', {}), list): kwargs['many'] = True - return super(ModelViewSet, self).get_serializer(*args, **kwargs) + return super().get_serializer(*args, **kwargs) def get_serializer_class(self): @@ -214,7 +239,7 @@ class FieldChoicesViewSet(ViewSet): fields = [] def __init__(self, *args, **kwargs): - super(FieldChoicesViewSet, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Compile a dict of all fields in this view self._fields = OrderedDict() diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 1cb3999ef05..ad6e8fd90b6 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -1,7 +1,29 @@ -from utilities.forms import ChainedModelMultipleChoiceField - - -# Fields which are used on ManyToMany relationships -M2M_FIELD_TYPES = [ - ChainedModelMultipleChoiceField, -] +COLOR_CHOICES = ( + ('aa1409', 'Dark red'), + ('f44336', 'Red'), + ('e91e63', 'Pink'), + ('ffe4e1', 'Rose'), + ('ff66ff', 'Fuschia'), + ('9c27b0', 'Purple'), + ('673ab7', 'Dark purple'), + ('3f51b5', 'Indigo'), + ('2196f3', 'Blue'), + ('03a9f4', 'Light blue'), + ('00bcd4', 'Cyan'), + ('009688', 'Teal'), + ('00ffff', 'Aqua'), + ('2f6a31', 'Dark green'), + ('4caf50', 'Green'), + ('8bc34a', 'Light green'), + ('cddc39', 'Lime'), + ('ffeb3b', 'Yellow'), + ('ffc107', 'Amber'), + ('ff9800', 'Orange'), + ('ff5722', 'Dark orange'), + ('795548', 'Brown'), + ('c0c0c0', 'Light grey'), + ('9e9e9e', 'Grey'), + ('607d8b', 'Dark grey'), + ('111111', 'Black'), + ('ffffff', 'White'), +) diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index dab35e9820d..06c5c8784e6 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings as django_settings diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index ca6e08fc1c3..5975788bcbd 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -1,9 +1,52 @@ from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema from rest_framework.fields import ChoiceField +from rest_framework.relations import ManyRelatedField +from taggit_serializer.serializers import TagListSerializerField from extras.api.customfields import CustomFieldsSerializer -from utilities.api import ChoiceField +from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer + + +class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): + def get_request_serializer(self): + serializer = super().get_request_serializer() + + if serializer is not None and self.method in self.implicit_body_methods: + properties = {} + for child_name, child in serializer.fields.items(): + if isinstance(child, (ChoiceField, WritableNestedSerializer)): + properties[child_name] = None + elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField): + properties[child_name] = None + + if properties: + writable_class = type('Writable' + type(serializer).__name__, (type(serializer),), properties) + serializer = writable_class() + + return serializer + + +class SerializedPKRelatedFieldInspector(FieldInspector): + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + if isinstance(field, SerializedPKRelatedField): + return self.probe_field_inspectors(field.serializer(), ChildSwaggerType, use_references) + + return NotHandled + + +class TagListFieldInspector(FieldInspector): + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + if isinstance(field, TagListSerializerField): + child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references) + return SwaggerType( + type=openapi.TYPE_ARRAY, + items=child_schema, + ) + + return NotHandled class CustomChoiceFieldInspector(FieldInspector): diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index 3b7eb7a5b92..da851095025 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.utils.html import escape from django.utils.safestring import mark_safe diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 34f59fe1601..104902b1fb0 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -1,11 +1,8 @@ -from __future__ import unicode_literals - from django.core.validators import RegexValidator from django.db import models from .forms import ColorSelect - ColorValidator = RegexValidator( regex='^[0-9a-f]{6}$', message='Enter a valid hexadecimal RGB color code.', @@ -31,8 +28,8 @@ class ColorField(models.CharField): def __init__(self, *args, **kwargs): kwargs['max_length'] = 6 - super(ColorField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def formfield(self, **kwargs): kwargs['widget'] = ColorSelect - return super(ColorField, self).formfield(**kwargs) + return super().formfield(**kwargs) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 90cdcd9fcd7..4da3a9856a4 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,15 +1,6 @@ -from __future__ import unicode_literals - -import itertools - import django_filters -from django import forms -from django.utils.encoding import force_text - +from taggit.models import Tag -# -# Filters -# class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): """ @@ -26,45 +17,21 @@ class NullableCharFieldFilter(django_filters.CharFilter): def filter(self, qs, value): if value != self.null_value: - return super(NullableCharFieldFilter, self).filter(qs, value) + return super().filter(qs, value) qs = self.get_method(qs)(**{'{}__isnull'.format(self.name): True}) return qs.distinct() if self.distinct else qs -class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): +class TagFilter(django_filters.ModelMultipleChoiceFilter): """ - This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is - used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null - choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar - to defining a MultipleChoiceField with: - - choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()] - - However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating - database migrations. + Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered + to objects matching all tags. """ - iterator = forms.models.ModelChoiceIterator - - def __init__(self, null_value=0, null_label='-- None --', *args, **kwargs): - self.null_value = null_value - self.null_label = null_label - super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs) + def __init__(self, *args, **kwargs): - def _get_choices(self): - if hasattr(self, '_choices'): - return self._choices - # Prepend the null choice to the queryset iterator - return itertools.chain( - [(self.null_value, self.null_label)], - self.iterator(self), - ) - choices = property(_get_choices, forms.ChoiceField._set_choices) + kwargs.setdefault('field_name', 'tags__slug') + kwargs.setdefault('to_field_name', 'slug') + kwargs.setdefault('conjoined', True) + kwargs.setdefault('queryset', Tag.objects.all()) - def clean(self, value): - # Strip all instances of the null value before cleaning - if value is not None: - stripped_value = [x for x in value if x != force_text(self.null_value)] - else: - stripped_value = value - super(NullableModelMultipleChoiceField, self).clean(stripped_value) - return value + super().__init__(*args, **kwargs) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index f54b418ca42..1d4671bf634 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -1,10 +1,7 @@ -from __future__ import unicode_literals - import csv -from io import StringIO import json import re -import sys +from io import StringIO from django import forms from django.conf import settings @@ -13,38 +10,18 @@ from django.urls import reverse_lazy from mptt.forms import TreeNodeMultipleChoiceField +from .constants import * from .validators import EnhancedURLValidator -COLOR_CHOICES = ( - ('aa1409', 'Dark red'), - ('f44336', 'Red'), - ('e91e63', 'Pink'), - ('ff66ff', 'Fuschia'), - ('9c27b0', 'Purple'), - ('673ab7', 'Dark purple'), - ('3f51b5', 'Indigo'), - ('2196f3', 'Blue'), - ('03a9f4', 'Light blue'), - ('00bcd4', 'Cyan'), - ('009688', 'Teal'), - ('2f6a31', 'Dark green'), - ('4caf50', 'Green'), - ('8bc34a', 'Light green'), - ('cddc39', 'Lime'), - ('ffeb3b', 'Yellow'), - ('ffc107', 'Amber'), - ('ff9800', 'Orange'), - ('ff5722', 'Dark orange'), - ('795548', 'Brown'), - ('c0c0c0', 'Light grey'), - ('9e9e9e', 'Grey'), - ('607d8b', 'Dark grey'), - ('111111', 'Black'), -) NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]' ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' +BOOLEAN_WITH_BLANK_CHOICES = ( + ('', '---------'), + ('True', 'Yes'), + ('False', 'No'), +) def parse_numeric_range(string, base=10): @@ -65,22 +42,6 @@ def parse_numeric_range(string, base=10): return list(set(values)) -def expand_numeric_pattern(string): - """ - Expand a numeric pattern into a list of strings. Examples: - 'ge-0/0/[0-3,5]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3', 'ge-0/0/5'] - 'xe-0/[0,2-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7'] - """ - lead, pattern, remnant = re.split(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1) - parsed_range = parse_numeric_range(pattern) - for i in parsed_range: - if re.search(NUMERIC_EXPANSION_PATTERN, remnant): - for string in expand_numeric_pattern(remnant): - yield "{}{}{}".format(lead, i, string) - else: - yield "{}{}{}".format(lead, i, remnant) - - def parse_alphanumeric_range(string): """ Expand an alphanumeric range (continuous or not) into a list. @@ -123,7 +84,7 @@ def expand_alphanumeric_pattern(string): def expand_ipaddress_pattern(string, family): """ Expand an IP address pattern into a list of strings. Examples: - '192.0.2.[1,2,100-250,254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24', '192.0.2.254/24'] + '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24'] '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64'] """ if family not in [4, 6]: @@ -151,9 +112,41 @@ def add_blank_choice(choices): return ((None, '---------'),) + tuple(choices) -def utf8_encoder(data): - for line in data: - yield line.encode('utf-8') +def unpack_grouped_choices(choices): + """ + Unpack a grouped choices hierarchy into a flat list of two-tuples. For example: + + choices = ( + ('Foo', ( + (1, 'A'), + (2, 'B') + )), + ('Bar', ( + (3, 'C'), + (4, 'D') + )) + ) + + becomes: + + choices = ( + (1, 'A'), + (2, 'B'), + (3, 'C'), + (4, 'D') + ) + """ + unpacked_choices = [] + for key, value in choices: + if key == 1300: + breakme = True + if isinstance(value, (list, tuple)): + # Entered an optgroup + for optgroup_key, optgroup_value in value: + unpacked_choices.append((optgroup_key, optgroup_value)) + else: + unpacked_choices.append((key, value)) + return unpacked_choices # @@ -174,8 +167,9 @@ class ColorSelect(forms.Select): option_template_name = 'widgets/colorselect_option.html' def __init__(self, *args, **kwargs): - kwargs['choices'] = COLOR_CHOICES - super(ColorSelect, self).__init__(*args, **kwargs) + kwargs['choices'] = add_blank_choice(COLOR_CHOICES) + super().__init__(*args, **kwargs) + self.attrs['class'] = 'netbox-select2-color-picker' class BulkEditNullBooleanSelect(forms.NullBooleanSelect): @@ -184,7 +178,7 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect): """ def __init__(self, *args, **kwargs): - super(BulkEditNullBooleanSelect, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Override the built-in choice labels self.choices = ( @@ -192,6 +186,7 @@ def __init__(self, *args, **kwargs): ('2', 'Yes'), ('3', 'No'), ) + self.attrs['class'] = 'netbox-select2-static' class SelectWithDisabled(forms.Select): @@ -202,30 +197,74 @@ class SelectWithDisabled(forms.Select): option_template_name = 'widgets/selectwithdisabled_option.html' -class SelectWithPK(forms.Select): +class StaticSelect2(SelectWithDisabled): + """ + A static content using the Select2 widget + + :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the + name of the filter-for field (child field) and the value is the name of the query param filter. + """ + + def __init__(self, filter_for=None, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.attrs['class'] = 'netbox-select2-static' + if filter_for: + for key, value in filter_for.items(): + self.add_filter_for(key, value) + + def add_filter_for(self, name, value): + """ + Add details for an additional query param in the form of a data-filter-for-* attribute. + + :param name: The name of the query param + :param value: The value of the query param + """ + self.attrs['data-filter-for-{}'.format(name)] = value + + +class StaticSelect2Multiple(StaticSelect2, forms.SelectMultiple): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.attrs['data-multiple'] = 1 + + +class SelectWithPK(StaticSelect2): """ Include the primary key of each option in the option label (e.g. "Router7 (4721)"). """ option_template_name = 'widgets/select_option_with_pk.html' +class ContentTypeSelect(forms.Select): + """ + Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example: + + This attribute can be used to reference the relevant API endpoint for a particular ContentType. + """ + option_template_name = 'widgets/select_contenttype.html' + + class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): """ MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget. """ def __init__(self, *args, **kwargs): self.delimiter = kwargs.pop('delimiter', ',') - super(ArrayFieldSelectMultiple, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def optgroups(self, name, value, attrs=None): # Split the delimited string of values into a list if value: value = value[0].split(self.delimiter) - return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs) + return super().optgroups(name, value, attrs) def value_from_datadict(self, data, files, name): # Condense the list of selected choices into a delimited string - data = super(ArrayFieldSelectMultiple, self).value_from_datadict(data, files, name) + data = super().value_from_datadict(data, files, name) return self.delimiter.join(data) @@ -235,47 +274,91 @@ class APISelect(SelectWithDisabled): :param api_url: API URL :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. + :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. - """ - - def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs): - - super(APISelect, self).__init__(*args, **kwargs) - - self.attrs['class'] = 'api-select' - self.attrs['api-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH + :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the + name of the filter-for field (child field) and the value is the name of the query param filter. + :param conditional_query_params: (Optional) A dict of URL query params to append to the URL if the + condition is met. The condition is the dict key and is specified in the form `__`. + If the provided field value is selected for the given field, the URL query param will be appended to + the rendered URL. The value is the in the from `=`. This is useful in cases where + a particular field value dictates an additional API filter. + :param additional_query_params: Optional) A dict of query params to append to the API request. The key is the + name of the query param and the value if the query param's value. + :param null_option: If true, include the static null option in the selection list. + """ + + def __init__( + self, + api_url, + display_field=None, + value_field=None, + disabled_indicator=None, + filter_for=None, + conditional_query_params=None, + additional_query_params=None, + null_option=False, + *args, + **kwargs + ): + + super().__init__(*args, **kwargs) + + self.attrs['class'] = 'netbox-select2-api' + self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH if display_field: self.attrs['display-field'] = display_field + if value_field: + self.attrs['value-field'] = value_field if disabled_indicator: self.attrs['disabled-indicator'] = disabled_indicator + if filter_for: + for key, value in filter_for.items(): + self.add_filter_for(key, value) + if conditional_query_params: + for key, value in conditional_query_params.items(): + self.add_conditional_query_param(key, value) + if additional_query_params: + for key, value in additional_query_params.items(): + self.add_additional_query_param(key, value) + if null_option: + self.attrs['data-null-option'] = 1 + + def add_filter_for(self, name, value): + """ + Add details for an additional query param in the form of a data-filter-for-* attribute. + + :param name: The name of the query param + :param value: The value of the query param + """ + self.attrs['data-filter-for-{}'.format(name)] = value + + def add_additional_query_param(self, name, value): + """ + Add details for an additional query param in the form of a data-* attribute. + + :param name: The name of the query param + :param value: The value of the query param + """ + self.attrs['data-additional-query-param-{}'.format(name)] = value + + def add_conditional_query_param(self, condition, value): + """ + Add details for a URL query strings to append to the URL if the condition is met. + The condition is specified in the form `__`. + + :param condition: The condition for the query param + :param value: The value of the query param + """ + self.attrs['data-conditional-query-param-{}'.format(condition)] = value + + +class APISelectMultiple(APISelect, forms.SelectMultiple): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) -class APISelectMultiple(APISelect): - allow_multiple_selected = True - - -class Livesearch(forms.TextInput): - """ - A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search - - :param query_key: The name of the parameter to query against - :param query_url: The name of the API URL to query - :param field_to_update: The name of the "real" form field whose value is being set - :param obj_label: The field to use as the option label (optional) - """ - - def __init__(self, query_key, query_url, field_to_update, obj_label=None, *args, **kwargs): - - super(Livesearch, self).__init__(*args, **kwargs) - - self.attrs = { - 'data-key': query_key, - 'data-source': reverse_lazy(query_url), - 'data-field': field_to_update, - } - - if obj_label: - self.attrs['data-label'] = obj_label + self.attrs['data-multiple'] = 1 # @@ -294,7 +377,7 @@ def __init__(self, fields, required_fields=[], *args, **kwargs): self.fields = fields self.required_fields = required_fields - super(CSVDataField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.strip = False if not self.label: @@ -309,12 +392,7 @@ def __init__(self, fields, required_fields=[], *args, **kwargs): def to_python(self, value): records = [] - - # Python 2 hack for Unicode support in the CSV reader - if sys.version_info[0] < 3: - reader = csv.reader(utf8_encoder(StringIO(value))) - else: - reader = csv.reader(StringIO(value)) + reader = csv.reader(StringIO(value)) # Consume and validate the first line of CSV data as column headers headers = next(reader) @@ -345,12 +423,12 @@ class CSVChoiceField(forms.ChoiceField): """ def __init__(self, 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} + super().__init__(choices=choices, *args, **kwargs) + self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] + self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} def clean(self, value): - value = super(CSVChoiceField, self).clean(value) + value = super().clean(value) if not value: return None if value not in self.choice_values: @@ -364,7 +442,7 @@ class ExpandableNameField(forms.CharField): Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] """ def __init__(self, *args, **kwargs): - super(ExpandableNameField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Alphanumeric ranges are supported for bulk creation.
        ' \ 'Mixed cases and types within a single range are not supported.
        ' \ @@ -384,7 +462,7 @@ class ExpandableIPAddressField(forms.CharField): Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] """ def __init__(self, *args, **kwargs): - super(ExpandableIPAddressField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Specify a numeric range to create multiple IPs.
        '\ 'Example: 192.0.2.[1,5,100-254]/24' @@ -413,7 +491,7 @@ def __init__(self, *args, **kwargs): required = kwargs.pop('required', False) label = kwargs.pop('label', self.default_label) help_text = kwargs.pop('help_text', self.default_helptext) - super(CommentField, self).__init__(required=required, label=label, help_text=help_text, *args, **kwargs) + super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) class FlexibleModelChoiceField(forms.ModelChoiceField): @@ -453,7 +531,7 @@ class ChainedModelChoiceField(forms.ModelChoiceField): """ def __init__(self, chains=None, *args, **kwargs): self.chains = chains - super(ChainedModelChoiceField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): @@ -462,7 +540,7 @@ class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): """ def __init__(self, chains=None, *args, **kwargs): self.chains = chains - super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class SlugField(forms.SlugField): @@ -472,7 +550,7 @@ class SlugField(forms.SlugField): def __init__(self, slug_source='name', *args, **kwargs): label = kwargs.pop('label', "Slug") help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - super(SlugField, self).__init__(label=label, help_text=help_text, *args, **kwargs) + super().__init__(label=label, help_text=help_text, *args, **kwargs) self.widget.attrs['slug-source'] = slug_source @@ -493,18 +571,20 @@ def __iter__(self): class FilterChoiceFieldMixin(object): iterator = FilterChoiceIterator - def __init__(self, null_label=None, *args, **kwargs): + def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs): self.null_label = null_label + self.count_attr = count_attr if 'required' not in kwargs: kwargs['required'] = False if 'widget' not in kwargs: kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) - super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def label_from_instance(self, obj): - label = super(FilterChoiceFieldMixin, self).label_from_instance(obj) - if hasattr(obj, 'filter_count'): - return '{} ({})'.format(label, obj.filter_count) + label = super().label_from_instance(obj) + obj_count = getattr(obj, self.count_attr, None) + if obj_count is not None: + return '{} ({})'.format(label, obj_count) return label @@ -516,38 +596,6 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple pass -class AnnotatedMultipleChoiceField(forms.MultipleChoiceField): - """ - Render a set of static choices with each choice annotated to include a count of related objects. For example, this - field can be used to display a list of all available device statuses along with the number of devices currently - assigned to each status. - """ - - def annotate_choices(self): - queryset = self.annotate.values( - self.annotate_field - ).annotate( - count=Count(self.annotate_field) - ).order_by( - self.annotate_field - ) - choice_counts = { - c[self.annotate_field]: c['count'] for c in queryset - } - annotated_choices = [ - (c[0], '{} ({})'.format(c[1], choice_counts.get(c[0], 0))) for c in self.static_choices - ] - - return annotated_choices - - def __init__(self, choices, annotate, annotate_field, *args, **kwargs): - self.annotate = annotate - self.annotate_field = annotate_field - self.static_choices = choices - - super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs) - - class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: @@ -562,7 +610,7 @@ 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) + super().__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Enter context data in JSON format.' self.widget.attrs['placeholder'] = '' @@ -584,7 +632,7 @@ class BootstrapMixin(forms.BaseForm): Add the base Bootstrap CSS classes to form elements. """ def __init__(self, *args, **kwargs): - super(BootstrapMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) exempt_widgets = [ forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect @@ -605,7 +653,7 @@ class ChainedFieldsMixin(forms.BaseForm): Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields. """ def __init__(self, *args, **kwargs): - super(ChainedFieldsMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for field_name, field in self.fields.items(): @@ -613,7 +661,7 @@ def __init__(self, *args, **kwargs): filters_dict = {} for (db_field, parent_field) in field.chains: - if self.is_bound and parent_field in self.data: + if self.is_bound and parent_field in self.data and self.data[parent_field]: filters_dict[db_field] = self.data[parent_field] or None elif self.initial.get(parent_field): filters_dict[db_field] = self.initial[parent_field] @@ -654,7 +702,10 @@ class ComponentForm(BootstrapMixin, forms.Form): """ def __init__(self, parent, *args, **kwargs): self.parent = parent - super(ComponentForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + + def get_iterative_data(self, iteration): + return {} class BulkEditForm(forms.Form): @@ -662,7 +713,7 @@ 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) + super().__init__(*args, **kwargs) self.model = model self.parent_obj = parent_obj self.nullable_fields = [] diff --git a/netbox/utilities/management/__init__.py b/netbox/utilities/management/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py new file mode 100644 index 00000000000..697a3ed9a7b --- /dev/null +++ b/netbox/utilities/management/commands/__init__.py @@ -0,0 +1,28 @@ +from django.db import models + + +EXEMPT_ATTRS = [ + 'choices', + 'help_text', + 'verbose_name', +] + +_deconstruct = models.Field.deconstruct + + +def custom_deconstruct(field): + """ + Imitate the behavior of the stock deconstruct() method, but ignore the field attributes listed above. + """ + name, path, args, kwargs = _deconstruct(field) + + # Remove any ignored attributes + for attr in EXEMPT_ATTRS: + kwargs.pop(attr, None) + + # A hack to accommodate TimeZoneField, which employs a custom deconstructor to check whether the default choices + # have changed + if hasattr(field, 'CHOICES'): + kwargs['choices'] = field.CHOICES + + return name, path, args, kwargs diff --git a/netbox/utilities/management/commands/makemigrations.py b/netbox/utilities/management/commands/makemigrations.py new file mode 100644 index 00000000000..fbcf82eafd4 --- /dev/null +++ b/netbox/utilities/management/commands/makemigrations.py @@ -0,0 +1,7 @@ +# noinspection PyUnresolvedReferences +from django.core.management.commands.makemigrations import Command +from django.db import models + +from . import custom_deconstruct + +models.Field.deconstruct = custom_deconstruct diff --git a/netbox/utilities/management/commands/migrate.py b/netbox/utilities/management/commands/migrate.py new file mode 100644 index 00000000000..2aa51b7136f --- /dev/null +++ b/netbox/utilities/management/commands/migrate.py @@ -0,0 +1,7 @@ +# noinspection PyUnresolvedReferences +from django.core.management.commands.migrate import Command +from django.db import models + +from . import custom_deconstruct + +models.Field.deconstruct = custom_deconstruct diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py index b112f4fae8c..724773c4628 100644 --- a/netbox/utilities/managers.py +++ b/netbox/utilities/managers.py @@ -1,28 +1,30 @@ -from __future__ import unicode_literals - from django.db.models import Manager +NAT1 = r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)" +NAT2 = r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')" +NAT3 = r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)" + -class NaturalOrderByManager(Manager): +class NaturalOrderingManager(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. + Order objects naturally by a designated field (defaults to 'name'). 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 + natural_order_field = 'name' def get_queryset(self): - queryset = super(NaturalOrderByManager, self).get_queryset() + queryset = super().get_queryset() db_table = self.model._meta.db_table db_field = self.natural_order_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), + '_nat1': NAT1.format(db_table, db_field), + '_nat2': NAT2.format(db_table, db_field), + '_nat3': NAT3.format(db_table, db_field), }) # Replace any instance of the designated natural ordering field with its three subfields diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index dafafde244e..4e321ab1974 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -1,7 +1,3 @@ -from __future__ import unicode_literals - -import sys - from django.conf import settings from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect @@ -72,11 +68,7 @@ def process_exception(self, request, exception): custom_template = 'exceptions/programming_error.html' elif isinstance(exception, ImportError): custom_template = 'exceptions/import_error.html' - elif ( - sys.version_info[0] >= 3 and isinstance(exception, PermissionError) - ) or ( - isinstance(exception, OSError) and exception.errno == 13 - ): + elif isinstance(exception, PermissionError): custom_template = 'exceptions/permission_error.html' # Return a custom error message, or fall back to Django's default 500 error handling diff --git a/netbox/utilities/models.py b/netbox/utilities/models.py index 4b04c03e1f8..3008fc39ab4 100644 --- a/netbox/utilities/models.py +++ b/netbox/utilities/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db import models from extras.models import ObjectChange diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index 9ebbbab5794..b49e3804863 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings from django.core.paginator import Paginator, Page @@ -9,7 +7,7 @@ class EnhancedPaginator(Paginator): def __init__(self, object_list, per_page, **kwargs): if not isinstance(per_page, int) or per_page < 1: per_page = getattr(settings, 'PAGINATE_COUNT', 50) - super(EnhancedPaginator, self).__init__(object_list, per_page, **kwargs) + super().__init__(object_list, per_page, **kwargs) def _get_page(self, *args, **kwargs): return EnhancedPage(*args, **kwargs) diff --git a/netbox/utilities/sql.py b/netbox/utilities/sql.py index ac2c7062462..d76bc339e26 100644 --- a/netbox/utilities/sql.py +++ b/netbox/utilities/sql.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db import connections, models from django.db.models.sql.compiler import SQLCompiler @@ -7,7 +5,7 @@ class NullsFirstSQLCompiler(SQLCompiler): def get_order_by(self): - result = super(NullsFirstSQLCompiler, self).get_order_by() + result = super().get_order_by() if result: return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result] return result @@ -30,5 +28,5 @@ class NullsFirstQuerySet(models.QuerySet): """ def __init__(self, model=None, query=None, using=None, hints=None): - super(NullsFirstQuerySet, self).__init__(model, query, using, hints) + super().__init__(model, query, using, hints) self.query = query or NullsFirstQuery(self.model) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index e531b5e3238..3564136ac1a 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django.utils.safestring import mark_safe @@ -9,7 +7,7 @@ class BaseTable(tables.Table): Default table for object lists """ def __init__(self, *args, **kwargs): - super(BaseTable, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: @@ -28,7 +26,7 @@ class ToggleColumn(tables.CheckBoxColumn): def __init__(self, *args, **kwargs): default = kwargs.pop('default', '') visible = kwargs.pop('visible', False) - super(ToggleColumn, self).__init__(*args, default=default, visible=visible, **kwargs) + super().__init__(*args, default=default, visible=visible, **kwargs) @property def header(self): @@ -48,3 +46,13 @@ def render(self, value): else: rendered = '' return mark_safe(rendered) + + +class ColorColumn(tables.Column): + """ + Display a color (#RRGGBB). + """ + def render(self, value): + return mark_safe( + ' '.format(value) + ) diff --git a/netbox/utilities/templates/widgets/colorselect_option.html b/netbox/utilities/templates/widgets/colorselect_option.html index 7d10b893fbf..a0e488f184a 100644 --- a/netbox/utilities/templates/widgets/colorselect_option.html +++ b/netbox/utilities/templates/widgets/colorselect_option.html @@ -1 +1 @@ - + diff --git a/netbox/utilities/templates/widgets/select_contenttype.html b/netbox/utilities/templates/widgets/select_contenttype.html new file mode 100644 index 00000000000..04c42c37149 --- /dev/null +++ b/netbox/utilities/templates/widgets/select_contenttype.html @@ -0,0 +1 @@ + diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 3090f45384d..b9a8bf6ec95 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import template from extras.models import ExportTemplate diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 39959a6685e..2b465d54a92 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,12 +1,15 @@ -from __future__ import unicode_literals - import datetime import json +import re from django import template from django.utils.safestring import mark_safe from markdown import markdown +from utilities.forms import unpack_grouped_choices +from utilities.utils import foreground_color + + register = template.Library() @@ -19,9 +22,21 @@ def oneline(value): """ Replace each line break with a single space """ + value = value.replace('\r', '') return value.replace('\n', ' ') +@register.filter() +def placeholder(value): + """ + Render a muted placeholder if value equates to False. + """ + if value: + return value + placeholder = '' + return mark_safe(placeholder) + + @register.filter() def getlist(value, arg): """ @@ -96,6 +111,8 @@ def humanize_speed(speed): 100000 => "100 Mbps" 10000000 => "10 Gbps" """ + if not speed: + return '' if speed >= 1000000000 and speed % 1000000000 == 0: return '{} Tbps'.format(int(speed / 1000000000)) elif speed >= 1000000 and speed % 1000000 == 0: @@ -115,14 +132,16 @@ def example_choices(field, arg=3): """ examples = [] if hasattr(field, 'queryset'): - choices = [(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]] + choices = [ + (obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1] + ] else: choices = field.choices - for id, label in choices: + for value, label in unpack_grouped_choices(choices): if len(examples) == arg: examples.append('etc.') break - if not id or not label: + if not value or not label: continue examples.append(label) return ', '.join(examples) or 'None' @@ -136,6 +155,17 @@ def tzoffset(value): return datetime.datetime.now(value).strftime('%z') +@register.filter() +def fgcolor(value): + """ + Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format. + """ + value = value.lower().strip('#') + if not re.match('^[0-9a-f]{6}$', value): + return '' + return '#{}'.format(foreground_color(value)) + + # # Tags # @@ -148,7 +178,7 @@ def querystring(request, **kwargs): querydict = request.GET.copy() for k, v in kwargs.items(): if v is not None: - querydict[k] = v + querydict[k] = str(v) elif k in querydict: querydict.pop(k) querystring = querydict.urlencode(safe='/') diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index dcc564dfa8e..86fa8c83603 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.auth.models import User from rest_framework.test import APITestCase as _APITestCase diff --git a/netbox/utilities/tests/test_managers.py b/netbox/utilities/tests/test_managers.py index 0bafaefde86..7ff23b69d29 100644 --- a/netbox/utilities/tests/test_managers.py +++ b/netbox/utilities/tests/test_managers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.test import TestCase from dcim.models import Site diff --git a/netbox/utilities/tests/test_utils.py b/netbox/utilities/tests/test_utils.py new file mode 100644 index 00000000000..4e0fec1ba65 --- /dev/null +++ b/netbox/utilities/tests/test_utils.py @@ -0,0 +1,89 @@ +from django.test import TestCase + +from utilities.utils import deepmerge + + +class DeepMergeTest(TestCase): + """ + Validate the behavior of the deepmerge() utility. + """ + + def setUp(self): + return + + def test_deepmerge(self): + + dict1 = { + 'active': True, + 'foo': 123, + 'fruits': { + 'orange': 1, + 'apple': 2, + 'pear': 3, + }, + 'vegetables': None, + 'dairy': { + 'milk': 1, + 'cheese': 2, + }, + 'deepnesting': { + 'foo': { + 'a': 10, + 'b': 20, + 'c': 30, + }, + }, + } + + dict2 = { + 'active': False, + 'bar': 456, + 'fruits': { + 'banana': 4, + 'grape': 5, + }, + 'vegetables': { + 'celery': 1, + 'carrots': 2, + 'corn': 3, + }, + 'dairy': None, + 'deepnesting': { + 'foo': { + 'a': 100, + 'd': 40, + }, + }, + } + + merged = { + 'active': False, + 'foo': 123, + 'bar': 456, + 'fruits': { + 'orange': 1, + 'apple': 2, + 'pear': 3, + 'banana': 4, + 'grape': 5, + }, + 'vegetables': { + 'celery': 1, + 'carrots': 2, + 'corn': 3, + }, + 'dairy': None, + 'deepnesting': { + 'foo': { + 'a': 100, + 'b': 20, + 'c': 30, + 'd': 40, + }, + }, + } + + self.assertEqual( + deepmerge(dict1, dict2), + merged + ) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 14c29d21128..1d1f12ddb3a 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,11 +1,11 @@ -from __future__ import unicode_literals +from collections import OrderedDict import datetime import json -import six from django.core.serializers import serialize -from django.http import HttpResponse + +from dcim.constants import LENGTH_UNIT_CENTIMETER, LENGTH_UNIT_FOOT, LENGTH_UNIT_INCH, LENGTH_UNIT_METER def csv_format(data): @@ -25,7 +25,7 @@ def csv_format(data): value = value.isoformat() # Force conversion to string first so we can check for any commas - if not isinstance(value, six.string_types): + if not isinstance(value, str): value = '{}'.format(value) # Double-quote the value if it contains a comma @@ -37,32 +37,6 @@ def csv_format(data): return ','.join(csv) -def queryset_to_csv(queryset): - """ - Export a queryset of objects as CSV, using the model's to_csv() method. - """ - output = [] - - # Start with the column headers - headers = ','.join(queryset.model.csv_headers) - output.append(headers) - - # Iterate through the queryset - for obj in queryset: - data = csv_format(obj.to_csv()) - output.append(data) - - # Build the HTTP response - response = HttpResponse( - '\n'.join(output), - content_type='text/csv' - ) - filename = 'netbox_{}.csv'.format(queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - - return response - - def foreground_color(bg_color): """ Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format. @@ -109,3 +83,34 @@ def serialize_object(obj, extra=None): data.update(extra) return data + + +def deepmerge(original, new): + """ + Deep merge two dictionaries (new into original) and return a new dict + """ + merged = OrderedDict(original) + for key, val in new.items(): + if key in original and isinstance(original[key], dict) and isinstance(val, dict): + merged[key] = deepmerge(original[key], val) + else: + merged[key] = val + return merged + + +def to_meters(length, unit): + """ + Convert the given length to meters. + """ + length = int(length) + if length < 0: + raise ValueError("Length must be a positive integer") + if unit == LENGTH_UNIT_METER: + return length + if unit == LENGTH_UNIT_CENTIMETER: + return length / 100 + if unit == LENGTH_UNIT_FOOT: + return length * 0.3048 + if unit == LENGTH_UNIT_INCH: + return length * 0.3048 * 12 + raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit)) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index 102e368a53c..cfa733208d3 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import re from django.core.validators import _lazy_re_compile, URLValidator diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index ef042176e5b..f52f4ea9eea 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - +import sys from collections import OrderedDict from copy import deepcopy -import sys from django.conf import settings from django.contrib import messages @@ -11,7 +9,7 @@ from django.db import transaction, IntegrityError from django.db.models import Count, ProtectedError from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea -from django.http import HttpResponseServerError +from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist, TemplateSyntaxError @@ -25,9 +23,8 @@ from django_tables2 import RequestConfig 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 +from utilities.utils import csv_format from .error_handlers import handle_protectederror from .forms import ConfirmationForm from .paginator import EnhancedPaginator @@ -58,9 +55,10 @@ class GetReturnURLMixin(object): 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()): + # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's + # considered safe. + query_param = request.GET.get('return_url') or request.POST.get('return_url') + if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()): return query_param # Next, check if the object being modified (if any) has an absolute URL. @@ -91,6 +89,23 @@ class ObjectListView(View): table = None template_name = None + def queryset_to_csv(self): + """ + Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method. + """ + csv_data = [] + + # Start with the column headers + headers = ','.join(self.queryset.model.csv_headers) + csv_data.append(headers) + + # Iterate through the queryset appending each object + for obj in self.queryset: + data = csv_format(obj.to_csv()) + csv_data.append(data) + + return csv_data + def get(self, request): model = self.queryset.model @@ -116,9 +131,17 @@ def get(self, request): request, "There was an error rendering the selected export template ({}).".format(et.name) ) - # Fall back to built-in CSV export if no template was specified + + # Fall back to built-in CSV formatting if export requested but no template specified elif 'export' in request.GET and hasattr(model, 'to_csv'): - return queryset_to_csv(self.queryset) + data = self.queryset_to_csv() + response = HttpResponse( + '\n'.join(data), + content_type='text/csv' + ) + filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list self.queryset = self.alter_queryset(request) @@ -140,7 +163,7 @@ def get(self, request): # Apply the request context paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(table) @@ -228,7 +251,7 @@ def post(self, request, *args, **kwargs): return redirect(request.get_full_path()) return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, host=request.get_host()): + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): return redirect(return_url) else: return redirect(self.get_return_url(request, obj)) @@ -286,7 +309,7 @@ def post(self, request, **kwargs): messages.success(request, 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()): + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): return redirect(return_url) else: return redirect(self.get_return_url(request, obj)) @@ -713,10 +736,11 @@ def post(self, request, pk): data = deepcopy(request.POST) data[self.parent_field] = parent.pk - for name in form.cleaned_data['name_pattern']: + for i, name in enumerate(form.cleaned_data['name_pattern']): # Initialize the individual component form data['name'] = name + data.update(form.get_iterative_data(i)) component_form = self.model_form(data) if component_form.is_valid(): @@ -766,9 +790,12 @@ class BulkComponentCreateView(GetReturnURLMixin, View): def post(self, request): + parent_model_name = self.parent_model._meta.verbose_name_plural + model_name = self.model._meta.verbose_name_plural + # Are we editing *all* objects in the queryset or just a selected subset? if request.POST.get('_all') and self.filter is not None: - pk_list = [obj.pk for obj in self.filter(request.GET, self.model.objects.only('pk')).qs] + pk_list = [obj.pk for obj in self.filter(request.GET, self.parent_model.objects.only('pk')).qs] else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] @@ -806,9 +833,9 @@ def post(self, request): messages.success(request, "Added {} {} to {} {}.".format( len(new_components), - self.model._meta.verbose_name_plural, + model_name, len(form.cleaned_data['pk']), - self.parent_model._meta.verbose_name_plural + parent_model_name )) return redirect(self.get_return_url(request)) @@ -817,7 +844,8 @@ def post(self, request): return render(request, self.template_name, { 'form': form, - 'component_name': self.model._meta.verbose_name_plural, + 'parent_model_name': parent_model_name, + 'model_name': model_name, 'table': table, 'return_url': self.get_return_url(request), }) diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py new file mode 100644 index 00000000000..fb6e2b0be67 --- /dev/null +++ b/netbox/virtualization/api/nested_serializers.py @@ -0,0 +1,62 @@ +from rest_framework import serializers + +from dcim.models import Interface +from utilities.api import WritableNestedSerializer +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine + +__all__ = [ + 'NestedClusterGroupSerializer', + 'NestedClusterSerializer', + 'NestedClusterTypeSerializer', + 'NestedInterfaceSerializer', + 'NestedVirtualMachineSerializer', +] + +# +# Clusters +# + + +class NestedClusterTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') + + class Meta: + model = ClusterType + fields = ['id', 'url', 'name', 'slug'] + + +class NestedClusterGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') + + class Meta: + model = ClusterGroup + fields = ['id', 'url', 'name', 'slug'] + + +class NestedClusterSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') + + class Meta: + model = Cluster + fields = ['id', 'url', 'name'] + + +# +# Virtual machines +# + +class NestedVirtualMachineSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') + + class Meta: + model = VirtualMachine + fields = ['id', 'url', 'name'] + + +class NestedInterfaceSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') + virtual_machine = NestedVirtualMachineSerializer(read_only=True) + + class Meta: + model = Interface + fields = ['id', 'url', 'virtual_machine', 'name'] diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index b749f1e5ea5..1b06dab3b4a 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,21 +1,21 @@ -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.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer 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 ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.models import VLAN +from tenancy.api.nested_serializers import NestedTenantSerializer +from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .nested_serializers import * # -# Cluster types +# Clusters # class ClusterTypeSerializer(ValidatedModelSerializer): @@ -25,18 +25,6 @@ class Meta: fields = ['id', 'name', 'slug'] -class NestedClusterTypeSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') - - class Meta: - model = ClusterType - fields = ['id', 'url', 'name', 'slug'] - - -# -# Cluster groups -# - class ClusterGroupSerializer(ValidatedModelSerializer): class Meta: @@ -44,18 +32,6 @@ class Meta: fields = ['id', 'name', 'slug'] -class NestedClusterGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') - - class Meta: - model = ClusterGroup - fields = ['id', 'url', 'name', 'slug'] - - -# -# Clusters -# - class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) @@ -69,45 +45,28 @@ class Meta: ] -class NestedClusterSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') - - class Meta: - model = Cluster - fields = ['id', 'url', 'name'] - - # # Virtual machines # -# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency -class VirtualMachineIPAddressSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - - class Meta: - model = IPAddress - fields = ['id', 'url', 'family', 'address'] - - class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) site = NestedSiteSerializer(read_only=True) - cluster = NestedClusterSerializer(required=False, allow_null=True) + cluster = NestedClusterSerializer() 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) + primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) class Meta: model = VirtualMachine fields = [ 'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'local_context_data', + 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields', + 'created', 'last_updated', ] @@ -116,44 +75,27 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): 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', - 'local_context_data', + 'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', '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: - model = VirtualMachine - fields = ['id', 'url', 'name'] - - # # VM interfaces # -# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') - - class Meta: - model = VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] - - class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) - untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), - serializer=InterfaceVLANSerializer, + serializer=NestedVLANSerializer, required=False, many=True ) @@ -165,12 +107,3 @@ class Meta: 'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] - - -class NestedInterfaceSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') - virtual_machine = NestedVirtualMachineSerializer(read_only=True) - - class Meta: - model = Interface - fields = ['id', 'url', 'virtual_machine', 'name'] diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 45db6aa6a96..b27e5be3de9 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ def get_view_name(self): router.APIRootView = VirtualizationRootView # Field choices -router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, basename='field-choice') # Clusters router.register(r'cluster-types', views.ClusterTypeViewSet) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index c3d644b8ff5..3b0c02b2233 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from dcim.models import Interface from extras.api.views import CustomFieldModelViewSet from utilities.api import FieldChoicesViewSet, ModelViewSet @@ -25,19 +23,19 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): class ClusterTypeViewSet(ModelViewSet): queryset = ClusterType.objects.all() serializer_class = serializers.ClusterTypeSerializer - filter_class = filters.ClusterTypeFilter + filterset_class = filters.ClusterTypeFilter class ClusterGroupViewSet(ModelViewSet): queryset = ClusterGroup.objects.all() serializer_class = serializers.ClusterGroupSerializer - filter_class = filters.ClusterGroupFilter + filterset_class = filters.ClusterGroupFilter class ClusterViewSet(CustomFieldModelViewSet): queryset = Cluster.objects.select_related('type', 'group').prefetch_related('tags') serializer_class = serializers.ClusterSerializer - filter_class = filters.ClusterFilter + filterset_class = filters.ClusterFilter # @@ -48,7 +46,7 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): queryset = VirtualMachine.objects.select_related( 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6' ).prefetch_related('tags') - filter_class = filters.VirtualMachineFilter + filterset_class = filters.VirtualMachineFilter def get_serializer_class(self): """ @@ -69,7 +67,7 @@ class InterfaceViewSet(ModelViewSet): virtual_machine__isnull=False ).select_related('virtual_machine').prefetch_related('tags') serializer_class = serializers.InterfaceSerializer - filter_class = filters.InterfaceFilter + filterset_class = filters.InterfaceFilter def get_serializer_class(self): request = self.get_serializer_context()['request'] diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index 768508cfb45..35d6e8266c0 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py index 307921e0ea6..37e9efea230 100644 --- a/netbox/virtualization/constants.py +++ b/netbox/virtualization/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from dcim.constants import DEVICE_STATUS_ACTIVE, DEVICE_STATUS_OFFLINE, DEVICE_STATUS_STAGED # VirtualMachine statuses (replicated from Device statuses) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 6af4e4a22e9..a103e9b2958 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,14 +1,13 @@ -from __future__ import unicode_literals - import django_filters +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI from netaddr.core import AddrFormatError -from dcim.models import DeviceRole, Interface, Platform, Site +from dcim.models import DeviceRole, Interface, Platform, Region, Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NumericInFilter +from utilities.filters import NumericInFilter, TagFilter from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -28,7 +27,10 @@ class Meta: class ClusterFilter(CustomFieldFilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -38,7 +40,7 @@ class ClusterFilter(CustomFieldFilterSet): label='Parent group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', label='Parent group (slug)', @@ -48,7 +50,7 @@ class ClusterFilter(CustomFieldFilterSet): label='Cluster type (ID)', ) type = django_filters.ModelMultipleChoiceFilter( - name='type__slug', + field_name='type__slug', queryset=ClusterType.objects.all(), to_field_name='slug', label='Cluster type (slug)', @@ -58,14 +60,12 @@ class ClusterFilter(CustomFieldFilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = Cluster @@ -81,7 +81,10 @@ def search(self, queryset, name, value): class VirtualMachineFilter(CustomFieldFilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -91,23 +94,23 @@ class VirtualMachineFilter(CustomFieldFilterSet): null_value=None ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( - name='cluster__group', + field_name='cluster__group', queryset=ClusterGroup.objects.all(), label='Cluster group (ID)', ) cluster_group = django_filters.ModelMultipleChoiceFilter( - name='cluster__group__slug', + field_name='cluster__group__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', label='Cluster group (slug)', ) cluster_type_id = django_filters.ModelMultipleChoiceFilter( - name='cluster__type', + field_name='cluster__type', queryset=ClusterType.objects.all(), label='Cluster type (ID)', ) cluster_type = django_filters.ModelMultipleChoiceFilter( - name='cluster__type__slug', + field_name='cluster__type__slug', queryset=ClusterType.objects.all(), to_field_name='slug', label='Cluster type (slug)', @@ -116,13 +119,23 @@ class VirtualMachineFilter(CustomFieldFilterSet): queryset=Cluster.objects.all(), label='Cluster (ID)', ) + region_id = django_filters.NumberFilter( + method='filter_region', + field_name='pk', + label='Region (ID)', + ) + region = django_filters.CharFilter( + method='filter_region', + field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( - name='cluster__site', + field_name='cluster__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='cluster__site__slug', + field_name='cluster__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -132,7 +145,7 @@ class VirtualMachineFilter(CustomFieldFilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -142,7 +155,7 @@ class VirtualMachineFilter(CustomFieldFilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -152,14 +165,12 @@ class VirtualMachineFilter(CustomFieldFilterSet): label='Platform (ID)', ) platform = django_filters.ModelMultipleChoiceFilter( - name='platform__slug', + field_name='platform__slug', queryset=Platform.objects.all(), to_field_name='slug', label='Platform (slug)', ) - tag = django_filters.CharFilter( - name='tags__slug', - ) + tag = TagFilter() class Meta: model = VirtualMachine @@ -173,15 +184,25 @@ def search(self, queryset, name, value): Q(comments__icontains=value) ) + def filter_region(self, queryset, name, value): + try: + region = Region.objects.get(**{name: value}) + except ObjectDoesNotExist: + return queryset.none() + return queryset.filter( + Q(cluster__site__region=region) | + Q(cluster__site__region__in=region.get_descendants()) + ) + class InterfaceFilter(django_filters.FilterSet): virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - name='virtual_machine', + field_name='virtual_machine', queryset=VirtualMachine.objects.all(), label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( - name='virtual_machine__name', + field_name='virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', label='Virtual machine', diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 8f973955ceb..70bbf091047 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,9 +1,5 @@ -from __future__ import unicode_literals - 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 @@ -14,10 +10,10 @@ from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, - add_blank_choice + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, + SmallTextarea, StaticSelect2, StaticSelect2Multiple ) from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -36,7 +32,9 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterType - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class ClusterTypeCSVForm(forms.ModelForm): @@ -59,7 +57,9 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterGroup - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class ClusterGroupCSVForm(forms.ModelForm): @@ -78,12 +78,29 @@ class Meta: # class ClusterForm(BootstrapMixin, CustomFieldForm): - comments = CommentField(widget=SmallTextarea) - tags = TagField(required=False) + comments = CommentField( + widget=SmallTextarea() + ) + tags = TagField( + required=False + ) class Meta: model = Cluster - fields = ['name', 'type', 'group', 'site', 'comments', 'tags'] + fields = [ + 'name', 'type', 'group', 'site', 'comments', 'tags', + ] + widgets = { + 'type': APISelect( + api_url="/api/virtualization/cluster-types/" + ), + 'group': APISelect( + api_url="/api/virtualization/cluster-groups/" + ), + 'site': APISelect( + api_url="/api/dcim/sites/" + ), + } class ClusterCSVForm(forms.ModelForm): @@ -120,44 +137,89 @@ class Meta: 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) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=Cluster.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ModelChoiceField( + queryset=ClusterType.objects.all(), + required=False, + widget=APISelect( + api_url="/api/virtualization/cluster-types/" + ) + ) + group = forms.ModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/virtualization/cluster-groups/" + ) + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) + comments = CommentField( + widget=SmallTextarea() + ) class Meta: - nullable_fields = ['group', 'site', 'comments'] + nullable_fields = [ + 'group', 'site', 'comments', + ] class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cluster q = forms.CharField(required=False, label='Search') type = FilterChoiceField( - queryset=ClusterType.objects.annotate(filter_count=Count('clusters')), + queryset=ClusterType.objects.all(), to_field_name='slug', required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-types/", + value_field='slug', + ) ) group = FilterChoiceField( - queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')), + queryset=ClusterGroup.objects.all(), to_field_name='slug', null_label='-- None --', required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/", + value_field='slug', + null_option=True, + ) ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('clusters')), + queryset=Site.objects.all(), to_field_name='slug', null_label='-- None --', required=False, + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field='slug', + null_option=True, + ) ) class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - region = TreeNodeChoiceField( + region = forms.ModelChoiceField( queryset=Region.objects.all(), required=False, - widget=forms.Select( - attrs={'filter-for': 'site', 'nullable': 'true'} + widget=APISelect( + api_url="/api/dcim/regions/", + filter_for={ + "site": "region_id", + }, + attrs={ + 'nullable': 'true', + } ) ) site = ChainedModelChoiceField( @@ -167,8 +229,11 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): ), required=False, widget=APISelect( - api_url='/api/dcim/sites/?region_id={{region}}', - attrs={'filter-for': 'rack'} + api_url='/api/dcim/sites/', + filter_for={ + "rack": "site_id", + "devices": "site_id", + } ) ) rack = ChainedModelChoiceField( @@ -178,8 +243,13 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): ), required=False, widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'devices', 'nullable': 'true'} + api_url='/api/dcim/racks/', + filter_for={ + "devices": "rack_id" + }, + attrs={ + 'nullable': 'true', + } ) ) devices = ChainedModelMultipleChoiceField( @@ -189,26 +259,27 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): ('rack', 'rack'), ), widget=APISelectMultiple( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + api_url='/api/dcim/devices/', display_field='display_name', disabled_indicator='cluster' ) ) class Meta: - fields = ['region', 'site', 'rack', 'devices'] + fields = [ + 'region', 'site', 'rack', 'devices', + ] def __init__(self, cluster, *args, **kwargs): self.cluster = cluster - super(ClusterAddDevicesForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['devices'].choices = [] def clean(self): - - super(ClusterAddDevicesForm, self).clean() + super().clean() # If the Cluster is assigned to a Site, all Devices must be assigned to that Site. if self.cluster.site is not None: @@ -222,7 +293,10 @@ def clean(self): class ClusterRemoveDevicesForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) # @@ -233,8 +307,14 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): cluster_group = forms.ModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, - widget=forms.Select( - attrs={'filter-for': 'cluster', 'nullable': 'true'} + widget=APISelect( + api_url='/api/virtualization/cluster-groups/', + filter_for={ + "cluster": "group_id", + }, + attrs={ + 'nullable': 'true', + } ) ) cluster = ChainedModelChoiceField( @@ -243,11 +323,15 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): ('group', 'cluster_group'), ), widget=APISelect( - api_url='/api/virtualization/clusters/?group_id={{cluster_group}}' + api_url='/api/virtualization/clusters/' ) ) - tags = TagField(required=False) - local_context_data = JSONField(required=False) + tags = TagField( + required=False + ) + local_context_data = JSONField( + required=False + ) class Meta: model = VirtualMachine @@ -256,7 +340,22 @@ class Meta: 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] help_texts = { - 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context", + 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " + "config context", + } + widgets = { + "status": StaticSelect2(), + "role": APISelect( + api_url="/api/dcim/device-roles/", + additional_query_params={ + "vm_role": "true" + } + ), + 'primary_ip4': StaticSelect2(), + 'primary_ip6': StaticSelect2(), + 'platform': APISelect( + api_url='/api/dcim/platforms/' + ) } def __init__(self, *args, **kwargs): @@ -268,7 +367,7 @@ def __init__(self, *args, **kwargs): initial['cluster_group'] = instance.cluster.group kwargs['initial'] = initial - super(VirtualMachineForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.pk: @@ -321,7 +420,9 @@ class VirtualMachineCSVForm(forms.ModelForm): } ) role = forms.ModelChoiceField( - queryset=DeviceRole.objects.filter(vm_role=True), + queryset=DeviceRole.objects.filter( + vm_role=True + ), required=False, to_field_name='name', help_text='Name of functional role', @@ -354,63 +455,161 @@ class Meta: 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) - role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) - vcpus = forms.IntegerField(required=False, label='vCPUs') - memory = forms.IntegerField(required=False, label='Memory (MB)') - disk = forms.IntegerField(required=False, label='Disk (GB)') - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + widget=forms.MultipleHiddenInput() + ) + status = forms.ChoiceField( + choices=add_blank_choice(VM_STATUS_CHOICES), + required=False, + initial='', + widget=StaticSelect2(), + ) + cluster = forms.ModelChoiceField( + queryset=Cluster.objects.all(), + required=False, + widget=APISelect( + api_url='/api/virtualization/clusters/' + ) + ) + role = forms.ModelChoiceField( + queryset=DeviceRole.objects.filter( + vm_role=True + ), + required=False, + widget=APISelect( + api_url="/api/dcim/device-roles/", + additional_query_params={ + "vm_role": "true" + } + ) + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelect( + api_url='/api/tenancy/tenants/' + ) + ) + platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/platforms/' + ) + ) + vcpus = forms.IntegerField( + required=False, + label='vCPUs' + ) + memory = forms.IntegerField( + required=False, + label='Memory (MB)' + ) + disk = forms.IntegerField( + required=False, + label='Disk (GB)' + ) + comments = CommentField( + widget=SmallTextarea() + ) class Meta: - nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + nullable_fields = [ + 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ] class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualMachine - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) cluster_group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url='/api/virtualization/cluster-groups/', + value_field="slug", + null_option=True, + ) ) cluster_type = FilterChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url='/api/virtualization/cluster-types/', + value_field="slug", + null_option=True, + ) ) cluster_id = FilterChoiceField( - queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), - label='Cluster' + queryset=Cluster.objects.all(), + label='Cluster', + widget=APISelectMultiple( + api_url='/api/virtualization/clusters/', + ) + ) + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url='/api/dcim/regions/', + value_field="slug", + null_option=True, + ) ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')), + queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url='/api/dcim/sites/', + value_field="slug", + null_option=True, + ) ) role = FilterChoiceField( - queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')), + queryset=DeviceRole.objects.filter(vm_role=True), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url='/api/dcim/device-roles/', + value_field="slug", + null_option=True, + additional_query_params={ + 'vm_role': 'true' + } + ) ) - status = AnnotatedMultipleChoiceField( + status = forms.MultipleChoiceField( choices=VM_STATUS_CHOICES, - annotate=VirtualMachine.objects.all(), - annotate_field='status', - required=False + required=False, + widget=StaticSelect2Multiple() ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')), + queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url='/api/tenancy/tenants/', + value_field="slug", + null_option=True, + ) ) platform = FilterChoiceField( - queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')), + queryset=Platform.objects.all(), to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url='/api/dcim/platforms/', + value_field="slug", + null_option=True, + ) ) @@ -419,7 +618,9 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): # class InterfaceForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Interface @@ -430,6 +631,7 @@ class Meta: widgets = { 'virtual_machine': forms.HiddenInput(), 'form_factor': forms.HiddenInput(), + 'mode': StaticSelect2() } labels = { 'mode': '802.1Q Mode', @@ -439,8 +641,7 @@ class Meta: } def clean(self): - - super(InterfaceForm, self).clean() + super().clean() # Validate VLAN assignments tagged_vlans = self.cleaned_data['tagged_vlans'] @@ -457,13 +658,34 @@ def clean(self): class InterfaceCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) - enabled = forms.BooleanField(required=False) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mac_address = forms.CharField(required=False, label='MAC Address') - description = forms.CharField(max_length=100, required=False) - tags = TagField(required=False) + name_pattern = ExpandableNameField( + label='Name' + ) + form_factor = forms.ChoiceField( + choices=VIFACE_FF_CHOICES, + initial=IFACE_FF_VIRTUAL, + widget=forms.HiddenInput() + ) + enabled = forms.BooleanField( + required=False + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mac_address = forms.CharField( + required=False, + label='MAC Address' + ) + description = forms.CharField( + max_length=100, + required=False + ) + tags = TagField( + required=False + ) def __init__(self, *args, **kwargs): @@ -471,17 +693,33 @@ def __init__(self, *args, **kwargs): kwargs['initial'] = kwargs.get('initial', {}).copy() kwargs['initial'].update({'enabled': True}) - super(InterfaceCreateForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) - enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['mtu', 'description'] + nullable_fields = [ + 'mtu', 'description', + ] # @@ -489,12 +727,32 @@ class Meta: # class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): - pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) - name_pattern = ExpandableNameField(label='Name') + pk = forms.ModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + widget=forms.MultipleHiddenInput() + ) + name_pattern = ExpandableNameField( + label='Name' + ) class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): - form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) - enabled = forms.BooleanField(required=False, initial=True) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - description = forms.CharField(max_length=100, required=False) + form_factor = forms.ChoiceField( + choices=VIFACE_FF_CHOICES, + initial=IFACE_FF_VIRTUAL, + widget=forms.HiddenInput() + ) + enabled = forms.BooleanField( + required=False, + initial=True + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + description = forms.CharField( + max_length=100, + required=False + ) diff --git a/netbox/virtualization/migrations/0001_virtualization.py b/netbox/virtualization/migrations/0001_virtualization.py index a5c7535cfd2..f34bee36cb2 100644 --- a/netbox/virtualization/migrations/0001_virtualization.py +++ b/netbox/virtualization/migrations/0001_virtualization.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-31 14:15 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py index 5b03b6e33e7..f9f5c72bdac 100644 --- a/netbox/virtualization/migrations/0002_virtualmachine_add_status.py +++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-14 17:49 -from __future__ import unicode_literals - from django.db import migrations, models 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 index 295ec7d176a..6ee06f91248 100644 --- 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 @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/virtualization/migrations/0003_cluster_add_site.py b/netbox/virtualization/migrations/0003_cluster_add_site.py index 5ac3c578bc1..bdcce88bc9d 100644 --- a/netbox/virtualization/migrations/0003_cluster_add_site.py +++ b/netbox/virtualization/migrations/0003_cluster_add_site.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-22 16:30 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/virtualization/migrations/0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py index 10dec60fa21..db416fc5da5 100644 --- a/netbox/virtualization/migrations/0004_virtualmachine_add_role.py +++ b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-29 14:32 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/virtualization/migrations/0006_tags.py b/netbox/virtualization/migrations/0006_tags.py index eed800852e1..5152086de1a 100644 --- a/netbox/virtualization/migrations/0006_tags.py +++ b/netbox/virtualization/migrations/0006_tags.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/virtualization/migrations/0007_change_logging.py b/netbox/virtualization/migrations/0007_change_logging.py index 954f9f2a902..4c2d342e577 100644 --- a/netbox/virtualization/migrations/0007_change_logging.py +++ b/netbox/virtualization/migrations/0007_change_logging.py @@ -1,7 +1,5 @@ # -*- 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 diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 119c9ee4ffa..ff9f39ee99d 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,11 +1,8 @@ -from __future__ import unicode_literals - from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager from dcim.models import Device @@ -18,7 +15,6 @@ # Cluster types # -@python_2_unicode_compatible class ClusterType(ChangeLoggedModel): """ A type of Cluster. @@ -53,7 +49,6 @@ def to_csv(self): # Cluster groups # -@python_2_unicode_compatible class ClusterGroup(ChangeLoggedModel): """ An organizational group of Clusters. @@ -88,7 +83,6 @@ def to_csv(self): # Clusters # -@python_2_unicode_compatible class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -164,7 +158,6 @@ def to_csv(self): # Virtual machines # -@python_2_unicode_compatible class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 84579af49f2..b825ba59f37 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 32f56b99b27..91792f8fb1f 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from netaddr import IPNetwork from rest_framework import status @@ -15,7 +13,7 @@ class ClusterTypeTest(APITestCase): def setUp(self): - super(ClusterTypeTest, self).setUp() + super().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') @@ -116,7 +114,7 @@ class ClusterGroupTest(APITestCase): def setUp(self): - super(ClusterGroupTest, self).setUp() + super().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') @@ -217,7 +215,7 @@ class ClusterTest(APITestCase): def setUp(self): - super(ClusterTest, self).setUp() + super().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') @@ -330,7 +328,7 @@ class VirtualMachineTest(APITestCase): def setUp(self): - super(VirtualMachineTest, self).setUp() + super().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') @@ -380,6 +378,18 @@ def test_create_virtualmachine(self): self.assertEqual(virtualmachine4.name, data['name']) self.assertEqual(virtualmachine4.cluster.pk, data['cluster']) + def test_create_virtualmachine_without_cluster(self): + + data = { + 'name': 'Test Virtual Machine 4', + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VirtualMachine.objects.count(), 3) + def test_create_virtualmachine_bulk(self): data = [ @@ -448,7 +458,7 @@ class InterfaceTest(APITestCase): def setUp(self): - super(InterfaceTest, self).setUp() + super().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) diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index b03b3bc0a40..5fc5997a853 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index d4728da4548..aa8a585a93a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction @@ -371,5 +369,6 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC form = forms.VirtualMachineBulkAddInterfaceForm model = Interface model_form = forms.InterfaceForm + filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable default_return_url = 'virtualization:virtualmachine_list' diff --git a/requirements.txt b/requirements.txt index b3bee6b6d4b..e313e9a691a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,18 @@ -Django>=1.11,<2.1 +Django>=2.1.5,<2.2 django-cors-headers==2.4.0 -django-debug-toolbar==1.9.1 -django-filter==1.1.0 +django-debug-toolbar==1.11 +django-filter==2.0.0 django-mptt==0.9.1 -django-tables2==1.21.2 -django-taggit==0.22.2 +django-tables2==2.0.3 +django-taggit==0.23.0 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 +django-timezone-field==3.0 +djangorestframework==3.9.0 +drf-yasg[validation]==1.11.1 +graphviz==0.10.1 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 - +Pillow==5.3.0 +psycopg2-binary==2.7.6.1 +py-gfm==0.1.4 +pycryptodome==3.7.2 diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 00000000000..5974f91d8ee --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -0,0 +1,14 @@ +#!/bin/sh +# Create a link to this file at .git/hooks/pre-commit to +# force PEP8 validation prior to committing +# +# Ignored violations: +# +# W504: Line break after binary operator +# E501: Line too long + +exec 1>&2 + +echo "Validating PEP8 compliance..." +pycodestyle --ignore=W504,E501 netbox/ + diff --git a/upgrade.sh b/upgrade.sh index a1930eb3d22..24e79f5bdb9 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -5,47 +5,22 @@ # Once the script completes, remember to restart the WSGI service (e.g. # gunicorn or uWSGI). -# Determine which version of Python/pip to use. Default to v3 (if available) -# but allow the user to force v2. PYTHON="python3" PIP="pip3" -type $PYTHON >/dev/null 2>&1 && type $PIP >/dev/null 2>&1 || PYTHON="python" PIP="pip" -while getopts ":2" opt; do - case $opt in - 2) - PYTHON="python" - PIP="pip" - echo "Forcing Python/pip v2" - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - exit - ;; - esac -done - -# Optionally use sudo if not already root, and always prompt for password -# before running the command -PREFIX="sudo -k " -if [ "$(whoami)" = "root" ]; then - # When running upgrade as root, ask user to confirm if they wish to - # continue - read -n1 -rsp $'Running NetBox upgrade as root, press any key to continue or ^C to cancel\n' - PREFIX="" -fi +# TODO: Remove this in v2.6 as it is no longer needed under Python 3 # Delete stale bytecode -COMMAND="${PREFIX}find . -name \"*.pyc\" -delete" +COMMAND="find . -name \"*.pyc\" -delete" echo "Cleaning up stale Python bytecode ($COMMAND)..." eval $COMMAND # Uninstall any Python packages which are no longer needed -COMMAND="${PREFIX}${PIP} uninstall -r old_requirements.txt -y" +COMMAND="${PIP} uninstall -r old_requirements.txt -y" echo "Removing old Python packages ($COMMAND)..." eval $COMMAND # Install any new Python packages -COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade" +COMMAND="${PIP} install -r requirements.txt --upgrade" echo "Updating required Python packages ($COMMAND)..." eval $COMMAND
        Virtual CPUs - {% if virtualmachine.vcpus %} - {{ virtualmachine.vcpus }} - {% else %} - N/A - {% endif %} - {{ virtualmachine.vcpus|placeholder }}
        Memory