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 d9433aacf4c..3505c977b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1748 +1,2118 @@ -v2.4.9 (FUTURE) - -## Enhancements - -* [#2089](https://github.com/digitalocean/netbox/issues/2089) - Add SONET interface form factors -* [#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 -* [#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. +2.5.13 (2019-05-31) + +## Enhancements + +* [#2813](https://github.com/digitalocean/netbox/issues/2813) - Add tenant group filters +* [#3085](https://github.com/digitalocean/netbox/issues/3085) - Catch all exceptions during export template rendering +* [#3138](https://github.com/digitalocean/netbox/issues/3138) - Add 2.5GE and 5GE interface form factors +* [#3151](https://github.com/digitalocean/netbox/issues/3151) - Add inventory item count to manufacturers list +* [#3156](https://github.com/digitalocean/netbox/issues/3156) - Add site link to rack reservations overview +* [#3183](https://github.com/digitalocean/netbox/issues/3183) - Enable bulk deletion of sites +* [#3185](https://github.com/digitalocean/netbox/issues/3185) - Improve performance for custom field access within templates +* [#3186](https://github.com/digitalocean/netbox/issues/3186) - Add interface name filter for IP addresses + +## Bug Fixes + +* [#3031](https://github.com/digitalocean/netbox/issues/3031) - Fixed form field population of tags with spaces +* [#3132](https://github.com/digitalocean/netbox/issues/3132) - Circuit termination missing from available cable termination types +* [#3150](https://github.com/digitalocean/netbox/issues/3150) - Fix formatting of cable length during cable trace +* [#3184](https://github.com/digitalocean/netbox/issues/3184) - Correctly display color block for white cables +* [#3190](https://github.com/digitalocean/netbox/issues/3190) - Fix custom field rendering for Jinja2 export templates +* [#3211](https://github.com/digitalocean/netbox/issues/3211) - Fix error handling when attempting to delete a protected object via API +* [#3223](https://github.com/digitalocean/netbox/issues/3223) - Fix filtering devices by "has power outlets" +* [#3227](https://github.com/digitalocean/netbox/issues/3227) - Fix exception when deleting a circuit with a termination(s) +* [#3228](https://github.com/digitalocean/netbox/issues/3228) - Fixed login link retaining query parameters + +--- + +2.5.12 (2019-05-01) + +## Bug Fixes + +* [#3127](https://github.com/digitalocean/netbox/issues/3127) - Fix natural ordering of device components + +--- + +2.5.11 (2019-04-29) + +## Notes + +This release upgrades the Django framework to version 2.2. + +## Enhancements + +* [#2986](https://github.com/digitalocean/netbox/issues/2986) - Improve natural ordering of device components +* [#3023](https://github.com/digitalocean/netbox/issues/3023) - Add support for filtering cables by connected device +* [#3070](https://github.com/digitalocean/netbox/issues/3070) - Add decommissioning status for devices + +## Bug Fixes + +* [#2621](https://github.com/digitalocean/netbox/issues/2621) - Upgrade Django requirement to 2.2 to fix object deletion issue in the changelog middleware +* [#3072](https://github.com/digitalocean/netbox/issues/3072) - Preserve multiselect filter values when updating per-page count for list views +* [#3112](https://github.com/digitalocean/netbox/issues/3112) - Fix ordering of interface connections list by termination B name/device +* [#3116](https://github.com/digitalocean/netbox/issues/3116) - Fix `tagged_items` count in tags API endpoint +* [#3118](https://github.com/digitalocean/netbox/issues/3118) - Disable `last_login` update on login when maintenance mode is enabled + +--- + +v2.5.10 (2019-04-08) + +## Enhancements + +* [#3052](https://github.com/digitalocean/netbox/issues/3052) - Add Jinja2 support for export templates + +## Bug Fixes + +* [#2937](https://github.com/digitalocean/netbox/issues/2937) - Redirect to list view after editing an object from list view +* [#3036](https://github.com/digitalocean/netbox/issues/3036) - DCIM interfaces API endpoint should not include VM interfaces +* [#3039](https://github.com/digitalocean/netbox/issues/3039) - Fix exception when retrieving change object for a component template via API +* [#3041](https://github.com/digitalocean/netbox/issues/3041) - Fix form widget for bulk cable label update +* [#3044](https://github.com/digitalocean/netbox/issues/3044) - Ignore site/rack fields when connecting a new cable via device search +* [#3046](https://github.com/digitalocean/netbox/issues/3046) - Fix exception at reports API endpoint +* [#3047](https://github.com/digitalocean/netbox/issues/3047) - Fix exception when writing mac address for an interface via API + +--- + +v2.5.9 (2019-04-01) + +## Enhancements + +* [#2933](https://github.com/digitalocean/netbox/issues/2933) - Add username to outbound webhook requests +* [#3011](https://github.com/digitalocean/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+) +* [#3025](https://github.com/digitalocean/netbox/issues/3025) - Add request ID to outbound webhook requests (for correlating all changes part of a single request) + +## Bug Fixes + +* [#2207](https://github.com/digitalocean/netbox/issues/2207) - Fixes deterministic ordering of interfaces +* [#2577](https://github.com/digitalocean/netbox/issues/2577) - Clarification of wording in API regarding filtering +* [#2924](https://github.com/digitalocean/netbox/issues/2924) - Add interface type for QSFP28 50GE +* [#2936](https://github.com/digitalocean/netbox/issues/2936) - Fix device role selection showing duplicate first entry +* [#2998](https://github.com/digitalocean/netbox/issues/2998) - Limit device query to non-racked devices if no rack selected when creating a cable +* [#3001](https://github.com/digitalocean/netbox/issues/3001) - Fix API representation of ObjectChange `action` and add `changed_object_type` +* [#3014](https://github.com/digitalocean/netbox/issues/3014) - Fixes VM Role filtering +* [#3019](https://github.com/digitalocean/netbox/issues/3019) - Fix tag population when running NetBox within a path +* [#3022](https://github.com/digitalocean/netbox/issues/3022) - Add missing cable termination types to DCIM `_choices` endpoint +* [#3026](https://github.com/digitalocean/netbox/issues/3026) - Tweak prefix/IP filter forms to filter using VRF ID rather than route distinguisher +* [#3027](https://github.com/digitalocean/netbox/issues/3027) - Ignore empty local context data when rendering config contexts +* [#3032](https://github.com/digitalocean/netbox/issues/3032) - Save assigned tags when creating a new secret + +--- + +v2.5.8 (2019-03-11) + +## Enhancements + +* [#2435](https://github.com/digitalocean/netbox/issues/2435) - Printer friendly CSS + +## Bug Fixes + +* [#2065](https://github.com/digitalocean/netbox/issues/2065) - Correct documentation for VM interface serializer +* [#2705](https://github.com/digitalocean/netbox/issues/2705) - Fix endpoint grouping in API docs +* [#2781](https://github.com/digitalocean/netbox/issues/2781) - Fix filtering of sites/devices/VMs by multiple regions +* [#2923](https://github.com/digitalocean/netbox/issues/2923) - Provider filter form's site field should be blank by default +* [#2938](https://github.com/digitalocean/netbox/issues/2938) - Enforce deterministic ordering of device components returned by API +* [#2939](https://github.com/digitalocean/netbox/issues/2939) - Exclude circuit terminations from API interface connections endpoint +* [#2940](https://github.com/digitalocean/netbox/issues/2940) - Allow CSV import of prefixes/IPs to VRF without an RD assigned +* [#2944](https://github.com/digitalocean/netbox/issues/2944) - Record the deletion of an IP address in the changelog of its parent interface (if any) +* [#2952](https://github.com/digitalocean/netbox/issues/2952) - Added the `slug` field to the Tenant filter for use in the API and search function +* [#2954](https://github.com/digitalocean/netbox/issues/2954) - Remove trailing slashes to fix root/template paths on Windows +* [#2961](https://github.com/digitalocean/netbox/issues/2961) - Prevent exception when exporting inventory items belonging to unnamed devices +* [#2962](https://github.com/digitalocean/netbox/issues/2962) - Increase ExportTemplate `mime_type` field length +* [#2966](https://github.com/digitalocean/netbox/issues/2966) - Accept `null` cable length_unit via API +* [#2972](https://github.com/digitalocean/netbox/issues/2972) - Improve ContentTypeField serializer to elegantly handle invalid data +* [#2976](https://github.com/digitalocean/netbox/issues/2976) - Add delete button to tag view +* [#2980](https://github.com/digitalocean/netbox/issues/2980) - Improve rendering time for API docs +* [#2982](https://github.com/digitalocean/netbox/issues/2982) - Correct CSS class assignment on color picker +* [#2984](https://github.com/digitalocean/netbox/issues/2984) - Fix logging of unlabeled cable ID on cable deletion +* [#2985](https://github.com/digitalocean/netbox/issues/2985) - Fix pagination page length for rack elevations + +--- + +v2.5.7 (2019-02-21) + +## Enhancements + +* [#2357](https://github.com/digitalocean/netbox/issues/2357) - Enable filtering of devices by rack face +* [#2638](https://github.com/digitalocean/netbox/issues/2638) - Add button to copy unlocked secret to clipboard +* [#2870](https://github.com/digitalocean/netbox/issues/2870) - Add Markdown rendering for provider NOC/admin contact fields +* [#2878](https://github.com/digitalocean/netbox/issues/2878) - Add cable types for OS1/OS2 singlemode fiber +* [#2890](https://github.com/digitalocean/netbox/issues/2890) - Add port types for APC fiber +* [#2898](https://github.com/digitalocean/netbox/issues/2898) - Enable filtering cables list by connection status +* [#2903](https://github.com/digitalocean/netbox/issues/2903) - Clarify purpose of tags field on interface edit form + +## Bug Fixes + +* [#2852](https://github.com/digitalocean/netbox/issues/2852) - Allow filtering devices by null rack position +* [#2884](https://github.com/digitalocean/netbox/issues/2884) - Don't display connect button for wireless interfaces +* [#2888](https://github.com/digitalocean/netbox/issues/2888) - Correct foreground color of device roles in rack elevations +* [#2893](https://github.com/digitalocean/netbox/issues/2893) - Remove duplicate display of VRF RD on IP address view +* [#2895](https://github.com/digitalocean/netbox/issues/2895) - Fix filtering of nullable character fields +* [#2901](https://github.com/digitalocean/netbox/issues/2901) - Fix ordering regions by site count +* [#2910](https://github.com/digitalocean/netbox/issues/2910) - Fix config context list and edit forms to use Select2 elements +* [#2912](https://github.com/digitalocean/netbox/issues/2912) - Cable type in filter form should be blank by default +* [#2913](https://github.com/digitalocean/netbox/issues/2913) - Fix assigned prefixes link on VRF view +* [#2914](https://github.com/digitalocean/netbox/issues/2914) - Fix empty connected circuit link on device interfaces list +* [#2915](https://github.com/digitalocean/netbox/issues/2915) - Fix bulk editing of pass-through ports + +--- + +v2.5.6 (2019-02-13) + +## Enhancements + +* [#2758](https://github.com/digitalocean/netbox/issues/2758) - Add cable trace button to pass-through ports +* [#2839](https://github.com/digitalocean/netbox/issues/2839) - Add "110 punch" type for pass-through ports +* [#2854](https://github.com/digitalocean/netbox/issues/2854) - Enable bulk editing of pass-through ports +* [#2866](https://github.com/digitalocean/netbox/issues/2866) - Add cellular interface types (GSM/CDMA/LTE) + +## Bug Fixes + +* [#2841](https://github.com/digitalocean/netbox/issues/2841) - Fix filtering by VRF for prefix and IP address lists +* [#2844](https://github.com/digitalocean/netbox/issues/2844) - Correct display of far cable end for pass-through ports +* [#2845](https://github.com/digitalocean/netbox/issues/2845) - Enable filtering of rack unit list by unit ID +* [#2856](https://github.com/digitalocean/netbox/issues/2856) - Fix navigation links between LAG interfaces and their members on device view +* [#2857](https://github.com/digitalocean/netbox/issues/2857) - Add `display_name` to DeviceType API serializer; fix DeviceType list for bulk device edit +* [#2862](https://github.com/digitalocean/netbox/issues/2862) - Follow return URL when connecting a cable +* [#2864](https://github.com/digitalocean/netbox/issues/2864) - Correct display of VRF name when no RD is assigned +* [#2877](https://github.com/digitalocean/netbox/issues/2877) - Fixed device role label display on light background color +* [#2880](https://github.com/digitalocean/netbox/issues/2880) - Sanitize user password if an exception is raised during login + +--- + +v2.5.5 (2019-01-31) + +## Enhancements + +* [#2805](https://github.com/digitalocean/netbox/issues/2805) - Allow null route distinguisher for VRFs +* [#2809](https://github.com/digitalocean/netbox/issues/2809) - Remove VRF child prefixes table; link to main prefixes view +* [#2825](https://github.com/digitalocean/netbox/issues/2825) - Include directly connected device for front/rear ports + +## Bug Fixes + +* [#2824](https://github.com/digitalocean/netbox/issues/2824) - Fix template exception when viewing rack elevations list +* [#2833](https://github.com/digitalocean/netbox/issues/2833) - Fix form widget for front port template creation +* [#2835](https://github.com/digitalocean/netbox/issues/2835) - Fix certain model filters did not support the `q` query param +* [#2837](https://github.com/digitalocean/netbox/issues/2837) - Fix select2 nullable filter fields add multiple null_option elements when paging + +--- + +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..8b9df7f2c46 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) | @@ -39,7 +37,7 @@ and run `upgrade.sh`. ## Alternative Installations -* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine)) +* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine)) * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle)) * [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae)) @@ -47,13 +45,13 @@ and run `upgrade.sh`. ## Supported SDK -- [pynetbox](https://github.com/digitalocean/pynetbox) Python API client library for Netbox. +- [pynetbox](https://github.com/digitalocean/pynetbox) - A Python API client library for Netbox ## Community SDK -- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) A ruby client library for Netbox v2. +- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) - A Ruby client library for Netbox +- [powerbox](https://github.com/BatmanAMA/powerbox) - A PowerShell library for Netbox ## Ansible Inventory -- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) Ansible dynamic inventory script for Netbox. - +- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) - Ansible dynamic inventory script for 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..33c3d95aed1 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( @@ -128,4 +128,4 @@ Reports can be run on the CLI by invoking the management command: python3 manage.py runreport ``` -One or more report modules may be specified. +where ```` is the name of the python file in the ``reports`` directory without the ``.py`` extension. One or more report modules may be specified. diff --git a/docs/additional-features/tags.md b/docs/additional-features/tags.md index 18edcad1287..f9495761647 100644 --- a/docs/additional-features/tags.md +++ b/docs/additional-features/tags.md @@ -1,6 +1,6 @@ # Tags -Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand as they are assigned to objects. Use commas to separate tags when adding multiple tags to an object 9for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`. +Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand as they are assigned to objects. Use commas to separate tags when adding multiple tags to an object (for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`. Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters. diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index 5afd7876d60..2ebea5ce57f 100644 --- a/docs/administration/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/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 08e11fe56ab..6dd68659420 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -30,7 +30,7 @@ psql -c 'create database netbox' psql netbox < netbox.sql ``` -Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. +Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](../installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. ## Export the Database Schema diff --git a/docs/api/overview.md b/docs/api/overview.md index 85d97200816..00ff9c27e52 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. @@ -215,7 +261,7 @@ A list of objects retrieved via the API can be filtered by passing one or more q GET /api/ipam/prefixes/?status=1 ``` -The same filter can be incldued multiple times. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes: +Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes: ``` GET /api/ipam/prefixes/?status=1&status=2 diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index b4de6fe7b1a..f8bd70e884d 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 = { @@ -267,6 +283,7 @@ REDIS = { 'PASSWORD': '', 'DATABASE': 0, 'DEFAULT_TIMEOUT': 300, + 'SSL': False, } ``` @@ -299,3 +316,9 @@ The TCP port to use when connecting to the Redis server. Default: None The password to use when authenticating to the Redis server (optional). + +### SSL + +Default: False + +Use secure sockets layer to encrypt the connections to the Redis server. 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 5ae599c73f4..176a7067617 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) @@ -19,6 +13,10 @@ Some devices house child devices which share physical resources, like space and !!! note This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. + For that application you should create a single Device for the chassis, and add Interfaces directly to it. Interfaces can be created in bulk using range patterns, e.g. "Gi1/[1-24]". + + Add Inventory Items if you want to record the line cards themselves as separate entities. There is no explicit relationship between each interface and its line card, but it may be implied by the naming (e.g. interfaces "Gi1/x" are on line card 1) + ## Manufacturers Each device type must be assigned to a manufacturer. The model number of a device type must be unique to its manufacturer. @@ -32,6 +30,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 +56,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 customizable. 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,10 +87,24 @@ 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. +Child devices are first-class Devices in their own right: that is, fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and interfaces. You cannot create a LAG between interfaces in different child devices. + +Therefore, Device bays are **not** suitable for modeling chassis-based switches and routers. These should instead be modeled as a single Device, with the line cards as Inventory Items. + +## 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 @@ -109,7 +119,7 @@ The assignment of platforms to devices is an optional feature, and may be disreg # Inventory Items -Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Like device types, each item can optionally be assigned a manufacturer. +Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Like device types, each item can optionally be assigned a manufacturer. --- @@ -118,3 +128,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 05b613da252..cd95c40e6be 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -83,7 +83,7 @@ An IP address can be designated as the network address translation (NAT) inside A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space). -Each VRF is assigned a unique name and route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced. +Each VRF is assigned a unique name and an optional route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced. Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any IP prefix or address not assigned to a VRF is said to belong to the "global" table. 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 06c8a3d5c21..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. 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/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/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 aacd837cf5e..dd1d445507f 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 Circuit, CircuitTermination, CircuitType, Provider -from dcim.api.serializers import NestedInterfaceSerializer, NestedSiteSerializer +from circuits.models import Provider, Circuit, CircuitTermination, CircuitType +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, NestedPackageSerializer from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer +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) @@ -73,25 +52,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 = NestedInterfaceSerializer(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 9bfcdeabec1..06ccc3bd924 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 @@ -8,6 +6,7 @@ CIRCUIT_STATUS_PROVISIONING = 3 CIRCUIT_STATUS_OFFLINE = 4 CIRCUIT_STATUS_DECOMMISSIONED = 5 +CIRCUIT_STATUS_QA = 5 CIRCUIT_STATUS_CHOICES = [ [CIRCUIT_STATUS_PLANNED, 'Planned'], [CIRCUIT_STATUS_PROVISIONING, 'Provisioning'], @@ -15,6 +14,7 @@ [CIRCUIT_STATUS_OFFLINE, 'Offline'], [CIRCUIT_STATUS_DEPROVISIONING, 'Deprovisioning'], [CIRCUIT_STATUS_DECOMMISSIONED, 'Decommissioned'], + [CIRCUIT_STATUS_QA, 'Quality assurance'], ] # CircuitTermination sides diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index a159fad4288..4decb716616 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,29 +1,30 @@ -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, TagFilter +from tenancy.filtersets import TenancyFilterSet +from utilities.filters import NameSlugSearchFilterSet, 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') +class ProviderFilter(CustomFieldFilterSet): + 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)', @@ -46,15 +47,18 @@ def search(self, queryset, name, value): ) -class CircuitTypeFilter(django_filters.FilterSet): +class CircuitTypeFilter(NameSlugSearchFilterSet): class Meta: model = CircuitType fields = ['name', 'slug'] -class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -64,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)', @@ -74,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)', @@ -83,23 +87,13 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=CIRCUIT_STATUS_CHOICES, null_value=None ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - 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)', @@ -117,6 +111,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() @@ -136,7 +131,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)', @@ -152,5 +147,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 02170869f1c..6c93a4197aa 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,16 +1,14 @@ -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.forms import TenancyFilterForm 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 +21,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 +62,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=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + asn = forms.IntegerField( + required=False, + label='ASN' + ) # @@ -84,7 +128,9 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class CircuitTypeCSVForm(forms.ModelForm): @@ -104,7 +150,9 @@ class Meta: class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Circuit @@ -117,6 +165,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(), + + } def clean(self): @@ -170,112 +228,107 @@ 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'] - - -def circuit_status_choices(): - status_counts = {} - for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES] + nullable_fields = [ + 'tenant', 'commit_rate', 'description', 'comments', + ] -class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): +class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit - q = forms.CharField(required=False, label='Search') + field_order = ['q', 'type', 'provider', 'status', 'site', 'tenant_group', 'tenant', 'commit_rate'] + 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')), + site = FilterChoiceField( + queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --' + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) ) - site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')), - to_field_name='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}}', - 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", @@ -284,26 +337,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/migrations/0015_merge_20190128_1143.py b/netbox/circuits/migrations/0015_merge_20190128_1143.py new file mode 100644 index 00000000000..eb222ef835f --- /dev/null +++ b/netbox/circuits/migrations/0015_merge_20190128_1143.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.4 on 2019-01-28 11:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0014_optional_port_speed_circuit_terminations'), + ('circuits', '0014_circuittermination_description'), + ] + + operations = [ + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index b19c2de48b1..977f54a5ca6 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,13 +1,11 @@ -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, STATUS_CLASSES from dcim.fields import ASNField +from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange from utilities.models import ChangeLoggedModel from tenancy.models import Tenant, Package @@ -15,7 +13,6 @@ 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 @@ -85,7 +82,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 @@ -117,12 +113,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, @@ -189,7 +184,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]) @@ -229,8 +224,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, @@ -246,13 +240,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)', blank=True, @@ -274,27 +272,40 @@ 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): """ Reference the parent circuit when recording the change. """ + try: + related_object = self.circuit + except Circuit.DoesNotExist: + # Parent circuit has been deleted + related_object = None ObjectChange( user=user, request_id=request_id, changed_object=self, - related_object=self.circuit, + related_object=related_object, action=action, object_data=serialize_object(self) ).save() + @property + def parent(self): + return self.circuit + def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' try: 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..60b6a7f7ca1 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 @@ -13,7 +11,7 @@ {% if perms.circuit.change_circuittype %} - + {% endif %} """ @@ -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 @@ -67,7 +59,7 @@ class CircuitTypeTable(BaseTable): name = tables.LinkColumn() circuit_count = tables.Column(verbose_name='Circuits') actions = tables.TemplateColumn( - template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) class Meta(BaseTable.Meta): diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index b3b1488c100..0810f0ff93b 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from rest_framework import status @@ -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,46 +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') - 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 - ) - 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=self.site1 - ) - device2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site2 - ) - self.interface1 = Interface.objects.create(device=device1, name='Test Interface 1') - self.interface2 = Interface.objects.create(device=device2, name='Test Interface 2') - self.interface3 = Interface.objects.create(device=device1, name='Test Interface 3') - self.interface4 = Interface.objects.create(device=device2, name='Test Interface 4') - self.interface5 = Interface.objects.create(device=device1, name='Test Interface 5') - self.interface6 = Interface.objects.create(device=device2, name='Test Interface 6') - 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.circuittermination1 = CircuitTermination.objects.create( - circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface1, port_speed=1000000 + circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 ) self.circuittermination2 = CircuitTermination.objects.create( - circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface2, port_speed=1000000 + circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000 ) self.circuittermination3 = CircuitTermination.objects.create( - circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface3, 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, interface=self.interface4, port_speed=1000000 + circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000 ) def test_get_circuittermination(self): @@ -390,7 +368,7 @@ def test_create_circuittermination(self): 'circuit': self.circuit3.pk, 'term_side': TERM_SIDE_A, 'site': self.site1.pk, - 'interface': self.interface5.pk, + 'port_speed': 1000000, } url = reverse('circuits-api:circuittermination-list') @@ -402,19 +380,18 @@ def test_create_circuittermination(self): self.assertEqual(circuittermination4.circuit_id, data['circuit']) self.assertEqual(circuittermination4.term_side, data['term_side']) self.assertEqual(circuittermination4.site_id, data['site']) - self.assertEqual(circuittermination4.interface_id, data['interface']) + self.assertEqual(circuittermination4.port_speed, data['port_speed']) def test_update_circuittermination(self): circuittermination5 = CircuitTermination.objects.create( - circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface5, port_speed=1000000 + circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 ) data = { 'circuit': self.circuit3.pk, 'term_side': TERM_SIDE_Z, 'site': self.site2.pk, - 'interface': self.interface6.pk, 'port_speed': 1000000, } @@ -426,7 +403,6 @@ def test_update_circuittermination(self): circuittermination1 = CircuitTermination.objects.get(pk=response.data['id']) self.assertEqual(circuittermination1.term_side, data['term_side']) self.assertEqual(circuittermination1.site_id, data['site']) - self.assertEqual(circuittermination1.interface_id, data['interface']) self.assertEqual(circuittermination1.port_speed, data['port_speed']) def test_delete_circuittermination(self): diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py new file mode 100644 index 00000000000..65ae6d7db4c --- /dev/null +++ b/netbox/circuits/tests/test_views.py @@ -0,0 +1,91 @@ +import urllib.parse + +from django.test import Client, TestCase +from django.urls import reverse + +from circuits.models import Circuit, CircuitType, Provider + + +class ProviderTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + Provider.objects.bulk_create([ + Provider(name='Provider 1', slug='provider-1', asn=65001), + Provider(name='Provider 2', slug='provider-2', asn=65002), + Provider(name='Provider 3', slug='provider-3', asn=65003), + ]) + + def test_provider_list(self): + + url = reverse('circuits:provider_list') + params = { + "q": "test", + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_provider(self): + + provider = Provider.objects.first() + response = self.client.get(provider.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class CircuitTypeTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + CircuitType.objects.bulk_create([ + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), + CircuitType(name='Circuit Type 2', slug='circuit-type-2'), + CircuitType(name='Circuit Type 3', slug='circuit-type-3'), + ]) + + def test_circuittype_list(self): + + url = reverse('circuits:circuittype_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class CircuitTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + provider = Provider(name='Provider 1', slug='provider-1', asn=65001) + provider.save() + + circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1') + circuittype.save() + + Circuit.objects.bulk_create([ + Circuit(cid='Circuit 1', provider=provider, type=circuittype), + Circuit(cid='Circuit 2', provider=provider, type=circuittype), + Circuit(cid='Circuit 3', provider=provider, type=circuittype), + ]) + + def test_circuit_list(self): + + url = reverse('circuits:circuit_list') + params = { + "provider": Provider.objects.first().slug, + "type": CircuitType.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_provider(self): + + provider = Provider.objects.first() + response = self.client.get(provider.get_absolute_url()) + self.assertEqual(response.status_code, 200) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 449da396467..440960d646c 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,48 +1,49 @@ -from __future__ import unicode_literals - -from django.conf.urls import url +from django.urls import path +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 = [ # Providers - url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'), - url(r'^providers/add/$', views.ProviderCreateView.as_view(), name='provider_add'), - url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'), - url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), - url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - url(r'^providers/(?P[\w-]+)/$', views.ProviderView.as_view(), name='provider'), - url(r'^providers/(?P[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), - url(r'^providers/(?P[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), - url(r'^providers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path(r'providers/', views.ProviderListView.as_view(), name='provider_list'), + path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), + path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), + path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), + path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), + path(r'providers//', views.ProviderView.as_view(), name='provider'), + path(r'providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), + path(r'providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), + path(r'providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types - url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), - url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), - url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), - url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - url(r'^circuit-types/(?P[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - url(r'^circuit-types/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), + path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), + path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), + path(r'circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path(r'circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits - url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), - url(r'^circuits/add/$', views.CircuitCreateView.as_view(), name='circuit_add'), - url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'), - url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), - url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), - url(r'^circuits/(?P\d+)/$', views.CircuitView.as_view(), name='circuit'), - url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), - url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), - url(r'^circuits/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), + path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'), + path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), + path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), + path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), + path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), + path(r'circuits//', views.CircuitView.as_view(), name='circuit'), + path(r'circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), + path(r'circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), + path(r'circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), + path(r'circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations - 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'), + path(r'circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), + path(r'circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), + path(r'circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), + path(r'circuit-terminations//connect/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + path(r'circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b42717262f4..f66f02cb479 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,17 +146,17 @@ 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() termination_y = CircuitTermination.objects.select_related( - 'site__region', 'interface__device' + 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=TERM_SIDE_Y ).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..e53259e942f --- /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', 'display_name'] + + +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 3ef91431c46..324eacb46a1 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,42 +1,58 @@ -from __future__ import unicode_literals - +from django.contrib.contenttypes.models import ContentType 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, VLANGroup -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, VLANGroup +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,10 +62,6 @@ 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) @@ -72,16 +84,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): @@ -92,18 +96,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: @@ -111,32 +103,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. @@ -153,31 +136,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. @@ -188,10 +151,6 @@ class RackUnitSerializer(serializers.Serializer): device = NestedDeviceSerializer(read_only=True) -# -# Rack reservations -# - class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() @@ -203,7 +162,7 @@ class Meta: # -# Manufacturers +# Device types # class ManufacturerSerializer(ValidatedModelSerializer): @@ -213,21 +172,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) @@ -235,25 +181,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', 'display_name', '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() @@ -262,10 +194,6 @@ class Meta: fields = ['id', 'device_type', 'name'] -# -# Console server port templates -# - class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -274,10 +202,6 @@ class Meta: fields = ['id', 'device_type', 'name'] -# -# Power port templates -# - class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -286,10 +210,6 @@ class Meta: fields = ['id', 'device_type', 'name'] -# -# Power outlet templates -# - class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -298,10 +218,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) @@ -311,9 +227,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() @@ -324,7 +255,7 @@ class Meta: # -# Device roles +# Devices # class DeviceRoleSerializer(ValidatedModelSerializer): @@ -334,64 +265,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): @@ -403,12 +282,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: @@ -416,8 +295,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 = [] @@ -430,7 +309,7 @@ def validate(self, data): validator(data) # Enforce model validation - super(DeviceSerializer, self).validate(data) + super().validate(data) return data @@ -452,166 +331,63 @@ 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) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = ConsoleServerPort - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return hasattr(obj, 'connected_console') and obj.connected_console is not None - + 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) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = ConsolePort - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return obj.cs_port is not None - + 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) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = PowerOutlet - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return hasattr(obj, 'connected_port') and obj.connected_port is not None - + 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) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = PowerPort - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return obj.power_outlet is not None - - -# -# Interfaces -# - -class IsConnectedMixin(object): - """ - Provide a method for setting is_connected on Interface serializers. - """ - def get_is_connected(self, obj): - """ - Return True if the interface has a connected interface or circuit. - """ - if obj.connection: - return True - if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None: - return True - return False - - -class NestedInterfaceSerializer(IsConnectedMixin, WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = Interface - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - -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', ] @@ -633,31 +409,30 @@ class Meta: fields = ['id', 'url', 'vid', 'name', 'display_name', 'group'] -class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, 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. @@ -675,39 +450,51 @@ def validate(self, data): "be global.".format(vlan) }) - return super(InterfaceSerializer, self).validate(data) + return super().validate(data) - 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'] # @@ -730,40 +517,79 @@ 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( + queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES) + ) + termination_b_type = ContentTypeField( + queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES) + ) + 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, allow_null=True) 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 = InterfaceConnection - fields = ['id', 'interface', 'connection_status'] + model = Cable + fields = [ + 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', + ] - def get_interface(self, obj): - return NestedInterfaceSerializer(self.context['interface'], context=self.context).data + +# +# 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 = Interface + fields = ['interface_a', 'interface_b', 'connection_status'] + + def get_interface_a(self, obj): + context = {'request': self.context['request']} + return NestedInterfaceSerializer(instance=obj, context=context).data # @@ -777,11 +603,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 fd4d3709654..8964e7fcb9c 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 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', 'termination_a_type', 'termination_b_type', '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): @@ -119,6 +159,11 @@ def units(self, request, pk=None): exclude_pk = None elevation = rack.get_rack_units(face, exclude_pk) + # Enable filtering rack units by ID + q = request.GET.get('q', None) + if q: + elevation = [u for u in elevation if q in str(u['id'])] + page = self.paginate_queryset(elevation) if page is not None: rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) @@ -132,7 +177,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 +191,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 +201,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 +211,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 +263,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 +273,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 +287,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): """ @@ -321,34 +378,56 @@ 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.filter( + device__isnull=False + ).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): @@ -361,16 +440,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 # @@ -378,21 +477,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__device' + ).filter( + # Avoid duplicate connections by only selecting the lower PK in a connected pair + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') + ) 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 # @@ -418,32 +543,39 @@ class ConnectedDeviceViewSet(ViewSet): * `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) - if not peer_device_name: - # TODO: remove this after 2.4 as the switch to using underscores is a breaking change - peer_device_name = request.query_params.get('peer-device') peer_interface_name = request.query_params.get(self._interface_param.name) - if not peer_interface_name: - # TODO: remove this after 2.4 as the switch to using underscores is a breaking change - peer_interface_name = request.query_params.get('peer-interface') + if not peer_device_name or not peer_interface_name: 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 6d8896f7ce0..bc4d5e4ba99 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,26 @@ [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'], +] + +# Device rack position +DEVICE_POSITION_CHOICES = [ + # Rack.u_height is limited to 100 + (i, 'Unit {}'.format(i)) for i in range(1, 101) +] + # Parent/child device roles SUBDEVICE_ROLE_PARENT = True SUBDEVICE_ROLE_CHILD = False @@ -57,6 +75,8 @@ IFACE_FF_1GE_FIXED = 1000 IFACE_FF_1GE_GBIC = 1050 IFACE_FF_1GE_SFP = 1100 +IFACE_FF_2GE_FIXED = 1120 +IFACE_FF_5GE_FIXED = 1130 IFACE_FF_10GE_FIXED = 1150 IFACE_FF_10GE_CX4 = 1170 IFACE_FF_10GE_SFP_PLUS = 1200 @@ -65,17 +85,25 @@ IFACE_FF_10GE_X2 = 1320 IFACE_FF_25GE_SFP28 = 1350 IFACE_FF_40GE_QSFP_PLUS = 1400 +IFACE_FF_50GE_QSFP28 = 1420 IFACE_FF_100GE_CFP = 1500 IFACE_FF_100GE_CFP2 = 1510 IFACE_FF_100GE_CFP4 = 1520 IFACE_FF_100GE_CPAK = 1550 IFACE_FF_100GE_QSFP28 = 1600 +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 +# Cellular +IFACE_FF_GSM = 2810 +IFACE_FF_CDMA = 2820 +IFACE_FF_LTE = 2830 # SONET IFACE_FF_SONET_OC3 = 6100 IFACE_FF_SONET_OC12 = 6200 @@ -91,6 +119,7 @@ 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 @@ -123,6 +152,8 @@ [ [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], + [IFACE_FF_2GE_FIXED, '2.5GBASE-T (2.5GE)'], + [IFACE_FF_5GE_FIXED, '5GBASE-T (5GE)'], [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], [IFACE_FF_10GE_CX4, '10GBASE-CX4 (10GE)'], ] @@ -138,11 +169,15 @@ [IFACE_FF_10GE_X2, 'X2 (10GE)'], [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], + [IFACE_FF_50GE_QSFP28, 'QSFP28 (50GE)'], [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)'], ] ], [ @@ -155,6 +190,14 @@ [IFACE_FF_80211AD, 'IEEE 802.11ad'], ] ], + [ + 'Cellular', + [ + [IFACE_FF_GSM, 'GSM'], + [IFACE_FF_CDMA, 'CDMA'], + [IFACE_FF_LTE, 'LTE'], + ] + ], [ 'SONET', [ @@ -176,6 +219,7 @@ [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)'], ] ], [ @@ -235,6 +279,44 @@ [IFACE_MODE_TAGGED_ALL, 'Tagged All'], ] +# Pass-through port types +PORT_TYPE_8P8C = 1000 +PORT_TYPE_110_PUNCH = 1100 +PORT_TYPE_ST = 2000 +PORT_TYPE_SC = 2100 +PORT_TYPE_SC_APC = 2110 +PORT_TYPE_FC = 2200 +PORT_TYPE_LC = 2300 +PORT_TYPE_LC_APC = 2310 +PORT_TYPE_MTRJ = 2400 +PORT_TYPE_MPO = 2500 +PORT_TYPE_LSH = 2600 +PORT_TYPE_LSH_APC = 2610 +PORT_TYPE_CHOICES = [ + [ + 'Copper', + [ + [PORT_TYPE_8P8C, '8P8C'], + [PORT_TYPE_110_PUNCH, '110 Punch'], + ], + ], + [ + 'Fiber Optic', + [ + [PORT_TYPE_FC, 'FC'], + [PORT_TYPE_LC, 'LC'], + [PORT_TYPE_LC_APC, 'LC/APC'], + [PORT_TYPE_LSH, 'LSH'], + [PORT_TYPE_LSH_APC, 'LSH/APC'], + [PORT_TYPE_MPO, 'MPO'], + [PORT_TYPE_MTRJ, 'MTRJ'], + [PORT_TYPE_SC, 'SC'], + [PORT_TYPE_SC_APC, 'SC/APC'], + [PORT_TYPE_ST, 'ST'], + ] + ] +] + # Device statuses DEVICE_STATUS_OFFLINE = 0 DEVICE_STATUS_ACTIVE = 1 @@ -242,6 +324,8 @@ DEVICE_STATUS_STAGED = 3 DEVICE_STATUS_FAILED = 4 DEVICE_STATUS_INVENTORY = 5 +DEVICE_STATUS_QA = 6 +DEVICE_STATUS_DECOMMISSIONING = 7 DEVICE_STATUS_CHOICES = [ [DEVICE_STATUS_ACTIVE, 'Active'], [DEVICE_STATUS_OFFLINE, 'Offline'], @@ -249,6 +333,8 @@ [DEVICE_STATUS_STAGED, 'Staged'], [DEVICE_STATUS_FAILED, 'Failed'], [DEVICE_STATUS_INVENTORY, 'Inventory'], + [DEVICE_STATUS_QA, 'Quality assurance'], + [DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'], ] # Site statuses @@ -261,7 +347,7 @@ [SITE_STATUS_RETIRED, 'Retired'], ] -# Bootstrap CSS classes for device statuses +# Bootstrap CSS classes for device/rack statuses STATUS_CLASSES = { 0: 'warning', 1: 'success', @@ -269,6 +355,8 @@ 3: 'primary', 4: 'danger', 5: 'default', + 6: 'info', + 7: 'warning', } # Console/power/interface connection statuses @@ -279,12 +367,93 @@ [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', 'circuittermination', ] + +# 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 = 3000 +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_SMF_OS1 = 3510 +CABLE_TYPE_SMF_OS2 = 3520 +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, 'Multimode 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_SMF_OS1, 'Singlemode Fiber (OS1)'), + (CABLE_TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'), + (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..9624ce0a3f1 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): @@ -34,7 +31,7 @@ def to_python(self, value): try: return EUI(value, version=48, dialect=mac_unix_expanded_uppercase) except AddrFormatError as e: - raise ValidationError(e) + raise ValidationError("Invalid MAC address format: {}".format(value)) def db_type(self, connection): return 'macaddr' diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index a8fb279543b..ec1e03983c1 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_filters from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist @@ -8,32 +6,29 @@ from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet +from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant -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, IFACE_FF_CHOICES, +from utilities.constants import COLOR_CHOICES +from utilities.filters import ( + NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter ) +from virtualization.models import Cluster +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, ) -class RegionFilter(django_filters.FilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class RegionFilter(NameSlugSearchFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), 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)', @@ -43,18 +38,12 @@ class Meta: model = Region fields = ['name', 'slug'] - def search(self, queryset, name, value): - if not value.strip(): - return queryset - qs_filter = ( - Q(name__icontains=value) | - Q(slug__icontains=value) - ) - return queryset.filter(qs_filter) - -class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class SiteFilter(TenancyFilterSet, CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -63,26 +52,17 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=SITE_STATUS_CHOICES, null_value=None ) - region_id = django_filters.ModelMultipleChoiceFilter( + region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), + field_name='region__in', label='Region (ID)', ) - region = django_filters.ModelMultipleChoiceFilter( - name='region__slug', + region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), + field_name='region__in', to_field_name='slug', label='Region (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) tag = TagFilter() class Meta: @@ -110,17 +90,13 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter) -class RackGroupFilter(django_filters.FilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class RackGroupFilter(NameSlugSearchFilterSet): 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 (slug)', @@ -130,25 +106,19 @@ class Meta: model = RackGroup fields = ['site_id', 'name', 'slug'] - def search(self, queryset, name, value): - if not value.strip(): - return queryset - qs_filter = ( - Q(name__icontains=value) | - Q(slug__icontains=value) - ) - return queryset.filter(qs_filter) - -class RackRoleFilter(django_filters.FilterSet): +class RackRoleFilter(NameSlugSearchFilterSet): class Meta: model = RackRole fields = ['name', 'slug', 'color'] -class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class RackFilter(TenancyFilterSet, CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -159,7 +129,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)', @@ -169,36 +139,34 @@ 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', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - 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)', ) + 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(): @@ -207,12 +175,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') +class RackReservationFilter(TenancyFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -222,43 +194,33 @@ 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', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label='User (ID)', ) user = django_filters.ModelMultipleChoiceFilter( - name='user', + field_name='user', queryset=User.objects.all(), to_field_name='username', label='User (name)', @@ -279,15 +241,18 @@ def search(self, queryset, name, value): ) -class ManufacturerFilter(django_filters.FilterSet): +class ManufacturerFilter(NameSlugSearchFilterSet): class Meta: model = Manufacturer 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', @@ -297,18 +262,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)', ) + 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): @@ -321,11 +309,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) -class DeviceTypeComponentFilterSet(django_filters.FilterSet): + 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(NameSlugSearchFilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), - name='device_type_id', + field_name='device_type_id', label='Device type (ID)', ) @@ -365,6 +374,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: @@ -372,21 +395,21 @@ class Meta: fields = ['name'] -class DeviceRoleFilter(django_filters.FilterSet): +class DeviceRoleFilter(NameSlugSearchFilterSet): class Meta: model = DeviceRole fields = ['name', 'slug', 'color', 'vm_role'] -class PlatformFilter(django_filters.FilterSet): +class PlatformFilter(NameSlugSearchFilterSet): 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)', @@ -397,19 +420,22 @@ class Meta: fields = ['name', 'slug'] -class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class DeviceFilter(TenancyFilterSet, 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)', @@ -419,46 +445,37 @@ 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)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), 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', - name='pk', + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', label='Region (ID)', ) - region = django_filters.CharFilter( - method='filter_region', - name='slug', + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( @@ -466,27 +483,31 @@ class DeviceFilter(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 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)', ) + position = django_filters.ChoiceFilter( + choices=DEVICE_POSITION_CHOICES, + null_label='Non-racked' + ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), 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)', @@ -496,21 +517,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', @@ -520,15 +529,39 @@ 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)', ) + 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 - fields = ['serial', 'position'] + fields = ['serial', 'face'] def search(self, queryset, name, value): if not value.strip(): @@ -541,16 +574,6 @@ 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: @@ -573,8 +596,33 @@ 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): + q = django_filters.CharFilter( + method='search', + label='Search', + ) device_id = django_filters.ModelChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', @@ -586,56 +634,91 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) tag = TagFilter() + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) + ) + 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)', ) @@ -659,14 +742,20 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', '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() @@ -708,6 +797,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: @@ -738,7 +851,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)', @@ -768,23 +881,23 @@ 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)', @@ -805,6 +918,46 @@ 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 + ) + device = django_filters.CharFilter( + method='filter_connected_device', + field_name='name' + ) + device_id = django_filters.CharFilter( + method='filter_connected_device', + field_name='pk' + ) + + 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) + + def filter_connected_device(self, queryset, name, value): + if not value.strip(): + return queryset + try: + device = Device.objects.get(**{name: value}) + except ObjectDoesNotExist: + return queryset.none() + cable_pks = device.get_cables(pk_list=True) + return queryset.filter(pk__in=cable_pks) + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', @@ -822,14 +975,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) ) @@ -850,14 +1003,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) ) @@ -872,21 +1025,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 3e76a81268d..f1505a6b620 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 @@ -13,25 +13,22 @@ from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm -from tenancy.models import Tenant +from tenancy.forms import TenancyFilterForm +from tenancy.models import Tenant, TenantGroup 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+\}' @@ -62,6 +59,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" + }) # @@ -73,7 +86,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): @@ -98,7 +118,10 @@ class Meta: class RegionFilterForm(BootstrapMixin, forms.Form): model = Site - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) # @@ -106,10 +129,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 @@ -119,8 +150,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", @@ -178,15 +219,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, @@ -200,31 +248,36 @@ 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): +class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Site - q = forms.CharField(required=False, label='Search') - status = AnnotatedMultipleChoiceField( - choices=SITE_STATUS_CHOICES, - annotate=Site.objects.all(), - annotate_field='status', - required=False + field_order = ['q', 'status', 'region', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' ) - region = FilterTreeNodeMultipleChoiceField( - queryset=Region.objects.annotate(filter_count=Count('sites')), - to_field_name='slug', + status = forms.MultipleChoiceField( + choices=SITE_STATUS_CHOICES, required=False, + widget=StaticSelect2Multiple() ) - tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('sites')), + region = forms.ModelMultipleChoiceField( + queryset=Region.objects.all(), to_field_name='slug', - null_label='-- None --' + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + ) ) @@ -237,7 +290,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): @@ -260,7 +320,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", + ) + ) # @@ -272,7 +339,9 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = [ + 'name', 'slug', 'color', + ] class RackRoleCSVForm(forms.ModelForm): @@ -299,17 +368,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", @@ -318,7 +389,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(), } @@ -344,6 +427,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, @@ -365,6 +453,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 @@ -376,7 +469,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') @@ -404,43 +497,136 @@ 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): +class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Rack - q = forms.CharField(required=False, label='Search') + field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] + 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')), - to_field_name='slug', - null_label='-- None --' + 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, + ) ) @@ -449,16 +635,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,34 +673,59 @@ def _get_unit_choices(self): return unit_choices -class RackReservationFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField(required=False, label='Search') - site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('racks__reservations')), - to_field_name='slug' +class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RackReservation.objects.all(), + widget=forms.MultipleHiddenInput() ) - group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')), - label='Rack group', - null_label='-- None --' + user = forms.ModelChoiceField( + queryset=User.objects.order_by( + 'username' + ), + required=False, + widget=StaticSelect2() ) - tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')), - to_field_name='slug', - null_label='-- None --' + 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 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) class Meta: nullable_fields = [] +class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): + field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + 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'), + label='Rack group', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + null_option=True, + ) + ) + + # # Manufacturers # @@ -510,10 +735,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 @@ -528,17 +756,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() } @@ -557,11 +792,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 @@ -573,17 +803,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() ) - 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' + 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_full_depth = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Is full depth' ) class Meta: @@ -592,25 +830,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 + ) ) @@ -622,95 +901,236 @@ 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'] - widgets = { + model = FrontPortTemplate + fields = [ + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + ] + widgets = { + 'device_type': forms.HiddenInput(), + 'rear_port': StaticSelect2(), + } + + +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.', + ) + + 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') + name_pattern = ExpandableNameField( + label='Name' + ) # @@ -722,7 +1142,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): @@ -746,8 +1168,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(), } @@ -779,8 +1206,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( @@ -790,9 +1220,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( @@ -800,14 +1229,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( @@ -817,10 +1249,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) @@ -829,29 +1284,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: @@ -880,7 +1351,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( @@ -975,7 +1446,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') @@ -1028,7 +1499,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') @@ -1077,7 +1548,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') @@ -1094,75 +1565,209 @@ 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/", + display_field='display_name' + ) + ) + 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): +class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Device - q = forms.CharField(required=False, label='Search') - region = FilterTreeNodeMultipleChoiceField( + field_order = [ + 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant', + 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip', + ] + 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", + ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('devices')), - to_field_name='slug', - null_label='-- None --', + 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 + ) ) @@ -1171,16 +1776,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 + ) # @@ -1188,537 +1815,139 @@ 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) + 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', +# +# Console server ports +# + +class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) + + class Meta: + model = ConsoleServerPort + fields = [ + 'device', 'name', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), } + + +class ConsoleServerPortCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' ) - cs_port = forms.CharField( - help_text='Console server port name' + tags = TagField( + required=False ) - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found', - } + + +class ConsoleServerPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() ) - console_port = forms.CharField( - help_text='Console port name' + + +class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() ) - connection_status = CSVChoiceField( - choices=CONNECTION_STATUS_CHOICES, - help_text='Connection status' + + +# +# Power ports +# + +class PowerPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False ) class Meta: - model = ConsolePort - fields = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] + model = PowerPort + fields = [ + 'device', 'name', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } - 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 +class PowerPortCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + tags = TagField( + required=False + ) - 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 +# +# Power outlets +# - def clean_cs_port(self): +class PowerOutletForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) - cs_port_name = self.cleaned_data.get('cs_port') - if not self.cleaned_data.get('console_server') or not cs_port_name: - return None + class Meta: + model = PowerOutlet + fields = [ + 'device', 'name', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } - 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 PowerOutletCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + tags = TagField( + required=False + ) -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', - ) - ) - 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='is_connected', - ) - ) - - 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) - - class Meta: - model = ConsoleServerPort - 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='is_connected' - ) - ) - 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', - } - - -class ConsoleServerPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) - - -class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) - - -# -# Power ports -# - -class PowerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) - - class Meta: - model = PowerPort - 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' - ) - ) - power_outlet = ChainedModelChoiceField( +class PowerOutletBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( queryset=PowerOutlet.objects.all(), - chains=( - ('device', 'pdu'), - ), - label='Outlet', - widget=APISelect( - api_url='/api/dcim/power-outlets/?device_id={{pdu}}', - disabled_indicator='is_connected' - ) - ) - - 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) - - class Meta: - model = PowerOutlet - 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'} - ) - ) - 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='is_connected' - ) - ) - connection_status = forms.BooleanField( - required=False, - initial=CONNECTION_STATUS_CONNECTED, - label='Status', - widget=forms.Select( - choices=CONNECTION_STATUS_CHOICES - ) + widget=forms.MultipleHiddenInput ) - 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) - class PowerOutletBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput + ) # @@ -1726,7 +1955,9 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # class InterfaceForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Interface @@ -1736,6 +1967,9 @@ class Meta: ] widgets = { 'device': forms.HiddenInput(), + 'form_factor': StaticSelect2(), + 'lag': StaticSelect2(), + 'mode': StaticSelect2(), } labels = { 'mode': '802.1Q Mode', @@ -1745,23 +1979,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'] @@ -1781,7 +2015,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, @@ -1794,7 +2032,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 @@ -1839,7 +2077,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,248 +2089,656 @@ def clean(self): def save(self, *args, **kwargs): - if self.cleaned_data['tagged']: - for vlan in self.cleaned_data['vlans']: - self.instance.tagged_vlans.add(vlan) - else: - self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] + if self.cleaned_data['tagged']: + for vlan in self.cleaned_data['vlans']: + self.instance.tagged_vlans.add(vlan) + else: + self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] + + return super().save(*args, **kwargs) + + +class InterfaceCreateForm(ComponentForm, forms.Form): + 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='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, + widget=StaticSelect2(), + ) + tags = TagField( + required=False + ) + + def __init__(self, *args, **kwargs): + + # Set interfaces enabled by default + kwargs['initial'] = kwargs.get('initial', {}).copy() + kwargs['initial'].update({'enabled': True}) + + 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.filter( + device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG + ) + else: + self.fields['lag'].queryset = Interface.objects.none() + + +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, + 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', 'mac_address', 'mtu', 'description', 'mode', + ] + + def __init__(self, *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: + 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() + ) + + +class InterfaceBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + +# +# Front pass-through ports +# + +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=[], + 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 super(InterfaceAssignVLANsForm, self).save(*args, **kwargs) + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } -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') - mgmt_only = forms.BooleanField( +class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PORT_TYPE_CHOICES), required=False, - label='OOB Management', - help_text='This interface is used only for out-of-band management' + widget=StaticSelect2() + ) + description = forms.CharField( + max_length=100, + required=False ) - description = forms.CharField(max_length=100, required=False) - mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - tags = TagField(required=False) - def __init__(self, *args, **kwargs): + class Meta: + nullable_fields = [ + 'description', + ] - # Set interfaces enabled by default - kwargs['initial'] = kwargs.get('initial', {}).copy() - kwargs['initial'].update({'enabled': True}) - super(InterfaceCreateForm, self).__init__(*args, **kwargs) +class FrontPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + - # 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( - device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG - ) - else: - self.fields['lag'].queryset = Interface.objects.none() +class FrontPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) -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) +# +# Rear pass-through ports +# + +class RearPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) class Meta: - nullable_fields = ['lag', 'mtu', 'description', 'mode'] + model = RearPort + fields = [ + 'device', 'name', 'type', 'positions', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + 'type': StaticSelect2(), + } - def __init__(self, *args, **kwargs): - super(InterfaceBulkEditForm, self).__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 - ) - else: - self.fields['lag'].choices = [] +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 InterfaceBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) +class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PORT_TYPE_CHOICES), + required=False, + widget=StaticSelect2() + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'description', + ] -class InterfaceBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) +class RearPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +class RearPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput + ) # -# Interface connections +# Cables # -class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - interface_a = forms.ChoiceField( - choices=[], - widget=SelectWithDisabled, - label='Interface' - ) - site_b = forms.ModelChoiceField( +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', + } + ) + 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' ) - interface_a = forms.CharField( - help_text='Name of interface A' + side_a_name = forms.CharField( + help_text='Side A component' ) - device_b = FlexibleModelChoiceField( + + # 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', + } + ) + 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' ) - interface_b = forms.CharField( - help_text='Name of interface B' + side_b_name = forms.CharField( + help_text='Side B component' ) - connection_status = CSVChoiceField( + + # 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, + widget=StaticSelect2(), + initial='' + ) + label = forms.CharField( + max_length=100, + required=False + ) + 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=add_blank_choice(CABLE_TYPE_CHOICES), + required=False, + widget=StaticSelect2() + ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(CONNECTION_STATUS_CHOICES), + widget=StaticSelect2() + ) + color = forms.CharField( + max_length=6, + required=False, + widget=ColorSelect() + ) + device = forms.CharField( + required=False, + label='Device name' + ) # @@ -2100,31 +2746,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, @@ -2136,7 +2791,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() + ) # @@ -2144,18 +2802,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' + ) # @@ -2163,11 +2842,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): @@ -2195,24 +2883,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') - device = forms.CharField(required=False, label='Device name') + 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 + ) + ) # @@ -2220,24 +2935,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 = [] @@ -2254,14 +2976,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 @@ -2291,8 +3015,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( @@ -2303,19 +3031,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' ) @@ -2324,19 +3059,46 @@ 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', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + tenant_group = FilterChoiceField( + queryset=TenantGroup.objects.all(), + to_field_name='slug', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/", + value_field="slug", + null_option=True, + filter_for={ + 'tenant': 'group' + } + ) ) tenant = FilterChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py new file mode 100644 index 00000000000..53f627a5b72 --- /dev/null +++ b/netbox/dcim/managers.py @@ -0,0 +1,73 @@ +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 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. + + The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device + components. + """ + + sql_col = '{}.name'.format(self.model._meta.db_table) + ordering = [ + '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk' + + ] + + 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/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/migrations/0069_deprecate_nullablecharfield.py b/netbox/dcim/migrations/0069_deprecate_nullablecharfield.py new file mode 100644 index 00000000000..77cee8517f1 --- /dev/null +++ b/netbox/dcim/migrations/0069_deprecate_nullablecharfield.py @@ -0,0 +1,38 @@ +# Generated by Django 2.1.5 on 2019-02-14 14:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0068_rack_new_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='asset_tag', + field=models.CharField(blank=True, max_length=50, null=True, unique=True), + ), + migrations.AlterField( + model_name='device', + name='name', + field=models.CharField(blank=True, max_length=64, null=True, unique=True), + ), + migrations.AlterField( + model_name='inventoryitem', + name='asset_tag', + field=models.CharField(blank=True, max_length=50, null=True, unique=True), + ), + migrations.AlterField( + model_name='rack', + name='asset_tag', + field=models.CharField(blank=True, max_length=50, null=True, unique=True), + ), + migrations.AlterField( + model_name='rack', + name='facility_id', + field=models.CharField(blank=True, max_length=50, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0069_merge_20190128_1143.py b/netbox/dcim/migrations/0069_merge_20190128_1143.py new file mode 100644 index 00000000000..bde92a621a8 --- /dev/null +++ b/netbox/dcim/migrations/0069_merge_20190128_1143.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.4 on 2019-01-28 11:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0065_latitude_decimal_fix'), + ('dcim', '0068_rack_new_fields'), + ] + + operations = [ + ] diff --git a/netbox/dcim/migrations/0070_merge_20190508_0805.py b/netbox/dcim/migrations/0070_merge_20190508_0805.py new file mode 100644 index 00000000000..5e742bde3aa --- /dev/null +++ b/netbox/dcim/migrations/0070_merge_20190508_0805.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.1 on 2019-05-08 08:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0069_deprecate_nullablecharfield'), + ('dcim', '0069_merge_20190128_1143'), + ] + + operations = [ + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 4907ad5596d..5e121fdace7 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,64 +1,184 @@ -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.fields import ColorField +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 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() + @property + def parent(self): + return getattr(self, 'device', None) + + +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 + + def get_cable_peer(self): + if self.cable is None: + return None + if self._cabled_as_a.exists(): + return self.cable.termination_b + if self._cabled_as_b.exists(): + return self.cable.termination_a + # # Regions # -@python_2_unicode_compatible class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. @@ -97,16 +217,17 @@ def to_csv(self): self.parent.name if self.parent else None, ) + def get_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 +318,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = SiteManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ @@ -256,6 +377,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 +390,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 +429,6 @@ def to_csv(self): ) -@python_2_unicode_compatible class RackRole(ChangeLoggedModel): """ Racks can be organized by functional role, similar to Devices. @@ -341,11 +461,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. @@ -354,7 +469,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): name = models.CharField( max_length=50 ) - facility_id = NullableCharField( + facility_id = models.CharField( max_length=50, blank=True, null=True, @@ -379,6 +494,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 +510,14 @@ class Rack(ChangeLoggedModel, CustomFieldModel): blank=True, verbose_name='Serial number' ) + asset_tag = models.CharField( + 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 +540,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 +565,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 +581,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 +619,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 +632,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 +661,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 +757,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 +830,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 +860,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 +898,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 +917,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 +931,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 +947,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 +967,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 " @@ -870,7 +979,7 @@ def clean(self): }) @property - def full_name(self): + def display_name(self): return '{} {}'.format(self.manufacturer.name, self.model) @property @@ -882,20 +991,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 = NaturalOrderingManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -903,24 +1013,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 = NaturalOrderingManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -928,24 +1036,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 = NaturalOrderingManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -953,24 +1059,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 = NaturalOrderingManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -978,12 +1082,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 +1104,7 @@ class InterfaceTemplate(ComponentModel): verbose_name='Management only' ) - objects = InterfaceQuerySet.as_manager() + objects = InterfaceManager() class Meta: ordering = ['device_type', 'name'] @@ -1013,12 +1113,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)] + ) + + objects = NaturalOrderingManager() + + 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 + ) + ) -@python_2_unicode_compatible -class DeviceBayTemplate(ComponentModel): +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 = NaturalOrderingManager() + + 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 +1211,8 @@ class DeviceBayTemplate(ComponentModel): max_length=50 ) + objects = NaturalOrderingManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -1038,15 +1220,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 +1262,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 +1295,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 +1317,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, @@ -1187,7 +1353,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): blank=True, null=True ) - name = NullableCharField( + name = models.CharField( max_length=64, blank=True, null=True, @@ -1198,7 +1364,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): blank=True, verbose_name='Serial number' ) - asset_tag = NullableCharField( + asset_tag = models.CharField( max_length=50, blank=True, null=True, @@ -1288,7 +1454,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = DeviceManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ @@ -1308,7 +1474,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 +1589,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()] @@ -1521,6 +1704,21 @@ def vc_interfaces(self): filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False) return Interface.objects.filter(filter) + def get_cables(self, pk_list=False): + """ + Return a QuerySet or PK list matching all Cables connected to a component of this Device. + """ + cable_pks = [] + for component_model in [ + ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort + ]: + cable_pks += component_model.objects.filter( + device=self, cable__isnull=False + ).values_list('cable', flat=True) + if pk_list: + return cable_pks + return Cable.objects.filter(pk__in=cable_pks) + def get_children(self): """ Return the set of child Devices installed in DeviceBays within this Device. @@ -1530,48 +1728,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 = NaturalOrderingManager() tags = TaggableManager() - csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] + csv_headers = ['device', 'name'] class Meta: ordering = ['device', 'name'] @@ -1583,16 +1772,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 +1783,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 = NaturalOrderingManager() tags = TaggableManager() + csv_headers = ['device', 'name'] + class Meta: unique_together = ['device', 'name'] @@ -1636,53 +1814,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 = NaturalOrderingManager() tags = TaggableManager() - csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] + csv_headers = ['device', 'name'] class Meta: ordering = ['device', 'name'] @@ -1694,16 +1864,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 +1875,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 = NaturalOrderingManager() tags = TaggableManager() + csv_headers = ['device', 'name'] + class Meta: unique_together = ['device', 'name'] @@ -1747,30 +1906,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 +1936,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 +1965,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 +2012,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 +2030,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 +2060,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 +2107,7 @@ def save(self, *args, **kwargs): if self.pk and self.mode is IFACE_MODE_TAGGED_ALL: 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 +2118,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 +2128,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,152 +2176,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' + ) + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES ) - interface_b = models.OneToOneField( - to='dcim.Interface', + 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'] - - def clean(self): + objects = NaturalOrderingManager() + tags = TaggableManager() - # An interface cannot be connected to itself - if self.interface_a == self.interface_b: - raise ValidationError({ - 'interface_b': "Cannot connect an interface to itself." - }) + csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] - # Only connectable interface types are permitted - 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() - ) - }) + class Meta: + ordering = ['device', 'name'] + unique_together = [ + ['device', 'name'], + ['rear_port', 'rear_port_position'], + ] - # Prevent the A side of one connection from being the B side of another - interface_a_connections = InterfaceConnection.objects.filter( - Q(interface_a=self.interface_a) | - Q(interface_b=self.interface_a) - ).exclude(pk=self.pk) - if interface_a_connections.exists(): - raise ValidationError({ - 'interface_a': "This interface is already connected." - }) - interface_b_connections = InterfaceConnection.objects.filter( - Q(interface_a=self.interface_b) | - Q(interface_b=self.interface_b) - ).exclude(pk=self.pk) - if interface_b_connections.exists(): - raise ValidationError({ - 'interface_b': "This interface is already connected." - }) + 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 + ) + ) + + +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 = NaturalOrderingManager() + tags = TaggableManager() + + csv_headers = ['device', 'name', 'type', 'positions', 'description'] + + class Meta: + ordering = ['device', 'name'] + unique_together = ['device', 'name'] + + def __str__(self): + return self.name - 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() + 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 @@ -2144,8 +2326,11 @@ class DeviceBay(ComponentModel): null=True ) + objects = NaturalOrderingManager() tags = TaggableManager() + csv_headers = ['device', 'name', 'installed_device'] + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -2156,8 +2341,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): @@ -2176,7 +2365,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. @@ -2215,7 +2403,7 @@ class InventoryItem(ComponentModel): verbose_name='Serial number', blank=True ) - asset_tag = NullableCharField( + asset_tag = models.CharField( max_length=50, unique=True, blank=True, @@ -2248,12 +2436,9 @@ 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 + '}', + self.device.name or '{{{}}}'.format(self.device.pk), self.name, self.manufacturer.name if self.manufacturer else None, self.part_id, @@ -2268,7 +2453,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). @@ -2311,3 +2495,198 @@ 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 __str__(self): + if self.label: + return self.label + + # Save a copy of the PK on the instance since it's nullified if .delete() is called + if not hasattr(self, 'id_string'): + self.id_string = '#{}'.format(self.pk) + + return 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_status_class(self): + return 'success' if self.status else 'info' + + 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/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 edd30d89fb6..aec96d04f2f 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, 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, ) REGION_LINK = """ @@ -31,7 +29,8 @@ """ COLOR_LABEL = """ - +{% load helpers %} + """ DEVICE_LINK = """ @@ -45,7 +44,7 @@ {% if perms.dcim.change_region %} - + {% endif %} """ @@ -57,7 +56,7 @@ {% if perms.dcim.change_rackgroup %} - + {% endif %} @@ -68,7 +67,7 @@ {% if perms.dcim.change_rackrole %} - + {% endif %} """ @@ -89,7 +88,7 @@ {% if perms.dcim.change_rackreservation %} - + {% endif %} """ @@ -98,7 +97,7 @@ {% if perms.dcim.change_manufacturer %} - + {% endif %} """ @@ -107,7 +106,7 @@ {% if perms.dcim.change_devicerole %} - + {% endif %} """ @@ -132,12 +131,13 @@ {% if perms.dcim.change_platform %} - + {% endif %} """ DEVICE_ROLE = """ - +{% load helpers %} + """ STATUS_LABEL = """ @@ -168,10 +168,22 @@ {% if perms.dcim.change_virtualchassis %} - + +{% 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 @@ -184,7 +196,7 @@ class RegionTable(BaseTable): slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn( template_code=REGION_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -227,7 +239,7 @@ class RackGroupTable(BaseTable): slug = tables.Column() actions = tables.TemplateColumn( template_code=RACKGROUP_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -246,7 +258,7 @@ class RackRoleTable(BaseTable): rack_count = tables.Column(verbose_name='Racks') color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, + actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): @@ -264,12 +276,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,40 +294,32 @@ 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 # class RackReservationTable(BaseTable): pk = ToggleColumn() - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + site = tables.LinkColumn( + viewname='dcim:site', + accessor=Accessor('rack.site'), + args=[Accessor('rack.site.slug')], + ) + tenant = tables.TemplateColumn(template_code=COL_TENANT) rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) unit_list = tables.Column(orderable=False, verbose_name='Units') actions = tables.TemplateColumn( - template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) class Meta(BaseTable.Meta): model = RackReservation - fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions') + fields = ('pk', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions') # @@ -323,16 +328,26 @@ class Meta(BaseTable.Meta): class ManufacturerTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') - devicetype_count = tables.Column(verbose_name='Device Types') - platform_count = tables.Column(verbose_name='Platforms') - slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + name = tables.LinkColumn() + devicetype_count = tables.Column( + verbose_name='Device Types' + ) + inventoryitem_count = tables.Column( + verbose_name='Inventory Items' + ) + platform_count = tables.Column( + verbose_name='Platforms' + ) + slug = tables.Column() + actions = tables.TemplateColumn( + template_code=MANUFACTURER_ACTIONS, + attrs={'td': {'class': 'text-right noprint'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = Manufacturer - fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions') + fields = ('pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'slug', 'actions') # @@ -347,9 +362,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 +374,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 +429,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() @@ -448,7 +478,7 @@ class DeviceRoleTable(BaseTable): slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn( template_code=DEVICEROLE_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -477,7 +507,7 @@ class PlatformTable(BaseTable): ) actions = tables.TemplateColumn( template_code=PLATFORM_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -503,7 +533,7 @@ class DeviceTable(BaseTable): device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') device_type = tables.LinkColumn( 'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', - text=lambda record: record.device_type.full_name + text=lambda record: record.device_type.display_name ) class Meta(BaseTable.Meta): @@ -576,6 +606,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 +629,145 @@ 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='' + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + 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') + 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_interface.device'), + args=[Accessor('_connected_interface.device.pk')], + verbose_name='Device B' + ) + interface_b = tables.LinkColumn( + viewname='dcim:interface', + accessor=Accessor('_connected_interface'), + args=[Accessor('_connected_interface.pk')], + verbose_name='Interface B' + ) + description_b = tables.Column( + accessor=Accessor('_connected_interface.description'), + verbose_name='Description' + ) class Meta(BaseTable.Meta): - model = InterfaceConnection - fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + model = Interface + fields = ( + 'device_a', 'interface_a', 'description_a', 'device_b', 'interface_b', 'description_b', 'connection_status', + ) # @@ -650,7 +794,7 @@ class VirtualChassisTable(BaseTable): member_count = tables.Column(verbose_name='Members') actions = tables.TemplateColumn( template_code=VIRTUALCHASSIS_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c7d90b816e1..b5492c1abd4 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, IFACE_MODE_ACCESS, 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') @@ -859,7 +855,7 @@ def test_list_devicetypes_brief(self): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'manufacturer', 'model', 'slug', 'url'] + ['display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] ) def test_create_devicetype(self): @@ -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', 'is_connected', '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', 'is_connected', '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', 'is_connected', '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', 'is_connected', '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', 'is_connected', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_interface(self): @@ -2606,7 +2599,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') @@ -2729,7 +2722,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') @@ -2841,228 +2834,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_get_cable(self): + + 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_consoleconnections(self): + def test_list_cables(self): - url = reverse('dcim-api:consoleconnections-list') + 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', + }, + ] + + 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']) -class PowerConnectionTest(APITestCase): + 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) + + 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) - def test_create_interfaceconnection(self): + # 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) + + 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) - def test_update_interfaceconnection(self): + def test_create_direct_circuittermination_connection(self): - new_connection_status = not self.interfaceconnection1.connection_status + 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') @@ -3087,7 +3368,9 @@ 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): @@ -3102,7 +3385,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') @@ -3189,7 +3472,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 2f006fbc44f..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,109 +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' + 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 ) - interface5 = Interface.objects.create( - device=device1, - name='Ethernet1/3/2/1' + 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 ) - interface6 = Interface.objects.create( - device=device1, - name='Loopback1' + + 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_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' ) - self.assertEqual( - list(Interface.objects.all().order_naturally()), - [interface6, interface1, interface5, interface4, interface3, interface2] + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - - 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, + self.device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site ) - interface1 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/3' + self.device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site ) - interface2 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/2.2' + 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 ) - interface3 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/0.120' + self.panel2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=site ) - interface4 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/0' + self.rear_port1 = RearPort.objects.create( + device=self.panel1, name='Rear Port 1', type=PORT_TYPE_8P8C ) - interface5 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/1.117' + self.front_port1 = FrontPort.objects.create( + device=self.panel1, name='Front Port 1', type=PORT_TYPE_8P8C, rear_port=self.rear_port1 ) - interface6 = Interface.objects.create( - device=device1, - name='GigabitEthernet0' + self.rear_port2 = RearPort.objects.create( + device=self.panel2, name='Rear Port 2', type=PORT_TYPE_8P8C ) - self.assertEqual( - list(Interface.objects.all().order_naturally()), - [interface6, interface4, interface3, interface5, interface2, interface1] + 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/tests/test_views.py b/netbox/dcim/tests/test_views.py new file mode 100644 index 00000000000..79f38a5c9b2 --- /dev/null +++ b/netbox/dcim/tests/test_views.py @@ -0,0 +1,458 @@ +import urllib.parse + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from dcim.constants import CABLE_TYPE_CAT6, IFACE_FF_1GE_FIXED +from dcim.models import ( + Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup, + RackReservation, RackRole, Site, Region, VirtualChassis, +) + + +class RegionTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + # Create three Regions + for i in range(1, 4): + Region(name='Region {}'.format(i), slug='region-{}'.format(i)).save() + + def test_region_list(self): + + url = reverse('dcim:region_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class SiteTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + region = Region(name='Region 1', slug='region-1') + region.save() + + Site.objects.bulk_create([ + Site(name='Site 1', slug='site-1', region=region), + Site(name='Site 2', slug='site-2', region=region), + Site(name='Site 3', slug='site-3', region=region), + ]) + + def test_site_list(self): + + url = reverse('dcim:site_list') + params = { + "region": Region.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_site(self): + + site = Site.objects.first() + response = self.client.get(site.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class RackGroupTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + RackGroup.objects.bulk_create([ + RackGroup(name='Rack Group 1', slug='rack-group-1', site=site), + RackGroup(name='Rack Group 2', slug='rack-group-2', site=site), + RackGroup(name='Rack Group 3', slug='rack-group-3', site=site), + ]) + + def test_rackgroup_list(self): + + url = reverse('dcim:rackgroup_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class RackTypeTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + RackRole.objects.bulk_create([ + RackRole(name='Rack Role 1', slug='rack-role-1'), + RackRole(name='Rack Role 2', slug='rack-role-2'), + RackRole(name='Rack Role 3', slug='rack-role-3'), + ]) + + def test_rackrole_list(self): + + url = reverse('dcim:rackrole_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class RackReservationTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + User = get_user_model() + user = User(username='testuser', email='testuser@example.com') + user.save() + + site = Site(name='Site 1', slug='site-1') + site.save() + + rack = Rack(name='Rack 1', site=site) + rack.save() + + RackReservation.objects.bulk_create([ + RackReservation(rack=rack, user=user, units=[1, 2, 3], description='Reservation 1'), + RackReservation(rack=rack, user=user, units=[4, 5, 6], description='Reservation 2'), + RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'), + ]) + + def test_rackreservation_list(self): + + url = reverse('dcim:rackreservation_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class RackTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + Rack.objects.bulk_create([ + Rack(name='Rack 1', site=site), + Rack(name='Rack 2', site=site), + Rack(name='Rack 3', site=site), + ]) + + def test_rack_list(self): + + url = reverse('dcim:rack_list') + params = { + "site": Site.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_rack(self): + + rack = Rack.objects.first() + response = self.client.get(rack.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class ManufacturerTypeTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + Manufacturer.objects.bulk_create([ + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ]) + + def test_manufacturer_list(self): + + url = reverse('dcim:manufacturer_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class DeviceTypeTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') + manufacturer.save() + + DeviceType.objects.bulk_create([ + DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer), + DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer), + DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer), + ]) + + def test_devicetype_list(self): + + url = reverse('dcim:devicetype_list') + params = { + "manufacturer": Manufacturer.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_devicetype(self): + + devicetype = DeviceType.objects.first() + response = self.client.get(devicetype.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class DeviceRoleTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + DeviceRole.objects.bulk_create([ + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ]) + + def test_devicerole_list(self): + + url = reverse('dcim:devicerole_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class PlatformTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + Platform.objects.bulk_create([ + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + Platform(name='Platform 3', slug='platform-3'), + ]) + + def test_platform_list(self): + + url = reverse('dcim:platform_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class DeviceTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') + manufacturer.save() + + devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) + devicetype.save() + + devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') + devicerole.save() + + Device.objects.bulk_create([ + Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), + ]) + + def test_device_list(self): + + url = reverse('dcim:device_list') + params = { + "device_type_id": DeviceType.objects.first().pk, + "role": DeviceRole.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_device(self): + + device = Device.objects.first() + response = self.client.get(device.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class InventoryItemTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') + manufacturer.save() + + devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) + devicetype.save() + + devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') + devicerole.save() + + device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) + device.save() + + InventoryItem.objects.bulk_create([ + InventoryItem(device=device, name='Inventory Item 1'), + InventoryItem(device=device, name='Inventory Item 2'), + InventoryItem(device=device, name='Inventory Item 3'), + ]) + + def test_inventoryitem_list(self): + + url = reverse('dcim:inventoryitem_list') + params = { + "device_id": Device.objects.first().pk, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_inventoryitem(self): + + inventoryitem = InventoryItem.objects.first() + response = self.client.get(inventoryitem.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class CableTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') + manufacturer.save() + + devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) + devicetype.save() + + devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') + devicerole.save() + + device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) + device1.save() + device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole) + device2.save() + + iface1 = Interface(device=device1, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED) + iface1.save() + iface2 = Interface(device=device1, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED) + iface2.save() + iface3 = Interface(device=device1, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED) + iface3.save() + iface4 = Interface(device=device2, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED) + iface4.save() + iface5 = Interface(device=device2, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED) + iface5.save() + iface6 = Interface(device=device2, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED) + iface6.save() + + Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save() + Cable(termination_a=iface2, termination_b=iface5, type=CABLE_TYPE_CAT6).save() + Cable(termination_a=iface3, termination_b=iface6, type=CABLE_TYPE_CAT6).save() + + def test_cable_list(self): + + url = reverse('dcim:cable_list') + params = { + "type": CABLE_TYPE_CAT6, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_cable(self): + + cable = Cable.objects.first() + response = self.client.get(cable.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class VirtualMachineTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + device_role = DeviceRole.objects.create( + name='Device Role', slug='device-role-1' + ) + + # Create 9 member Devices + device1 = Device.objects.create( + device_type=device_type, device_role=device_role, name='Device 1', site=site + ) + device2 = Device.objects.create( + device_type=device_type, device_role=device_role, name='Device 2', site=site + ) + device3 = Device.objects.create( + device_type=device_type, device_role=device_role, name='Device 3', site=site + ) + device4 = Device.objects.create( + device_type=device_type, device_role=device_role, name='Device 4', site=site + ) + device5 = Device.objects.create( + device_type=device_type, device_role=device_role, name='Device 5', site=site + ) + device6 = Device.objects.create( + device_type=device_type, device_role=device_role, name='Device 6', site=site + ) + + # Create three VirtualChassis with two members each + vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1') + Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2) + vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2') + Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2) + vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3') + Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) + + def test_virtualchassis_list(self): + + url = reverse('dcim:virtualchassis_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_virtualchassis(self): + + virtualchassis = VirtualChassis.objects.first() + response = self.client.get(virtualchassis.get_absolute_url()) + self.assertEqual(response.status_code, 200) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 8e6b0d6f846..25c3a5a4d00 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,246 +1,283 @@ -from __future__ import unicode_literals - -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView, ImageAttachmentEditView from ipam.views import ServiceCreateView 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' urlpatterns = [ # Regions - url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), - url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'), - url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'), - url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), - url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), - url(r'^regions/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + path(r'regions/', views.RegionListView.as_view(), name='region_list'), + path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'), + path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), + path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path(r'regions//edit/', views.RegionEditView.as_view(), name='region_edit'), + path(r'regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites - url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), - url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'), - url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'), - url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), - url(r'^sites/delete/$', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), - url(r'^sites/(?P[\w-]+)/$', views.SiteView.as_view(), name='site'), - url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), - url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), - url(r'^sites/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), - url(r'^sites/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), + path(r'sites/', views.SiteListView.as_view(), name='site_list'), + path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'), + path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), + path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), + path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), + path(r'sites//', views.SiteView.as_view(), name='site'), + path(r'sites//edit/', views.SiteEditView.as_view(), name='site_edit'), + path(r'sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), + path(r'sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path(r'sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups - url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), - url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'), - url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), - url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), - url(r'^rack-groups/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), - url(r'^rack-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), + path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), + path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), + path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), + path(r'rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + path(r'rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles - url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), - url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'), - url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), - url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), - url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), - url(r'^rack-roles/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), + path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), + path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), + path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), + path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path(r'rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), + path(r'rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations - url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'), - url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), - url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), - url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), - url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), - url(r'^rack-reservations/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), + path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), + path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), + path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), + path(r'rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), + path(r'rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + path(r'rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), # Racks - url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), - url(r'^rack-elevations/$', views.RackElevationListView.as_view(), name='rack_elevation_list'), - url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'), - url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'), - url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), - url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), - url(r'^racks/(?P\d+)/$', views.RackView.as_view(), name='rack'), - url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), - url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), - url(r'^racks/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), - url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), - url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), + path(r'racks/', views.RackListView.as_view(), name='rack_list'), + path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), + path(r'racks/add/', views.RackEditView.as_view(), name='rack_add'), + path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), + path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), + path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), + path(r'racks//', views.RackView.as_view(), name='rack'), + path(r'racks//edit/', views.RackEditView.as_view(), name='rack_edit'), + path(r'racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), + path(r'racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), + path(r'racks//reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), + path(r'racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers - url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), - url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), - url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), - url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - url(r'^manufacturers/(?P[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - url(r'^manufacturers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), + path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), + path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), + path(r'manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path(r'manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types - url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), - url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), - url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), - url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), - url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), - url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), - url(r'^device-types/(?P\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), - url(r'^device-types/(?P\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), - url(r'^device-types/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), + path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), + path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), + path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), + path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), + path(r'device-types//', views.DeviceTypeView.as_view(), name='devicetype'), + path(r'device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), + path(r'device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), + path(r'device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), # Console port templates - url(r'^device-types/(?P\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), - url(r'^device-types/(?P\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), + path(r'device-types//console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), + path(r'device-types//console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), # Console server port templates - url(r'^device-types/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'), - url(r'^device-types/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), + path(r'device-types//console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'), + path(r'device-types//console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), # Power port templates - url(r'^device-types/(?P\d+)/power-ports/add/$', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'), - url(r'^device-types/(?P\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), + path(r'device-types//power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'), + path(r'device-types//power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), # Power outlet templates - url(r'^device-types/(?P\d+)/power-outlets/add/$', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'), - url(r'^device-types/(?P\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), + path(r'device-types//power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'), + path(r'device-types//power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), # Interface templates - url(r'^device-types/(?P\d+)/interfaces/add/$', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'), - 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'), + path(r'device-types//interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'), + path(r'device-types//interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), + path(r'device-types//interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), + + # Front port templates + path(r'device-types//front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'), + path(r'device-types//front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'), + + # Rear port templates + path(r'device-types//rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'), + path(r'device-types//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'), + path(r'device-types//device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), + path(r'device-types//device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), # Device roles - url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'), - url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), - url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), - url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - url(r'^device-roles/(?P[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - url(r'^device-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), + path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), + path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), + path(r'device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path(r'device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms - url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), - url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'), - url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'), - url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - url(r'^platforms/(?P[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), - url(r'^platforms/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'), + path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), + path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), + path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), + path(r'platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path(r'platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices - url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), - url(r'^devices/add/$', views.DeviceCreateView.as_view(), name='device_add'), - url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'), - url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), - url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), - url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), - url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), - url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), - url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), - url(r'^devices/(?P\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - url(r'^devices/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), - url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), - url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), - url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), - url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), - url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'), - url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), + path(r'devices/', views.DeviceListView.as_view(), name='device_list'), + path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'), + path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), + path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), + path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), + path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), + path(r'devices//', views.DeviceView.as_view(), name='device'), + path(r'devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), + path(r'devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), + path(r'devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), + path(r'devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path(r'devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), + path(r'devices//status/', views.DeviceStatusView.as_view(), name='device_status'), + path(r'devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), + path(r'devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + path(r'devices//add-secret/', secret_add, name='device_addsecret'), + path(r'devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), + path(r'devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports - 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+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), - url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), + path(r'devices//console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), + path(r'devices//console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), + path(r'console-ports//connect/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path(r'console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), + path(r'console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + path(r'console-ports//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+)/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/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), + path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), + path(r'devices//console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), + path(r'devices//console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), + path(r'console-server-ports//connect/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path(r'console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), + path(r'console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + path(r'console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), + path(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+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), - url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), + path(r'devices//power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), + path(r'devices//power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), + path(r'power-ports//connect/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path(r'power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), + path(r'power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + path(r'power-ports//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+)/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/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), + path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), + path(r'devices//power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), + path(r'devices//power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), + path(r'power-outlets//connect/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path(r'power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), + path(r'power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + path(r'power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), + path(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+)/$', views.InterfaceView.as_view(), name='interface'), - url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), - url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), - url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), - url(r'^interfaces/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), - url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), + path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), + path(r'devices//interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), + path(r'devices//interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + path(r'devices//interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + path(r'interfaces//connect/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), + path(r'interfaces//', views.InterfaceView.as_view(), name='interface'), + path(r'interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), + path(r'interfaces//assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), + path(r'interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), + path(r'interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), + path(r'interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), + path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), + + # Front ports + # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), + path(r'devices//front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), + path(r'devices//front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), + path(r'devices//front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), + path(r'front-ports//connect/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path(r'front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), + path(r'front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + path(r'front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), + path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), + + # Rear ports + # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), + path(r'devices//rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), + path(r'devices//rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), + path(r'devices//rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), + path(r'rear-ports//connect/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path(r'rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), + path(r'rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), + path(r'rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), + path(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'), - url(r'^devices/(?P\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'), - url(r'^devices/(?P\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), - url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), - url(r'^device-bays/(?P\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - url(r'^device-bays/(?P\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), - url(r'^device-bays/(?P\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), - url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), + path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), + path(r'devices//bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), + path(r'devices//bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), + path(r'device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), + path(r'device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), + path(r'device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), + path(r'device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), + path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), # Inventory items - url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'), - url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), - url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), - url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), - url(r'^inventory-items/(?P\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), - 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 - 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'), + path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'), + path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), + path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), + path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), + path(r'inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), + path(r'inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), + path(r'devices//inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), + + # Cables + path(r'cables/', views.CableListView.as_view(), name='cable_list'), + path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), + path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), + path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), + path(r'cables//', views.CableView.as_view(), name='cable'), + path(r'cables//edit/', views.CableEditView.as_view(), name='cable_edit'), + path(r'cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), + path(r'cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + + # Console/power/interface connections (read-only) + path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), + path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'), + path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), # Virtual chassis - url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), - url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), - url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), - url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - url(r'^virtual-chassis/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), - url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), - url(r'^virtual-chassis-members/(?P\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), + path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), + path(r'virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + path(r'virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + path(r'virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), + path(r'virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + path(r'virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b359bb1dfdd..1ff2fb56e82 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,41 +1,37 @@ -from __future__ import unicode_literals - -from operator import attrgetter +import re +from django.conf import settings 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 +53,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 +86,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 +94,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 +125,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 +136,13 @@ def post(self, request, pk): # class RegionListView(ObjectListView): - queryset = Region.objects.annotate(site_count=Count('sites')) + queryset = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True + ) filter = filters.RegionFilter filter_form = forms.RegionFilterForm table = tables.RegionTable @@ -151,7 +169,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' @@ -344,8 +362,9 @@ def get(self, request): total_count = racks.count() # Pagination - paginator = EnhancedPaginator(racks, 25) + per_page = request.GET.get('per_page', settings.PAGINATE_COUNT) page_number = request.GET.get('page', 1) + paginator = EnhancedPaginator(racks, per_page) try: page = paginator.page(page_number) except PageNotAnInteger: @@ -413,7 +432,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' @@ -439,7 +458,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class RackReservationListView(ObjectListView): - queryset = RackReservation.objects.all() + queryset = RackReservation.objects.select_related('rack__site') filter = filters.RackReservationFilter filter_form = forms.RackReservationFilterForm table = tables.RackReservationTable @@ -497,6 +516,7 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ManufacturerListView(ObjectListView): queryset = Manufacturer.objects.annotate( devicetype_count=Count('device_types', distinct=True), + inventoryitem_count=Count('inventory_items', distinct=True), platform_count=Count('platforms', distinct=True), ) table = tables.ManufacturerTable @@ -548,29 +568,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'): @@ -579,6 +605,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', { @@ -588,6 +616,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, }) @@ -731,6 +761,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 @@ -823,8 +887,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 @@ -841,44 +906,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__provider', 'untagged_vlan' + interfaces = device.vc_interfaces.select_related( + 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', 'untagged_vlan' ).prefetch_related( - 'tags', 'ip_addresses', 'tagged_vlans' + 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags', 'tagged_vlans' ) + # 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() @@ -898,11 +961,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, @@ -950,10 +1015,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', { @@ -1057,101 +1120,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 @@ -1170,13 +1138,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 # @@ -1191,106 +1152,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 @@ -1313,9 +1174,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' @@ -1338,102 +1196,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 @@ -1452,13 +1214,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 # @@ -1473,104 +1228,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 @@ -1593,11 +1250,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' @@ -1616,13 +1268,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'), @@ -1645,7 +1290,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, }) @@ -1679,18 +1325,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() @@ -1701,10 +1335,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() @@ -1712,6 +1352,110 @@ 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 FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_frontport' + queryset = FrontPort.objects.all() + parent_model = Device + table = tables.FrontPortTable + form = forms.FrontPortBulkEditForm + + +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 RearPortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_rearport' + queryset = RearPort.objects.all() + parent_model = Device + table = tables.RearPortTable + form = forms.RearPortBulkEditForm + + +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 # @@ -1830,6 +1574,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' @@ -1841,6 +1586,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' @@ -1852,6 +1598,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' @@ -1863,6 +1610,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' @@ -1874,6 +1622,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' @@ -1885,117 +1634,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' # @@ -2003,34 +1738,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..cca783bc6a2 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,24 +1,25 @@ -from __future__ import unicode_literals - +from django.contrib.contenttypes.models import ContentType 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, + ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField, + ValidatedModelSerializer, ) +from .nested_serializers import * # @@ -54,10 +55,17 @@ def get_embed_link(self, obj): # class ExportTemplateSerializer(ValidatedModelSerializer): + template_language = ChoiceField( + choices=TEMPLATE_LANGUAGE_CHOICES, + default=TEMPLATE_LANGUAGE_JINJA2 + ) class Meta: model = ExportTemplate - fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension'] + fields = [ + 'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type', + 'file_extension', + ] # @@ -89,7 +97,9 @@ class Meta: # class ImageAttachmentSerializer(ValidatedModelSerializer): - content_type = ContentTypeField() + content_type = ContentTypeField( + queryset=ContentType.objects.all() + ) parent = serializers.SerializerMethodField(read_only=True) class Meta: @@ -109,7 +119,7 @@ def validate(self, data): ) # Enforce model validation - super(ImageAttachmentSerializer, self).validate(data) + super().validate(data) return data @@ -189,18 +199,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) @@ -218,14 +216,25 @@ class ReportDetailSerializer(ReportSerializer): # class ObjectChangeSerializer(serializers.ModelSerializer): - user = NestedUserSerializer(read_only=True) - content_type = ContentTypeField(read_only=True) - changed_object = serializers.SerializerMethodField(read_only=True) + user = NestedUserSerializer( + read_only=True + ) + action = ChoiceField( + choices=OBJECTCHANGE_ACTION_CHOICES, + read_only=True + ) + changed_object_type = ContentTypeField( + read_only=True + ) + changed_object = serializers.SerializerMethodField( + read_only=True + ) class Meta: model = ObjectChange fields = [ - 'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data', + 'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object', + 'object_data', ] def get_changed_object(self, obj): @@ -234,22 +243,14 @@ def get_changed_object(self, obj): """ if obj.changed_object is None: return None - serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') - if serializer is None: + + try: + serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') + except SerializerNotFound: return obj.object_repr - context = {'request': self.context['request']} + 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'] + return data 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..2150cb5b52b 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 @@ -12,8 +10,7 @@ from extras import filters from extras.models import ( - ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, - UserAction, + ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, ) from extras.reports import get_report, get_reports from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet @@ -26,8 +23,9 @@ class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): fields = ( - (CustomField, ['type']), + (ExportTemplate, ['template_language']), (Graph, ['type']), + (ObjectChange, ['action']), ) @@ -53,7 +51,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 +60,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 +70,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 +80,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 +90,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 +100,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)) @@ -119,9 +116,11 @@ def render(self, request, pk): # class TagViewSet(ModelViewSet): - queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items')) + queryset = Tag.objects.annotate( + tagged_items=Count('taggit_taggeditem_items', distinct=True) + ) serializer_class = serializers.TagSerializer - filter_class = filters.TagFilter + filterset_class = filters.TagFilter # @@ -142,7 +141,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 +230,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..6e608369190 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): @@ -24,6 +22,7 @@ def ready(self): port=settings.REDIS_PORT, db=settings.REDIS_DATABASE, password=settings.REDIS_PASSWORD or None, + ssl=settings.REDIS_SSL, ) rs.ping() except redis.exceptions.ConnectionError: diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 9707d91211c..13c15cbba96 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,13 +49,21 @@ 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 'cluster', 'virtualmachine', # Virtualization ] +# ExportTemplate language choices +TEMPLATE_LANGUAGE_DJANGO = 10 +TEMPLATE_LANGUAGE_JINJA2 = 20 +TEMPLATE_LANGUAGE_CHOICES = ( + (TEMPLATE_LANGUAGE_DJANGO, 'Django'), + (TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'), +) + # Topology map types TOPOLOGYMAP_TYPE_NETWORK = 1 TOPOLOGYMAP_TYPE_CONSOLE = 2 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 3abd5b4cfa1..d5457a5a6fd 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): @@ -85,7 +82,7 @@ class ExportTemplateFilter(django_filters.FilterSet): class Meta: model = ExportTemplate - fields = ['content_type', 'name'] + fields = ['content_type', 'name', 'template_language'] class TagFilter(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 6fc4b885943..54eee0c5c45 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,20 +1,17 @@ -from __future__ import unicode_literals - from collections import OrderedDict from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist -from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField from taggit.models import Tag from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField, - FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, + add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, + FilterChoiceField, 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 +101,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 +137,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 +151,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 +174,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,13 +192,15 @@ 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) @@ -210,7 +209,10 @@ def __init__(self, *args, **kwargs): class TagFilterForm(BootstrapMixin, forms.Form): model = Tag - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) # @@ -218,10 +220,6 @@ class TagFilterForm(BootstrapMixin, forms.Form): # class ConfigContextForm(BootstrapMixin, forms.ModelForm): - regions = TreeNodeMultipleChoiceField( - queryset=Region.objects.all(), - required=False - ) data = JSONField() class Meta: @@ -230,6 +228,26 @@ class Meta: 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data', ] + widgets = { + 'regions': APISelectMultiple( + api_url="/api/dcim/regions/" + ), + 'sites': APISelectMultiple( + api_url="/api/dcim/sites/" + ), + 'roles': APISelectMultiple( + api_url="/api/dcim/device-roles/" + ), + 'platforms': APISelectMultiple( + api_url="/api/dcim/platforms/" + ), + 'tenant_groups': APISelectMultiple( + api_url="/api/tenancy/tenant-groups/" + ), + 'tenants': APISelectMultiple( + api_url="/api/tenancy/tenants/" + ) + } class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): @@ -251,7 +269,9 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = [ + 'description', + ] class ConfigContextFilterForm(BootstrapMixin, forms.Form): @@ -259,29 +279,53 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterTreeNodeMultipleChoiceField( + region = FilterChoiceField( queryset=Region.objects.all(), - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + ) ) site = FilterChoiceField( queryset=Site.objects.all(), - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) ) role = FilterChoiceField( queryset=DeviceRole.objects.all(), - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/device-roles/", + value_field="slug", + ) ) platform = FilterChoiceField( queryset=Platform.objects.all(), - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/platforms/", + value_field="slug", + ) ) tenant_group = FilterChoiceField( queryset=TenantGroup.objects.all(), - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/", + value_field="slug", + ) ) tenant = FilterChoiceField( queryset=Tenant.objects.all(), - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + ) ) @@ -293,28 +337,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( @@ -329,3 +374,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..9c8e7b69dca 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() @@ -32,11 +29,11 @@ def cache_changed_object(instance, **kwargs): def _record_object_deleted(request, instance, **kwargs): - # Record that the object was deleted. + # Record that the object was deleted if hasattr(instance, 'log_change'): instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) - enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE) + enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE) class ObjectChangeMiddleware(object): @@ -46,7 +43,7 @@ class ObjectChangeMiddleware(object): 1. Create an ObjectChange to reflect the modification to the object in the changelog. 2. Enqueue any relevant webhooks. - The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit + The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit differently for each. Objects being saved are cached into thread-local storage for action *after* the response has completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags) have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the @@ -64,10 +61,10 @@ def __call__(self, request): # the same request. request.id = uuid.uuid4() - # Signals don't include the request context, so we're currying it into the pre_delete function ahead of time. + # Signals don't include the request context, so we're currying it into the post_delete function ahead of time. record_object_deleted = curry(_record_object_deleted, request) - # Connect our receivers to the post_save and pre_delete signals. + # Connect our receivers to the post_save and post_delete signals. post_save.connect(cache_changed_object, dispatch_uid='record_object_saved') post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted') @@ -82,7 +79,7 @@ def __call__(self, request): obj.log_change(request.user, request.id, action) # Enqueue webhooks - enqueue_webhooks(obj, action) + enqueue_webhooks(obj, request.user, request.id, action) # Housekeeping: 1% chance of clearing out expired ObjectChanges if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: 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 1021c20c51b..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,6 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:19 -from __future__ import unicode_literals from django.conf import settings import django.contrib.postgres.fields.jsonb 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 9c26f50ba35..e0c74753200 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-26 21:25 -from __future__ import unicode_literals from django.conf import settings import django.contrib.postgres.fields.jsonb 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/migrations/0017_exporttemplate_mime_type_length.py b/netbox/extras/migrations/0017_exporttemplate_mime_type_length.py new file mode 100644 index 00000000000..29283e0d17b --- /dev/null +++ b/netbox/extras/migrations/0017_exporttemplate_mime_type_length.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-03-05 18:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0016_exporttemplate_add_cable'), + ] + + operations = [ + migrations.AlterField( + model_name='exporttemplate', + name='mime_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py b/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py new file mode 100644 index 00000000000..1177ac2fbde --- /dev/null +++ b/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.7 on 2019-04-08 14:49 + +from django.db import migrations, models + + +def set_template_language(apps, schema_editor): + """ + Set the language for all existing ExportTemplates to Django (Jinja2 is the default for new ExportTemplates). + """ + ExportTemplate = apps.get_model('extras', 'ExportTemplate') + ExportTemplate.objects.update(template_language=10) + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0017_exporttemplate_mime_type_length'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='template_language', + field=models.PositiveSmallIntegerField(default=20), + ), + migrations.RunPython(set_template_language), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index de3edca9b38..8d8a05e10eb 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,24 +1,21 @@ -from __future__ import unicode_literals - from collections import OrderedDict from datetime import date -import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField from django.core.validators import ValidationError from django.db import models -from django.db.models import Q +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 +import graphviz +from jinja2 import Environment 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 +24,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 @@ -106,17 +102,22 @@ def clean(self): # class CustomFieldModel(models.Model): + _cf = None class Meta: abstract = True + @property def cf(self): """ Name-based CustomFieldValue accessor for use in templates """ - if not hasattr(self, 'get_custom_fields'): - return dict() - return {field.name: value for field, value in self.get_custom_fields().items()} + if self._cf is None: + # Cache all custom field values for this instance + self._cf = { + field.name: value for field, value in self.get_custom_fields().items() + } + return self._cf def get_custom_fields(self): """ @@ -129,14 +130,13 @@ def get_custom_fields(self): # If the object exists, populate its custom fields with values if hasattr(self, 'pk'): - values = CustomFieldValue.objects.filter(obj_type=content_type, obj_id=self.pk).select_related('field') + values = self.custom_field_values.all() values_dict = {cfv.field_id: cfv.value for cfv in values} return OrderedDict([(field, values_dict.get(field.pk)) for field in fields]) else: return OrderedDict([(field, None) for field in fields]) -@python_2_unicode_compatible class CustomField(models.Model): obj_type = models.ManyToManyField( to=ContentType, @@ -227,7 +227,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 +267,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 +299,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 +307,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 +348,6 @@ def embed_link(self, obj): # Export templates # -@python_2_unicode_compatible class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, @@ -365,9 +361,13 @@ class ExportTemplate(models.Model): max_length=200, blank=True ) + template_language = models.PositiveSmallIntegerField( + choices=TEMPLATE_LANGUAGE_CHOICES, + default=TEMPLATE_LANGUAGE_JINJA2 + ) template_code = models.TextField() mime_type = models.CharField( - max_length=15, + max_length=50, blank=True ) file_extension = models.CharField( @@ -384,17 +384,37 @@ class Meta: def __str__(self): return '{}: {}'.format(self.content_type, self.name) - def render_to_response(self, queryset): + def render(self, queryset): """ - Render the template to an HTTP response, delivered as a named file attachment + Render the contents of the template. """ - template = Template(self.template_code) - mime_type = 'text/plain' if not self.mime_type else self.mime_type - output = template.render(Context({'queryset': queryset})) + context = { + 'queryset': queryset + } + + if self.template_language == TEMPLATE_LANGUAGE_DJANGO: + template = Template(self.template_code) + output = template.render(Context(context)) + + elif self.template_language == TEMPLATE_LANGUAGE_JINJA2: + template = Environment().from_string(source=self.template_code) + output = template.render(**context) + + else: + return None # Replace CRLF-style line terminators output = output.replace('\r\n', '\n') + return output + + def render_to_response(self, queryset): + """ + Render the template to an HTTP response, delivered as a named file attachment + """ + output = self.render(queryset) + mime_type = 'text/plain' if not self.mime_type else self.mime_type + # Build the response response = HttpResponse(output, content_type=mime_type) filename = 'netbox_{}{}'.format( @@ -410,7 +430,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 +534,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 +560,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 +592,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 +633,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 +747,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 self.local_context_data is not None: - data.update(self.local_context_data) + # If the object has local config context data defined, merge it last + if self.local_context_data: + data = deepmerge(data, self.local_context_data) return data @@ -769,7 +789,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 +871,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 +890,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..70c93968ff0 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,8 +1,24 @@ -from __future__ import unicode_literals +from collections import OrderedDict from django.db.models import Q, QuerySet +class CustomFieldQueryset: + """ + Annotate custom fields on objects within a QuerySet. + """ + def __init__(self, queryset, custom_fields): + self.queryset = queryset + self.model = queryset.model + self.custom_fields = custom_fields + + def __iter__(self): + for obj in self.queryset: + values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()} + obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields]) + yield obj + + class ConfigContextQuerySet(QuerySet): def get_for_object(self, obj): 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 cf2b6f88846..f6933bf484f 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor from taggit.models import Tag, TaggedItem @@ -70,7 +68,7 @@ class TagTable(BaseTable): ) actions = tables.TemplateColumn( template_code=TAG_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) 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/tests/test_views.py b/netbox/extras/tests/test_views.py new file mode 100644 index 00000000000..d478f069c36 --- /dev/null +++ b/netbox/extras/tests/test_views.py @@ -0,0 +1,105 @@ +import urllib.parse +import uuid + +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse +from taggit.models import Tag + +from dcim.models import Site +from extras.models import ConfigContext, ObjectChange + + +class TagTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + Tag.objects.bulk_create([ + Tag(name='Tag 1', slug='tag-1'), + Tag(name='Tag 2', slug='tag-2'), + Tag(name='Tag 3', slug='tag-3'), + ]) + + def test_tag_list(self): + + url = reverse('extras:tag_list') + params = { + "q": "tag", + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + +class ConfigContextTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + # Create three ConfigContexts + for i in range(1, 4): + configcontext = ConfigContext( + name='Config Context {}'.format(i), + data='{{"foo": {}}}'.format(i) + ) + configcontext.save() + configcontext.sites.add(site) + + def test_configcontext_list(self): + + url = reverse('extras:configcontext_list') + params = { + "q": "foo", + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_configcontext(self): + + configcontext = ConfigContext.objects.first() + response = self.client.get(configcontext.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class ObjectChangeTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + user = User(username='testuser', email='testuser@example.com') + user.save() + + site = Site(name='Site 1', slug='site-1') + site.save() + + # Create three ObjectChanges + for i in range(1, 4): + site.log_change( + user=user, + request_id=uuid.uuid4(), + action=2 + ) + + def test_objectchange_list(self): + + url = reverse('extras:objectchange_list') + params = { + "user": User.objects.first(), + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_objectchange(self): + + objectchange = ObjectChange.objects.first() + response = self.client.get(objectchange.get_absolute_url()) + self.assertEqual(response.status_code, 200) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index a97019a0424..5ba7c110ce5 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,6 +1,4 @@ -from __future__ import unicode_literals - -from django.conf.urls import url +from django.urls import path from extras import views @@ -8,32 +6,32 @@ urlpatterns = [ # Tags - url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), - 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'), + path(r'tags/', views.TagListView.as_view(), name='tag_list'), + path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + path(r'tags//', views.TagView.as_view(), name='tag'), + path(r'tags//edit/', views.TagEditView.as_view(), name='tag_edit'), + path(r'tags//delete/', views.TagDeleteView.as_view(), name='tag_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/(?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'), + path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), + path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), + path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), + path(r'config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), + path(r'config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), + path(r'config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), # Image attachments - url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), - url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + path(r'image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + path(r'image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), # Reports - url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), - url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), - url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), + path(r'reports/', views.ReportListView.as_view(), name='report_list'), + path(r'reports//', views.ReportView.as_view(), name='report'), + path(r'reports//run/', views.ReportRunView.as_view(), name='report_run'), # Change logging - url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'), - url(r'^changelog/(?P\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'), + path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), + path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3e918649099..2f088eb7773 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import template from django.conf import settings from django.contrib import messages @@ -32,7 +30,7 @@ class TagListView(ObjectListView): queryset = Tag.objects.annotate( - items=Count('taggit_taggeditem_items') + items=Count('taggit_taggeditem_items', distinct=True) ).order_by( 'name' ) @@ -58,7 +56,7 @@ def get(self, request, slug): # Generate a table of all items tagged with this Tag items_table = TaggedItemTable(tagged_items) paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(items_table) @@ -84,7 +82,7 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'circuits.delete_circuittype' + permission_required = 'taggit.delete_tag' queryset = Tag.objects.annotate( items=Count('taggit_taggeditem_items') ).order_by( diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 35ec56febce..1ad05086611 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -3,13 +3,13 @@ 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 -def enqueue_webhooks(instance, action): +def enqueue_webhooks(instance, user, request_id, action): """ Find Webhook(s) assigned to this instance + action and enqueue them to be processed @@ -47,5 +47,7 @@ def enqueue_webhooks(instance, action): serializer.data, instance._meta.model_name, action, - str(datetime.datetime.now()) + str(datetime.datetime.now()), + user.username, + request_id ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 30f86f311ef..45d996f9baa 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,7 +10,7 @@ @job('default') -def process_webhook(webhook, data, model_name, event, timestamp): +def process_webhook(webhook, data, model_name, event, timestamp, username, request_id): """ Make a POST request to the defined Webhook """ @@ -18,6 +18,8 @@ def process_webhook(webhook, data, model_name, event, timestamp): 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(), 'timestamp': timestamp, 'model': model_name, + 'username': username, + 'request_id': request_id, '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 4779eaffd76..d6556473d78 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,7 +87,7 @@ def validate(self, data): validator(data) # Enforce model validation - super(VLANGroupSerializer, self).validate(data) + super().validate(data) return data @@ -173,24 +130,17 @@ 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 # class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): + family = ChoiceField(choices=AF_CHOICES, read_only=True) site = NestedSiteSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -208,16 +158,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 +179,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', @@ -255,11 +202,14 @@ def get_url(self, obj): class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): + family = ChoiceField(choices=AF_CHOICES, read_only=True) vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False, 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 +221,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 a681f3fd354..98b48045649 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 700a25ae9e8..a5464e4d0d5 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,36 +1,27 @@ -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, TagFilter +from tenancy.filtersets import TenancyFilterSet +from utilities.filters import NameSlugSearchFilterSet, 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') +class VRFFilter(TenancyFilterSet, CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) tag = TagFilter() def search(self, queryset, name, value): @@ -47,16 +38,22 @@ class Meta: fields = ['name', 'rd', 'enforce_unique'] -class RIRFilter(django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class RIRFilter(NameSlugSearchFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) class Meta: model = RIR fields = ['name', 'slug', 'is_private'] -class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class AggregateFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -66,7 +63,7 @@ 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)', @@ -89,19 +86,30 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter) -class RoleFilter(django_filters.FilterSet): +class RoleFilter(NameSlugSearchFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = Role fields = ['name', 'slug'] -class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet): + 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', @@ -123,27 +131,17 @@ 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)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (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 (slug)', @@ -153,7 +151,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( @@ -161,7 +159,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)', @@ -187,6 +185,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: @@ -227,8 +234,11 @@ def filter_mask_length(self, queryset, name, value): return queryset.filter(prefix__net_mask_length=value) -class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -250,42 +260,38 @@ 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)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - 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)', ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (ID)', + ) interface_id = django_filters.ModelMultipleChoiceFilter( queryset=Interface.objects.all(), label='Interface (ID)', @@ -347,13 +353,13 @@ def filter_device(self, queryset, name, value): return queryset.none() -class VLANGroupFilter(django_filters.FilterSet): +class VLANGroupFilter(NameSlugSearchFilterSet): 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 (slug)', @@ -364,8 +370,11 @@ class Meta: fields = ['name', 'slug'] -class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class VLANFilter(TenancyFilterSet, CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -375,7 +384,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)', @@ -385,27 +394,17 @@ 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', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), 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)', @@ -441,7 +440,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)', @@ -451,7 +450,7 @@ 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)', 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 4f70e2016d9..2855dbf8f97 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,19 +1,17 @@ -from __future__ import unicode_literals - from django import forms from django.core.exceptions import MultipleObjectsReturned from django.core.validators import MaxValueValidator, MinValueValidator -from django.db.models import Count from taggit.forms import TagField from dcim.models import Site, Rack, Device, Interface from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm +from tenancy.forms import TenancyFilterForm 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 +34,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 +71,39 @@ 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): +class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VRF - q = forms.CharField(required=False, label='Search') - tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), - to_field_name='slug', - null_label='-- None --' + field_order = ['q', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' ) @@ -99,7 +116,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 +133,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 +147,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 +184,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 +242,9 @@ class RoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = Role - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class RoleCSVForm(forms.ModelForm): @@ -206,8 +267,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 +286,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 +304,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 +316,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,17 +335,17 @@ 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' class PrefixCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField( + vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), - required=False, to_field_name='rd', - help_text='Route distinguisher of parent VRF', + required=False, + help_text='Route distinguisher of parent VRF (or {ID})', error_messages={ 'invalid_choice': 'VRF not found.', } @@ -313,7 +396,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 +430,141 @@ 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): +class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, 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') - vrf = FilterChoiceField( - queryset=VRF.objects.annotate(filter_count=Count('prefixes')), - to_field_name='rd', - label='VRF', - null_label='-- Global --' + field_order = [ + 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant', + 'is_pool', 'expand', + ] + q = forms.CharField( + required=False, + label='Search' ) - tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), - to_field_name='slug', - null_label='-- None --' + 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() ) - status = AnnotatedMultipleChoiceField( + mask_length = forms.ChoiceField( + required=False, + choices=PREFIX_MASK_LENGTH_CHOICES, + label='Mask length', + widget=StaticSelect2() + ) + vrf_id = FilterChoiceField( + queryset=VRF.objects.all(), + label='VRF', + null_label='-- Global --', + widget=APISelectMultiple( + api_url="/api/ipam/vrfs/", + null_option=True, + ) + ) + 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 +580,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 +596,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 +615,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 +630,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 +648,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 +667,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 +689,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 +699,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,26 +722,37 @@ 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' class IPAddressCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField( + vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), - required=False, to_field_name='rd', - help_text='Route distinguisher of the assigned VRF', + required=False, + help_text='Route distinguisher of parent VRF (or {ID})', error_messages={ 'invalid_choice': 'VRF not found.', } @@ -614,8 +807,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 +856,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 +871,113 @@ 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): +class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, 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') - vrf = FilterChoiceField( - queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), - to_field_name='rd', - label='VRF', - null_label='-- Global --' + field_order = [ + 'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant', + ] + q = forms.CharField( + required=False, + label='Search' ) - tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), - to_field_name='slug', - null_label='-- None --' + 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() ) - status = AnnotatedMultipleChoiceField( + vrf_id = FilterChoiceField( + queryset=VRF.objects.all(), + label='VRF', + null_label='-- Global --', + widget=APISelectMultiple( + api_url="/api/ipam/vrfs/", + null_option=True, + ) + ) + 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 +990,14 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = VLANGroup - fields = ['site', 'name', 'slug', 'outervid'] + fields = [ + 'site', 'name', 'slug', 'outervid' + ] + widgets = { + 'site': APISelect( + api_url="/api/dcim/sites/" + ) + } class VLANGroupCSVForm(forms.ModelForm): @@ -762,9 +1022,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 +1041,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 +1059,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 +1077,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 +1131,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 +1142,104 @@ 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): +class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VLAN - q = forms.CharField(required=False, label='Search') + field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] + 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 --' - ) - tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('vlans')), - to_field_name='slug', - null_label='-- None --' + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/ipam/vlan-groups/", + 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 +1248,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 +1291,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/migrations/0024_vrf_allow_null_rd.py b/netbox/ipam/migrations/0024_vrf_allow_null_rd.py new file mode 100644 index 00000000000..611644f6c49 --- /dev/null +++ b/netbox/ipam/migrations/0024_vrf_allow_null_rd.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.5 on 2019-01-31 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0023_change_logging'), + ] + + operations = [ + migrations.AlterField( + model_name='vrf', + name='rd', + field=models.CharField(blank=True, max_length=21, null=True, unique=True), + ), + ] diff --git a/netbox/ipam/migrations/0026_merge_20190508_0805.py b/netbox/ipam/migrations/0026_merge_20190508_0805.py new file mode 100644 index 00000000000..d20a91631ef --- /dev/null +++ b/netbox/ipam/migrations/0026_merge_20190508_0805.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.1 on 2019-05-08 08:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0024_vrf_allow_null_rd'), + ('ipam', '0025_merge_20181012_0920'), + ] + + operations = [ + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 2a3091e3447..3aecf63e544 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,26 +1,23 @@ -from __future__ import unicode_literals - import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Q from django.db.models.expressions import RawSQL from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager from dcim.models import Interface -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, ObjectChange from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object from .constants import * from .fields import IPNetworkField, IPAddressField 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 @@ -33,6 +30,8 @@ class VRF(ChangeLoggedModel, CustomFieldModel): rd = models.CharField( max_length=21, unique=True, + blank=True, + null=True, verbose_name='Route distinguisher' ) tenant = models.ForeignKey( @@ -67,7 +66,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]) @@ -83,12 +82,11 @@ def to_csv(self): @property def display_name(self): - if self.name and self.rd: + if self.rd: return "{} ({})".format(self.name, self.rd) - return None + return self.name -@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 +126,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 +201,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 +220,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 +252,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 +372,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 +388,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 +441,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 +507,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 @@ -614,7 +628,28 @@ 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 log_change(self, user, request_id, action): + """ + Include the connected Interface (if any). + """ + + # It's possible that an IPAddress can be deleted _after_ its parent Interface, in which case trying to resolve + # the interface will raise DoesNotExist. + try: + parent_obj = self.interface + except ObjectDoesNotExist: + parent_obj = None + + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=parent_obj, + action=action, + object_data=serialize_object(self) + ).save() def to_csv(self): @@ -639,6 +674,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 +702,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. @@ -717,7 +760,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 @@ -791,7 +833,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]) @@ -830,10 +872,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 abf5d1d0f9f..5b18bf6e7b6 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 @@ -32,7 +30,7 @@ {% if perms.ipam.change_rir %} - + {% endif %} """ @@ -54,7 +52,7 @@ {% if perms.ipam.change_role %} - + {% endif %} """ @@ -154,7 +152,7 @@ {% endif %} {% endwith %} {% if perms.ipam.change_vlangroup %} - + {% endif %} """ @@ -205,7 +203,7 @@ class RIRTable(BaseTable): name = tables.LinkColumn(verbose_name='Name') is_private = BooleanColumn(verbose_name='Private') aggregate_count = tables.Column(verbose_name='Aggregates') - actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') + actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): model = RIR @@ -290,7 +288,7 @@ class RoleTable(BaseTable): orderable=False, verbose_name='VLANs' ) - actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') + actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): model = Role @@ -321,6 +319,7 @@ class Meta(BaseTable.Meta): class PrefixDetailTable(PrefixTable): utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(PrefixTable.Meta): fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description') @@ -351,6 +350,7 @@ class IPAddressDetailTable(IPAddressTable): nat_inside = tables.LinkColumn( 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)' ) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(IPAddressTable.Meta): fields = ( @@ -395,7 +395,7 @@ class VLANGroupTable(BaseTable): vlan_count = tables.Column(verbose_name='VLANs') outervid = tables.Column(verbose_name='Outer VLAN ID') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}}, + actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): @@ -426,6 +426,7 @@ class Meta(BaseTable.Meta): class VLANDetailTable(VLANTable): prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(VLANTable.Meta): fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') @@ -433,14 +434,14 @@ 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 ) actions = tables.TemplateColumn( template_code=VLAN_MEMBER_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -467,7 +468,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 f2dbaf31fb0..5bd867c4716 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals +import json from django.urls import reverse from netaddr import IPNetwork @@ -14,11 +14,11 @@ 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') - self.vrf3 = VRF.objects.create(name='Test VRF 3', rd='65000:3') + self.vrf3 = VRF.objects.create(name='Test VRF 3') # No RD def test_get_vrf(self): @@ -46,19 +46,26 @@ def test_list_vrfs_brief(self): def test_create_vrf(self): - data = { - 'name': 'Test VRF 4', - 'rd': '65000:4', - } + data_list = [ + # VRF with RD + { + 'name': 'Test VRF 4', + 'rd': '65000:4', + }, + # VRF without RD + { + 'name': 'Test VRF 5', + } + ] url = reverse('ipam-api:vrf-list') - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VRF.objects.count(), 4) - vrf4 = VRF.objects.get(pk=response.data['id']) - self.assertEqual(vrf4.name, data['name']) - self.assertEqual(vrf4.rd, data['rd']) + for data in data_list: + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + vrf = VRF.objects.get(pk=response.data['id']) + self.assertEqual(vrf.name, data['name']) + self.assertEqual(vrf.rd, data['rd'] if 'rd' in data else None) def test_create_vrf_bulk(self): @@ -115,7 +122,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 +223,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 +326,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 +427,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 +575,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 +592,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 +637,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 +648,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 +666,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 +765,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', outervid=1) self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2', outervid=2) @@ -865,12 +872,14 @@ 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') self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3') + self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24')) + def test_get_vlan(self): url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) @@ -961,12 +970,26 @@ def test_delete_vlan(self): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(VLAN.objects.count(), 2) + def test_delete_vlan_with_prefix(self): + self.prefix1.vlan = self.vlan1 + self.prefix1.save() + + url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) + response = self.client.delete(url, **self.header) + + # can't use assertHttpStatus here because we don't have response.data + self.assertEqual(response.status_code, 409) + + content = json.loads(response.content.decode('utf-8')) + self.assertIn('detail', content) + self.assertTrue(content['detail'].startswith('Unable to delete object.')) + 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/tests/test_views.py b/netbox/ipam/tests/test_views.py new file mode 100644 index 00000000000..20c16df9b62 --- /dev/null +++ b/netbox/ipam/tests/test_views.py @@ -0,0 +1,282 @@ +from netaddr import IPNetwork +import urllib.parse + +from django.test import Client, TestCase +from django.urls import reverse + +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from ipam.constants import IP_PROTOCOL_TCP +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF + + +class VRFTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + VRF.objects.bulk_create([ + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + VRF(name='VRF 3', rd='65000:3'), + ]) + + def test_vrf_list(self): + + url = reverse('ipam:vrf_list') + params = { + "q": "65000", + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_configcontext(self): + + vrf = VRF.objects.first() + response = self.client.get(vrf.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class RIRTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + RIR.objects.bulk_create([ + RIR(name='RIR 1', slug='rir-1'), + RIR(name='RIR 2', slug='rir-2'), + RIR(name='RIR 3', slug='rir-3'), + ]) + + def test_rir_list(self): + + url = reverse('ipam:rir_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_rir(self): + + rir = RIR.objects.first() + response = self.client.get(rir.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class AggregateTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + rir = RIR(name='RIR 1', slug='rir-1') + rir.save() + + Aggregate.objects.bulk_create([ + Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir), + Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir), + Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir), + ]) + + def test_aggregate_list(self): + + url = reverse('ipam:aggregate_list') + params = { + "rir": RIR.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_aggregate(self): + + aggregate = Aggregate.objects.first() + response = self.client.get(aggregate.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class RoleTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + Role.objects.bulk_create([ + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + Role(name='Role 3', slug='role-3'), + ]) + + def test_role_list(self): + + url = reverse('ipam:role_list') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class PrefixTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + Prefix.objects.bulk_create([ + Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site), + Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site), + Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site), + ]) + + def test_prefix_list(self): + + url = reverse('ipam:prefix_list') + params = { + "site": Site.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_prefix(self): + + prefix = Prefix.objects.first() + response = self.client.get(prefix.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class IPAddressTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + vrf = VRF(name='VRF 1', rd='65000:1') + vrf.save() + + IPAddress.objects.bulk_create([ + IPAddress(family=4, address=IPNetwork('10.1.0.0/16'), vrf=vrf), + IPAddress(family=4, address=IPNetwork('10.2.0.0/16'), vrf=vrf), + IPAddress(family=4, address=IPNetwork('10.3.0.0/16'), vrf=vrf), + ]) + + def test_ipaddress_list(self): + + url = reverse('ipam:ipaddress_list') + params = { + "vrf": VRF.objects.first().rd, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_ipaddress(self): + + ipaddress = IPAddress.objects.first() + response = self.client.get(ipaddress.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class VLANGroupTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + VLANGroup.objects.bulk_create([ + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site), + VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site), + ]) + + def test_vlangroup_list(self): + + url = reverse('ipam:vlangroup_list') + params = { + "site": Site.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + +class VLANTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1') + vlangroup.save() + + VLAN.objects.bulk_create([ + VLAN(group=vlangroup, vid=101, name='VLAN101'), + VLAN(group=vlangroup, vid=102, name='VLAN102'), + VLAN(group=vlangroup, vid=103, name='VLAN103'), + ]) + + def test_vlan_list(self): + + url = reverse('ipam:vlan_list') + params = { + "group": VLANGroup.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_vlan(self): + + vlan = VLAN.objects.first() + response = self.client.get(vlan.get_absolute_url()) + self.assertEqual(response.status_code, 200) + + +class ServiceTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') + manufacturer.save() + + devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1') + devicetype.save() + + devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') + devicerole.save() + + device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) + device.save() + + Service.objects.bulk_create([ + Service(device=device, name='Service 1', protocol=IP_PROTOCOL_TCP, port=101), + Service(device=device, name='Service 2', protocol=IP_PROTOCOL_TCP, port=102), + Service(device=device, name='Service 3', protocol=IP_PROTOCOL_TCP, port=103), + ]) + + def test_service_list(self): + + url = reverse('ipam:service_list') + params = { + "device_id": Device.objects.first(), + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) + + def test_service(self): + + service = Service.objects.first() + response = self.client.get(service.get_absolute_url()) + self.assertEqual(response.status_code, 200) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 700d78ae49c..2a1dcdf05da 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,107 +1,104 @@ -from __future__ import unicode_literals - -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView from . import views from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF - app_name = 'ipam' urlpatterns = [ # VRFs - url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'), - url(r'^vrfs/add/$', views.VRFCreateView.as_view(), name='vrf_add'), - url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'), - url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), - url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), - url(r'^vrfs/(?P\d+)/$', views.VRFView.as_view(), name='vrf'), - url(r'^vrfs/(?P\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), - url(r'^vrfs/(?P\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), - url(r'^vrfs/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'), + path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), + path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), + path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), + path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), + path(r'vrfs//', views.VRFView.as_view(), name='vrf'), + path(r'vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), + path(r'vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), + path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), # RIRs - url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), - url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'), - url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'), - url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), - url(r'^rirs/(?P[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), - url(r'^vrfs/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path(r'rirs/', views.RIRListView.as_view(), name='rir_list'), + path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), + path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), + path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), + path(r'rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates - url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), - url(r'^aggregates/add/$', views.AggregateCreateView.as_view(), name='aggregate_add'), - url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'), - url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), - url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), - url(r'^aggregates/(?P\d+)/$', views.AggregateView.as_view(), name='aggregate'), - url(r'^aggregates/(?P\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), - url(r'^aggregates/(?P\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), - url(r'^aggregates/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), + path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), + path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), + path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), + path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), + path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), + path(r'aggregates//', views.AggregateView.as_view(), name='aggregate'), + path(r'aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), + path(r'aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), + path(r'aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), # Roles - url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), - url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'), - url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'), - url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), - url(r'^roles/(?P[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), - url(r'^roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path(r'roles/', views.RoleListView.as_view(), name='role_list'), + path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'), + path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), + path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), + path(r'roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path(r'roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes - url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), - url(r'^prefixes/add/$', views.PrefixCreateView.as_view(), name='prefix_add'), - url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'), - url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), - url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), - url(r'^prefixes/(?P\d+)/$', views.PrefixView.as_view(), name='prefix'), - url(r'^prefixes/(?P\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), - url(r'^prefixes/(?P\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), - url(r'^prefixes/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), - url(r'^prefixes/(?P\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), - url(r'^prefixes/(?P\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'), + path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), + path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), + path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), + path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), + path(r'prefixes//', views.PrefixView.as_view(), name='prefix'), + path(r'prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), + path(r'prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), + path(r'prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), + path(r'prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), + path(r'prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), # IP addresses - url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'), - url(r'^ip-addresses/add/$', views.IPAddressCreateView.as_view(), name='ipaddress_add'), - url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), - url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), - url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), - url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), - url(r'^ip-addresses/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), - url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), - url(r'^ip-addresses/(?P\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), - url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), - url(r'^ip-addresses/(?P\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), + path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), + path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), + path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), + path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), + path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + path(r'ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), + path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), + path(r'ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), + path(r'ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), + path(r'ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), # VLAN groups - url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'), - url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), - url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), - url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), - url(r'^vlan-groups/(?P\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), - url(r'^vlan-groups/(?P\d+)/vlans/$', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), - url(r'^vlan-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), + path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), + path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), + path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), + path(r'vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + path(r'vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), + path(r'vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs - url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), - url(r'^vlans/add/$', views.VLANCreateView.as_view(), name='vlan_add'), - url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'), - url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), - url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), - url(r'^vlans/(?P\d+)/$', views.VLANView.as_view(), name='vlan'), - url(r'^vlans/(?P\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'), - url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), - url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), - url(r'^vlans/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), + path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'), + path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), + path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), + path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), + path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), + path(r'vlans//', views.VLANView.as_view(), name='vlan'), + path(r'vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), + path(r'vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), + path(r'vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), + path(r'vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services - url(r'^services/$', views.ServiceListView.as_view(), name='service_list'), - url(r'^services/edit/$', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), - url(r'^services/delete/$', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), - url(r'^services/(?P\d+)/$', views.ServiceView.as_view(), name='service'), - url(r'^services/(?P\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), - url(r'^services/(?P\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), - url(r'^services/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), + path(r'services/', views.ServiceListView.as_view(), name='service_list'), + path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), + path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), + path(r'services//', views.ServiceView.as_view(), name='service'), + path(r'services//edit/', views.ServiceEditView.as_view(), name='service_edit'), + path(r'services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), + path(r'services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 2e3e0105c04..2f76089a281 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 @@ -128,14 +126,11 @@ class VRFView(View): def get(self, request, pk): vrf = get_object_or_404(VRF.objects.all(), pk=pk) - prefix_table = tables.PrefixTable( - list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role')), orderable=False - ) - prefix_table.exclude = ('vrf',) + prefix_count = Prefix.objects.filter(vrf=vrf).count() return render(request, 'ipam/vrf.html', { 'vrf': vrf, - 'prefix_table': prefix_table, + 'prefix_count': prefix_count, }) @@ -338,7 +333,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) @@ -514,7 +509,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 +548,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 +712,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 +837,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 +896,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 34faba23332..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 diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index a0a9e91465e..d8592f34195 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,25 +140,25 @@ def get_previous_link(self): if not self.limit: return None - return super(OptionalLimitOffsetPagination, self).get_previous_link() + return super().get_previous_link() # # Miscellaneous # -def get_view_name(view_cls, suffix=None): +def get_view_name(view, suffix=None): """ Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. """ - if hasattr(view_cls, 'queryset'): + if hasattr(view, 'queryset'): # Determine the model name from the queryset. - name = view_cls.queryset.model._meta.verbose_name + name = view.queryset.model._meta.verbose_name name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word else: # Replicate DRF's built-in behavior. - name = view_cls.__name__ + name = view.__class__.__name__ name = formatting.remove_trailing_string(name, 'View') name = formatting.remove_trailing_string(name, 'ViewSet') name = formatting.camelcase_to_spaces(name) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index daecda6cd3b..55da682334b 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', @@ -132,15 +132,25 @@ 'PASSWORD': '', 'DATABASE': 0, 'DEFAULT_TIMEOUT': 300, + 'SSL': False, } # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' +# 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 5c98dc20aa9..ee1a22cf0c6 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.9-dev-OF' +VERSION = '2.5.13-dev-OF' 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,12 +114,24 @@ '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) REDIS_PASSWORD = REDIS.get('PASSWORD', '') REDIS_DATABASE = REDIS.get('DATABASE', 0) REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) +REDIS_SSL = REDIS.get('SSL', False) # Email EMAIL_HOST = EMAIL.get('SERVER') @@ -184,7 +198,7 @@ TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR + '/templates/'], + 'DIRS': [BASE_DIR + '/templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -210,7 +224,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) -STATIC_ROOT = BASE_DIR + '/static/' +STATIC_ROOT = BASE_DIR + '/static' STATIC_URL = '/{}static/'.format(BASE_PATH) STATICFILES_DIRS = ( os.path.join(BASE_DIR, "project-static"), @@ -233,9 +247,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 @@ -270,6 +292,7 @@ 'DB': REDIS_DATABASE, 'PASSWORD': REDIS_PASSWORD, 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, + 'SSL': REDIS_SSL, } } @@ -294,6 +317,7 @@ 'utilities.custom_inspectors.IdInFilterInspector', 'drf_yasg.inspectors.CoreAPICompatInspector', ], + 'DEFAULT_MODEL_DEPTH': 1, 'DEFAULT_PAGINATOR_INSPECTORS': [ 'utilities.custom_inspectors.NullablePaginatorInspector', 'drf_yasg.inspectors.DjangoRestResponsePagination', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 9354e24b9ab..efcd17a8735 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,10 +1,9 @@ -from __future__ import unicode_literals - from django.conf import settings -from django.conf.urls import include, url +from django.conf.urls import include +from django.urls import path, re_path 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 @@ -26,58 +25,58 @@ _patterns = [ # Base views - url(r'^$', HomeView.as_view(), name='home'), - url(r'^search/$', SearchView.as_view(), name='search'), + path(r'', HomeView.as_view(), name='home'), + path(r'search/', SearchView.as_view(), name='search'), # Login/logout - url(r'^login/$', LoginView.as_view(), name='login'), - url(r'^logout/$', LogoutView.as_view(), name='logout'), + path(r'login/', LoginView.as_view(), name='login'), + path(r'logout/', LogoutView.as_view(), name='logout'), # Apps - url(r'^circuits/', include('circuits.urls')), - url(r'^dcim/', include('dcim.urls')), - url(r'^extras/', include('extras.urls')), - url(r'^ipam/', include('ipam.urls')), - url(r'^secrets/', include('secrets.urls')), - url(r'^tenancy/', include('tenancy.urls')), - url(r'^user/', include('users.urls')), - url(r'^virtualization/', include('virtualization.urls')), + path(r'circuits/', include('circuits.urls')), + path(r'dcim/', include('dcim.urls')), + path(r'extras/', include('extras.urls')), + path(r'ipam/', include('ipam.urls')), + path(r'secrets/', include('secrets.urls')), + path(r'tenancy/', include('tenancy.urls')), + path(r'user/', include('users.urls')), + path(r'virtualization/', include('virtualization.urls')), # API - url(r'^api/$', APIRootView.as_view(), name='api-root'), - url(r'^api/circuits/', include('circuits.api.urls')), - url(r'^api/dcim/', include('dcim.api.urls')), - url(r'^api/extras/', include('extras.api.urls')), - url(r'^api/ipam/', include('ipam.api.urls')), - url(r'^api/secrets/', include('secrets.api.urls')), - url(r'^api/tenancy/', include('tenancy.api.urls')), - url(r'^api/virtualization/', include('virtualization.api.urls')), - url(r'^api/docs/$', schema_view.with_ui('swagger'), name='api_docs'), - url(r'^api/redoc/$', schema_view.with_ui('redoc'), name='api_redocs'), - url(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), + path(r'api/', APIRootView.as_view(), name='api-root'), + path(r'api/circuits/', include('circuits.api.urls')), + path(r'api/dcim/', include('dcim.api.urls')), + path(r'api/extras/', include('extras.api.urls')), + path(r'api/ipam/', include('ipam.api.urls')), + path(r'api/secrets/', include('secrets.api.urls')), + path(r'api/tenancy/', include('tenancy.api.urls')), + path(r'api/virtualization/', include('virtualization.api.urls')), + path(r'api/docs/', schema_view.with_ui('swagger'), name='api_docs'), + path(r'api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), + re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), # Serving static media in Django to pipe it through LoginRequiredMiddleware - url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), + path(r'media/', serve, {'document_root': settings.MEDIA_ROOT}), # Admin - url(r'^admin/', admin_site.urls), + path(r'admin/', admin_site.urls), ] if settings.WEBHOOKS_ENABLED: _patterns += [ - url(r'^admin/webhook-backend-status/', include('django_rq.urls')), + path(r'admin/webhook-backend-status/', include('django_rq.urls')), ] if settings.DEBUG: import debug_toolbar _patterns += [ - url(r'^__debug__/', include(debug_toolbar.urls)), + path(r'__debug__/', include(debug_toolbar.urls)), ] # Prepend BASE_PATH urlpatterns = [ - url(r'^{}'.format(settings.BASE_PATH), include(_patterns)) + path(r'{}'.format(settings.BASE_PATH), include(_patterns)) ] handler500 = 'utilities.views.server_error' diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index da938f2194f..034232734df 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 @@ -169,9 +184,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(), @@ -252,6 +268,7 @@ def get(self, request): class APIRootView(APIView): _ignore_model_permissions = True exclude_from_schema = True + swagger_schema = None def get_view_name(self): return "API Root" 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/clipboard-2.0.4.min.js b/netbox/project-static/clipboard-2.0.4.min.js new file mode 100755 index 00000000000..02c549e35c8 --- /dev/null +++ b/netbox/project-static/clipboard-2.0.4.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.4 + * https://zenorocha.github.io/clipboard.js + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return function(n){var o={};function r(t){if(o[t])return o[t].exports;var e=o[t]={i:t,l:!1,exports:{}};return n[t].call(e.exports,e,e.exports,r),e.l=!0,e.exports}return r.m=n,r.c=o,r.d=function(t,e,n){r.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,"a",e),e},r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.p="",r(r.s=0)}([function(t,e,n){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i=function(){function o(t,e){for(var n=0;n' + + '
' + 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..e8cc6aa1f43 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,235 @@ $(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) { + // Remove any existing color-selection classes + $(container).attr('class', function(i, c) { + return c.replace(/(^|\s)color-selection-\S+/g, ''); + }); + $(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 is_nullable = $(filter_for_element).attr("nullable"); + var is_visible = $(filter_for_element).is(":visible"); + var value = $(filter_for_element).val(); + + if (param_name && is_visible && value) { + parameters[param_name] = value; + } else if (param_name && is_visible && is_nullable) { + parameters[param_name] = "null"; + } + }); - // 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, but only add it once + if (element.getAttribute('data-null-option') && data.previous === null) { + 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: netbox_api_path + "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) { + // If tag contains space add double quotes + if (/\s/.test(obj.name)) + obj.name = '"' + obj.name + '"' + + 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 2d5afe70085..92902acfd50 100644 --- a/netbox/project-static/js/livesearch.js +++ b/netbox/project-static/js/livesearch.js @@ -42,8 +42,8 @@ $(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 diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index a592330fdb5..0fee86d4070 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -1,4 +1,6 @@ $(document).ready(function() { + // Instantiate ClipboardJS on all copy buttons + new ClipboardJS('button.copy-secret'); // Unlocking a secret $('button.unlock-secret').click(function(event) { @@ -45,6 +47,7 @@ $(document).ready(function() { console.log("Secret retrieved successfully"); $('#secret_' + secret_id).text(response.plaintext); $('button.unlock-secret[secret-id=' + secret_id + ']').hide(); + $('button.copy-secret[secret-id=' + secret_id + ']').show(); $('button.lock-secret[secret-id=' + secret_id + ']').show(); } else { console.log("Secret was not decrypted. Prompt user for private key."); @@ -67,6 +70,7 @@ $(document).ready(function() { var secret_div = $('#secret_' + secret_id); secret_div.html('********'); $('button.lock-secret[secret-id=' + secret_id + ']').hide(); + $('button.copy-secret[secret-id=' + secret_id + ']').hide(); $('button.unlock-secret[secret-id=' + secret_id + ']').show(); } 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 aa7e02e43d9..6548708b560 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -1,15 +1,13 @@ -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, TagFilter +from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from .models import Secret, SecretRole -class SecretRoleFilter(django_filters.FilterSet): +class SecretRoleFilter(NameSlugSearchFilterSet): class Meta: model = 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,7 +38,7 @@ 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)', 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 6beb86c9e95..8190cd1dd94 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import os import sys @@ -13,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 @@ -50,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 @@ -88,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 @@ -128,7 +125,7 @@ def clean(self, *args, **kwargs): ) }) - super(UserKey, self).clean() + super().clean() def save(self, *args, **kwargs): @@ -141,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): @@ -151,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): """ @@ -188,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. @@ -234,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): @@ -259,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 @@ -312,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 @@ -362,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: 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..1f937f54be0 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 @@ -10,7 +8,7 @@ {% if perms.secrets.change_secretrole %} - + {% endif %} """ @@ -25,7 +23,7 @@ class SecretRoleTable(BaseTable): secret_count = tables.Column(verbose_name='Secrets') slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn( - template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) class Meta(BaseTable.Meta): 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 2fb7c3781a5..b3ba0cee10b 100644 --- a/netbox/secrets/tests/test_models.py +++ b/netbox/secrets/tests/test_models.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals import string from Crypto.PublicKey import RSA diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py new file mode 100644 index 00000000000..f9985db2822 --- /dev/null +++ b/netbox/secrets/tests/test_views.py @@ -0,0 +1,82 @@ +import urllib.parse + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from secrets.models import Secret, SecretRole + + +class SecretRoleTestCase(TestCase): + + def setUp(self): + + TEST_USERNAME = 'testuser' + TEST_PASSWORD = 'testpassword' + + User = get_user_model() + User.objects.create(username=TEST_USERNAME, email='testuser@example.com', password=TEST_PASSWORD) + + self.client = Client() + self.client.login(username=TEST_USERNAME, password=TEST_PASSWORD) + + SecretRole.objects.bulk_create([ + SecretRole(name='Secret Role 1', slug='secret-role-1'), + SecretRole(name='Secret Role 2', slug='secret-role-2'), + SecretRole(name='Secret Role 3', slug='secret-role-3'), + ]) + + def test_secretrole_list(self): + + url = reverse('secrets:secret_list') + + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + + +class SecretTestCase(TestCase): + + def setUp(self): + + self.client = Client() + + site = Site(name='Site 1', slug='site-1') + site.save() + + manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') + manufacturer.save() + + devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1') + devicetype.save() + + devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') + devicerole.save() + + device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) + device.save() + + secretrole = SecretRole(name='Secret Role 1', slug='secret-role-1') + secretrole.save() + + Secret.objects.bulk_create([ + Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'), + Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'), + Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'), + ]) + + def test_secret_list(self): + + url = reverse('secrets:secret_list') + params = { + "role": SecretRole.objects.first().slug, + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) + self.assertEqual(response.status_code, 200) + + def test_configcontext(self): + + secret = Secret.objects.first() + response = self.client.get(secret.get_absolute_url(), follow=True) + self.assertEqual(response.status_code, 200) diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 952725b5400..9d07dd63c71 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -1,6 +1,4 @@ -from __future__ import unicode_literals - -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView from . import views @@ -10,21 +8,21 @@ urlpatterns = [ # Secret roles - url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'), - url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'), - url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), - url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), - url(r'^secret-roles/(?P[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), - url(r'^secret-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), + path(r'secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), + path(r'secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path(r'secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), + path(r'secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), + path(r'secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path(r'secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets - url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), - url(r'^secrets/import/$', views.SecretBulkImportView.as_view(), name='secret_import'), - url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), - url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), - url(r'^secrets/(?P\d+)/$', views.SecretView.as_view(), name='secret'), - url(r'^secrets/(?P\d+)/edit/$', views.secret_edit, name='secret_edit'), - url(r'^secrets/(?P\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'), - url(r'^secrets/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), + path(r'secrets/', views.SecretListView.as_view(), name='secret_list'), + path(r'secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), + path(r'secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), + path(r'secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), + path(r'secrets//', views.SecretView.as_view(), name='secret'), + path(r'secrets//edit/', views.secret_edit, name='secret_edit'), + path(r'secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), + path(r'secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), ] diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index d15c9cbc25c..99b725528d9 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 @@ -122,6 +120,8 @@ def secret_add(request, pk): secret.plaintext = str(form.cleaned_data['plaintext']) secret.encrypt(master_key) secret.save() + form.save_m2m() + messages.success(request, "Added new secret: {}.".format(secret)) if '_addanother' in request.POST: return redirect('dcim:device_addsecret', pk=device.pk) @@ -230,7 +230,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 2a9da7b15a9..02b6bb32cb8 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 @@ + + @@ -52,7 +54,7 @@

        Maintenance Mode

        {% now 'Y-m-d H:i:s T' %}

        -
        +

        Docs · API · @@ -66,6 +68,8 @@

        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..0dd8095a594 --- /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..08ecf783d25 --- /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..b49190cda81 100644 --- a/netbox/templates/dcim/console_connections_list.html +++ b/netbox/templates/dcim/console_connections_list.html @@ -2,18 +2,16 @@ {% load buttons %} {% 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 3a446758d32..6021b16e1b2 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,11 +1,11 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load helpers %} {% block title %}{{ device }}{% endblock %} {% block header %} -
        +
        -
        +
        {% 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 %} @@ -148,28 +163,16 @@

        {{ device }}

        Device Type -
        {{ device.device_type.full_name }} ({{ device.device_type.u_height }}U) + {{ device.device_type.display_name }} ({{ device.device_type.u_height }}U) 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 }}
        @@ -196,7 +199,7 @@

        {{ device }}

        {% endfor %} -
        -
        -
        - 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 %}
        @@ -369,7 +352,7 @@

        {{ device }}

        {% csrf_token %}
        -